YAML catalog
Stageflow uses a pipeline-owned catalog: each pipeline file lists stages as object entries with uses: (external YAML) or an inline body. Tasks are separate *.task.yaml files. A repo-root stageflow.yaml manifest declares which directories the operator console browses.
Canonical fixtures: tests/fixtures/pipelines/, tests/fixtures/stages/, tests/fixtures/tasks/, tests/fixtures/triggers/.
Layout
my-project/
stageflow.yaml
.mcp.json # optional project MCP catalog
pipelines/
hello.pipeline.yaml # inline or uses: stage entries
tasks/
hello.task.yaml
evals/ # optional: *.eval.yaml (see Evals)
datasets/ # optional: *.dataset.jsonl|json|csv
workshop/ # Workshop Author saves (scan root; created on the first save)
.stageflow/ # per-project settings only
Run state (SQLite and workspaces) lives under the global durable root ($STAGEFLOW_HOME, default ~/.stageflow/), not under the git root. See Data directory.
Flat layout — pipeline and task files may also live at the repo root (e.g. hello.pipeline.yaml, my-task.task.yaml) beside stageflow.yaml; validation and CLI accept any filesystem path. This repo uses a flat root for some pipelines under tests/fixtures/.
Runnable examples live under examples/. This repo's manifest is stageflow.yaml: the examples plus a workshop scan root (tests/fixtures excluded from browse). Workshop Author writes saved packages under workshop/<pipeline-id>/ and adds that root when it is missing. See Workshop Author.
Filename patterns
| Kind | Pattern | Example |
|---|---|---|
| Pipeline | *.pipeline.yaml |
hello.pipeline.yaml |
| Task | *.task.yaml |
my-task.task.yaml |
| Trigger | *.trigger.yaml |
manual-hello-world.trigger.yaml |
| Eval | *.eval.yaml |
review-findings.eval.yaml |
| Dataset | *.dataset.jsonl, *.dataset.json, *.dataset.csv |
review-diffs.dataset.jsonl |
| Stage (external) | any *.yaml beside pipeline or under shared pool |
research.yaml, ../stages/clarify.yaml |
CLI --pipeline and --task require filesystem paths — there is no bare-id fallback.
Pipelines (*.pipeline.yaml)
Required top-level fields:
| Field | Type | Description |
|---|---|---|
id |
string | Pipeline identifier (should match filename stem) |
stages |
array | Non-empty list of object stage entries |
Optional top-level fields:
| Field | Type | Description |
|---|---|---|
model |
string | Pipeline default LLM/provider id for stages that omit their own model (see Model defaults and precedence) |
agent |
string | Pipeline default execution backend (pi or Claude-family). Backend selection is separate from model; see Architecture |
schemas |
object | Named JSON Schema map for $ref: "#/schemas/NAME" on stage io (see Pipeline schemas) |
browser |
object | Browser session settings (see Stage browser); adds the agent-browser requirement |
requires |
array | Toolchain declarations { tool, version? } checked before start (see Toolchain requires) |
channels |
array | Default communication channels every stage inherits unless it overrides them (see Stage channels) |
memories |
array | Ids of memories every stage can use. A stage does not list memories of its own |
Bare string stage refs are rejected.
Stage entries
Each stage is an object with one of:
| Form | Fields | Use when |
|---|---|---|
| External | uses: path/to/stage.yaml |
Stage body lives in another YAML file |
| Inline | system_prompt, optional model, … |
Single-file pipeline |
id may be omitted when it is inferable from the uses: basename (*.yaml or *.stage.yaml).
Wiring (any entry, including uses:): route, entry, uses, on_verify_fail, replay_safe. A Clone Chain emitter also takes clone_cap (integer ≥ 1) and clone_mode (parallel | sequential) — see Clone Chain. mcp, browser, secrets, and requires may sit on a uses: wrapper or on the body — see Stage MCP, Stage browser, Stage secrets, and Toolchain requires. skill is body-only — see Skill binding. needs, fork, feedback_loop, route_select, allow_none, clonable, and clone_actions are rejected. clone_cap / clone_mode on a stage that is not a Clone Chain emitter also fail load.
Body (inline entry or external stage file): system_prompt (required), model (optional when a pipeline or manifest default supplies it), io (required — both io.input.schema and io.output.schema), verify, gate_kinds, skill, mcp, secrets, requires, timeout_ms. Effective model is materialized at pipeline load — see Model defaults and precedence. io.output.schema is the producer contract for success payload; io.input.schema is what the stage requires to start. Omitting io, a side, or schema fails load (stage.invalid_io). JSON Schema subset: Envelopes — io schemas. io.output.schema implies emit-time payload validation on success. verify is one list of checks with when: [emit], [after], or both — see Verify. Optional timeout_ms is a positive integer wall-clock budget for the stage attempt in milliseconds (default 3600000 / 60 minutes when omitted). When the budget elapses the attempt fails with stage timed out after …ms and the session is kept so the operator can resume the same attempt (sf runs resume / Resume session) instead of retrying from scratch. clonable and clone_actions are not accepted. clone_cap and clone_mode belong on the pipeline entry of a Clone Chain emitter, not on the reusable stage body — see Clone Chain and Rejected clone fields.
gate_kinds controls whether ask_operator is registered on the sealed stage session: omit or gate_kinds: [] → tool off; a non-empty list → tool on with that kinds allowlist. See HITL — Gate kinds.
uses: plus any body key except mcp, secrets, and requires is rejected (pipeline.stage_uses_inline_conflict). mcp, secrets, and requires may sit on the uses: wrapper. skill: on a uses: wrapper is rejected (pipeline.stage_wrapper_skill_rejected) — put it on the stage file or inline body.
on_verify_fail is pipeline-stage wiring. It may sit beside uses: because a reusable stage can recover differently in different pipelines. verify belongs on the body (the uses: target or the inline entry), not on the wrapper.
Upgrading older catalogs {#upgrading-older-catalogs}
Write Author YAML only: io / verify / on_verify_fail for contracts, and route / entry / { type: loop } for wiring.
This release still loads catalogs that use the previous contract keys (second column). Convert them with sf migrate-yaml (dry-run default; --write to apply). Mixed old and new contract keys in one file fail load — do not author both spellings. Wiring (needs / fork / feedback_loop) is a separate hard cutover — see Wiring.
Runtime IR names in TypeScript/JSON after load (payload_schema, pre_emit_checks, completion, recovery) are not what you write in catalog YAML.
| Write this instead | Legacy author keys (still loaded) |
|---|---|
io.output.schema |
payload_schema: (top-level field) |
io.input.schema |
clone_input_schema |
verify items with when including emit |
pre_emit_checks |
verify items with when including after |
completion |
on_verify_fail |
recovery |
type: artifact + basename + when: [emit] |
type: artifact_declared under pre_emit_checks |
payload_schema means two different things:
- Legacy author field
payload_schema:→ writeio.output.schemainstead. - Check type
type: payload_schemaunderverifyis current and correct: an optional after-phase re-check of the captured payload againstio.output.schema. Presence ofio.output.schemaalready validates on emit; the check type is only if you want that re-check after the attempt.
Before → after (legacy pre_emit_checks / completion merge into one verify: list):
# Legacy (still loaded)
pre_emit_checks:
- id: plan-declared
type: artifact_declared
basename: plan.md
completion:
mode: all
checks:
- id: tests
type: command
run: npm test
# Write this instead
verify:
- id: plan-declared
type: artifact
basename: plan.md
when: [emit]
- id: tests
type: command
run: npm test
# omitted when → [after] for command
After you migrate, you can optionally set STAGEFLOW_LEGACY_YAML=0 to reject leftover legacy authoring keys (sf migrate-yaml still reads them). You do not need that env var to author io / verify / on_verify_fail.
Wiring: needs / fork / feedback_loop → route {#upgrading-wiring}
Contract dual-read is only for payload_schema / pre_emit_checks / completion / recovery / clone_input_schema. sf migrate-yaml converts those keys only. It does not rewrite wiring.
needs, fork, feedback_loop, route_select, and allow_none are a hard cutover: load fails.
"needs" is no longer supported — declare the wiring on the source stage's "route" instead"fork" is no longer supported — use "route" instead; listed route targets always run"feedback_loop" is no longer supported — use a "type: loop" entry inside "route" instead"route_select" is no longer supported — listed route targets always run"allow_none" is no longer supported — listed route targets always run
Those “always run” phrases describe the catalog DAG (every listed to: stays on the graph). Optional if can still skip a listed successor at runtime.
clonable, clone_actions, envelope clone_forks, and emit-time skip / once / fanout are a hard cutover to Clone Chain. There is no dual-read. One run per list item is a sealed emitter → clone child → Join with clone_cap / clone_mode on the emitter pipeline entry.
Cheat sheet:
needs: [A]on B → on A:route: [{ to: B }]; mark rootsentry: true- structured need
{ id: A, on: [...] }on Join → on A:route: [{ to: Join, on: [...] }] fork: { select: one|subset }plus envelopefork_choice→ list allto:; gate withif/on:— catalog YAML does not pick exclusive successors viafork_choice- top-level
feedback_loop:→ a{ type: loop, to, max_replays, on_max_replays, replay_session }entry insideroute
Join / skip (the child no longer declares parents; default edge is succeeded-only):
- Single-parent: a skipped parent skip-cascades children whose
ondoes not includeskipped. Launch still requires a succeeded parent. Includingskippedorfailedinon:only opts that edge out of skip-cascade; it does not launch the child from a skipped or failed parent. - Multi-parent Join: never skip-cascaded from one parent. Wait until every parent is terminal. Runs if at least one parent succeeded (skipped siblings do not block). Stays pending if any parent failed (even if
onlistsfailed). Force-skipped if every parent skipped. - Route
ifmiss on a single-parent edge skips that successor (and cascade-skips its single-parent dependents). A requirediffield missing from the payload fails the run (missing_field); it is not treated as a miss.
Validate:
catalog.legacy_yaml— contract dual-read warning;--strictdoes not promote- wiring keys above — hard errors
- illegal Clone Chain shape or policy — hard error (load /
sf validate) pipeline.route_if_invalid— errorpipeline.route_all_gated— warning,ok: true;--strictdoes not promotepipeline.model_applies— warning
See Route wiring, Generic fan-in, Clone Chain, Feedback loops, examples/route-wiring-smoke-test/, examples/route-if-tour/.
For contributors
Target dialect compiles in src/config/yamlDialect.ts. Loading legacy author keys lives in src/config/legacyYaml.ts. Dropping that adapter later means deleting it plus the migrator — not renaming runtime IR.
Model defaults and precedence
model is an LLM/provider id string. It is distinct from agent, which selects the execution backend (Pi vs Claude SDK). The two hierarchies share the same tier shape but use separate keys — this model hierarchy is separate from agent.
Effective model for each stage is materialized at pipeline load (not at stage runtime):
stage.model ?? pipeline.model ?? stageflow.yaml model
| Tier | Source | Wins when |
|---|---|---|
| Stage | Inline body or external stage YAML | Stage sets model |
| Pipeline | Top-level model on the root *.pipeline.yaml |
Stage omits model |
| Global | Top-level model on stageflow.yaml |
Stage and pipeline omit model |
Pipeline model is read only from the root pipeline file (the path passed to load/validate). Top-level model on included fragments is ignored — same pattern as pipeline agent. When that root model fills one or more stages that omit model, sf validate emits warning pipeline.model_applies (not an error; --strict does not promote it).
Global tier: absence of stageflow.yaml means no global default. A present but invalid stageflow.yaml (bad shape, empty model, etc.) fails pipeline load with catalog/manifest errors — it is not treated as “no global.”
If the chain still leaves model unset, pipeline load fails with a clear error. There is no silent hardcoded model string (unlike backend selection, which falls back to "pi").
Canonical fixtures:
| Case | Path |
|---|---|
| Manifest-only fill | tests/fixtures/model-hierarchy/global-default/ |
| Pipeline-only fill | tests/fixtures/model-hierarchy/pipeline-default/ |
| Stage overrides pipeline and global | tests/fixtures/model-hierarchy/stage-override/ |
| All three tiers empty (must fail) | tests/fixtures/model-hierarchy/missing-all/ |
Verify {#verify}
verify is a body list of checks. Written when is [emit], [after], or both. Omitted when is allowed except on type: artifact, which must set when explicitly.
Default when when is omitted |
Types |
|---|---|
[emit] |
gate |
[after] |
command, checkout_changes, checklist, payload_schema (verify check type — not the legacy payload_schema: field; see Upgrading older catalogs) |
Emit-phase checks run in-session during emit_stage_envelope (soft reject: isError, no terminate). After-phase checks are Verified Stage Execution — hard proof after a candidate envelope is captured. See Envelopes — emit-phase verify and Verified Stage Execution.
type: command checks run as bash -c <run> with the stage's curated environment (not /bin/sh). Install bash on PATH. Pipefail is not added implicitly — authors may set -eo pipefail themselves.
verify:
- id: plan-accepted
type: gate
kind: artifact_backed
- id: plan-declared
type: artifact
basename: plan.md
when: [emit]
- id: plan-on-disk
type: artifact
path: plan.md
nonempty: true
when: [after]
- id: tests
type: command
run: npm test
Check discriminator is type: (not kind:). Gate widgets still use kind: on type: gate.
| Check type | Required fields | Optional fields | Legal when |
|---|---|---|---|
gate |
id, kind |
— | emit (default), after, or both |
artifact |
id, when |
basename (emit), path / nonempty (after) |
emit, after, or both — required |
command |
id, run |
cwd, timeout_ms |
after only |
checklist |
id, items |
— | after only |
browser_login |
id |
— | after only; needs browser.check on the stage and headed not false; see Human login stage |
payload_schema |
id |
— | after only; requires io.output.schema (optional re-check; emit already validates when that schema is present) |
checkout_changes |
id |
path_fields |
after only |
Check IDs are unique within the stage. artifact.path is relative to the stage attempt's artifact directory. Emit type: artifact is a basename list check on envelope.artifacts (no disk I/O). After type: artifact is an on-disk file under the attempt artifacts dir. gate.kind must also appear in the stage's gate_kinds (and that list must be non-empty so ask_operator is registered — omit / [] means no HITL tool). Each checkout_changes.path_fields entry must name a required array-of-strings field in io.output.schema. Optional type: payload_schema with when: [after] re-checks the captured payload against io.output.schema (see Upgrading older catalogs for the legacy field vs this check type); emit-time validation already runs when that schema is present. type: checklist is scored against the success envelope's checklist_attestations field (one { check_id, items } entry per checklist, matched as a set against the declared items) — see Envelopes — Checklist attestations.
on_verify_fail {#on-verify-fail}
on_verify_fail is wiring and applies only after an after-phase verify failure. It requires at least one after-phase verify item on the resolved stage.
| Mode | Required fields | Behavior |
|---|---|---|
repair |
max_attempts, retry_safety: idempotent, include_failed_checks |
Stageflow starts fresh attempts until the limit, carrying failed-check evidence when configured. |
manual |
retry_safety |
An operator explicitly starts a new attempt with optional guidance or stops recovery for that run. |
Use manual for side-effecting work such as publishing or payments. Operator commands keep their names: sf runs recover / sf runs verify, MCP recover_manual_stage, HTTP /api/runs/:id/stages/:id/recovery. See Verified Stage Execution.
Pipeline schemas {#pipeline-schemas}
Optional pipeline-file schemas: is the $ref root for this release. Refs are JSON Pointer #/schemas/NAME. Cycles fail load. schemas: on an include fragment fails load. Isolated stage validate of a $ref-only schema fails with stage.unresolved_schema_ref; pipeline validate resolves after attach.
Sequential and fan-in edges: consumer 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. Compatible vs incompatible handoffs: 11-sequential-io-handoff.pipeline.yaml, rejected/17-reject-io-incompatible.pipeline.yaml. Richer $ref/nested/array subset example: 12-complex-io-schemas.pipeline.yaml. All-$ref input and output: 13-ref-io-handoff.pipeline.yaml; incompatible $ref pair: rejected/21-reject-ref-io.pipeline.yaml.
id: story-handoff
model: anthropic/claude-sonnet-4-5
schemas:
story-slice:
type: object
required: [title]
properties:
title:
type: string
stages:
- id: draft
system_prompt: Draft the slice and emit a success envelope.
entry: true
route:
- to: review
io:
input:
schema:
type: object
output:
schema:
$ref: "#/schemas/story-slice"
- id: review
system_prompt: Review the slice.
io:
input:
schema:
$ref: "#/schemas/story-slice"
output:
schema:
type: object
Runnable demo with uses: stages: examples/feature-loop/ (schemas.story-assignment → plan io.input.schema).
uses: paths are relative to the pipeline file's directory.
Linear chain with external stages:
id: linear-explicit
stages:
- id: clarify
uses: ../stages/clarify.yaml
entry: true
route:
- to: design-doc
- id: design-doc
uses: ../stages/design-doc.yaml
route:
- to: implementation-plan
- id: implementation-plan
uses: ../stages/implementation-plan.yaml
See tests/fixtures/pipelines/linear-explicit.pipeline.yaml.
Inline single stage (explicit stage model):
id: hello
stages:
- id: research
system_prompt: Summarize the task goal.
model: anthropic/claude-sonnet-4-5
Pipeline-level model with omitted stage model (filled from the pipeline default):
id: hello
model: anthropic/claude-sonnet-4-5
stages:
- id: research
system_prompt: Summarize the task goal.
See also tests/fixtures/model-hierarchy/pipeline-default/.
Parallel fan-out: multiple forward to: entries on the source. Listed to: stay on the DAG. Default on: is succeeded-only. After the source succeeds, a single-parent successor can run (then if, if present, is evaluated). Including failed or skipped in on: opts that edge out of skip-cascade; it does not launch a child from a skipped or failed parent. Optional if can skip a listed successor after success. The completing agent does not pick which successors run.
stages:
- id: clarify
uses: ../stages/clarify.yaml
entry: true
route:
- to: design-doc
- to: implementation-plan
- id: design-doc
uses: ../stages/design-doc.yaml
- id: implementation-plan
uses: ../stages/implementation-plan.yaml
See tests/fixtures/pipelines/parallel-after-clarify.pipeline.yaml.
route is a list of entries. Forward entries name to:, optional on: (succeeded | failed | skipped; default succeeded-only skip-cascade policy), and optional if. Multiple to: entries fan out; if is a runtime gate on that edge, not a missing DAG edge. Load and pipeline create invert preserve if on the matching outbound Route entry. HTTP create needs remains ungated (id/on or a parent id string). Keyed generic fan-in is one child targeted by two or more parents — see Generic fan-in.
Generic fan-in {#generic-fan-in}
A stage may wait for two or more catalog parents. Each parent lists a forward to: to the join. Optional on: on a single-parent edge is skip-cascade policy (default succeeded-only). On a Join inbound edge, on does not decide Join readiness. The Join waits until every parent is terminal; it stays pending if any parent failed (even if on lists failed); it force-skips if every parent skipped; otherwise it follows the succeeded / if rules below. Skipped siblings do not block a Join that has a succeeded parent. Optional if on that same entry is evaluated against that parent's output payload after success.
id: diamond-fan-in
stages:
- id: clarify
uses: ../stages/clarify.yaml
entry: true
route:
- to: research
- to: validation
- id: research
uses: ../stages/research.yaml
route:
- to: synthesize
- id: validation
uses: ../stages/validation.yaml
route:
- to: synthesize
- id: synthesize
uses: ../stages/synthesize.yaml
Listing failed / skipped on a Join inbound on: (skip-cascade policy, not a launch after failure):
- id: research
uses: ../stages/research.yaml
route:
- to: synthesize
on: [succeeded, failed, skipped]
Listing failed or skipped on a Join inbound edge does not run the Join after failure. Skipped siblings still do not block when another parent succeeded.
The Join starts only after every declared parent is terminal. A false inbound if does not skip the child while another parent is still running.
After every parent succeeded, the child runs only if every inbound edge fired (if true — including nested all / any / not composition — or no if). It then opens with every parent's success envelope (complete set, no hole). If any inbound if missed, the child is skipped — not pending forever, not failed, not opened with a partial envelope set. Sequential io subset checks still apply when the Join child runs; a skipped Join child is not opened.
A failed parent still blocks the Join. if does not redefine failure joins.
Pipelines whose Routes have no forward if keep today's Join: it runs if at least one parent succeeded. Skipped parents do not block, and their envelopes are omitted from join input. The Join stays pending if a parent failed. It is skipped if every parent skipped.
The all-inbound-fired check applies only when every parent succeeded. If some parents skipped and at least one succeeded, the Join can still run even if a succeeded parent's inbound if missed; skipped parents' envelopes stay omitted from join input.
A false if on a single-parent edge skips that successor and skip-cascades its single-parent dependents. A Join with two or more parents is never skip-cascaded from one parent.
Join input is priorEnvelopesByStage, keyed in YAML declaration order. priorEnvelope is null. See Envelopes. Walkthrough: examples/generic-fan-in/.
A Clone Chain Join's parents are that chain's Clone Instances. Every instance must succeed; a failed or skipped instance is a failed Join parent and blocks the Join. That Join does not run on a partial set.
Fixtures:
diamond-fan-in.pipeline.yaml— static diamonddiamond-fan-in-accepted.pipeline.yaml— inboundonlistsfailedandskipped(skip-cascade policy; does not run the Join after a failed parent)route-if-join.pipeline.yaml— Join gated by inboundifs
Runtime coverage: tests/runtime.genericFanIn.schedule.test.ts, tests/runtime.genericFanIn.retry.test.ts, tests/runtime.envelopeRouting.test.ts, tests/runstore.trackProjection.test.ts.
Pipeline fragments (include:)
At the pipeline top level (not inside a stage entry), merge stage lists from fragment files:
id: include-merge
include:
- local: ./fragments/gates.yaml
stages:
- id: finish
uses: ./finish.yaml
Fragment files contain a stages: array (same entry shapes as the parent pipeline). Paths in local: are relative to the pipeline file's directory. Top-level model / agent on a fragment are ignored; only the root pipeline file supplies those defaults (see Model defaults and precedence).
Nested include: is allowed. Cycles and duplicate stage ids across files are rejected. Includes merge before the declaring file's own stages.
Fixture: tests/fixtures/pipeline-owned/include-merge/main.pipeline.yaml.
Route wiring {#route}
Wiring is declared on the source stage as route. Each forward entry names a to: target. Optional on: is skip-cascade / acceptance policy (default succeeded-only), not a launch schedule for failed or skipped parents. Optional if is a payload predicate evaluated after the source succeeds, against that stage's output payload only. A pipeline that uses route must mark at least one entry: true root.
Listed forward to: targets still appear on the DAG. A miss skips that successor at runtime; it does not remove the edge. After success, an entry with no if is eligible to run (single-parent) or participates in Join readiness as above. on does not launch from failed or skipped. Two matching ifs on different targets both fire (not first-match-wins). Duplicate to: in one Route stays illegal. The completing agent does not pick which successors run, and catalog YAML does not produce a fork on the resolved DAG. HITL (ask_operator) inside a stage is not a parallel router: if still runs only after that stage succeeds and emits a payload.
if is a predicate, not a string: a leaf { field, op, value } or a composition node { all: [...] } | { any: [...] } | { not: {...} } (including nesting such as not around all, or any of alls). A node must be exactly one of those shapes; mixing leaf keys with all / any / not, or any unknown key, is pipeline.route_if_invalid. Empty all / any / in / not_in lists are invalid. There is no exists operator and no else/fallback arm.
Operators: eq, ne, gt, gte, lt, lte, in, not_in. eq / ne are only for string | number | integer | boolean schema fields. gt / gte / lt / lte are only for number | integer. Comparison is type-strict (1 is not "1"). in / not_in mean the scalar field is a member of a non-empty literal list whose items share the field's scalar type — not array-contains. value is required for every operator.
field is a dot-separated path into the source output payload. Validate walks the source io.output.schema after $ref / pipeline schemas: expansion. Every segment must be a property of an object schema and listed in that object's required. Array schemas and index segments (items.0) are pipeline.route_if_invalid. Optional path segments (not in required) are also invalid, including after $ref inlining.
Illegal if is error pipeline.route_if_invalid (not pipeline.dag_error). When every forward to: on a stage has if, validate warns pipeline.route_all_gated with ok: true; --strict does not promote that warning. Sequential io subset checks still apply on a gated edge that fires; a skipped child is not opened.
if is legal only on a forward Route Entry after success. It is pipeline.route_if_invalid on a { type: loop } entry, or when combined with on other than succeeded-only. Entries without if keep on: as skip-cascade policy (including on: [failed]). Backward or circular forward to: without type: loop remains pipeline.dag_error.
fork, route_select, and allow_none are rejected:
"fork" is no longer supported — use "route" instead; listed route targets always run"route_select" is no longer supported — listed route targets always run"allow_none" is no longer supported — listed route targets always run
Those messages mean listed to: stay on the DAG (no agent exclusive pick); if can still skip a listed successor.
on: [failed] on hotfix keeps hotfix from being skip-cascaded when run-tests fails; it does not launch hotfix. Launch still requires a succeeded parent. Prefer if on a succeeded payload for deterministic branching.
id: release-gate
stages:
- id: run-tests
uses: ./run-tests.yaml
entry: true
route:
- to: ship
on: [succeeded]
- to: hotfix
on: [failed]
- id: hotfix
uses: ./hotfix.yaml
- id: ship
uses: ./ship.yaml
id: fan-out
stages:
- id: decide
uses: ./decide.yaml
entry: true
route:
- to: branch-a
- to: branch-b
- id: branch-a
uses: ./branch-a.yaml
- id: branch-b
uses: ./branch-b.yaml
id: gated-page
stages:
- id: triage
uses: ./triage.yaml
entry: true
route:
- to: page
if:
all:
- field: severity
op: eq
value: high
- field: customer.tier
op: in
value: [gold, silver]
- to: notify
- id: page
uses: ./page.yaml
- id: notify
uses: ./notify.yaml
Fixtures:
fork-one-of-two.pipeline.yaml— two listed targets, both runfork-route-cascade.pipeline.yaml— fan-out plus a downstream child of one armfork-route-subset.pipeline.yaml— three listed targets, all runparallel-after-clarify.pipeline.yaml— same fan-out shaperoute-if-eq.pipeline.yaml— gatedeqplus always-run siblingroute-if-composition.pipeline.yaml—notaroundall, nested required paths,inroute-if-two-match.pipeline.yaml— two matchingifs both run
Walkthrough: examples/route-wiring-smoke-test/ (14-if-eq-gating.pipeline.yaml, 16-if-composition.pipeline.yaml).
Clone Chain {#clone-chain}
A Clone Chain is a sealed inbound path of three roles: emitter → clone child → Join. Detection is from that shape plus a named $ref, not from a clonable flag. Use it when one stage produces a list and the next stage should run once per element (research items, ops tickets, story slices, or any other list).
- The emitter's
io.output.schemais an object with exactly one array property whoseitemsare{ $ref: '#/schemas/<id>' }(a Clone Array). Sibling fields next to that array are allowed. - The clone child's entire
io.input.schemais{ $ref: '#/schemas/<id>' }with the same id. - The emitter's only forward
routeis the clone child; the clone child's only forwardrouteis the Join. Neither inbound edge usesif. No other stage routes to the Join. - The emitter's pipeline entry requires
clone_cap(integer ≥ 1) andclone_mode(parallel|sequential). Those keys are invalid on any other stage and invalid on a stage file body. - On emitter success, N is the Clone Array length. Stageflow mints Clone Instances
{child}~{n}(1-based, including~1when N is 1). Each instance receives that array element only. The Join waits on those instances and receives their success envelopes in array order.
Empty and over-cap. Load compiles the Clone Array as minItems: 1 / maxItems: <clone_cap>. An empty array or a length above the Clone Cap fails the emitter emit — Stageflow does not skip the chain or truncate the list. Gate a zero-item path before the emitter (ordinary if / routing on an earlier stage), not by emitting an empty array.
Clone Mode is catalog policy on the emitter entry:
parallel— Clone Instances may overlap. A failure does not stop siblings that are already running; they may finish after the failure. The Join stays blocked.sequential— Clone Instances run in Clone Array order. A failure skips instances that have not started. A successful retry of the failed instance then starts the next skipped instance.
In both modes a failed or skipped Clone Instance is a failed Join parent. The Join does not run on a hole.
Loop. The Join may Loop only to a stage before the emitter. The emitter, clone child, and Join are not Loop targets. The clone child cannot declare a Loop and cannot send_back.
Illegal at load (sf validate / pipeline load). A pipeline that looks like a Clone Chain but violates sealed shape fails immediately:
- Extra routes on the emitter or clone child
ifon either chain inbound edge- An extra parent routing to the Join
- Two clone children from one emitter
- Nested Clone Array (a Clone Instance emitting another Clone Array)
- Two named-
$refarray fields on the emitter - Inline (anonymous) array items
- A root-level array payload
clone_cap/clone_modeon a stage that is not the emitter- Shared emitter, clone child, or Join across two Clone Chains
A pipeline may have many Clone Chains if they share no emitter, clone child, or Join. A Join may later be the emitter of a following Clone Chain.
Canonical sample: tests/fixtures/pipelines/clone-chain-smallest.pipeline.yaml. Walkthrough: examples/feature-loop/.
id: clone-chain-smallest
model: anthropic/claude-sonnet-4-5
schemas:
Issue:
type: object
required: [id, title]
properties:
id:
type: string
title:
type: string
stages:
- id: emit-items
entry: true
clone_cap: 4
clone_mode: parallel
route:
- to: handle-item
system_prompt: Emit a list of issues.
io:
input:
schema:
type: object
output:
schema:
type: object
required: [items]
properties:
items:
type: array
items:
$ref: "#/schemas/Issue"
summary:
type: string
- id: handle-item
route:
- to: gather
system_prompt: Handle one issue.
io:
input:
schema:
$ref: "#/schemas/Issue"
output:
schema:
type: object
- id: gather
system_prompt: Gather clone results.
io:
input:
schema:
type: object
output:
schema:
type: object
Rejected clone fields {#rejected-clone-fields}
Older #clonable-successors links should use this heading.
clonable, clone_actions, and envelope clone_forks are not accepted. clone_cap / clone_mode on a stage that is not a Clone Chain emitter fail load. Those fields fail with a message naming the field and pointing at a Clone Chain. There is no dual-read of skip / once / fanout.
examples/archify-on-pr gates author-diagrams with Route if on author_diagrams. It remains a single author-diagrams session, not a Clone Chain.
Feedback loops {#feedback-loops}
A stage may declare a source-owned loop as a type: loop entry inside route so that, on success, it can either advance downstream (continue) or send work back to an earlier ancestor (send_back). Loop entries do not add reverse DAG edges — the catalog DAG stays forward-only; replay is a runtime schedule over the existing route.
id: feedback-loop
stages:
- id: plan
uses: ./plan.yaml
entry: true
route:
- to: implement
- id: implement
uses: ./implement.yaml
route:
- to: review
- id: review
uses: ./review.yaml
route:
- to: submit
- type: loop
to: implement
max_replays: 2
on_max_replays: require_continue
replay_session: resume
- id: submit
uses: ./submit.yaml
replay_safe: false
| Field | Required | Description |
|---|---|---|
type |
yes | Must be loop. |
to |
yes | Stage id of an earlier ancestor of the source (via forward route). |
max_replays |
yes | Positive integer — how many accepted send_back replays the source may take before the max-replays policy applies. |
on_max_replays |
yes | require_continue — a further send_back is rejected (emit fails with exceeded max_replays); the source must emit continue instead. wait_for_human — park for an operator decision (extend / continue / abandon). |
replay_session |
yes | resume — reopen the prior agent session (feedback_resume). new_session — start a fresh session for the replayed stage. |
Optional on any stage entry: replay_safe (boolean). Omitted means safe — the stage may appear on a feedback replay route. Set replay_safe: false on stages that must not be replayed (for example a one-shot submit). Catalog validation rejects a loop whose target→source route includes any replay_safe: false stage.
Validation rules
- The policy lives on the source stage (the one that emits
feedback_loopin its envelope). The target must already be declared and must be an ancestor — not the source itself, not a sibling, not a descendant. - A stage may declare at most one
type: looproute entry. ifis not allowed on a{ type: loop }entry (pipeline.route_if_invalid).- The top-level
feedback_loopfield is rejected — use atype: loopentry insideroute.
Runtime behavior (session, forks, artifacts)
- On
send_back, Stageflow replays the inclusive route from the target through the source (forward order). Downstream of the source stays held until the loop continues or is abandoned. - Replayed stages receive a Feedback Loop Context block in the agent prompt (
loop_id,replay_id, source envelope, remaining replays, route ids, optional prior attempt / active fork generation). See Envelopes — Feedback loops. replay_session: resumemaps to session modefeedback_resume(resume token from the prior attempt).new_sessionmaps tonew_session.- Artifacts stay attempt-scoped. Downstream stages still consume the latest accepted envelopes on the active route; the send-back feedback itself is the source envelope carried in Feedback Loop Context.
Operator / host-down decisions when on_max_replays: wait_for_human: CLI sf runs feedback-decide, MCP decide_feedback_loop, or POST /api/runs/:runId/stages/:stageId/feedback-decision. While parked, the source stage pass is persisted as waiting; extend/continue then mark it succeeded, and abandon marks it failed.
Fixtures: feedback-loop.pipeline.yaml, feedback-loop-wait-human.pipeline.yaml.
Walkthroughs: examples/feedback-loop/, examples/mcp-hitl-tour/.
Skill binding {#skill-binding}
Bind a Pi skill on the stage body — the external stage file or an inline stage mapping. Prefer the stage file for uses: stages. skill: on a uses: wrapper is rejected.
# author-diagrams.yaml
id: author-diagrams
skill: archify
system_prompt: |
…
# pipeline
stages:
- id: author-diagrams
uses: ./author-diagrams.yaml
| Behavior | Detail |
|---|---|
| Resolution | Looks up the skill name in order (first match wins) — see precedence table below |
| Startup | Stage fails before the agent session if the skill is not installed |
| Agent prompt | Skill instructions are injected for the stage attempt |
Skill precedence
| Order | Origin | Location |
|---|---|---|
| 1 | run |
$STAGEFLOW_HOME/runs/<runId>/skills/<name>/ — materialised from start_run.skills (MCP/REST); never written into the checkout/worktree |
| 2 | checkout |
<checkoutRoot>/.pi/skills/<name>/ — gated by trust_workspace_config for repository bindings |
| 3 | host |
Pi agent skills dir (agentDir/skills) / operator image skills |
Same name: run wins. Checkout-sourced skills still require trust for repository bindings; run-scoped skills bypass that gate. MCP list_skills (optional runId) reports name, description, and origin.
Ship skills with the run (harness):
{
"skills": {
"archify": {
"SKILL.md": "---\nname: archify\ndescription: …\n---\n"
}
}
}
Install durable host skills before sf run in CI (operator/image path):
sf skills install --from-zip https://example.com/skill.zip --skill-name archify
Walkthrough: examples/archify-on-pr/ — GHA provisions Archify, agents author JSON specs only; shell steps run deliver outside the agent.
Stage MCP {#stage-mcp}
Pass MCP servers (from the project .mcp.json and servers saved in the console, see MCP servers) to a stage on the pipeline stage entry (alongside uses: or inline body). The loader also accepts mcp: in external stage files; a pipeline-entry mcp overrides the file value on merge, including mcp: [] to clear a file list. Prefer the pipeline entry. Omit the field or set mcp: [] for no author-declared MCP.
Interpolation resolves against the stage's curated environment (not the Host ambient env), plus any console secret the stage lists under secrets: (a console secret fills MCP servers only and is never put in the stage environment; see MCP servers). Default MCP connect timeout is 30s. Missing catalog command binaries on PATH fail validate with a distinct error. See migration-stage-environment.md.
stages:
- id: use-echo
uses: ./use-echo.yaml
mcp: [echo]
Project .mcp.json lives at the same root as stageflow.yaml:
{
"mcpServers": {
"echo": {
"command": "node",
"args": ["examples/stage-mcp/echo-mcp.mjs"],
"env": {
"ECHO_TOKEN": "${ECHO_TOKEN:-local}"
}
}
}
}
| Behavior | Detail |
|---|---|
| Catalog | .mcp.json { "mcpServers": { "NAME": { … } } } at the project root that holds stageflow.yaml, merged with servers saved from the console (Settings → Project MCP; a console server wins on a name clash). Repository-bound stages read the repository's own .mcp.json as workspace content and never see console servers |
| Interpolation | ${VAR} and ${VAR:-default} in command, args, env values, url, headers values, and cwd. Resolves against the curated stage env only. Stage attach sets STAGEFLOW_STAGE_ARTIFACTS_DIR to the attempt artifacts directory and substitutes ${STAGEFLOW_STAGE_ARTIFACTS_DIR} in the stage system_prompt (Settings Check does not). |
| Spawn root | stdio servers stamp cwd to the catalog project root. Relative command/args paths resolve against that root. A catalog cwd must already be an absolute path inside the project root. |
| Validate | sf validate checks names, shape, reserved-name collision, and that each catalog command is on PATH. It does not require env vars to be set or a live connect. |
| Run | A required var that is still unset, and a passed server that will not connect, fail the stage at run time before the agent is treated as having those tools. Connect default is 30s. |
| Tools | On Pi, a stage gets the named tools of its listed servers (for example exa_web_fetch_exa). The adapter's mcpScript JavaScript tool and its mcp gateway tool are not exposed. On a first attempt Stageflow connects the listed servers once before the session starts so their tools are known when the stage tool list is fixed. |
| Large results | On Pi, every stage tool result except read, bash, and the Stageflow tools is measured against the remaining context window (with a reserve for the rest of the stage, and at most 30% of the window per result). A result that does not fit is written to stages/<stageId>/attempts/<n>/artifacts/tool-output/<toolCallId>-<tool>.json (pretty-printed when the body is JSON) or .txt, and the model sees the path, the size, and a short preview. A result the MCP adapter already cut at its 8 MiB backstop is always spilled, with the full body taken from the adapter's temp file (which is then removed); if the adapter could not save that file, the notice says the rest is not available. The file is an ordinary attempt artifact: list it in the envelope artifacts and let the model read or grep it. Stage prompts should treat that file as the source and should not ask the model to copy tool output into write_stage_artifact. |
| Reserved | The server name stageflow is reserved. Stageflow stage tools (emit_stage_envelope, write_stage_artifact, and ask_operator when the stage allows it) stay available without being listed in mcp. |
| Settings inspect | Operator console Settings lists project .mcp.json and console server names and Check connects without a run. Inspect is not attach. |
Validate-time failures (author language):
- Neither a project
.mcp.jsonnor any console server exists when a stage lists one or moremcpnames - A listed name is in neither
mcpServersnor the console servers - The catalog or a stage list uses the reserved name
stageflow - The catalog is not valid JSON, is not an object, is missing
mcpServers, or a server entry is not an object
A stage may now list a console secret under secrets: (stage.unknown_secret only fires when the name is neither a console secret nor a registered Host secret). If .mcp.json is present, validate checks its shape and reserved names even when no stage lists mcp. Connect failure and unset required vars fail at run, not sf validate.
GitHub, Notion, or a company MCP use the same authoring shape when those servers exist in .mcp.json or the console.
Pi and Claude both receive the servers named on the stage; transports and protocol features may differ. Walkthroughs: examples/stage-mcp/, examples/playwright-mcp/ (open a page and save a PNG screenshot; pass ${STAGEFLOW_STAGE_ARTIFACTS_DIR}/page.png because Playwright named filename values resolve against the project checkout, not --output-dir), examples/context7-mcp/ (resolve a library, fetch docs, write a brief).
MCP elicitation is unsupported — a passed server cannot ask the operator a question through Stageflow.
Settings can list the project .mcp.json and console servers and Check whether a server can connect without starting a run. That inspect is not attach: YAML mcp: still allowlists what a stage receives.
Operator-host MCP (sf ui / sf mcp) is a different surface — see MCP. Adding servers and secrets from the console, OAuth and the routes: MCP servers.
Memories {#memories}
A memory is a named folder the host keeps ($STAGEFLOW_HOME/memories/<id>/). Create it in the console first (Memories). A pipeline stores the memory id, not the folder and not a card key.
memory.yaml stays version 1: id, version, and description. body.md is the cover page. Cards live beside that page. Replacing the cover does not delete cards. The stage never receives a filesystem path.
memories:
- candidate-pool
An id is [a-z][a-z0-9_-]{0,63}. An empty list is the same as omitting the key. A duplicate id is pipeline.invalid_memories. A missing, unreadable, or mismatched memory is pipeline.memory_unavailable: sf validate and run start both fail, and the run does not start.
Every stage in the pipeline receives that list. memories on a stage entry is rejected (pipeline.invalid_shape). There is no per-stage override.
On the Pi backend each attached stage gets read_memory and replace_memory for the cover, and add_memory_card, update_memory_card, read_memory_card, and search_memory_cards for cards. The prompt lists the memory ids and their descriptions. It does not include the cover or any card body. Pi stages with memories block shell access to the memory store; use the memory tools instead. The Claude backend does not provide the memory tools, so a pipeline that lists memories fails at stage start there, the same way a stage with channels: does.
The cover and each card body are capped at 64,000 characters. A write over the cap is refused. A memory holds at most 200 cards. Two cover replacements that overlap are last-write-wins.
A card is a keyed note for one subject: a person, a ticket, a release, or a path. The key is that subject's own id (an email, a ticket id, a version, or a path), 1–1024 characters after trim. The file name is a hash of the key; the key is stored in the card. A key may contain /, spaces, and .. because it is not a path segment. A memory id is not a card key.
add_memory_card refuses a key that already exists. update_memory_card refuses a key that is missing. read_memory_card returns the current body. search_memory_cards is a case-insensitive word scan of the current keys and bodies, at most 20 hits, each a key and a snippet. A stage cannot delete a card and cannot read the previous copy.
In the Memories console the operator can list keys, open a card, correct it, and delete it. Delete removes the current card and the previous copy and cannot be undone. That screen shows the previous card copy and the previous cover text when they exist. The operator can edit the cover. The operator does not create cards; a session adds them. There is one previous copy, not a full history, and no restore action.
Stage channels {#stage-channels}
Status: built for AgentMail, Slack, Telegram, iMessage and WhatsApp. Connections are defined in the console or in
connections.yaml; see Communication channels.channels:loads, merges and validates. A stage that declares asendchannel gets asend_messagetool on the Pi backend, delivered by the Host with the target pinned here, and the Host refuses to start the stage if the connection no longer matches. The Claude backend does not providesend_message, so a stage withchannels:fails at start there (email:still has the older gap). Run start (and the MCPpreflighttool andsf doctor) runs a live check of each declared connection and blocks the run withchannel_check_failedif one fails, naming the connection and the reason; see MCPpreflight. The pipeline editor edits stage channels in its inspector.
A stage declares which communication connections it may send on. Each entry points at a connection defined in connections.yaml and pins the target, so the agent can never choose a different recipient. This is separate from email:, which keeps working unchanged for IMAP/SMTP accounts.
channels:
- connection: ops-slack
to: C0123ABCD # one target: a Slack channel id; phone, chat id, etc. for the others
operations: [send]
channels:
- connection: support-inbox
to: [oncall@acme.com, lead@acme.com] # AgentMail only: several recipients, one message
cc: [manager@acme.com]
operations: [send]
| Field | Required | Description |
|---|---|---|
connection |
yes | Id of a connection in connections.yaml (letters, digits, ., _, -) |
to |
yes | Target. A string, or a list of strings (1 to 20). What is valid depends on the provider and is checked at validate time |
operations |
yes | Closed list, [send] today. It is a list so reply/receive can be added later without changing the shape |
alias |
no | Name for this entry; the agent passes it as send_message's channel. Only matters when several channels are enabled, because with one the agent can omit channel |
cc |
no | List of strings. Accepted by providers that support it, rejected by the rest |
template |
no | Provider-specific message template. Only WhatsApp uses it (name, languageCode, bodyParams; the text `` in a parameter is replaced with the agent's message) |
- One channel per stage (for now). A stage may use exactly one channel. A second entry is rejected wherever a stage is loaded (
sf validate, the console,start_run, run start) and again when a message is sent, including a second channel that only appears after merging a pipeline default (usechannels: []on the stage to drop the default). To use two channels, use two stages. One connection can still reach several recipients through its own target list (AgentMailtoandcc). The limit is one policy constant,MAX_CHANNELS_PER_STAGE, and the alias, merge and per-channel idempotency logic still support several, so it can be raised later. Entry names (alias, or connection id when there is no alias) must be unique within a stage. channelsis a stage body key: it goes in a stage file or an inline entry. Likeemail, it is rejected on auses:wrapper.- Pipeline defaults. A top-level
channels:on the pipeline is inherited by every stage. A stage that declares its ownchannels:merges by entry name with the stage entry winning, and keeps the other defaults.channels: []on a stage clears every inherited entry. The merged result must still fit the one-channel limit. - Live check (run start,
preflight,sf doctor): every distinct connection a pipeline's stages use is checked once, in parallel, and some providers also check the pinned targets. Only transient failures are retried (up to two retries). A failure blocks the run withchannel_check_failed. It never sends a message. - Static validation (
sf validate, and again at run start) fails a stage whenconnections.yamlcannot be loaded, the connection is not defined or is disabled, the provider does not support the operation, or the target is not valid for the provider. It never calls a provider and never reads a secret. A stage that declares no channels never readsconnections.yaml. - Invalid shapes are reported as
stage.invalid_channels(stage) orpipeline.invalid_channels(pipeline root). Connection and target problems arestage.channel_unavailable.
Stage browser {#stage-browser}
browser: gives a stage browser-session settings. Allowed on a pipeline stage entry (inline body or uses: wrapper) and in an external stage file; a pipeline-entry browser replaces the file value as a whole, like mcp.
| Key | Type | Meaning |
|---|---|---|
profile |
string | Optional profile name: 1-64 characters of letters, digits, -, _ |
headed |
boolean | Optional. Show the browser window (true) or run headless (false) |
allow_domains |
non-empty list | Optional bare domains (example.com, *.example.com); no scheme, port, or path |
login_url |
string | Optional. Page a human login stage opens; defaults to check.url |
check |
object | Optional login check: url (required), logged_in_url (glob), logged_out_url (glob or list of globs) |
Stages of one run that name the same profile share one browser, each in its own tab, and the run holds the profile until it ends. See Browser sessions.
Unknown keys fail load with stage.invalid_browser. So do path, scope, and secret: the Host chooses where profiles live and which scope owns them, never YAML. A stage with browser automatically requires the agent-browser binary; it merges with an explicit requires entry for the same tool, and sf validate reports a missing binary with the distinct toolchain error (see Toolchain requires).
stages:
- id: read-feed
uses: ./read-feed.yaml
browser:
profile: work
headed: true
allow_domains: [example.com, "*.example.com"]
check:
url: https://example.com/feed
logged_in_url: https://example.com/feed*
logged_out_url: [https://example.com/login*]
Login check stage {#browser-login-check}
When a stage has browser.check, the Host (not the model) opens check.url with the stage's profile at stage start and matches the settled final address: logged_out_url wins over logged_in_url; neither matching is unknown. Globs match the whole URL; * matches any characters (including /), ? one character. The result is stored as browser-login-check.json in the stage run dir and appended to the stage prompt. The stage must emit payload: { logged_in, url }; the Host rejects any envelope whose logged_in or url differs from its result. For unknown, the prompt tells the agent to inspect the page with the browser skill and decide; the Host then only requires a boolean logged_in and the same url.
Write the check as a thin stage (no ready-made stage file ships). Route only the check-to-login edge on logged_in; the work stage is a join of check and login, so its inbound edges carry no if, and its io.input must be a subset of both parents' output:
stages:
- id: check
entry: true
system_prompt: Report the Host login check result.
browser:
profile: work
check: { url: https://example.com/feed, logged_in_url: "https://example.com/feed*", logged_out_url: ["https://example.com/login*"] }
route:
- to: login
if: { field: logged_in, op: eq, value: false }
- to: work
io:
output:
schema: { type: object, required: [logged_in, url], properties: { logged_in: { type: boolean }, url: { type: string } } }
See tests/fixtures/pipelines/browser-login-check.pipeline.yaml for the full check, login, work topology.
Human login stage {#browser-human-login}
A stage with browser whose verify has type: browser_login is a human login stage. It needs browser.check (the same check used by the check stage) and must not set headed: false.
- id: login
system_prompt: Ask the operator to log in.
gate_kinds: [confirm]
browser:
profile: work
login_url: https://example.com/login # optional; defaults to check.url
check: { url: https://example.com/feed, logged_in_url: "https://example.com/feed*", logged_out_url: ["https://example.com/login*"] }
verify:
- id: logged-in
type: browser_login # after-phase only; no other keys
on_verify_fail: { mode: repair, max_attempts: 3, retry_safety: idempotent, include_failed_checks: true }
route:
- to: work
Before the agent starts, the Host opens login_url in a visible window (no headless fallback, and no pre-agent login check) and the stage prompt tells the agent to call ask_operator with kind: confirm and leave the browser open. The window (the run's shared browser) and the run's profile lease stay held while the stage waits. After the operator accepts and the agent emits, the Host re-runs the login check in the same session; browser_login passes only when the result is logged in. A wrong confirm fails the after-phase check, and on_verify_fail with mode: repair runs the stage again (a new attempt, a new gate, the same session) up to max_attempts. on_verify_fail is a recovery policy for the same stage, not a route target, so this self-loop is its native form. When the check stage already finds a valid login, its if route skips the login stage and the work stage still joins (see the fixture tests/fixtures/pipelines/browser-human-login.pipeline.yaml).
If the Host has no screen (Linux without DISPLAY / WAYLAND_DISPLAY) and no live view, the stage fails before the agent starts: "A visible browser is needed for login, but this Host has no screen and no live view." In Docker the message adds a hint to log in on a machine with a screen first. A Host that serves the console (sf ui, sf mcp) has a live view: the operator logs in through the browser picture in the gate instead (see Browser sessions: live view).
Every gate (ask_operator prompt) of a stage with browser carries Host-injected fields: handoff: { kind: "local_window" } (or { kind: "live_view", url } on a Host without a screen), site (host of check.url, else the first allow_domains entry, else the host of login_url), and profile (the profile name, never a path). The agent cannot set or omit them; gates of stages without browser never have them. See HITL — gate handoff.
Third-party sites can forbid automation in their terms; check the rules of any site before pointing a stage at it.
Toolchain requires {#toolchain-requires}
requires: declares binaries the Host must provide before a run starts. Allowed on the pipeline root and on a stage body (or uses: wrapper). Shape: a list of { tool: string, version?: string }. tool is a PATH binary name; version is an npm-style semver range (omit to mean “must exist”). Unknown sub-keys fail load (pipeline.invalid_requires / stage.invalid_requires).
When pipeline and stage name the same tool, ranges are intersected (stricter) at load; non-intersecting ranges fail with pipeline.requires_conflict.
id: checkout-verify
requires:
- tool: node
version: ">=22"
- tool: pnpm
version: ">=9"
stages:
- id: verify-build
uses: stages/verify-build.stage.yaml
requires:
- tool: pnpm
version: ">=9.5"
Preflight surfaces (sf doctor --pipeline, MCP preflight, and start_run before createRun) check against the image toolchain manifest (/etc/stageflow/toolchain.json, override with STAGEFLOW_TOOLCHAIN_MANIFEST) with live PATH fallback under the curated stage env. Statuses: ok | missing_tool | tool_version_mismatch | unknown_version. unknown_version passes by default; sf doctor --strict / MCP preflight with strict: true fails it. Stageflow never installs tools from requires:.
Stage environment and secrets {#stage-secrets}
Stages receive a constructed environment (exact-name allowlist + computed vars + declared grants) — never a raw spread of the Host env. Full migration: migration-stage-environment.md.
| Blocked by default | Always included | Declared per stage |
|---|---|---|
Provider API keys, GITHUB_TOKEN/GH_TOKEN as raw env (unless as: env), control/read tokens (never grantable), cloud/NPM_TOKEN/SSH_AUTH_SOCK |
PATH, locale/TZ/TERM/TMPDIR, proxy + CA vars, STAGEFLOW_HOME, SF_STAGE_WORKER, Slot 2 STAGEFLOW_* binding vars, cache vars under $STAGEFLOW_HOME/cache |
secrets: names from the Host registry |
stages:
- id: raise-pr
uses: ./raise-pr.yaml
secrets:
- GITHUB_TOKEN # GIT_ASKPASS + materialised token file (no raw env)
- { name: NPM_TOKEN, as: env } # raw env (WARN); no askpass
# Prefer { name: GITHUB_TOKEN, as: env } when gh/MCP need the env var:
# that form still sets GIT_ASKPASS + token file for plain HTTPS git (WARN).
For GITHUB_TOKEN / GH_TOKEN, { as: env } is additive with askpass: the value is in the curated env (for gh / MCP) and GIT_ASKPASS plus a materialised token file are still set for plain HTTPS git. Other secrets with as: env stay env-only. Askpass/env grants apply only to secrets declared on the stage.
verify command checks run as bash -c <command> with the same curated env. Host stopgaps: STAGEFLOW_STAGE_ENV_ALLOW and deprecated STAGEFLOW_STAGE_ENV_PASSTHROUGH=all.
External stage files
Stage YAML (referenced via uses:) requires:
| Field | Description |
|---|---|
id |
Must match the pipeline entry id and the filename stem (sf validate) |
system_prompt |
Agent instructions |
Optional when a pipeline or manifest default can fill it:
| Field | Description |
|---|---|
model |
Provider/model string; resolved via Model defaults and precedence |
Required on the file (not on the uses: wrapper): io.input.schema and io.output.schema. Optional body fields: model (when inherited from a higher default), verify, gate_kinds, timeout_ms, skill — see Envelopes — io schemas, Verify, and Skill binding. The loader also accepts mcp: here; prefer binding MCP on the pipeline entry (see Stage MCP). io.input.schema is the successor assignment contract (not the child's later io.output.schema). Wiring keys (route, entry, on_verify_fail, needs, fork, route_select, allow_none, clonable, clone_cap, clone_mode) are errors on an external stage file (body only — put wiring on the pipeline entry). timeout_ms is an optional positive integer millisecond attempt budget (default 60 minutes). When it elapses the attempt fails and the session is kept for sf runs resume.
Shared pool example: tests/fixtures/stages/plan-review.yaml.
Tasks (*.task.yaml)
| Field | Required | Description |
|---|---|---|
id |
yes | Task identifier |
goal |
yes | What the run should accomplish |
context |
no | Background for agents |
constraints |
no | Boundaries |
checkout |
no | Relative or absolute path to working tree (path binding; conflicts with repository) |
repository |
no | GitHub owner/repo for a Host-owned worktree binding (requires ref) |
ref |
no | Branch, tag, or SHA for repository binding (required when repository is set) |
input |
no | Structured object matched against each entry stage's io.input; omitted is {} |
Do not set both checkout and repository on the same task. CLI overrides: sf run --repository / --ref / --checkout. Repo-bound Docker notes: Docker — Repo-bound run.
Prose-only tasks (no input) stay valid as files. If an entry stage declares io.input.schema and the task omits input, start-run / preparePipeline treat it as {} and fail with task.invalid_shape when that does not match. sf validate of the task file alone still succeeds. Non-entry stages still receive the full task in the agent prompt.
Runnable demo: examples/hello-world/ — task input paired with entry io.input.schema (see that README’s “What this demonstrates”). See also tests/fixtures/tasks/sample.task.yaml.
Agents (agents/*.agent.yaml) {#agents-agent-yaml}
An agent is a project-owned definition. The file name is the agent id: agents/<id>.agent.yaml. This is not the stage agent: field, which still selects the Pi or Claude backend.
| Field | Required | Description |
|---|---|---|
id |
yes in the file | [a-z][a-z0-9_]*, max 64. Fixed after create. Must match the file name. The console, API, CLI and MCP generate it from name (suffixed _2, _3 on a clash) when a create omits it |
name |
yes | Operator-facing name |
instructions |
yes | Instructions applied to sessions started on this revision |
description, greeting |
no | Directory summary; opening line shown in new sessions |
enabled |
no | Defaults to false. A disabled agent accepts no new sessions, turns or approvals |
execution |
no | trusted-local (default) or sandbox |
project_context |
no | Default false. Give sessions the project context |
model |
no | { backend: pi|claude, id?, thinking: off|low|medium|high } |
tools |
no | { builtin: [read|write|shell] (whole groups), builtin_tools: [read|grep|find|ls|write|edit|bash] (single tools), stageflow: { groups: [...], tools: [...] }, mcp: { <server>: [tool names or "*"] }, skills: [], browser: bool }. Groups and single tools combine: builtin: [read] plus builtin_tools: [edit] allows the four read tools and edit. read = read, grep, find, ls; write = write, edit; shell = bash |
approvals |
no | { ask_first: [tool ids], approvers: { operators, channel_ids }, timeout_minutes, on_timeout: skip|deny }. bash always asks first when granted, whether or not it is listed |
sub_agents, sub_agent_depth |
no | Agent ids this agent may delegate to; max nesting (default 1) |
guardrails |
no | { block_patterns: [regex] } — applied to assistant text after each turn |
evals |
no | { groundedness: bool } |
observability |
no | { capture: off|metadata|full, otlp: bool } |
channel |
no | { connection_id, allow_from: [email | +phone | @domain | *] } — the one connection the agent answers on; an empty allow_from lets nobody in. The old channels: [{ connection_id }] list still loads as one channel |
memories |
no | Ids of memories every session of this agent can use (cover and cards). A missing id is agent.memory_unavailable and blocks save and enable. Sub-agents do not inherit the list |
web_chat |
no | { enabled, slug?, rate_limit_per_minute (1-120, default 10), max_message_chars (1-20000, default 4000) } — the public chat page at /chat/<slug>; see Agents. Agents created through the console, API, CLI or MCP start with it on and a generated slug (the link is the credential); a save that omits the block keeps the current one |
schedule |
no | { cron, timezone?, message? } |
limits |
no | { session_minutes, max_turns, cost_usd: number|null, long_context: summarize|truncate, always_on: bool } — always_on ends no session by time or turns and counts cost_usd per day |
Unknown keys are rejected. Defaults are omitted when the console writes the file. Every issue carries a field path (tools.mcp.web); missing MCP servers, skills or connections are warnings, not errors.
A valid save or a valid edit of the file creates a new revision (recording its source: console, file, author, restore or api) for future sessions; revisions can be compared and restored. An invalid edit leaves the last valid revision in place and reports agent.invalid without echoing the file. A console save whose baseRevision is not the current revision returns agent.conflict and does not write the file. Removing the file archives the agent and keeps its sessions readable.
Triggers (*.trigger.yaml) {#triggers-trigger-yaml}
A trigger is a small catalog file that binds one addressable id to an existing pipeline + task, so something outside the pipeline DAG can start a run without knowing the underlying catalog paths — an operator running sf trigger fire, an external system calling the REST route, a schedule-kind trigger firing on its own, or an event-kind trigger watching GitHub via the poll adapter or a mailbox via the email adapter. See Architecture for what backs a trigger today versus what is deferred.
| Field | Required | Description |
|---|---|---|
id |
yes | Trigger identifier (project-scoped, like pipelines and tasks) |
pipeline |
yes | id of a catalog pipeline (*.pipeline.yaml) this trigger starts |
task |
no | id of a catalog task (*.task.yaml); presence is the either/or mode switch — see below |
kind |
yes | manual | schedule | event |
enabled |
yes | Whether firing this trigger is allowed; a disabled trigger fails fire with a 409 |
schedule |
no | { cron: string, timezone?: string } — read by the schedule adapter; see below |
event |
no | { source: string, match?: Record<string, unknown>, config?: Record<string, unknown> } — see below for the GitHub poller's, webhook adapter's, and email adapter's config shapes |
Catalog mode vs. dynamic mode
task is optional, and whether it's present is the whole mode switch — there is no separate mode field, and the two modes are never merged:
taskset → catalog mode. The trigger always runs that one catalog task, on every fire. Firing it with a task supplied anyway is rejected:trigger.task_override_not_allowed(422).taskabsent → dynamic mode. The trigger has no fixed task; whoever fires it must supply one — aTaskFile, inline — and that exact task is used for the run. Firing it with no task supplied is rejected:trigger.task_required(422).
See sf trigger fire for the --task-inline flag and the same two rejections over HTTP/MCP.
kind: manual only runs when fired explicitly (sf trigger fire, POST /api/triggers/:id/fire), resolving the pipeline ref (and, in catalog mode, the task ref) and starting a real run through the same internal path the CLI/MCP/console already use.
kind: schedule triggers also fire on their own while the Host is running: ScheduleSource ticks on an interval (STAGEFLOW_TRIGGER_TICK_INTERVAL_MS, default 30s — see CLI reference), computing each trigger's next fire time from cron/timezone via croner and persisting it (next_run_at) so a Host restart doesn't lose or re-derive it. If the Host was down past a trigger's next_run_at, it fires once on the next boot for the most recent missed occurrence — a schedule does not queue one run per missed interval — then recomputes next_run_at from the current time. Firing a schedule trigger manually with sf trigger fire still works the same as any other trigger. A schedule trigger in dynamic mode is accepted (nothing blocks it at the schema level) but always fails when its timer fires, since nothing supplies a task on an automatic tick — dynamic mode is only useful for triggers fired manually with a task in hand.
kind: event triggers whose event.source starts with github. are picked up by GithubPollSource (src/runtime/githubPollSource.ts): it ticks on an interval (STAGEFLOW_GITHUB_POLL_INTERVAL_MS, default 60s — see CLI reference), groups matching triggers by distinct event.config.repo (one API call per repo per tick, regardless of how many triggers watch it), and uses an ETag-conditioned GitHub PR list request so an unchanged repo costs nothing against rate limit. New PR activity since the last persisted cursor is normalized and checked against each trigger's event.match; a catalog-mode trigger fires with its own fixed task, a dynamic-mode trigger fires with a TaskFile built from the PR (goal summarizing it, input carrying the normalized event). A never-before-seen repo only seeds the cursor on its first tick — it doesn't fire for pre-existing PR history. A brief Host outage isn't a missed event: the next tick's diff-since-cursor naturally catches up, bounded by GitHub's own event retention, so (unlike schedule) no boot catch-up tick is needed.
event.config for the GitHub poller:
| Field | Required | Description |
|---|---|---|
repo |
yes | "owner/repo" string identifying the watched GitHub repository |
secretRef |
no | Name of the secret (resolved via readSecretFromEnvOrFile) holding a GitHub PAT with repo read scope; defaults to GITHUB_TOKEN |
The normalized event handed to match/input has these fields: action ("opened" | "merged" | "closed" | "updated"), number, title, url, author, repo, updatedAt.
Dynamic mode, filtering to closed PRs only:
id: github-pr-closed
pipeline: hello
kind: event
enabled: true
event:
source: github.pulls
match:
action: closed
config:
repo: owner/repo
secretRef: GITHUB_TOKEN
Webhook adapter (event.source: webhook)
An event-kind trigger with event.source: webhook gets its own inbound HTTP endpoint: POST /api/triggers/:id/webhook. One URL per trigger, not a shared fan-out endpoint — the trigger id in the URL is the routing, matching how GitHub/Stripe/Slack webhooks already work. Unlike every other /api/* route, it needs no bearer token: an external sender can't present Stageflow's own control token, so the route authenticates the request itself via per-trigger HMAC signature verification instead (the carve-out lives in src/server/controlToken.ts's isTriggerWebhookRoute). It's still gated the same way every other /api/* route is — loopback/allowed-hosts — only the bearer requirement is exempted.
event.config for the webhook adapter:
| Field | Required | Description |
|---|---|---|
secretRef |
yes | Name of the secret (resolved via readSecretFromEnvOrFile) holding the HMAC signing secret |
header |
yes | Request header carrying the signature, e.g. "x-hub-signature-256" |
scheme |
no | "hex" | "base64" — encoding of the signature value; defaults to "hex" |
Request handling, in order:
- Unknown trigger id →
404. event.config.secretRef/headermissing, or the referenced secret isn't set →400 { error: "this trigger has no webhook signing configured" }— checked before touching the body.- HMAC verified over the raw request body (
crypto.createHmac("sha256", secret), compared withcrypto.timingSafeEqual) against the value of the configured header → wrong/missing signature is401 { error: "Invalid webhook signature" }, andfireTriggeris never called. - Body parsed as JSON (only after the signature check, so a tampered-but-still-valid-JSON body fails on signature rather than slipping through because it happened to parse) → malformed JSON, or a non-object body, is
400. event.matchevaluated against the parsed payload, using the same subset-matchmatchesEventFilterthe GitHub poller uses (src/runtime/triggerEventMatch.ts) → no match is200 { fired: false }— accepted but deliberately not fired, since a webhook provider often disables or retry-storms a subscription that doesn't return 2xx.- Match → fires, same either/or rule as every other fire path: a catalog-mode trigger runs its fixed task; a dynamic-mode trigger runs with a
TaskFilebuilt from the payload (inputcarrying it as-is) →201 { fired: true, runId }(plusqueued/queuePositionwhen the run queues, same shape as/fire).
Catalog mode:
id: webhook-catalog
pipeline: hello
task: my-task
kind: event
enabled: true
event:
source: webhook
config:
secretRef: WEBHOOK_SECRET
header: x-hub-signature-256
scheme: hex
See tests/fixtures/triggers/webhook-catalog.trigger.yaml (dynamic-mode variant: webhook-dynamic.trigger.yaml). event.config is authored the same way as any other event field — as part of the event object passed to POST /api/triggers, create_trigger, or written directly into the YAML; there is no operator-console builder for it yet (the console only edits event.source).
Reachability note, restated from the goal this adapter solves: the Host binds loopback-only by default, so this endpoint is only genuinely reachable by a real external sender behind a tunnel (ngrok/cloudflared) for testing, or in a deployment that deliberately exposes the Host.
Email adapter (event.source: email.*)
An event-kind trigger whose event.source starts with email. is picked up by EmailSource (src/runtime/emailSource.ts). Unlike the poll-based adapters, this one is push-based: it opens a real IMAP connection to the configured mailbox and holds it in IDLE, so it reacts to the server's own new-message notification instead of ticking on an interval. Triggers are grouped by distinct mailbox (host+port+user) — multiple triggers watching the same mailbox with different event.match filters share exactly one IMAP/IDLE connection. On a dropped connection, the adapter reconnects with bounded exponential backoff and resumes from the last persisted UID watermark (the generic trigger_adapter_state table — no separate migration needed) rather than re-firing already-seen messages.
Bring your own mailbox. Stageflow doesn't own or provide an email account — the operator supplies IMAP credentials for a mailbox they control, same as configuring any mail client. A dedicated mailbox (not a personal inbox) is the sane setup, though that's an operational choice for the operator to make. Credentials are supplied via secretRef, resolved with the same readSecretFromEnvOrFile secrets pattern used everywhere else in this codebase — never inline in the trigger YAML.
event.config for the email adapter:
| Field | Required | Description |
|---|---|---|
host |
yes | IMAP server hostname |
port |
yes | IMAP server port (993/465 are treated as implicit TLS) |
user |
yes | Mailbox username |
secretRef |
yes | Name of the secret (resolved via readSecretFromEnvOrFile) holding the password or app-password |
The normalized message handed to match/input has these fields: from, subject, date, text (body excerpt, truncated). match is a small subset-match filter, the same matchesEventFilter every adapter uses (src/runtime/triggerEventMatch.ts): every key/value in match must equal-match the corresponding field on the normalized message — there is no substring/contains operator, so a subject filter matches only a message whose subject is exactly that string, not a partial match.
v1 limitation: no attachment handling. Subject, sender, date, and body text map into the constructed TaskFile's input; attachments are out of scope for this release — a deliberate limitation, not an oversight.
Dynamic mode, filtering to a specific subject:
id: email-match-invoice
pipeline: hello
kind: event
enabled: true
event:
source: email.message
match:
subject: Invoice
config:
host: imap.example.com
port: 993
user: notifications@example.com
secretRef: EMAIL_PASSWORD
See tests/fixtures/triggers/email-match-invoice.trigger.yaml (unfiltered dynamic-mode variant: email-dynamic.trigger.yaml; catalog-mode variant: email-catalog.trigger.yaml). event.config is authored the same way as any other event field — as part of the event object passed to POST /api/triggers, create_trigger, or written directly into the YAML; there is no operator-console builder for it yet (the console only edits event.source).
Other event.source values (besides github.*, webhook, and email.*) still only load and validate; nothing polls or receives them yet — see Architecture. The only way to use dynamic mode for one of those right now is a manual fire with a task supplied (sf trigger fire --task-inline, POST .../fire with a task body, or fire_trigger's task param).
Catalog mode:
id: manual-hello-world
pipeline: hello
task: my-task
kind: manual
enabled: true
See tests/fixtures/triggers/manual-hello-world.trigger.yaml.
Dynamic mode — no task key at all:
id: dynamic-hello
pipeline: hello
kind: manual
enabled: true
See tests/fixtures/triggers/dynamic-hello.trigger.yaml.
Validation
sf validate loads every *.trigger.yaml under the catalog's trigger roots (catalog.triggers in the stageflow.yaml manifest, below) and confirms its pipeline ref, and its task ref when task is present, resolve against known catalog ids, the same way stage uses: refs are checked. A dangling ref is caught at validate time, not fire time. A dynamic-mode trigger (task omitted) produces no task-related finding — absence is valid, not an error:
| Code | Meaning |
|---|---|
trigger.invalid_shape |
Missing or invalid id, pipeline, kind, or enabled, or a task present with the wrong type (task itself is optional) |
trigger.unknown_pipeline |
pipeline does not match any catalog pipeline id |
trigger.unknown_task |
task does not match any catalog task id |
See tests/fixtures/triggers/manual-dangling-refs.trigger.yaml — a trigger whose pipeline/task reference ids that don't exist in the catalog.
Evals (*.eval.yaml) {#evals-eval-yaml}
An eval names one target (a stage or an agent), a dataset of cases, graders and run defaults. How to use evals, read results and what they cost: Evals. Samples: examples/evals/. The agent-specific keys are in Agent evals below.
version: 1
id: review-findings
name: Review findings
description: Review flags real defects
type: capability
target:
kind: stage
pipeline: ../pipelines/feature-ship.pipeline.yaml
stage: review
dataset: ../datasets/review-diffs.dataset.jsonl
task:
goal: Review the change and list defects.
defaults:
repeats: 3
judge: anthropic/claude-sonnet-4-5
pass_threshold: 0.8
max_cost_usd: 2
concurrency: 4
graders:
- id: output-schema
type: schema
must_pass: true
- id: clear-and-actionable
type: rubric
rubric: A developer could act on each finding without asking questions.
scale: 1_to_5
pass_at: 4
budget:
latency_ms: 300000
cost_usd: 0.3
Unknown keys at any level are an error (eval.unknown_key).
| Key | Type | Required | Rule |
|---|---|---|---|
version |
1 |
yes | Only 1 is accepted |
id |
string | yes | ^[a-z0-9][a-z0-9-]{0,62}$. Must equal the filename stem. Unique in the project |
name |
string | no | 1 to 80 characters. Default: id |
description |
string | no | Up to 500 characters |
type |
capability | regression |
no | Default capability. Display and guidance only |
target |
object | yes | A stage (below) or an agent (Agent evals) |
dataset |
string | yes | Path to a dataset file, relative to the eval file's directory, inside the project root |
task |
object | no | { goal?, context?, constraints? }, all strings. Template for each case's task. Stage targets only (eval.task_not_applicable on an agent target) |
defaults |
object | no | See below |
graders |
array | yes | 1 to 20 graders with unique ids |
conversation |
object | no | Agent targets only. See Agent evals |
budget |
object | no | { latency_ms?, cost_usd? }, positive numbers. Per-result warnings, never failures |
Target. kind is required and is stage or agent (eval.target_kind_unsupported otherwise). A stage target has exactly one of two forms. Paths are relative to the eval file.
| Form | Keys | Meaning |
|---|---|---|
| Pipeline stage | pipeline: <path>, stage: <stage id> |
A stage of a pipeline (uses: or inline), materialized by the normal loader with the pipeline's model default |
| Stage file | file: <path> |
A standalone stage YAML file. Model resolves stage, then the manifest default |
Defaults.
| Key | Type | Default | Rule |
|---|---|---|---|
repeats |
integer | 3 | 1 to 10 |
judge |
string | none | A model id provider/model. Required when a rubric grader has none of its own. Judge-capable providers: anthropic, openai, openrouter |
pass_threshold |
number | 0.8 | 0 < x <= 1 |
max_cost_usd |
number | 2 (agents: 1) | > 0. Stage runs plus judge calls; for agents, agent turns, simulator calls and judge calls |
concurrency |
integer | 4 (agents: 2) | 1 to 8 |
model |
string | none | Replaces the stage's model for every case (the agent's model for an agent target) |
user_model |
string | the effective judge |
Agent targets only: the model that plays the user in simulated scenarios. Judge-capable providers only |
Graders. Common keys: id (same pattern as the eval id, unique), type, description (up to 200 characters), weight (number above 0, default 1) and must_pass (default false; a failing must_pass grader fails the result and its weight is ignored).
type |
Keys | Notes |
|---|---|---|
schema |
none | Envelope status is success and the payload validates against the stage's io.output.schema (required) |
field |
checks: non-empty array of { path, op, value? } |
All checks must hold. path is a dotted path over { status, summary, artifacts, payload }, with [n] and [*]. op: exists, not_empty, equals, not_equals, contains, matches, gte, lte, between, in, length_gte, length_lte |
reference |
mode: each_mentioned | equals (required); path (default payload); expected (path into the case, required); pass_at (0 to 1, default 1) |
each_mentioned needs an array of strings and scores the share found, case-insensitively |
rubric |
rubric (1 to 4,000 characters, required); scale: pass_fail (default) | 1_to_5; pass_at (integer 1 to 5, default 4, only with 1_to_5); judge; examples (up to 6) |
Judged by a model. Examples are { verdict: pass|fail, output, reason? } for pass_fail and { score: 1-5, output, reason? } for 1_to_5 |
code |
code: body of a JavaScript function (required) |
Receives (output, testCase, context); returns a boolean, a number 0 to 1, or { pass, score?, reason? }. 5 second limit. Runs on the author's machine with the author's permissions. On an agent target output is the conversation |
tool_calls |
must_call, must_not_call, must_ask_first: string arrays; max_calls: 0 to 200; args: up to 20 of { tool, path, op, value? } |
Agent targets only. Reads the recorded tool calls. A call counts whatever its outcome. must_call and must_not_call default to the case's expected.tools and expected.forbidden_tools; [] opts out |
conversation_check |
max_turns (1 to 100), max_agent_questions_per_turn (0 to 10), final_reply: up to 10 of { op, value? } |
Agent targets only. At least one key. Deterministic |
goal_reached |
judge (optional), guidance (up to 2,000 characters) |
Agent targets only. A judge reads the conversation and the case's input.goal (else expected.outcome). Pass or fail |
command and groundedness graders are not available. schema applies to stage targets only; tool_calls, conversation_check and goal_reached apply to agent targets only (eval.grader_not_applicable otherwise). The grader types, scoring and statistics are explained in Evals.
Agent evals
An agent target is target: { kind: agent, agent: <id> }, where <id> is an agent in the project's agents/ directory. pipeline, stage, file and revision are not allowed on it (eval.target_invalid): a run uses the agent's current revision and records it. The conversation block says how the conversation is played and how tools are treated:
| Key | Type | Default | Rule |
|---|---|---|---|
mode |
script | simulated |
script |
Default for all scenarios; a scenario overrides it with input.mode |
max_turns |
integer | 8 | 1 to 30 user turns per scenario. Reaching it is a normal end |
turn_timeout_ms |
integer | 120000 | 5000 to 600000, one user turn |
tools |
real | read_only | mocked |
read_only |
The tool policy |
approvals |
deny | auto_approve_read | record_only |
record_only |
The approval policy |
read_only_tools |
string array | [] |
Up to 50 MCP tool ids you declare read-only (server.tool, server.*). Built-in and Stageflow tools cannot be declared read-only |
allow_real_tools |
boolean | false |
Your consent to tools: real. Only valid with tools: real |
Omitting conversation means script mode, 8 turns, read_only, record_only. Unknown keys are eval.unknown_key; a bad value is eval.conversation_invalid. Key order when Stageflow writes the file: version, id, name, description, type, target, dataset, task, defaults, conversation, graders, budget.
target: { kind: agent, agent: support }
dataset: ../datasets/support-scenarios.dataset.jsonl
defaults: { repeats: 3, pass_threshold: 0.8, max_cost_usd: 1, concurrency: 2 }
conversation: { mode: script, max_turns: 4, tools: read_only, approvals: record_only }
graders:
- { id: no-forbidden-tools, type: tool_calls, must_pass: true }
- { id: one-question, type: conversation_check, max_agent_questions_per_turn: 1 }
On an agent target, field and reference paths start at the conversation (for example final_reply, turns, stop_reason, messages[*].text, tool_calls[*].name, tool_calls[0].args.invoice_id) and reference defaults to path: final_reply. The full chapter, with the conversation shape, is Evals for agents. A complete example: examples/evals/support-sample.eval.yaml.
Eval validation findings {#eval-validation-findings}
sf validate, sf eval validate and the console report these with category: "eval".
| Code | Severity | When |
|---|---|---|
eval.load_error |
error | YAML does not parse |
eval.not_object |
error | The document is not a mapping |
eval.invalid_version |
error | version is not 1 |
eval.unknown_key |
error | A key that is not listed |
eval.invalid_id |
error | id does not match the pattern |
eval.id_filename_mismatch |
error | id differs from the filename stem |
eval.duplicate_id |
error | Two evals in the project share an id |
eval.invalid_field |
error | Wrong type or range |
eval.target_kind_unsupported |
error | target.kind is neither stage nor agent |
eval.target_invalid |
error | Not exactly one target form |
eval.target_unresolved |
error | The pipeline, stage or file is not found or does not load (for example no resolvable model) |
eval.dataset_unresolved |
error | The dataset file is not found or is outside the project |
eval.dataset_invalid |
error | The dataset file fails its own validation |
eval.cases_skipped |
warning | Some cases fail the stage's io.input.schema; they are skipped when a run starts |
eval.no_valid_cases |
error | No case remains after skipping |
eval.grader_duplicate_id |
error | A repeated grader id |
eval.grader_type_unsupported |
error | A type other than schema, field, reference, rubric, code, tool_calls, conversation_check, goal_reached (command has its own message) |
eval.grader_invalid |
error | A type-specific rule is broken |
eval.schema_grader_without_output_schema |
error | A schema grader on a stage with no io.output.schema |
eval.judge_missing |
error | A rubric needs a judge and none is set |
eval.judge_unknown_model |
warning | The judge id is not in the model catalog (the catalog may be offline) |
eval.judge_same_as_stage |
warning | The effective judge is the stage's own model |
eval.stage_has_gates |
warning | The target stage declares gate_kinds |
eval.stage_needs_checkout |
warning | The stage requires a repository |
eval.cases_missing_expected |
warning | A reference grader and some cases lack the expected path |
eval.large_run |
warning | Cases x repeats is above 500 |
eval.too_large_run |
error | Cases x repeats is above 5,000 |
eval.agent_not_found |
error | The agent target names an agent the project does not have |
eval.agent_archived |
error | The agent is archived |
eval.agent_disabled |
warning | The agent is disabled. Eval sessions run anyway |
eval.agent_invalid |
error | The agent file has problems (the message names the first) |
eval.agents_unavailable |
error | A run starts on a Host with no agent service |
eval.conversation_invalid |
error | A bad conversation value (the message names the key) |
eval.conversation_not_applicable |
error | conversation or defaults.user_model on a stage target |
eval.task_not_applicable |
error | task on an agent target |
eval.grader_not_applicable |
error | A grader type that does not apply to the target kind |
eval.tool_not_granted |
warning | A tool named in tool_calls, read_only_tools or a scenario's tool_mocks is not granted to the agent |
eval.read_only_tools_not_mcp |
error | read_only_tools lists a built-in or Stageflow tool (write, bash, ...). Only MCP tools can be declared read-only; the entry has no effect |
eval.tools_real |
warning | tools: real: real tools can change files and call services from this machine |
eval.real_tools_not_allowed |
error | tools: real without allow_real_tools: true |
eval.tools_unstubbed_list |
warning | read_only and some granted tools are not read-only; they are stubbed and recorded, not executed |
eval.tools_mocked_no_mocks |
warning | tools: mocked and no scenario has tool_mocks |
eval.user_model_missing |
error | A scenario is simulated and neither user_model nor judge is set |
eval.simulator_unsupported |
error | user_model is not an anthropic, openai or openrouter model |
eval.simulator_unknown_model |
warning | user_model is not in the model catalog |
eval.simulator_same_as_agent |
warning | The simulator model is the agent's own model |
eval.cases_missing_goal |
warning | A goal_reached grader and some scenarios have neither input.goal nor expected.outcome |
Datasets (*.dataset.jsonl) {#datasets-dataset-jsonl}
A dataset is a file of cases. The loader checks only the case shape; matching input to a stage, or reading it as a scenario for an agent, happens when an eval uses the dataset.
| Key | Type | Required | Rule |
|---|---|---|---|
id |
string | yes | ^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$, unique in the file |
input |
object | yes | Becomes task.input; must match the stage's io.input.schema |
title |
string | no | Up to 120 characters |
expected |
any JSON | no | Reference data for reference graders and rubric judges |
tags |
string[] | no | Up to 10, each ^[a-z0-9][a-z0-9-]{0,31}$ |
goal |
string | no | Replaces the eval's task.goal for this case |
metadata |
object | no | Free-form, never read by Stageflow |
Formats: JSONL (one object per line, blank lines ignored), JSON (an array or { "cases": [...] }) and CSV (header id, title, goal, tags split on ;, input.<name>, expected.<name>, any header ending :json parsed as JSON). Limits: 5,000 cases, 1 MB per case, 50 MB per uploaded file. Dataset findings use category: "dataset": dataset.load_error, dataset.empty, dataset.not_object, dataset.invalid_shape, dataset.unknown_key, dataset.case_missing_id, dataset.case_invalid_id, dataset.duplicate_id, dataset.case_missing_input, dataset.invalid_field, dataset.csv_unknown_column, dataset.too_many_cases, dataset.case_too_large, dataset.file_too_large (errors) and dataset.input_mismatch, dataset.few_cases (warnings). See Evals: Datasets.
Scenarios (agent evals). For an agent target input is a scenario, checked per case in the effective mode (input.mode, else conversation.mode):
input key |
Mode | Rule |
|---|---|---|
mode |
both | script or simulated; overrides conversation.mode for this case |
script |
script | Required. 1 to 40 user messages of 1 to 4,000 characters |
opening |
simulated | Required. The first user message, 1 to 2,000 characters |
persona |
simulated | Required. 1 to 1,000 characters |
goal |
simulated | Required. 1 to 1,000 characters; also read by goal_reached |
facts |
simulated | Optional. Up to 30 strings of 300 characters, revealed when asked |
tool_mocks |
both | Optional. Up to 50 tool ids, each { "result": ... } or { "error": "..." } |
expected keys with a meaning for agents: outcome (string), tools and forbidden_tools (string arrays). Scenario findings: dataset.input_mismatch (warning; the message starts "scenario"; the case is skipped when a run starts), dataset.scenario_unknown_field (warning; the case still runs), dataset.expected_invalid (warning) and dataset.mapping_unsupported_field (error: a CSV mapping cannot target input.script or input.tool_mocks). See Evals for agents.
Manifest (stageflow.yaml)
Declares catalog roots for sf validate (manifest-all) and operator-console browse. Optional top-level model is the global default LLM id for stages that omit both stage and pipeline model (see Model defaults and precedence). Optional top-level agent selects the default execution backend and is independent of model.
version: 1
# model: anthropic/claude-sonnet-4-5 # optional global default LLM id
# agent: pi # optional global default backend
catalog:
pipelines:
- examples/hello-world
- examples/plan-review
tasks:
- examples/hello-world
- examples/plan-review
triggers: []
evals: [evals] # optional; default [evals]
datasets: [datasets] # optional; default [datasets]
patterns:
pipeline: "*.pipeline.yaml"
task: "*.task.yaml"
trigger: "*.trigger.yaml"
eval: "*.eval.yaml"
dataset: ["*.dataset.jsonl", "*.dataset.json", "*.dataset.csv"]
exclude:
- tests/fixtures
model: optional global default; participates instage → pipeline → globalresolution.agent: optional global default backend; separate frommodel.exclude: paths omitted from console browse (fixtures may still be loaded by explicit CLI path in tests).patterns: glob for directory scans (defaults shown above).triggers: catalog roots scanned for*.trigger.yaml— see Triggers.evalsanddatasets: catalog roots scanned for*.eval.yamland dataset files — see Evals. When a key is absent the loader scansevals/anddatasets/under the project root; a key that is present replaces that default.patterns.datasetaccepts a string or an array.excludeapplies to both.
Scaffold a new project: sf init creates stageflow.yaml, pipelines/ (with an inline stage in hello.pipeline.yaml), and tasks/. The manifest lists pipelines, tasks, and workshop as scan roots. workshop/ is created on the first Workshop save. There is no global stages/ pool.
Validation
sf validate --strict # manifest-all: pipelines, stages, and tasks from git root
sf validate --pipeline path/to/x.pipeline.yaml --strict # that pipeline and its stages
sf validate --task path/to/x.task.yaml --strict # that task
sf migrate-yaml # dry-run convert contract keys to io / verify / on_verify_fail (not wiring)
sf migrate-yaml --write # apply
Validation checks pipeline shape, uses: resolution, DAG (route, cycles), stage file shape, io / verify / on_verify_fail, and task shape. It also resolves the effective model per stage (stage → pipeline → global); omitting model at all three tiers is an error — see tests/fixtures/model-hierarchy/missing-all/. When .mcp.json is present, it also checks catalog shape and reserved-name collision. When a stage lists mcp, it checks those names exist in the catalog. It does not verify provider credentials, checkout paths, env vars, or a live MCP connect. sf migrate-yaml converts contract keys (payload_schema / pre_emit_checks / completion / recovery / clone_input_schema) only — it does not rewrite wiring. needs / fork / feedback_loop / route_select / allow_none are hard errors. Illegal Clone Chains fail load and sf validate. pipeline.route_if_invalid is an error. catalog.legacy_yaml is a contract dual-read warning; --strict does not promote it. pipeline.route_all_gated and pipeline.model_applies are warnings (ok: true); --strict does not promote them. See sf migrate-yaml and Upgrading older catalogs.
CLI run
sf run \
--pipeline examples/hello-world/hello.pipeline.yaml \
--task examples/hello-world/my-task.task.yaml
Run state is stored under the global durable root ($STAGEFLOW_HOME, default ~/.stageflow/) — see Data directory. Catalog browse still uses the project git root regardless of which subdirectory you start sf ui from.