Skip to content

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).

narrate:
  announces: [<string>]

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 same events/branches/git_tags conditions used everywhere, extended with the outcomes: dimension (success | failure | warning). Omitted when: = always.
  • Empty bodies skip — a notification whose rendered body is empty (every line elided) does not ping anyone.
  • max_length trims 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, enforcedtype: llm stencils (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.