Skip to content

Loop Nodes

DAG workflow nodes support a loop field that runs an AI prompt repeatedly until a completion condition is met. Each iteration is a full AI agent session that can read files, write code, run commands, and produce output.

Use loop nodes for autonomous multi-step work: implement N stories from a PRD, iterate on a design until validation passes, or refine output until quality criteria are met.

A loop node’s iteration prompt can live inline (loop.prompt) or in a command file (loop.command, resolved the same way command: nodes load their text). Provide exactly one — both at once, or neither, is rejected at workflow load time.

name: iterate-until-done
description: Implement stories one at a time
nodes:
- id: setup
bash: |
echo "Found 3 stories to implement"
- id: implement
depends_on: [setup]
loop:
prompt: |
Read the PRD and implement the next unfinished story.
Validate your changes before committing.
Setup context: $setup.output
User request: $USER_MESSAGE
When all stories are done, output: <promise>COMPLETE</promise>
until: COMPLETE
max_iterations: 10
fresh_context: true
- id: report
depends_on: [implement]
prompt: |
Summarize what was implemented: $implement.output

A loop node iterates its prompt until one of these conditions is met:

  1. LLM completion signal — the AI outputs <promise>SIGNAL</promise> where SIGNAL matches the until value
  2. Deterministic bash check — an until_bash script exits with code 0
  3. Structured field — the iteration’s validated output_format payload has the until_field property set to true
  4. Max iterations reached — the node fails with a clear error

A loop must declare at least one of until / until_bash / until_field — none is required on its own. They are OR’d: whichever fires first ends the loop, and the cheap channels are checked before until_bash, which is skipped once another one fired (it cannot change the outcome, and skipping it avoids an extra run of a side-effecting script).

Each iteration is a full AI agent invocation with tool access. Between iterations, the executor checks for workflow cancellation.

Your completion conditionDeclare
Externally checkable — tests pass, a file exists, a state field flippeduntil_bash only. No prose is matched, so a sentinel the model happens to emit while reasoning about the criteria cannot end the loop early.
A judgment the model makes, no human in the loopoutput_format + until_field. The decision becomes a schema-validated boolean instead of a string match — the model cannot end the loop by mentioning a word.
A judgment the model makes, shown to a human at a gateuntil (with interactive). The iteration’s output is a message a person reads and replies to, so prose is the right medium — declaring output_format would replace it with JSON.

Declaring more than one is fine and means “whichever fires first ends it”.

- id: my-loop
loop:
prompt: "..." # Inline prompt. Exactly one of `prompt` or `command` is required.
# command: <name> # Alternative to `prompt`: package-local or shared command name,
# # loaded once per run and reused for every iteration.
# # Never combine with `prompt` — the loader rejects both together.
until: COMPLETE # Prose completion signal.
max_iterations: 10 # Required. Hard limit — node fails if exceeded.
fresh_context: true # Optional. Default: false.
until_bash: "..." # Bash script checked after each iteration; exit 0 = complete.
until_field: done # Property in this node's `output_format` whose validated
# value `true` ends the loop.
#
# At least ONE completion channel is required — any of
# until / until_bash / until_field. None is required alone.
interactive: true # Optional. Default: false. Pause after each non-completing
# iteration for user input via /workflow approve.
gate_message: "..." # Required when interactive: true. Message shown to the
# user at each pause with the run ID and approve command.
signal_completes: true # Optional. Default: false. Interactive loops only: a detected
# completion signal completes the node immediately (even on
# iteration 1) instead of gating for confirmation.

The prompt text sent to the AI each iteration. Supports all standard variable substitution:

VariableValue
$ARGUMENTS / $USER_MESSAGEOriginal user message
$ARTIFACTS_DIRWorkflow artifacts directory
$BASE_BRANCHRepository base branch
$DOCS_DIRDocumentation directory path (default: docs/)
$WORKFLOW_IDCurrent workflow run ID
$nodeId.outputOutput from upstream nodes
$LOOP_USER_INPUTUser feedback provided via /workflow approve <id> <text> at an interactive loop gate. Only populated on the first iteration of a resumed interactive loop; empty string on all other iterations.
$LOOP_PREV_OUTPUTCleaned output of the previous loop iteration. Empty string on the first iteration. Useful for fresh_context: true loops that need to reference what the previous pass produced or why it failed.

