Envelopes
When a stage finishes, it hands off structured state to the pipeline via an envelope. The next stage (and the operator console) read this contract instead of scraping chat transcripts.
Envelopes are domain-neutral — you choose what goes in summary, payload, and artifacts for your workflow.
Contract
Type definition (src/types/envelope.ts):
type StageEnvelope = {
status: "success" | "failure";
summary: string;
artifacts: string[];
payload?: Record<string, unknown>;
fork_choice?: string[];
feedback_loop?: FeedbackLoopAction;
checklist_attestations?: Array<{ check_id: string; items: string[] }>;
stage_id?: string;
notes?: string;
};
type FeedbackLoopAction =
| { action: "continue" }
| { action: "send_back"; target: string };
| Field | Required | Description |
|---|---|---|
status |
yes | "success" advances the pipeline. Catalog wiring is route on the source, not inbound needs. "failure" skip-cascades single-parent successors whose on does not include failed. Including failed in on: opts that edge out of cascade; it does not launch that successor — launch still requires a succeeded parent. A generic fan-in Join stays pending if any parent failed, even when that parent's on includes failed; the run fails. Skipped siblings do not block a Join that has a succeeded parent |
summary |
yes | Non-empty human-readable summary |
artifacts |
yes | Array of run-relative artifact paths (may be empty []) |
payload |
no | Structured data for downstream stages; required on success (io.output.schema is required on every stage body) |
fork_choice |
no* | Immediate successor ids to run; required on success when the stage has a fork field |
feedback_loop |
no† | Continue or send-back decision; required on success when the stage declares a { type: loop } route entry |
checklist_attestations |
no‡ | Array of { check_id, items }; one entry per stage verify item of type: checklist the agent is attesting to |
stage_id |
no | Optional stage id echo |
notes |
no | Optional free-form notes |
* Required for fork stages on success (fork_choice). On failure, fork_choice is not required or validated. clone_forks is rejected on every envelope — see Rejected clone fields.
† Required on success for stages that declare a { type: loop } route entry. Forbidden on failure and on stages that do not declare a { type: loop } route entry. See Feedback loops.
‡ Optional structurally (the tool accepts it being absent), but a stage with an after-phase type: checklist verify item fails that check unless the matching attestation is present at emit time — see Checklist attestations.
Emitting an envelope
Stage agents call the Pi tool emit_stage_envelope exactly once when finished. The pipeline cannot advance until this succeeds.
Example success emit (conceptual):
{
"status": "success",
"summary": "Clarified requirements and listed three open questions.",
"artifacts": [],
"payload": {
"requirements": ["…"],
"open_questions": ["…"]
}
}
On status: "failure", the envelope is accepted. Catalog wiring is route on the source, not inbound needs. Single-parent successors whose on does not include failed are skip-cascaded. Including failed in on: opts that edge out of cascade; it does not launch that successor — launch still requires a succeeded parent. A generic fan-in Join stays pending if any parent failed, even when that parent's on includes failed; the run fails. Skipped siblings do not block a Join that has a succeeded parent. fork_choice is not required or validated on failure.
Every stage body declares io.output.schema. On success, payload is validated against that JSON Schema subset — see io schemas.
When the agent stops without emitting {#emit-reminders}
An invalid emit returns an error to the agent and the session continues, so the agent can fix the envelope and call the tool again. If the agent ends its turn with no accepted envelope (it replied with text, or gave up after a rejected emit), Stageflow sends up to two short reminder turns asking it to finish and emit, quoting the last rejection when there was one. Both agent backends do this: Pi continues the same session; the Claude backend resumes the same Claude session (resume: <session_id>) for each reminder. The stage fails as missing emit_stage_envelope only after those reminders go unanswered.
No reminder is sent when:
- the turn ended on a provider error. Both backends already retry transient errors (rate limits, overloads, server errors); what is left is usually setup (an invalid or missing key, an unavailable model). The stage fails with
provider error: <message>instead ofmissing emit_stage_envelope. On the Claude backend this is aresultmessage withis_error(the API error text and HTTP status) or an error subtype with itserrors. - the turn was aborted (stage timeout or cancel), or the stage is closing or parked on
ask_operator. - the agent emitted
status: "failure". That is the agent's decision and ends the stage.
On Pi, reminders run inside the stage attempt's timeout_ms budget; on the Claude backend each reminder is its own query() turn with the same per-turn budget as an operator-answer resume. Reminders are recorded in the transcript as user messages.
Fork stages
Catalog YAML route does not use fork_choice. Listed to: targets stay on the DAG; optional if can skip an edge. There is no agent exclusive pick. Do not emit fork_choice to select YAML successors. fork_choice is only validated when the resolved DAG node has a fork field (constructed DAGs, not catalog YAML).
If a constructed DAG node has a fork field, the success emit must include fork_choice: string[] naming which of those successors to run. Absent or illegal choices cause the emit to be rejected (isError: true); the stage fails when no valid emit follows before the session ends.
{
"status": "success",
"summary": "Chose design-doc branch.",
"artifacts": [],
"fork_choice": ["design-doc"]
}
Rules:
- Every id in
fork_choicemust be an immediate successor of this stage. fork_choice: []is accepted only whenallow_none: trueis set withselect: subset.select: onealways requires exactly one choice — emptyfork_choicefails emit even ifallow_none: true.- On failure,
fork_choiceis not required or validated.
Unchosen successors are skipped — the same status used when a parent fails. Catalog pipelines use route instead of fork_choice.
Rejected clone fields {#rejected-clone-fields}
A Clone Chain emitter succeeds with a valid payload only (Clone Array length 1..cap). Stageflow mints Clone Instances; agents do not emit clone lists.
clone_forks is not a valid envelope field. Presence fails emit with a message naming the field and pointing at a Clone Chain. See YAML catalog — Rejected clone fields.
Feedback loops {#feedback-loops}
When the emitting stage's pipeline entry declares a { type: loop } route entry (see YAML catalog), a successful emit must include feedback_loop:
{ "action": "continue" }
or
{ "action": "send_back", "target": "implement" }
| Action | Effect |
|---|---|
continue |
Accept the stage and schedule normal successors (exit the loop / advance past the source). |
send_back |
Replay from target through the source. target must equal the policy's target. |
Rules:
feedback_loopis required on success for a configured source; omitting it rejects the emit.feedback_loopis not allowed whenstatusisfailure.feedback_loopis not allowed on stages that do not declare a{ type: loop }route entry.send_backcannot be combined withfork_choiceon the same envelope.continuemay still carryfork_choicewhen that field is otherwise required for the stage.
On replay, agents see a Feedback Loop Context section (JSON) with loop/replay ids, the source's send-back envelope (summary, artifacts, payload), remaining replays, route stage ids, and optional prior-attempt / active fork-generation hints. Use that context — not scraped transcripts — to address the feedback.
After max_replays, behavior follows on_max_replays (require_continue or wait_for_human). Human decisions: CLI, MCP decide_feedback_loop, or POST /api/runs/:runId/stages/:stageId/feedback-decision.
Walkthroughs: examples/feedback-loop/, examples/mcp-hitl-tour/. Fixture: feedback-loop.pipeline.yaml.
io schemas {#io-schemas}
Every stage body must declare both io.input.schema and io.output.schema. Omitting io, a side, or schema fails load (stage.invalid_io). Success payload is required and checked against io.output.schema using a JSON Schema subset (src/envelope/payloadSchema.ts). io.input.schema is the same subset, used for predecessor success payloads on normal edges, and matching optional task input on entry stages (omitted input is {}; mismatch is an error). Sequential and fan-in edges: the child's io.input must be a structural subset of each parent's io.output; pipeline load and sf validate report a mismatch as pipeline.io_incompatible. The root must be type: object and cannot be nullable. Supported node types: object, string, number, integer, boolean, array. Keywords: properties, required, items, additionalProperties (boolean only), minItems, maxItems (both non-negative integers), enum (string and integer), minimum, maximum. String nodes also accept pattern (a JavaScript RegExp string, unicode semantics), minLength, and maxLength (non-negative integers). Nested nodes may set nullable: true, compiling to a union of that type with null. Unknown keywords are ignored. Pipeline-file schemas: is the $ref root (#/schemas/<name>); see YAML catalog — Pipeline schemas.
Fixture: tests/fixtures/stages/name-selection.yaml.
Emit-phase verify {#verify-emit}
A stage may declare emit-phase items on body verify — a small, in-session gate emit_stage_envelope itself enforces on every success emit, this attempt, before a candidate envelope is even captured:
id: approve-plan
gate_kinds: [artifact_backed]
verify:
- id: plan-approved
type: gate
kind: artifact_backed # last artifact_backed exchange this attempt must be accept
- id: plan-artifact-present
type: artifact
basename: implementation-plan.md # must appear in envelope.artifacts (suffix match, no disk I/O)
when: [emit]
system_prompt: |
Review the plan artifact. Ask the operator to accept it, then emit success.
model: anthropic/claude-sonnet-4-5
| Check type | Required fields | Semantics |
|---|---|---|
gate |
id, kind |
The last ask_operator exchange of this kind in this attempt must satisfy it: confirm/artifact_backed need decision: "accept"; free_text/multi_question need any completed (answered) exchange. Default when is [emit]. |
artifact with when including emit |
id, when, basename or path |
basename (or the basename of path) must appear in the emitted artifacts list, either verbatim or as a /<basename> path suffix. Purely a list check — no filesystem access. type: artifact requires when. |
A failing emit-phase check rejects the emit (isError: true, no terminate) so the agent can retry in the same turn; it never fails the stage outright. Checks run in declaration order and stop at the first failure. Emit-phase verify is skipped entirely on status: "failure" emits, and omitted/empty verify is a no-op — existing stages are unaffected.
Not the same as after-phase verify. After-phase items (when includes after) run after a candidate envelope has already been captured (via repair/manual recovery), and gate/artifact there are disk- and history-aware (after-phase gate accepts any accepted decision over the run so far; after-phase artifact does a real on-disk lstat/sha256 check). See Verified Stage Execution. Emit and after may look at overlapping facts (defense in depth) as items on the same verify list.
Checklist attestations {#checklist-attestations}
A stage body verify item with type: checklist (default when: [after] — see YAML catalog — Verify) is scored against the success envelope's checklist_attestations field, not against free text in the agent's reply:
{
"status": "success",
"summary": "Implemented the change and reviewed it against the plan.",
"artifacts": [],
"payload": { "changed_files": ["src/foo.ts"] },
"checklist_attestations": [
{
"check_id": "self-review",
"items": [
"Implementation matches the approved plan",
"Unrelated files were not changed"
]
}
]
}
- One
checklist_attestationsentry per checklist check the agent is attesting to, keyed bycheck_idmatching theverifyitem'sid. itemsis compared as a set against that check's declareditems(order-independent, but every declared item must be present and no extra items). A missing check id, a missing item, or an extra item fails the check.- Omitting
checklist_attestationsentirely still passes emit-phase validation (the field is optional on the tool schema), but the after-phasechecklistcheck then fails with every declared item reported missing. - This is an attestation, not independent verification — pair it with a
commandorcheckout_changescheck for anything consequential.
Artifacts
Use write_stage_artifact to create files under the stage attempt directory. The tool path is relative to stages/<stageId>/attempts/<n>/artifacts/:
stages/<stageId>/attempts/<n>/artifacts/<your-file>
The tool returns a run-relative path for emit_stage_envelope and ask_operator.
Example paths referenced in fixtures:
stages/plan-review/attempts/1/artifacts/plan.mdstages/hitl-four-kinds/attempts/1/artifacts/summary.md
Artifact-backed HITL gates reference these paths in ask_operator — see HITL.
Storage
Accepted envelopes persist in the SQLite run store (SF_STORE=sqlite only; see CLI storage). Artifacts still live under stages/<stageId>/attempts/<n>/artifacts/. CI recipes that write envelope.json (see CI consumption below) are sf envelope get exports, not run-workspace storage.
Rules
- One advancing emit per stage attempt — later emits are ignored after the first acceptance
artifactsis required — pass[]when there are no filessummarymust be non-emptyask_operatordoes not complete the stage — callemit_stage_envelopeafter gates are resolved
Downstream consumption
Later stages receive prior envelope context through the stage bootstrap (task + upstream summaries/payloads). Exact prompt assembly is handled by the runtime; authors focus on meaningful payload and summary content.
Keyed generic fan-in — two or more parents list a forward to: to this child. Join input is priorEnvelopesByStage, a record keyed in YAML declaration order. priorEnvelope is null. Skipped and failed parents are omitted from join input (not synthesized). The Join does not run after a failed parent, so agents do not consume synthetic failure envelopes at the Join. See YAML catalog — generic fan-in, examples/generic-fan-in/, and diamond-fan-in.pipeline.yaml.
Inspect envelopes in the operator console: run detail → stage → envelope view (#/runs/<runId>/stages/<stageId>/envelope).
MCP get_run returns envelope summary and artifact paths without full event streams.
CI consumption {#ci-consumption}
In headless CI, downstream shell steps read envelopes via the CLI instead of querying SQLite or scraping transcripts.
Raw envelope (stage contract as emitted):
sf envelope get --from sf-run.json --stage detect-changes --format envelope --json
Handoff deliverables (normalized shape for GHA scripts — absolute artifact paths; --detect-stage skip is constructed-DAG fork_choice: []):
sf envelope get --from sf-run.json --stage author-diagrams \
--detect-stage detect-changes --format handoff --json > envelope.json
When --detect-stage is set and that stage emits fork_choice: [], handoff output is { "skipped": true } and downstream deliver/upload steps can no-op. That skip shape is for constructed DAGs. Catalog YAML does not author fork / fork_choice. examples/archify-on-pr skips author-diagrams with Route if on author_diagrams.
Typical CI flow:
sf run --json --include stages > sf-run.jsonsf envelope get --format handoff …→envelope.json- Shell script consumes
envelope.json(seeexamples/archify-on-pr/)
Full recipe: CI / headless · CLI flags: sf envelope get
See also
- YAML catalog —
ioon stage bodies - HITL — gates before emit
- Verified Stage Execution — after-phase
verify(whenincludesafter) is distinct from emit-phase items - CLI reference —
sf envelope get, handoff format tests/fixtures/stages/— stages that exercise emit + artifacts