A nightly digest with retries and an approval#
You will build nightly-digest: every night at 02:15 UTC it reads the
previous day's incidents from a public status page, has an agent write a
digest of them (skipped on a quiet day), asks an admin, and publishes the
digest — on the execution's drive, in its log, and to a chat webhook if
you give one. Each box is retried on failure. Everything in it runs as
written: the images are public, the incidents come from GitHub's public
status page (no key), and the only agent it names is the one you create in
step 1.
flowchart LR
S([nightly<br/>schedule 02:15 UTC]) --> C[collect<br/>run · retry 3]
C --> A[summarise<br/>agent · when count ≠ 0 · retry 2]
A --> O[ok<br/>approval · 8 h]
O --> P[publish<br/>run · retry 3]
Before you begin#
- A machine that can run agents, and a model, as in the Anemoi quick start.
- A data location on that machine (the installer asks for one; see Choose where a machine keeps data). Each execution keeps its files on a drive of its own there.
- Runs that may reach the internet over HTTPS:
collectpullspython:3.12-slimfrom Docker Hub and readshttps://www.githubstatus.com/api/v2/incidents.json. - The editor role to create the agent and the workflow and to start it; someone with the admin role — you, if you have it — to approve.
-
astrasigned in and pointed at your workspace (see Install the CLI). Below,<org>/<workspace>stands for yours: -
For the API tabs:
ASTRALYX_APIandASTRALYX_TOKENas in the API Reference.
1. Create the agent that writes the digest#
The workflow's summarise box runs an agent named incident-writer: the
Assistant with your model, no tools — everything it needs is in its input.
- Open Anemoi → Agents and press New agent.
- Name:
incident-writer. - Start from: Blank. Agent: Assistant.
- Model: the Eos deployment or the provider and credential you used in the Anemoi quick start.
- Connections: none.
- Instructions: You write an incident digest for engineers from the incidents you are given. For each incident: a heading with its title and impact, then what happened, who was affected, how long it lasted (UTC) and what to follow up. Use only the facts given; say when something is not known. Markdown, no preamble.
- Limits: Longest run, minutes
10, Tokens per run50000, When reached Stop the run. - Press Create the agent:
Ready, version 1.
{
"metadata": {"name": "incident-writer"},
"spec": {
"kind": "astralyx",
"model": {"deployment": "chat"},
"instructions": "You write an incident digest for engineers from the incidents you are given. For each incident: a heading with its title and impact, then what happened, who was affected, how long it lasted (UTC) and what to follow up. Use only the facts given; say when something is not known. Markdown, no preamble.",
"budget": {"max_seconds": 600, "max_tokens": 50000}
}
}
chat is the Eos deployment of the Anemoi quick start; with a
provider, "model": {"provider": "anthropic", "model":
"claude-sonnet-4-5", "credential": "anthropic-key"}.
2. Write the workflow#
Save this file. The two programs the runs execute are in it, under
configs: each is mounted read-only into its run and started with
python.
apiVersion: anemoi.astralyx/v1
kind: Flow
metadata:
name: nightly-digest
spec:
description: Collect a day's incidents from a public status page, summarise them, ask, then publish.
triggers:
- {id: nightly, kind: schedule, at: "02:15"} # every day, UTC
inputs:
- name: day
description: The day to report on (YYYY-MM-DD, UTC). Empty, the day before the run
default: ""
- name: status_page
description: A public Statuspage site to read incidents from
default: https://www.githubstatus.com
- name: webhook
description: Optional. A URL to POST the digest to as {"text"} (a Slack or Mattermost incoming webhook)
default: ""
timeout_seconds: 43200 # the whole execution: 12 h
steps:
- id: collect
kind: run
retry: {max: 3, backoff_seconds: 300}
timeout_seconds: 1800
template:
image: python:3.12-slim
command: python
args: ["/opt/digest/collect.py"]
env:
DAY: "${{ inputs.day }}"
SCHEDULED_AT: "${{ event.scheduled_at }}"
STATUS_PAGE: "${{ inputs.status_page }}"
configs:
- mounts: ["/opt/digest/collect.py"]
value: |
import json, os, re, sys, urllib.request
from datetime import datetime, timedelta, timezone
day, site = os.environ.get("DAY", ""), os.environ["STATUS_PAGE"].rstrip("/")
if not day:
# The day before the schedule's time (or before now, run by hand).
at = os.environ.get("SCHEDULED_AT", "")
now = datetime.fromisoformat(at) if at else datetime.now(timezone.utc)
day = (now - timedelta(days=1)).date().isoformat()
if not re.fullmatch(r"\d{4}-\d{2}-\d{2}", day):
sys.exit(f"day must be YYYY-MM-DD, not {day!r}")
def utc(t):
return datetime.fromisoformat(t).astimezone(timezone.utc) if t else None
url = f"{site}/api/v2/incidents.json" # Statuspage's public API: no key
with urllib.request.urlopen(url, timeout=30) as r:
incidents = [i for i in json.load(r)["incidents"]
if utc(i["created_at"]).date().isoformat() == day]
# The day's incidents as the status page gives them, kept on the drive.
os.makedirs("/flow/collect", exist_ok=True)
with open("/flow/collect/incidents.json", "w") as f:
json.dump(incidents, f, indent=2)
# A brief for the agent: outputs are one line each, 4 KiB in all.
def line(i):
last = i["incident_updates"][0]["body"] if i["incident_updates"] else ""
last = re.sub(r"<[^>]+>", " ", last) # updates may hold HTML
start, end = utc(i["created_at"]), utc(i["resolved_at"])
until = end.strftime("%H:%M") if end else "not resolved"
return f'[{i["impact"]}] {i["name"]} ({start:%H:%M}-{until} UTC): {last}'
brief = " || ".join(" ".join(line(i).split()) for i in incidents)
brief = brief.encode()[:3400].decode(errors="ignore")
with open(os.environ["ASTRAEUS_OUTPUTS"], "a") as f:
f.write(f"day={day}\ncount={len(incidents)}\nbrief={brief}\n")
print(f"{len(incidents)} incidents on {day} at {site}")
requested_resources: {cpu_cores: 1, memory_bytes: 268435456}
- id: summarise
kind: agent
needs: [collect]
when: steps.collect.outputs.count != '0'
agent: incident-writer
input: >-
Write the incident digest for ${{ steps.collect.outputs.day }} from these
${{ steps.collect.outputs.count }} incidents of ${{ inputs.status_page }}
(impact, title, times, last update; separated by ||):
${{ steps.collect.outputs.brief }}
retry: {max: 2, backoff_seconds: 600}
timeout_seconds: 3600
- id: ok
kind: approval
needs: [summarise]
target: "Publish the incident digest for ${{ steps.collect.outputs.day }} (${{ steps.collect.outputs.count }} incidents)?"
approvers: ["role:admin"]
wait_seconds: 28800 # 8 h
- id: publish
kind: run
needs: [ok]
retry: {max: 3, backoff_seconds: 60}
timeout_seconds: 600
template:
image: python:3.12-slim
command: python
args: ["/opt/digest/publish.py"]
env:
DAY: "${{ steps.collect.outputs.day }}"
WEBHOOK: "${{ inputs.webhook }}"
configs:
- mounts: ["/opt/digest/publish.py"]
value: |
import json, os, pathlib, sys, urllib.request
answer = pathlib.Path("/flow/summarise/answer.md")
if not answer.exists():
sys.exit(f"{answer} is missing: summarise ran on another machine (name a shared drive)")
digest = f"# Incidents of {os.environ['DAY']}\n\n{answer.read_text().strip()}\n"
out = pathlib.Path("/flow/publish/digest.md")
out.parent.mkdir(parents=True, exist_ok=True)
out.write_text(digest)
print(digest)
hook, sent = os.environ.get("WEBHOOK", ""), "no"
if hook:
req = urllib.request.Request(hook, data=json.dumps({"text": digest}).encode(),
headers={"Content-Type": "application/json"})
with urllib.request.urlopen(req, timeout=30) as r:
sent = str(r.status)
with open(os.environ["ASTRAEUS_OUTPUTS"], "a") as f:
f.write(f"path={out}\nwebhook={sent}\n")
requested_resources: {cpu_cores: 1, memory_bytes: 268435456}
outputs:
day: "${{ steps.collect.outputs.day }}"
incidents: "${{ steps.collect.outputs.count }}"
published: "${{ steps.publish.state }}"
What to notice:
- The schedule.
nightlyfires every day at 02:15 UTC (at, nodays, notimezone). Its event is{"trigger": "nightly", "scheduled_at": "…"};collectreadsscheduled_atand reports on the day before. It fires first at its next 02:15 after the workflow is written, never for one already past. - Inputs. A schedule gives no inputs, so each has a default.
dayempty means the day before; a run by hand may name one.status_pagedefaults to GitHub's; any site hosted on Atlassian Statuspage works the same (https://www.cloudflarestatus.com,https://status.openai.com).webhookis empty unless you give one; it is kept with the execution's inputs, which everyone in the workspace can read, so use a webhook made for this digest. The inputs reach the programs as environment variables (env), never pasted into code. - Outputs.
collectwritesday,countandbriefto$ASTRAEUS_OUTPUTS, and the later boxes readdayfrom it. A run's outputs are at most 4 KiB, one line each, sobriefis the day's incidents cut to 3,400 bytes; the full record is in/flow/collect/incidents.json. - The agent's input carries the data.
summarisereadscountin itswhenandbriefin its input. The Assistant's file tools reach only its own folder (/sandbox), not/flow, so the incidents travel in the input, not as a file. - A quiet day. With no incident,
countis0andsummariseis Skipped. A box whose need was skipped is skipped too, sookandpublishare skipped and the execution Succeeds withincidents: 0. (A box that should run anyway setsallow_skipped: true.) - The answer. The agent's answer is written to
/flow/summarise/answer.md;publishreads it, writes/flow/publish/digest.md, prints it to its log and, ifwebhookis set, posts{"text": <digest>}to it — the shape Slack and Mattermost incoming webhooks take. - Retries.
retrymakes new attempts after a failure, with a pause: a status page that does not answer, a model call that fails, a webhook that is down.
One machine, or a shared drive
The workflow names no drive, so each execution gets a drive of its
own, a copy on each machine. That is enough when one machine runs the
boxes. With several machines, summarise and publish may run on
different ones, and publish then stops with
/flow/summarise/answer.md is missing. To share the files, create a
shared drive of the workspace (a shared source on a filesystem every
machine mounts; see Drives) and add
drive: <its name> under spec.
3. Check it and apply it#
Iris → Workflows → New workflow, name nightly-digest, open
YAML, paste the file (from spec), check the graph on Boxes,
and press Create the workflow.
$ astra iris workflows validate -f nightly-digest.yaml
nightly-digest.yaml: warning: drive: each execution gets a drive of its own, a copy on each machine: steps on different machines do not see each other's files. Name a shared drive to share them
nightly-digest.yaml: flow nightly-digest is valid
$ astra iris workflows apply -f nightly-digest.yaml
flow nightly-digest: version 1 written, now current
The warning is the note above; on one machine you can leave it.
The checks do not look for the agent: if incident-writer does not exist,
the workflow is written and summarise fails when it runs.
4. Run it once by hand#
Pick a recent day with incidents on
githubstatus.com/history: the API
returns the 50 most recent incidents, so a day older than a few weeks
finds none. Add --input webhook=<your webhook URL> to post the digest.
On the workflow's page, Run now, day = 2026-09-15.
collect ends with count=2 in its outputs, summarise writes the
digest, and the execution stops at ok: it is Waiting.
5. Approve it#
The approval goes to the workspace's admins, for 8 hours
(wait_seconds). Read the digest before you decide: it is the output of
the summarise box's run, linked from the execution's page.
Open Anemoi → Approvals, then Publish the incident digest for 2026-09-15 (2 incidents)?, and press Approve.
$ curl -sS "$ASTRALYX_API/anemoi/approvals?state=Pending" -H "Authorization: Bearer $ASTRALYX_TOKEN" \
| jq -r '.items[] | select(.spec.kind == "flow_step") | [.metadata.name, .spec.target] | @tsv'
approval-64bf968ac66992da Publish the incident digest for 2026-09-15 (2 incidents)?
$ curl -sS -X POST "$ASTRALYX_API/anemoi/approvals/approval-64bf968ac66992da/decision" -H "Authorization: Bearer $ASTRALYX_TOKEN" \
-H "Content-Type: application/json" -d '{"decision": "approve"}' | jq .status.state
"Approved"
From a terminal: astra anemoi approvals list, then astra anemoi
approvals approve <name>. Once approved, publish runs and the
execution Succeeds with incidents: 2 and published: Succeeded. The
digest is in the publish run's log (astra iris workflows get names the
run under LAST; astra astraeus logs <that run>-0) and in your channel
if you gave a webhook. Denied, or not decided in 8 hours, ok fails and
so does the execution. See Handle approvals.
6. Every night#
The schedule needs nothing more. Check when it fires next:
Each night's execution runs as the workflow's owner — whoever created
it — and its page shows Trigger nightly (schedule). A night missed
because Astralyx could not start it is started once, up to an hour late;
later than that, it is skipped (see
Missed times). To start it from a
scheduler of your own instead, remove the trigger and run
astra iris workflows run nightly-digest with a personal API token
(API tokens) in ASTRA_TOKEN.
7. When a box fails#
- A box that fails all its attempts fails the execution (
on_failure: stop) and cancels boxes under way. Retry step on the execution's page (orastra iris workflows retry <execution> <step>) gives it one more attempt; the execution resumes from there — finished boxes are not run again. collectfails with day must be YYYY-MM-DD on a malformedday, and is retried when the status page does not answer.- A denied or lapsed approval fails
ok. Retrying it asks again. astra iris workflows executions --flow nightly-digestlists the nights, with their state and how far each got.
Clean up#
Press Delete on each execution's page (an execution that ended; its
drive goes with it), then on the workflow's page and on the agent's page.
In the API: DELETE $ASTRALYX_API/anemoi/flow-executions/{name},
DELETE $ASTRALYX_API/anemoi/flows/nightly-digest,
DELETE $ASTRALYX_API/anemoi/agents/incident-writer.