$USER_MESSAGE is particularly important for fresh_context: true loops — the agent has no memory of prior iterations, so the prompt must include all context needed to continue the work. $LOOP_PREV_OUTPUT complements this by exposing the previous iteration’s own output without forcing the engine to thread the session.

Alternative to prompt — names a command file whose body is loaded as the iteration prompt. Exactly one of prompt or command is required; specifying both, or neither, is rejected at workflow load time with a clear error.

The named command resolves exactly like a command: node. In a packaged workflow it resolves only from that workflow’s commands/ directory, including after include: expansion. In a legacy workflow it uses repo → home → bundled precedence. The same command-name safety rules apply — no path separators, no .., no leading . — and unsafe names are rejected at parse time. Static workflow validation also flags a loop.command that points at a missing file, with guidance to create it in the owning workflow’s commands/ directory for packaged workflows or in .archon/commands/ for legacy workflows, the same way it does for command: nodes.

For an ordinary workflow, the file is read once per run when the loop node starts. For a workflow composed through include:, Archon resolves and compiles the command body during load-time composition so its node references and declared inputs are proven before the child joins the parent’s flat DAG. A matching file that is unreadable fails closed; Archon never falls through to a lower-precedence command with the same name. A missing, empty, unreadable, or non-hermetic included command fails before a fresh AI turn.

For any interactive loop — whether authored with prompt or command — Archon persists the resolved prompt template at the gate. A resumed run prefers that snapshot, so editing inline YAML or a command source while the run is paused does not change its prompt; deleting a command source does not break that resume either. Source edits affect fresh runs, which still require successful command resolution or compilation.

Once loaded, the text behaves identically to an inline prompt: all the variable substitution above applies unchanged (including $LOOP_PREV_OUTPUT and $LOOP_USER_INPUT), as do all the iteration semantics (until, until_bash, max_iterations, fresh_context, interactive / gate_message).

- id: implement
model: opus
depends_on: [generate-prd]
loop:
command: archon-ralph-implement # resolved repo → home → bundled (see precedence above)
until: COMPLETE
max_iterations: 15
fresh_context: true

Use this when the iteration prompt is long enough that keeping it inline obscures the shape of the pipeline — Ralph-style implement/build loops and iterate-until-valid loops are the typical case. It is the loop-node parallel of moving a prompt: node to a command: node.

The completion signal string. The executor checks each iteration’s output for:

  1. Tag format (recommended): <promise>COMPLETE</promise> — case-insensitive match (both tags and signal value), whitespace-tolerant. Prevents false positives from the AI mentioning the signal word in discussion.
  2. Plain signal (fallback): The signal at the very end of output (trailing whitespace and punctuation tolerated) or on its own line. More prone to false positives — prefer the tag format.

The <promise> tags are automatically stripped from output sent to the user and to downstream nodes.

Optional, like every completion channel. Omit until entirely whenever another channel decides completion — the executor then never matches prose at all, so the signal cannot false-positive on a model that mentions its own exit criterion. What the loader requires is that at least one channel is declared; a loop with none is rejected at load.

Hard safety limit. If the loop reaches this count without meeting any declared completion channel, the node fails (not succeeds). This prevents runaway loops from burning tokens indefinitely.

Choose based on the work scope:

  • Simple refinement loops: 3–5
  • Multi-story implementation: 10–15
  • Long-running autonomous agents: 15–20

Controls session continuity between iterations:

ValueBehaviorUse when
trueEach iteration starts a fresh AI session. No memory of prior iterations.Work state lives on disk (files, git). Prevents context window exhaustion on long loops.
false (default)Sessions thread — each iteration resumes the prior conversation.Iterative refinement where the agent needs to remember what it tried before.

The first iteration is always fresh regardless of this setting.

A structured-output re-ask (see until_field) does not disturb threading. It runs in its own throwaway session so an invalid turn is not carried forward as context, and that session is discarded — the next iteration still resumes the conversation the loop was already threading. The repaired answer still reaches it through $LOOP_PREV_OUTPUT.

Bash script executed after each iteration. If it exits with code 0, the loop completes — even if the AI didn’t output the completion signal. Optional when until is set; on its own it is the whole completion channel, which is the preferred shape whenever the condition is externally checkable:

