Narration & Notifications¶
Narration is the message half of StageFreight's audience text: the run
summary printed at the end of a pipeline, the notification that lands on your
phone, and the optional AI-processed retelling of either. It shares one grammar
with Stencils & Scribe — freeform bodies of facts and {stencil}
embeds — but where scribe places rendered text into files, narration
dispatches it as messages. That difference carries a hard rule: AI output is
dispatch-only (stdout cards, notifications, release bodies by the author's
choice) and can never enter a scribe file region — the committed record stays
deterministic.
The run story: facts, union bodies, elision¶
Every subsystem records its outcome and metrics into the pipeline state as it runs; narration renders them through facts — tokens that always resolve and elide when their domain recorded nothing. The complete vocabulary:
Identity & status (bare names, usable anywhere):
| Fact | Renders |
|---|---|
{project} |
project name (from the git remote) |
{modality} |
the run's lifecycle modality |
{ref} |
branch or tag the run built |
{sha} / {version} |
the run's recorded identity (short SHA / version) |
{commit_title} |
HEAD commit title |
{pipeline_url} |
forge pipeline URL (tap-through target) |
{duration} |
elapsed from the run's first state write ("3m 12s") |
{status} / {status_icon} / {status_verb} |
pipeline outcome as word / icon / verb |
Domain facts ({domain.key} — one domain per subsystem; every metric
elides when its domain recorded nothing):
| Domain | Keys |
|---|---|
failure. |
subsystem · reason (the first pipeline-failing subsystem) |
publish. |
tags · registries |
tests. |
total · passed · coverage |
security. |
blocking · critical · high · medium · low · total · sbom · blocking_list |
changelog. |
count · range |
reconcile. (gitops) |
total · succeeded · failed · declined · backend · units · cluster · failures |
ansible. (hosts) |
total · converged · changed · unreachable · failed |
retention. |
pruned |
Every recorded domain additionally serves {domain.outcome} and
{domain.reason} — the universal subsystem keys.
Producers and arcs (shipped stencils, embeddable as {id} and overridable
by shadowing): {summary} and {postmortem} (the arc bodies), plus the four
looping producers {changelog}, {failures}, {vulns}, {artifacts} —
self-bounding row lists that render nothing when empty.
Everything else falls through to the template leaf-pass: {base},
{major}/{minor}/{patch}, {env:NAME}, {var:name}, {project.*}, and
the date/counter vocabulary — see Concepts. Resolution order per
token: caller context (release elements) → recorded facts → your stencils: →
shipped stencils → the leaf-pass; unknown tokens stay visibly literal, so a
typo shows itself.
This vocabulary is ratcheted: the registry lives beside the fact resolver
(src/cistate/vocabulary.go) and a docs test fails any fact that ships
undocumented.
The shipped summary (success arc) and postmortem (failure arc) are union
bodies: one template holding every modality's lines, composed per line — a
line whose facts all resolved empty drops out, label and all. An image repo, a
gitops repo, a host-converging repo, or one doing all three renders coherently
from the same body:
Shipped {publish.tags} → {publish.registries}
Converged {reconcile.succeeded}/{reconcile.total} {reconcile.units} on {reconcile.cluster}
Converged {ansible.converged}/{ansible.total} nodes · {ansible.changed} changed
Tests — {tests.passed}/{tests.total} passed · {tests.coverage} coverage
Overriding is shadowing: declare a stencil named summary (or postmortem)
and your body replaces the shipped one everywhere it is embedded. Adding a
modality never means a new template — it means new facts and new lines.
narrate¶
Narrate is the stdout storytelling surface: announces: lists stencil ids rendered as structured-output cards at the end of the run (default: the built-in summary).
notifications: — messages that dispatch themselves¶
The top-level notifications: block is an id → entry map where endpoint and
message are fused — a notification is one thought, not a router plus a
template. Subject, body, and click are freeform stencil bodies; omitted, they
default to the shipped subject ({project} {ref} — {status} in {duration})
and the run's arc body (summary on success, postmortem on failure).
notifications:
phone:
provider: ntfy
url: https://ntfy.example.com/MyRepo-CI
credentials: NTFY # → NTFY_TOKEN (Authorization: Bearer)
priority: high
tags: [rotating_light]
when:
outcomes: [failure] # composes with branches:/events:/git_tags:
branches: [main]
max_length: 4096 # trim at a line boundary; pipeline link survives
recap:
provider: ntfy
url: https://ntfy.example.com/MyRepo-CI
credentials: NTFY
body: "{summary}"
when:
outcomes: [success]
Dispatch rules worth knowing:
when:is the one grammar — the sameevents/branches/git_tagsconditions used everywhere, extended with theoutcomes:dimension (success|failure|warning). Omittedwhen:= always.- Empty bodies skip — a notification whose rendered body is empty (every line elided) does not ping anyone.
max_lengthtrims at line boundaries and always preserves the pipeline-link line, so tap-through survives truncation.- Full ntfy header vocabulary:
priority,tags(emoji),click,attach,actions,markdown,email.
notifications¶
Notifications sends a message when a run finishes: id → { provider, on, subject, body, … }. Flat, one entry per notification. subject/body accept {…} embeds; an omitted body defaults to the run's apex summary. Dispatched by the narrate phase.
notifications:
<id>: # entry key = the unique id
provider: <string> # ntfy | webhook · required
url: <string> # Transport. Credentials follows the shipped env-prefix convention (credentials: NTFY → NTFY_TOKEN…
credentials: <string>
subject: <string> # Message — freeform stencil bodies.
body: <string>
when: # When gates dispatch: outcomes: (success | failure | warning) composing with…
- branches: [<string>] # Branches lists branch filters. Each entry is a policy name or "re:<regex>". Empty = no branch…
git_tags: [<string>] # GitTags lists git tag filters. Each entry is a policy name or "re:<regex>". Empty = no tag…
events: [<string>] # Events lists CI event type filters. Supported: push, tag, release, schedule, manual, pull_request…
forges: [<string>] # Forges restricts this target to specific CI forges by provider name (github, gitlab, gitea…
outcomes: [<string>] # Outcomes gates on the RUN outcome (success | failure | warning) — the notification dimension of…
max_length: <int> # MaxLength hard-caps the rendered body in bytes (ntfy's default server limit is 4096). Trimming…
priority: <string> # ntfy knobs — the full header vocabulary.
tags: [<string>] # emoji tags (comma-joined into the Tags header)
click: <string> # tap-through URL (stencil body; default {pipeline_url})
attach: <string> # attachment URL
actions: <string> # ntfy actions spec string
markdown: false # render body as markdown
email: <string> # forward to email address
Dispatching AI narration¶
AI lives elsewhere in the config — model endpoints in the
llms: library (Identity & Connectivity, a sibling of forges
and registries) and the type: llm stencil that composes input
and consumes a backend (Stencils & Scribe). Narration is where their output
DISPATCHES: embed an AI stencil in a notification body like any other stencil.
notifications:
failure-triage:
provider: ntfy
url: https://ntfy.example.com/MyRepo-CI
credentials: NTFY
body: "{postmortem}\n\n{triage}" # triage is a type: llm stencil
when:
outcomes: [failure]
The dispatch-side behaviors that matter here: an unreachable backend renders nothing, and the empty-body skip means no broken ping goes out; generation is memoized per run, so a subject and body embedding the same AI stencil cost one generation. AI output is dispatch-only — these bodies and release notes, never scribe file regions.
Where narration runs¶
The narrate phase is presentation only: it gathers recorded facts, renders the arc body and any configured notifications, and dispatches. It holds no build capabilities and no cluster credentials — by the time narration runs, the truth is already recorded; narration just tells it.