Every Roscoe workflow is a single .workflow.yaml file describing a finite
state machine. This page covers the top-level shape of that file: what fields
are required, how states reference one another, and the integrity rules the
loader checks before a run can start. Read this first when you are writing a
workflow from scratch or trying to understand someone else's.
The top-level fields#
name: my-pipeline
version: 1
description: A short summary shown in the web UI and CLI listing.
initial: greet
states:
greet:
type: script
label: Print greeting
script: "console.log('hello'); export default true;"
validator:
kind: boolean
on:
'true': done
'false': done
done:
type: end
label: Done
outcome: success| Field | Required | Notes |
|---|---|---|
name |
yes | Kebab-case: [a-z0-9_-]+. Must be unique within ROSCOE_WORKFLOWS_DIR. |
version |
yes | Always the literal 1. Reserved for future schema revisions. |
description |
no | Free text. Appears in the web UI and the CLI's workflow list. |
initial |
yes | The id of the state to enter first. Must be a key in states. |
states |
yes | A map of state id to state definition. State ids are arbitrary strings. |
inputs |
no | Declared input contract. Required when this workflow is called as a subworkflow or run with --input from the CLI. |
outputs |
no | Declared output contract. Required for callers to read named values out of a subworkflow run. |
spendingCap |
no | Default per-run cost ceiling: { usd?, tokens? }. The run stops (cancelled, stop_reason: spending_cap_exceeded) once its total cost or tokens exceed a set value. Off when omitted; a per-run cap (--spending-cap, MCP spending_cap_usd) overrides it. See /docs/running/cli. |
There is no nodes: array and no edges: block. Transitions live inline on
each state under its on map (see below).
States#
Every entry in states has a type and a label. Every type except end
also declares an on map describing where to go next.
states:
<state-id>:
type: <node-type>
label: Human-readable label
# ...type-specific fields...
on:
<event>: <next-state-id>The available type values are:
script— runs JavaScript/TypeScript on Bun and branches on the value it returns.ai_agent— calls a model and produces text. Exactly one outgoing transition.ai_judge— calls a model and routes on a JSON envelope.consensus— fans out to N agents and routes on quorum.round_robin— judges candidate ideas head-to-head and routes ondecided/tie.map— runs the same step over a list and routes onsuccess/failure.condition— evaluates an expression againstvariables.human— waits for a human approval event.subworkflow— runs another workflow as a single node; routes onsuccess/failure/cancelled.end— terminates the run withoutcome: success | failure | cancelled.
See Node types for the per-type fields.
The on map#
The on map is how a state hands control to the next state. Keys are the
transition events the executor can emit; values are the state ids to jump
to.
on:
'true': test
'false': notify-failureQuote keys that are YAML keywords. true, false, yes, and no are all
parsed by YAML as booleans unless quoted. Always write 'true' and 'false'
(or use "true") for boolean validators. Numeric or symbol-like keys should
be quoted too.
What events are emitted?#
The events depend on the node type and validator:
| Node type | Events emitted |
|---|---|
ai_agent |
A single user-defined event (e.g. done). Cannot branch. |
ai_judge / script boolean validator |
'true' and 'false' |
ai_judge / script confidence validator |
'true' and 'false' |
ai_judge / script enum validator |
One key per declared route |
consensus boolean / confidence validator |
approved (quorum reached) and rejected (quorum not reached) |
consensus answer / most_consistent validator |
decided (agents converged) and undecided (they didn't) |
round_robin |
decided (a clear winner) and tie (level / cyclic) |
map |
success (at least one branch completed) and failure (all branches failed) |
condition |
'true' and 'false' |
human |
Whatever events you declare (e.g. approved, rejected) |
subworkflow |
Locked: success, failure, cancelled |
end |
None. Terminal. |
See Validators for the JSON envelope that AI nodes use.
The done sentinel#
The target id done is treated as a terminal sentinel. You can write
on: { 'true': done } even when no state with id done exists, and the run
will end at that transition. Any other unknown target id is an integrity
error. If you want a richer terminal state, define an explicit end node and
point at it instead: that lets you set an outcome and a message.
Worked example#
A three-state workflow: a script preflight, an AI agent step, then an explicit end.
name: summarise-build
version: 1
description: Run a build, summarise the output with Claude, then finish.
initial: build
states:
# 1. Run a build command. Validator branches on the value the script returns.
build:
type: script
label: Run build
script: |
import { $ } from 'bun';
const res = await $`npm run build`.nothrow();
export default res.exitCode === 0;
validator:
kind: boolean
on:
'true': summarise # returned true
'false': failed # returned false
# 2. ai_agent has exactly one outgoing transition. Pick any event name —
# `done` is conventional. Reference the prior step's stdout via
# {{ build.stdout }}.
summarise:
type: ai_agent
label: Summarise build output
model: claude-haiku-4-5
prompt: |
Summarise this build output in two sentences:
{{ build.stdout }}
on:
done: success
# 3. Terminal nodes: an explicit success and an explicit failure.
success:
type: end
label: Done
outcome: success
message: Build summarised.
failed:
type: end
label: Build failed
outcome: failure
message: The build command failed.See Output chaining for the
{{ build.stdout }} syntax.
Integrity errors you will hit#
The loader runs an integrity pass before any run starts. The most common errors:
Unknown transition target. A value in on: does not match any state id
and is not the sentinel done.
on:
'true': sumarise # typo — should be `summarise`ai_agent with multiple outgoing transitions. ai_agent is intentionally
non-branching. Use ai_judge if you need to route on the model's answer.
ask:
type: ai_agent
on:
'true': a # error: ai_agent cannot branch
'false': bBoolean validator missing a target. Boolean and confidence validators
require both 'true' and 'false' keys when you declare any of them.
gate:
type: ai_judge
validator:
kind: boolean
on:
'true': pass
# error: missing 'false'Wrong key names. If you write pass/fail instead of 'true'/'false'
for a boolean validator, the loader rejects the workflow rather than failing
silently at runtime.
Enum validator with no routes. Enum validators must declare at least one route, otherwise the workflow can never advance past the node.
classify:
type: ai_judge
validator:
kind: enum
routes: [] # error: must list at least one routeInitial state does not exist. initial: foo with no foo key under
states is rejected.
Closed-loop cycle has no maxIterations. Any cycle made entirely of
automated nodes (no human gate) must declare maxIterations on at least
one participating node. Otherwise the loop has no programmatic upper bound,
and the only stop is the global 1000-step safety guard. Add the cap to any
node in the cycle and the workflow loads.
# Bad — fix and judge form a cycle with no cap anywhere
states:
fix:
type: ai_agent
on: { done: judge }
judge:
type: ai_judge
validator: { kind: boolean }
on:
'true': done
'false': fix # <- back-edge closes the cycle
# Good — cap on either node bounds the whole loop
fix:
maxIterations: 5See /docs/recipes/closed-loop-feedback for the full pattern, including stall detection and the human-gate exemption.
Where to next#
- Output chaining — reference upstream outputs in prompts, scripts, and conditions.
- Validators — how outputs become transition keys.
- Condition expressions — branching on context variables.
- Node types — per-type field reference.