Skip to content

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: collect pulls python:3.12-slim from Docker Hub and reads https://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.
  • astra signed in and pointed at your workspace (see Install the CLI). Below, <org>/<workspace> stands for yours:

    $ astra login
    $ astra use <org>/<workspace>
    
  • For the API tabs: ASTRALYX_API and ASTRALYX_TOKEN as 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.

  1. Open Anemoi → Agents and press New agent.
  2. Name: incident-writer.
  3. Start from: Blank. Agent: Assistant.
  4. Model: the Eos deployment or the provider and credential you used in the Anemoi quick start.
  5. Connections: none.
  6. 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.
  7. Limits: Longest run, minutes 10, Tokens per run 50000, When reached Stop the run.
  8. Press Create the agent: Ready, version 1.
incident-writer.json
{
  "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"}.

$ curl -sS -X POST "$ASTRALYX_API/anemoi/agents" -H "Authorization: Bearer $ASTRALYX_TOKEN" \
    -H "Content-Type: application/json" -d @incident-writer.json | jq '{current, status: .status.state}'
{
  "current": 1,
  "status": "Ready"
}

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.

nightly-digest.yaml
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. nightly fires every day at 02:15 UTC (at, no days, no timezone). Its event is {"trigger": "nightly", "scheduled_at": "…"}; collect reads scheduled_at and 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. day empty means the day before; a run by hand may name one. status_page defaults to GitHub's; any site hosted on Atlassian Statuspage works the same (https://www.cloudflarestatus.com, https://status.openai.com). webhook is 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. collect writes day, count and brief to $ASTRAEUS_OUTPUTS, and the later boxes read day from it. A run's outputs are at most 4 KiB, one line each, so brief is 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. summarise reads count in its when and brief in 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, count is 0 and summarise is Skipped. A box whose need was skipped is skipped too, so ok and publish are skipped and the execution Succeeds with incidents: 0. (A box that should run anyway sets allow_skipped: true.)
  • The answer. The agent's answer is written to /flow/summarise/answer.md; publish reads it, writes /flow/publish/digest.md, prints it to its log and, if webhook is set, posts {"text": <digest>} to it — the shape Slack and Mattermost incoming webhooks take.
  • Retries. retry makes 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.

$ yq -o=json '{"metadata": .metadata, "spec": .spec}' nightly-digest.yaml \
    | curl -sS -X POST "$ASTRALYX_API/anemoi/flows" -H "Authorization: Bearer $ASTRALYX_TOKEN" -H "Content-Type: application/json" -d @-

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.

$ astra iris workflows run nightly-digest --input day=2026-09-15
execution nightly-digest-cb4855 of flow nightly-digest started
$ astra iris workflows get nightly-digest-cb4855
$ curl -sS -X POST "$ASTRALYX_API/anemoi/flows/nightly-digest/executions" -H "Authorization: Bearer $ASTRALYX_TOKEN" \
    -H "Content-Type: application/json" -d '{"inputs": {"day": "2026-09-15"}}' | jq -r .metadata.name
nightly-digest-cb4855

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:

The workflow's Triggers tab: Next under nightly.

$ astra iris workflows triggers nightly-digest

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 (or astra iris workflows retry <execution> <step>) gives it one more attempt; the execution resumes from there — finished boxes are not run again.
  • collect fails with day must be YYYY-MM-DD on a malformed day, 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-digest lists 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.