Use this when an automated step should keep iterating until a separate reviewer node approves the result: the canonical "AI judge sends a draft back for another pass until it's good enough" shape. Roscoe treats cycles as a first-class pattern. The runner enforces a per-loop iteration cap and can short-circuit a stuck loop when consecutive iterations produce identical results.
The workflow#
A two-node loop where a draft step produces work and a review judge gates
the exit. The back-edge from review re-enters draft when the verdict is
negative; maxIterations on draft bounds the loop so it can't run forever.
name: reply-revision-loop
version: 1
description: Draft a customer reply and revise it until an AI reviewer signs off, or fail at the cap.
initial: draft
inputs:
- name: email
type: string
description: The customer's email needing a reply.
states:
draft:
type: ai_agent
label: Draft the customer reply
model: claude-sonnet-5
prompt: |
Draft a reply to this customer email. Incorporate the reviewer's latest
feedback: {{ review.reasoning || "" }}
Customer email: {{ email }}
maxIterations: 5
on:
done: review
review:
type: ai_judge
label: Review the reply
model: claude-sonnet-5
prompt: |
Does this reply meet our tone and policy guidelines? Reply true (send)
or false (revise), and include reasoning the next draft can act on.
validator:
kind: boolean
detectStall: true
on:
'true': approved
'false': draft
approved:
type: end
label: Approved
outcome: successThe || "" fallback in draft's prompt matters on the first pass: review
hasn't run yet at that point, and the loader rejects a template reference to
a node that isn't guaranteed to have run before the one reading it. A
fallback satisfies that check and reads as an empty string until the first
verdict exists.
What happens at runtime:
draftruns, produces a reply.doneroutes toreview.reviewreturnsfalse(needs more work). Thefalsetransition is a back-edge: it points to a node already on the current path. The runner re-entersdraftand increments its visit counter.- The loop runs up to
maxIterations: 5attempts. If the 6th entry intodraftis required, the run fails with a structured error pointing at the gate. Ifreviewreturns the same verdict three times in a row,detectStall: trueshort-circuits the loop even earlier.
The cap#
Set maxIterations: N on at least one node in any automated cycle. The
cap can sit on the draft node (as above), on the reviewer, or on a different
node in a larger cycle: every iteration of the loop must pass through it, so
the count is correct regardless of where you put it. Common choice: put it
on the node whose retries cost the most (the draft node, usually).
Limits: integer between 1 and 50. Without a cap, the loader rejects the
workflow with closed-loop cycle (...) has no maxIterations on any participating node. One exception: a cycle containing a human node is
exempt, because every iteration requires an explicit user action (approve,
retry, abort), and the human's pace is already a practical cap. Self-loops
still need one; they're the simplest case and the easiest to forget.
Stall detection#
detectStall: true on a gate node turns on output fingerprinting. Each
visit, the runner records a normalised digest of the node's output and
chosen transition. Three identical digests in a row → the run fails as
stalled before the cap fires. Useful when an AI judge gets stuck in a
"reject because still bad" rut where each iteration looks the same.
Fingerprinting is whitespace-collapsed, case-insensitive, and order- independent for JSON objects. A judge that rejects with literally identical reasoning three times stalls; a judge that rejects with new specifics each time keeps going up to the cap.
detectStall is available on every node type that also takes
maxIterations: script, ai_agent, ai_judge, consensus,
round_robin, map, condition, and subworkflow. Most useful on
reviewer-shaped nodes; not useful on a draft node whose output legitimately
changes every pass.
What you see in the run view#
The web UI surfaces three affordances when a workflow contains a cycle.
Loop-back edges render dashed, with a ↻ icon in the transition-label pill,
so back-edges read distinctly from forward flow. The loop-target node shows
a small loop badge: max 5 (or no cap) at author time, 3/5 while a run
is iterating, and 5/5 in crimson once the loop hits its cap. Failed runs
render an Alert banner with the structured __loopError payload (the
offending node, the cap, and the last output) for triage.
Failure shapes#
A failed loop run persists a __loopError object in the run context:
{
"kind": "iteration_exhausted",
"nodeId": "draft",
"cap": 5,
"attempts": 5,
"lastOutput": { "response": "..." },
"message": "Iteration limit reached at node \"draft\" after 5 attempts",
}{
"kind": "stalled",
"nodeId": "review",
"attempts": 3,
"lastFingerprint": "...",
"lastOutput": { "result": false, "reasoning": "..." },
"message": "Node \"review\" stalled — produced an identical result 3 times in a row",
}Both are read by the run-view banner and available via the REST/MCP run detail endpoints for programmatic handling.
Multi-node cycles#
The cap and stall semantics work the same for cycles of any size. Three-node
example (plan → fix → review → plan):
initial: plan
states:
plan:
type: ai_agent
label: Plan the work
model: claude-haiku-4-5
prompt: 'Plan the changes given {{ review.reasoning || "" }}.'
on:
done: fix
fix:
type: ai_agent
label: Apply the plan
model: claude-sonnet-5
prompt: 'Apply this plan: {{ plan.response }}.'
on:
done: review
review:
type: ai_judge
label: Review the result
model: claude-sonnet-5
prompt: 'Does this meet the brief?'
validator: { kind: boolean }
maxIterations: 10
on:
'true': shipped
'false': plan
shipped:
type: end
label: Shipped
outcome: successCap on review (the loop-target of the back-edge) bounds the whole cycle.
The runner records iterationIndex for every node visit, so the run
timeline shows each node's full visit history rather than only its most
recent pass.
Variations#
- Multiple back-edges to the same target. Several branches can route back to the same draft node; they merge into one cycle, and the cap on the target bounds all of them together.
- Cap on the reviewer instead of the draft node. Functionally identical for most workflows. Put the cap where the constraint reads most naturally ("review at most 5 times" vs "draft at most 5 times").
- Human-in-the-loop variant. Replace the AI judge with a
humannode. The cap requirement drops automatically because the loop can't run unattended. - Remember work across iterations. A
scriptnode in the loop can use$ROSCOE_RUN_DIR(a temporary folder unique to the run) as loop memory: read a file at the top of each pass, update it, write it back. It's keyed to the run, so concurrent runs never clobber each other, unlike the old hazard of writing to the workflow-wide$ROSCOE_WORKFLOW_DIR. See /docs/nodes/script.
Try it locally#
Ready-to-run fixtures covering every permutation live in
packages/core/test-fixtures/closed-loop/:
| Fixture | What it demonstrates |
|---|---|
01-self-loop-exits.yaml |
A single-node retry loop that exits naturally |
02-self-loop-cap-exhausted.yaml |
iteration_exhausted when the loop never exits |
03-two-node-bounded.yaml |
Canonical gate ↔ fix shape exiting cleanly |
04-two-node-cap-exhausted.yaml |
Same shape, gate never passes, cap fires |
05-three-node-cycle.yaml |
Multi-node cycle with the cap on review |
06-stall-detected.yaml |
detectStall short-circuits identical verdicts |
07-stall-output-varies.yaml |
detectStall allows progress when output changes |
08-human-gated.yaml |
Human-gated cycle that doesn't need a cap |
Each fixture takes a counter file via --var counterFile=/tmp/... so it can
be run repeatedly without manual state cleanup.
See also#
- /docs/nodes/ai-judge — the most common reviewer node type.
- /docs/nodes/consensus — when a single reviewer isn't enough.
- /docs/recipes/multi-step-analysis — the linear "summarize → verify → loop on reject" recipe this generalises.
- /docs/authoring/yaml-structure — the cycle integrity rules the loader enforces.