loop:
prompt: "Fix the failing tests"
max_iterations: 5
until_bash: "bun run test" # Loop ends when tests pass. No `until:` — nothing to
# false-positive on, and no dead field to invent.

until_bash runs only on iterations that no other completion channel already ended, so a loop declaring more than one never pays for a redundant check.

This is useful for deterministic completion criteria: test suites, lint checks, build success. The bash script supports the same variable substitution as prompt ($ARTIFACTS_DIR, $nodeId.output, etc.). Note: $nodeId.output values are shell-escaped when substituted into until_bash. The same double-quoting footgun that applies to bash: nodes applies here — see Shell Quoting in bash: vs script: for the unquoted idiom to use.

Names a property in this node’s output_format schema. The loop ends on the first iteration whose validated payload has that property set to exactly true.

- id: triage
output_format:
type: object
properties:
done: { type: boolean }
remaining: { type: integer }
required: [done]
loop:
prompt: |
Work the next item in the backlog. Report whether the backlog is now empty.
max_iterations: 20
until_field: done

Use this when completion is a judgment the model makes and there is nothing on disk to test. It replaces a prose sentinel with a schema-validated boolean, so the model cannot end the loop by mentioning a word while reasoning about its criteria.

Four rules are checked at load time, so a mistake fails the workflow rather than looping silently:

  1. the node must declare output_format;
  2. the property must appear in output_format.properties;
  3. it must be listed in output_format.required — an optional property the model omits would read as “not complete” and burn max_iterations;
  4. if it declares a type, that type must be boolean.

Termination is strict identity: true ends the loop, and nothing else does — not "true", not 1. A payload that fails schema validation is a failure, not a quiet “not done yet” (see below).

The loop’s output becomes the validated JSON. With output_format declared, $loopId.output is the payload rather than the prose, $loopId.output.<field> gets the same strict field access every other producer enforces, and $LOOP_PREV_OUTPUT carries the same value.

Per-provider behavior is the same as any other node. On a provider with enforced structured output (Claude, Codex, OpenCode) the schema constrains decoding. On a best-effort provider (Pi, Copilot) the schema is appended to the prompt, and a payload that fails validation is re-asked up to three times within the same iteration — a reask does not consume a loop iteration. If those are exhausted, the node fails with the validation errors; it is never treated as an incomplete iteration. See provider capabilities.

until_field is not supported on loop_group, and deliberately so: a group’s body node can already declare output_format and be read by a deterministic check, which is the existing way to express the same thing.

# loop_group equivalent — no new field needed
loop_group:
max_iterations: 10
until_bash: '[ $decide.output.done = "true" ]'
nodes:
- id: decide
output_format:
type: object
properties: { done: { type: boolean } }
required: [done]
prompt: Is the backlog empty?

Each iteration reads state from disk, does one unit of work, writes state back. The prompt tells the agent it has no memory and must bootstrap from files.

- id: implement
depends_on: [setup]
idle_timeout: 600000
loop:
prompt: |
You are in a FRESH session — no memory of previous iterations.
Read the PRD tracking file to find the next unfinished story.
Implement it, validate, commit, update tracking.
When all stories are done: <promise>COMPLETE</promise>
Project context: $setup.output
until: COMPLETE
max_iterations: 15
fresh_context: true

