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 — {dotted.tokens} that always
resolve and elide when their domain recorded nothing:
- Identity:
{project}{ref}{sha}{version}{commit_title}{pipeline_url}{duration} - Status:
{status}{status_icon}{status_verb}, and on failure{failure.subsystem}/{failure.reason} - Domains:
{publish.*},{tests.*},{security.*},{changelog.*},{retention.pruned},{reconcile.*}(gitops),{ansible.*}(host convergence)
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
llms: + type: llm — AI as a stencil¶
AI narration is two declarations: a backend in the llms: library, and a
stencil of type: llm that composes an input body and sends it through that
backend. The library exists so endpoints and credentials never leak into
composition — an AI stencil says llm: local and stays pure text.
llms:
local:
provider: ollama # openai | anthropic | claude-agent reserved
url: http://ollama.example.com:11434
model: deepseek-r1:1.5b
stencils:
triage:
type: llm
llm: local
body: |
You are a CI triage assistant. In two sentences, explain the most likely
cause of this failure and the first thing to check.
{postmortem}
notifications:
failure-triage:
provider: ntfy
url: https://ntfy.example.com/MyRepo-CI
credentials: NTFY
body: "{postmortem}\n\n{triage}"
when:
outcomes: [failure]
The body: of an llm stencil is the composed input: facts and stencil
embeds resolve first (so the model receives the real postmortem, the real
changelog), then the result goes to the backend and the response renders as the
stencil's output. Contracts that keep this safe and cheap:
- Degrade to empty — an unreachable backend or failed generation renders nothing (and the empty-body skip means no broken ping). The pipeline never fails because a model was down.
- Per-run memoization — a given llm stencil generates once per run, however many bodies embed it.
- Reasoning-model hygiene —
<think>…</think>traces are stripped from output before rendering. - Dispatch-only, enforced —
type: llmstencils (and any text stencil that transitively embeds one) are rejected by validation in scribe file regions. The one deliberate exception: release bodies may embed AI stencils — what a release says is the author's editorial choice, and the release elements ({release.changes}etc.) flow into the stencil's input so a model can rewrite them.
llms¶
LLMs is the model endpoint library (llms:): id → { provider, url, model, credentials }, referenced by type: llm stencils via llm:
llms:
<id>: # entry key = the unique id
provider: <string> # ollama (openai | anthropic | claude-agent reserved) · required
url: <string> # ollama: server base URL
model: <string> # model name/tag
credentials: <string> # env prefix for hosted providers
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.