Workflow YAML structure

The top-level shape of a workflow file: name, initial, states, transitions.

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 on decided / tie.
  • map — runs the same step over a list and routes on success / failure.
  • condition — evaluates an expression against variables.
  • human — waits for a human approval event.
  • subworkflow — runs another workflow as a single node; routes on success / failure / cancelled.
  • end — terminates the run with outcome: 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-failure

Quote 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': b

Boolean 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 route

Initial 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: 5

See /docs/recipes/closed-loop-feedback for the full pattern, including stall detection and the human-gate exemption.

Where to next#

View this page as Markdown

Predictable workflows from unpredictable AI