When to use: Multi-story implementation, long-running tasks where context window exhaustion is a risk. The agent reads .archon/ralph/*/prd.json or similar tracking files to know what’s done and what’s next.

When fresh_context: true is needed (to keep each iteration’s context window small) but the agent still benefits from knowing what the previous pass said — typical of implement→validate or generate→review loops — inject the previous iteration’s output via $LOOP_PREV_OUTPUT:

- id: implement-and-qa
loop:
prompt: |
Implement the plan, then run `bun run validate`.
If checks fail, fix the failures.
Previous iteration output (empty on first pass):
$LOOP_PREV_OUTPUT
Use the above to focus your fixes. When all checks pass output:
<promise>QA_PASS</promise>
until: QA_PASS
fresh_context: true
max_iterations: 3

In a continuous run, the first iteration sees $LOOP_PREV_OUTPUT substituted to an empty string; iterations 2+ see the previous iteration’s cleaned output (after <promise> tags are stripped).

When a loop resumes from an interactive approval gate, the first executed iteration after the resume also receives an empty $LOOP_PREV_OUTPUT even if its numeric iteration is 2+ — the prior output lived in a different run and is not carried across the gate.

The agent builds on its own prior work across iterations. Good for iterative refinement where remembering previous attempts matters.

- id: refine
loop:
prompt: |
Review the current implementation and improve it.
Run validation after each change.
When validation passes with zero issues: <promise>DONE</promise>
until: DONE
max_iterations: 5
fresh_context: false

When to use: Fix-iterate cycles, design refinement, test-driven development where the agent needs to remember what it already tried.

Combine LLM work with a deterministic completion check:

- id: fix-tests
loop:
prompt: |
Run the test suite. Read the failures. Fix them one at a time.
If all tests pass: <promise>TESTS_PASS</promise>
until: TESTS_PASS
max_iterations: 8
until_bash: "bun run test"
fresh_context: false

The loop ends either when the AI signals completion or when the bash check succeeds — whichever comes first.

No channel vetoes another. They are OR’d, so an AI that emits TESTS_PASS while tests still fail ends the loop anyway — the bash check never gets to disagree. If the tests are the real exit criterion, say so by dropping until and the sentence that teaches the model to emit it:

- id: fix-tests
loop:
prompt: Run the test suite. Read the failures. Fix them one at a time.
max_iterations: 8
until_bash: "bun run test"
fresh_context: false

Now the only way out is a passing suite.

  • depends_on — upstream dependencies
  • when — conditional execution
  • trigger_rule — join semantics
  • idle_timeout — per-iteration timeout (default: 30 minutes)
  • provider / model — node-level overrides are resolved and used for every iteration
  • $nodeId.output — downstream nodes receive the last iteration’s output

Set interactive: true to pause the loop between iterations and wait for human input. After each iteration the executor:

  1. Sends the gate message to the user along with the run ID and a /workflow approve command. The gate text is engine-generated: a status line naming the completion channel or channels that ended the iteration, or every declared channel when none did, plus a bounded excerpt of the iteration output — followed by your gate_message, so the gate always reports the real iteration outcome. The status line leads the persisted gate message (metadata.approval.message, also the approval_requested event data — what workflow get --json and manage_run read); the chat-delivered message wraps the same text in a ⏸ Input required (loop ..., iteration N): prefix, so in chat the status line appears right after that prefix.
  2. Pauses the workflow run
  3. Waits — the workflow resumes when the user runs /workflow approve <id> [feedback]

The user’s feedback is injected into the next iteration’s prompt via $LOOP_USER_INPUT.

Approve semantics (finalize vs iterate). What an approve does depends on whether any declared completion channel ended the paused iteration:

  • Gate paused on a completed iteration (the status line starts ✅ Completion condition met via for one completed channel or ✅ Completion conditions met via for several): /workflow approve <id> with no feedback accepts the completion — the node finalizes from the already-computed output and the workflow proceeds, with no re-run. Approving with feedback discards that completion and runs another iteration with your feedback as $LOOP_USER_INPUT.
  • Gate paused without a completed condition (the status line starts ⚠️ No completion condition met in this iteration): both forms run another iteration (there is nothing to finalize).

The same rule applies on every approve surface: chat /workflow approve, the CLI (archon workflow approve <id> [--json] — omit the comment to finalize), the HTTP endpoint (omit comment), the web console (“Accept & complete” with an empty comment field), and the manage_run chat tool (no message, or accept: true).

A plain chat message at a loop gate is not itself an approve — the same rule now holds at approval gates. Say what you want and the agent resolves the gate for you, passing your words through as the feedback; ask a question and nothing is resolved. Because your words travel as the approve comment, that route iterates. To finalize, use /workflow approve <id> with no comment, the CLI/web surfaces above, manage_run with accept: true, or signal_completes.

signal_completes — autonomous completion

Section titled “signal_completes — autonomous completion”

By default an interactive loop always gates first, even when the very first iteration reaches a completion condition — the human confirms before the node completes. If you want a completed iteration to finish the node immediately (no gate, no approve), set signal_completes: true:

- id: validate
loop:
prompt: |
Run the validation suite. On PASS output <promise>VALIDATED</promise>.
On failure, describe what failed and wait for instructions.
until: VALIDATED
max_iterations: 5
interactive: true
gate_message: Validation did not pass — review the failures above.
signal_completes: true # signal ⇒ node completes immediately, no gate

With signal_completes: true the gate only appears on iterations where no declared completion channel fired — the pattern for “pass through on success, pause for a human on failure”. The flag has no effect on non-interactive loops (a completed iteration already finishes them); setting it without interactive: true emits a loader warning.

AI approvers / relay steering. An orchestrating agent can steer another run’s gate: read the structured gate state first (archon workflow get <id> --json.metadata.approval.completionSignaled, or the manage_run get action, which prints completionSignaled, the iteration, and an output excerpt), then finalize with archon workflow approve <id> --json (no comment) or manage_run approve with accept: true — or iterate by passing feedback. The --json approve records the decision without resuming; a later resume executes the finalize or the next iteration.

Note: Interactive loop nodes require interactive: true at the workflow level as well. If only the loop node has interactive: true, a loader warning is emitted and the workflow will not pause correctly in web background mode.

name: guided-refine
description: Refine output with human review between iterations.
interactive: true # Required at workflow level for interactive loops
nodes:
- id: refine
loop:
prompt: |
Review the current draft and improve it based on this feedback: $LOOP_USER_INPUT
When the output is satisfactory, output: <promise>DONE</promise>
until: DONE
max_iterations: 5
interactive: true
gate_message: Review the output above. Reply with your feedback or type DONE to finish.
  • retry — rejected at parse time. The loader fails the workflow if retry: is set on a loop node.
  • context: fresh — silently ignored. Session control is handled exclusively by fresh_context within the loop: config
  • hooks — per-node SDK hooks are not passed through to loop iterations
  • mcp — per-node MCP server configs are not loaded for loop nodes
  • skills — skill preloading is not applied to loop iterations
  • allowed_tools / denied_tools — tool restrictions are not enforced on loop iterations

These fields (except retry) are silently discarded at parse time with a loader warning — the workflow still loads but the fields have no effect. retry is the exception: it causes a hard load error.

The loop executor manages its own AI sessions independently from the standard node executor. If you need hooks, MCP, skills, or tool restrictions, consider using a command node that wraps the iterative logic in a command file.

A loop node’s output (available via $nodeId.output to downstream nodes) is the last iteration’s output only — not a concatenation of all iterations.

If you need to accumulate results across iterations, write them to files in $ARTIFACTS_DIR and have the downstream node read from there.

ScenarioBehavior
Iteration throws an errorNode fails immediately (no more iterations)
Max iterations exceededNode fails with descriptive error
Workflow cancelledDetected between iterations, node stops
Idle timeout per iterationIteration completes with whatever output was collected; loop continues to next iteration
retry configured on nodeRejected at parse time — workflow fails to load

A loop node iterates a single prompt. A loop_group node iterates a multi-node sub-DAG — a sealed body of nodes that re-runs in full each iteration until a completion condition is met. Use it when “iterate until done” needs a pipeline of steps (e.g. implement → test → review), not just one repeated step.

name: fix-until-green
description: Implement, test, and review until tests pass
nodes:
- id: fix-loop
loop_group:
until: TESTS_PASS # completion signal in the body's terminal output
max_iterations: 5
fresh_context: false # false (default) = body AI sessions continue across iterations
nodes: # sealed sub-DAG body — repeats as a unit
- id: implement
prompt: Fix the failing tests. Emit TESTS_PASS only when all pass.
depends_on: []
- id: test
bash: |
if bun test; then
printf 'green'
else
printf 'red'
fi
depends_on: [implement]
- id: review
prompt: Summarize the result; echo TESTS_PASS if tests are green.
depends_on: [test]
  • From the outer DAG’s perspective, fix-loop is one node. The cycle is encapsulated inside it — the outer DAG stays acyclic.
  • Each iteration runs the body’s topological layers in full (concurrent nodes within a layer run in parallel, same as a normal DAG).
  • The body is sealed: a body node’s depends_on may only reference sibling body nodes, not outer-DAG nodes. Outer context is still reachable via $nodeId.output refs in body prompts.
  • Loop-control and body lifecycle events use the namespaced step name {groupId}.{nodeId} in the persisted event log, with the iteration recorded in event data. Nested groups compose the prefix.
  • A body node that fails fails the whole group immediately with that node’s error (no further iterations run) — same semantics as a failed node in a top-level DAG.
  • $fix-loop.output (visible to the outer DAG) is the final iteration’s terminal-node output.

include: is valid in a loop_group body. Discovery expands the block before the run starts, so each iteration executes ordinary namespaced body nodes — there is no child run or runtime-resolved graph:

- id: converge
loop_group:
until_bash: test $review.output.ready = "true"
max_iterations: 5
nodes:
- id: review
include: reusable-review
with:
request: $INPUTS.request

The block’s returns: node supplies $review.output, including in until_bash. Internal dependencies, inputs, gates, and namespacing behave the same as for a top-level include. A workflow: node still cannot appear in a group body because it creates a separately governed runtime sub-run rather than static composition.

A body node can reference a sibling’s output from the previous iteration with $LOOP_PREV.<nodeId>.output (and $LOOP_PREV.<nodeId>.output.<field> for structured output):

nodes:
- id: fix-loop
loop_group:
# The terminal node's own verdict ends the loop; nothing here emits a
# prose sentinel, so `until:` would run to max_iterations on a green run.
until_bash: test $test.output = "green"
max_iterations: 5
nodes:
- id: implement
prompt: |
Previous attempt's test output:
$LOOP_PREV.test.output
Fix what failed.
depends_on: []
- id: test
bash: |
if bun test; then
printf 'green'
else
printf 'red'
fi
depends_on: [implement]

On iteration 1 (no prior iteration), $LOOP_PREV.* resolves to an empty string. Field access uses the same strict semantics as $nodeId.output.field (a field not in the producer’s declared schema fails the consuming node rather than silently degrading).

A body node can also gate on the previous iteration’s typed output with when::

- id: repair
prompt: Repair the failing tests.
depends_on: [test]
when: "$LOOP_PREV.test.output == 'red'"

This is a condition reference, not text substitution. On iteration 1 it resolves to '', so the non-empty equality is false. See Condition Syntax.

$LOOP_PREV works in a body node’s with: binding string values too, with the same text semantics as every other body surface: the previous iteration’s output is substituted into the value each pass (so with: { prev: $LOOP_PREV.test.output } delivers last iteration’s text, '' on iteration 1 — never a typed value). It is not a node reference, so a binding directive’s from: cannot read it — the loader rejects that at load and names the string form instead.

loop_group shares the same iteration-control fields as loop: until, max_iterations, fresh_context, until_bash, interactive, and gate_message — including the at-least-one-of until / until_bash rule and the short-circuit between them. The difference is the body: loop takes a single prompt; loop_group takes a nodes array.

As with a loop: node (whose node-level model/provider are resolved and applied to every iteration), model and provider set on a loop_group node are honored: they become the default for every body AI node, overridable per body node.

Two distinct cases:

  • Interactive-gate resume (/workflow approve <id>): the loop continues with the next iteration’s whole body. With fresh_context: false, the body’s AI session continues from where it paused (the session cursor is persisted across the gate). $LOOP_PREV.* refs on the first resumed iteration resolve to the prior iteration’s real body outputs, restored from the persisted run history — the same as an un-paused next iteration would have seen.
  • Failure resume (/workflow resume <id> after a crash/failure): there is no persisted iteration cursor — the loop_group node restarts from iteration 1. Per-body-node resume granularity is not supported in v1.

What is NOT supported on loop_group nodes (v1)

Section titled “What is NOT supported on loop_group nodes (v1)”
  • until_field / output_format — the group never calls the provider itself, so it has no payload of its own to read. Express the same thing with a body node that declares output_format plus until_bash reading $node.output.field — see until_field.
  • retry (the loop manages its own iteration) — rejected at parse time.
  • persist_session for body AI nodes across iterations — body sessions reset per iteration (governed by fresh_context).
  • Per-body-node resume (skip-to-failed-body-node) — the whole iteration re-runs.
  • $LOOP_PREV.<id>.output[N] history indexing — only the immediately prior iteration is reachable.

Nested loop_group inside a loop_group body is supported by construction (the body is a normal nodes array), but is not hardened in v1.