Some work only converges through repetition: revise, test, revise again, until the result is good enough or the effort stops paying off. A single codon cannot express that, because a codon runs once. Asking the agent itself to decide when it is "done" moves the stopping decision into a place where the runtime cannot check it, meter it, or show it to you.
A loop makes the repetition explicit instead. You declare the condition that ends the loop, and hankweave expands the next iteration only when the current one reaches its boundary. That gives you a fixed iteration ceiling or a runtime context boundary in place of an agent's undefined sense of "done." This page builds up how that works: why the stop condition belongs to the runtime, what a loop declares, how it decides to stop, what can interrupt it mid-iteration, what the validator enforces, and what a finished loop leaves behind.
Why the runtime, not the agent, decides when to stop#
An agent can judge a task, so why declare a stop condition at all? Because two different decisions are involved, and they belong to different parties. The agent judges the work; the runtime decides whether to schedule more of it. Only the second decision can be validated, budgeted, and shown in the debugger.
A loop's terminateOn value is a schema-checked choice between a numeric iteration limit and a context-exhaustion signal. The runtime places only the first iteration in the plan; after its last codon completes, it checks the stop condition before deciding whether to inject another iteration. A fixed limit supplies a known ceiling, while a context boundary supplies a signal that cannot be predicted before the work fills the context.
The figure below shows that decision point. Only iteration 0 exists in the plan at the start; each later iteration is injected lazily, after the check passes.
Read the diagram as text
iteration 0 is placed in the plan
│
▼
last codon completes ──► check stop condition and runtime limits
│ │
│ stop │ continue
▼ ▼
next top-level item inject iteration 1, then repeat
# Pseudocode: lazy expansion
plan ← [iteration 0]
after the last codon of iteration N completes:
check terminateOn and runtime limits
if the loop is not done:
inject iteration N + 1 after the current position
otherwise:
continue with the next top-level item
This is not a promise that every iteration will finish the work. It is a decision point at every boundary: the runtime can stop for the declared condition, budget exhaustion, or context overflow without planning work that has not run. The debugger therefore shows the work that has actually entered execution rather than a speculative expanded plan.
What a loop declares#
A loop is a hank array entry with type: "loop". Its required fields are id, name, terminateOn, and codons; description, budget, and archiveOnSuccess are optional. The schema strip below shows the supported field shapes and the required set.
| field | type | default | required | constraints | description |
|---|---|---|---|---|---|
type | string | yes | = loop | Type discriminator - required for loops | |
id | string | yes | minLength 1 | Unique identifier for this loop | |
name | string | yes | minLength 1 | Human-readable name displayed in UI and logs | |
description | string | no | Optional description shown to users | ||
terminateOn | iterationLimit | contextExceeded | yes | Termination condition for the loop | ||
codons | array<object> | yes | minItems 1 | Array of codons to execute in each iteration | |
budget | object | no | Budget scope for this loop. | ||
archiveOnSuccess | array<string> | no | Paths to archive when the loop terminates. Paths are relative to the agent workspace. |
The body is a non-empty array of codon objects. Nested loops are not accepted by the loop schema. Each codon instance receives a runtime ID such as append-a#0; the iteration number is zero-based. The execution entry also carries loopContext with the loop ID, the zero-based iteration, and the codon's zero-based position within the loop.
To see these fields in combination, here is a minimal fixture with two codons and an iterationLimit of 2. It validates with exit status 0.
{
"$schema": "https://unpkg.com/hankweave@0.10.0/schemas/hank.schema.json",
"meta": {
"name": "Minimal loop",
"version": "1.0.0",
"description": "Two haiku codons inside one loop bounded by iterationLimit 2."
},
"hank": [
{
"type": "loop",
"id": "append-loop",
"name": "Append loop",
"terminateOn": {
"type": "iterationLimit",
"limit": 2
},
"codons": [
{
"id": "append-a",
"name": "Codon A appends",
"model": "haiku",
"continuationMode": "fresh",
"promptFile": "./prompts/append-a.md",
"checkpointedFiles": [
"log.txt"
]
},
{
"id": "append-b",
"name": "Codon B appends",
"model": "haiku",
"continuationMode": "fresh",
"promptFile": "./prompts/append-b.md",
"checkpointedFiles": [
"log.txt"
],
"outputFiles": [
{
"copy": [
"log.txt"
]
}
]
}
]
}
]
}
A loop iterates until it terminates. For the mechanisms used by its body codons, including continuation, retry, sentinels, checkpoints, output files, and codon-level budgets, see codon concepts and the hank JSON reference.
Validating this fixture produces the plan and run captures below. Read the plan first: append-loop has two iterations, each containing append-a followed by append-b. The run capture then shows the corresponding runtime IDs, from append-a#0 and append-b#0 through append-a#1 and append-b#1, with a loop.iteration.completed event after each iteration.
╭────────────────────────────────────────────────────────────────────╮
│ Hankweave v0.10.0 │
│ darwin arm64 • node v23.8.0 │
╰────────────────────────────────────────────────────────────────────╯
Calculating data signature for validation...
> Validating configuration: <workspace>/fixtures/scenarios/loops/hank.json
Data source: <workspace>/fixtures/scenarios/loops/data
Execution path: ~/.hankweave-executions/validation-<id>
✓ Configuration is valid!
╭──────────────────────────────────────────────────────────────────────────────╮
│ Minimal loop v1.0.0 │
│ 2 codons • 1 loop │
╰──────────────────────────────────────────────────────────────────────────────╯
└─ [1] LOOP: append-loop (Append loop) × 2 iterations
╭────────────────────────────────────────────────────────────
│
├─ [1.1] append-a (Codon A appends)
│ model: haiku │ mode: fresh │ prompts: 1 (2 lines)
│ checkpointedGlobs: 1
│ ↓
└─ [1.2] append-b (Codon B appends)
model: haiku │ mode: fresh │ prompts: 1 (2 lines)
checkpointedGlobs: 1
│
╰────────────────────────────────────────────────────────────
╭─ GOOD TO RUN! ─────────────────────────────────────────────────────╮
│ 2 codons • 2 prompts • 0 system prompts • 0 rigs • 2 checkpoints │
╰────────────────────────────────────────────────────────────────────╯
Run it: hankweave hank.json <data_path>
Environment Variables:
From System (HANKWEAVE_ prefixed):
- CAPTURE_VERSION: 0.10.0
Warnings:
- Loop 'append-loop' > Codon 1 (append-a): loop codon defaults to 'onFailure: abort'. A single transient failure (idle timeout, one-off provider error) on any iteration will halt the entire loop run. Consider 'onFailure: retry' (with retryConfig for bounded attempts) or 'onFailure: ignore' to keep the loop progressing across transient blips.
- Loop 'append-loop' > Codon 2 (append-b): loop codon defaults to 'onFailure: abort'. A single transient failure (idle timeout, one-off provider error) on any iteration will halt the entire loop run. Consider 'onFailure: retry' (with retryConfig for bounded attempts) or 'onFailure: ignore' to keep the loop progressing across transient blips.
exit=0
{"id":"<id>","timestamp":"<ts>","type":"codon.started","data":{"codonId":"append-a#0","codonName":"Codon A appends","sessionId":"<session>","startTime":"<ts>"}}
{"id":"<id>","timestamp":"<ts>","type":"codon.completed","data":{"codonId":"append-a#0","success":true,"cost":"<n>","duration":"<n>","exitStatus":{"type":"success"}}}
{"id":"<id>","timestamp":"<ts>","type":"codon.started","data":{"codonId":"append-b#0","codonName":"Codon B appends","sessionId":"<session>","startTime":"<ts>"}}
{"id":"<id>","timestamp":"<ts>","type":"codon.completed","data":{"codonId":"append-b#0","success":true,"cost":"<n>","duration":"<n>","exitStatus":{"type":"success"}}}
{"id":"<id>","timestamp":"<ts>","type":"loop.iteration.completed","data":{"loopId":"append-loop","iteration":0,"durationMs":"<n>","costUsd":"<n>","tokensUsed":"<n>","isFinal":false}}
{"id":"<id>","timestamp":"<ts>","type":"codon.started","data":{"codonId":"append-a#1","codonName":"Codon A appends","sessionId":"<session>","startTime":"<ts>"}}
{"id":"<id>","timestamp":"<ts>","type":"codon.completed","data":{"codonId":"append-a#1","success":true,"cost":"<n>","duration":"<n>","exitStatus":{"type":"success"}}}
{"id":"<id>","timestamp":"<ts>","type":"codon.started","data":{"codonId":"append-b#1","codonName":"Codon B appends","sessionId":"<session>","startTime":"<ts>"}}
{"id":"<id>","timestamp":"<ts>","type":"codon.completed","data":{"codonId":"append-b#1","success":true,"cost":"<n>","duration":"<n>","exitStatus":{"type":"success"}}}
{"id":"<id>","timestamp":"<ts>","type":"loop.iteration.completed","data":{"loopId":"append-loop","iteration":1,"durationMs":"<n>","costUsd":"<n>","tokensUsed":"<n>","isFinal":true,"terminationReason":"iteration_limit"}}
How a loop decides to stop#
Every loop chooses exactly one terminateOn variant. The two variants and their constraints are shown below.
| field | type | default | required | constraints | description |
|---|---|---|---|---|---|
iterationLimit | object | type = iterationLimit | |||
↳ limit | integer | yes | min 1 | ||
contextExceeded | object | type = contextExceeded |
With iterationLimit, the limit is checked after the last codon of an iteration completes. A limit of 1 runs iteration 0 once; a limit of 3 runs iterations 0, 1, and 2. With contextExceeded, the loop continues until the runtime reports context exhaustion; it is not a precomputed iteration count.
Context exhaustion is a successful loop termination. The loop is marked complete and execution continues with the next top-level item, but the context remains exhausted, so a following top-level codon cannot use continue-previous.
Compaction changes which signal supplies context exhaustion. With autoCompact: false (the default), the provider's context-overflow error is the signal. With autoCompact: true, the harness emits compact_boundary, compacts the session, and continues until that boundary is reached.
What happens when the money runs out#
The declared stop condition is not the only thing that can end a loop. Budget exhaustion is a separate early-stop mechanism, not a third terminateOn variant. It can preempt either declared termination condition when the loop's dollar or time budget is exhausted during an iteration. The loop terminates at the current codon, removes the remaining codons in that iteration, and proceeds to the next top-level item.
Dollar usage is metered as assistant messages arrive. When accumulated cost reaches maxDollars, the budget tracker emits the exceeded state and the codon runner requests SIGTERM; an interrupt can therefore arrive after an already-billable message. A completed codon can expose budgetExceeded with currency, limit, and used. The loop iteration event's terminationReason does not include a budget value.
A loop budget uses the same container shape as a hank-level budget override:
| field | type | default | required | constraints | description |
|---|---|---|---|---|---|
maxDollars | number | no | > 0 | Total cost budget in USD. | |
maxTimeSeconds | number | no | > 0 | Wall-clock time limit in seconds. | |
allocation | enum | shared | no | shared | proportional | proportional-strict | How the dollar budget is distributed among children. 'shared': first-past-the-post (default). 'proportional': pre-allocate shares, unspent flows back. 'proportional-strict': pre-allocate shares, unspent evaporates. |
shares | object<number> | no | Map of child codon/loop ID to fraction (0-1) of the budget. | ||
onExceeded | enum | no | complete | fail | What happens when budget is exceeded. 'complete' = graceful completion (default). 'fail' = codon failure, triggers onFailure policy. |
For allocation behavior, see budgets. Its complete and fail handling is part of that budget contract; do not infer a loop event termination reason from the onExceeded value.
When a loop can stop mid-iteration#
The stop mechanisms described so far fire at different moments, and the order matters. The runtime checks termination in this order: budget exhaustion can fire at any codon; context exhaustion can also fire at any codon in a contextExceeded loop; an iterationLimit check runs only at the iteration's last codon. A contextExceeded loop keeps going until its signal arrives. This is why a loop can stop before all of its declared body has run.
In a contextExceeded loop, if context fills during a multi-codon iteration, the current codon completes with contextExceeded, the remaining codons in that iteration are removed, the loop terminates successfully, and execution continues after the loop. The same removal rule applies when a loop budget is exceeded.
Within an iteration, codons run in array order. With [A, B] and limit: 2, the runtime order is A#0, B#0, A#1, B#1.
Rules the validator enforces before you run#
Several loop constraints are checked at load time rather than discovered mid-run:
- A
contextExceededloop requires every body codon to usecontinuationMode: "continue-previous". Afreshcodon resets context between iterations, so the validator rejects that combination at load time. - Consecutive
continue-previouscodons inside a loop must use the same model because different models cannot share one session ID; this is also a load-time error. - The default
onFailure: "abort"on a loop codon produces a validation warning. A transient failure on one iteration can halt the loop; chooseretryorignorewhen that is the intended policy. - A loop codon's
rigSetupoperations produce a validation warning unless they setallowFailure: true. Setup that creates a resource can succeed on iteration 0 and fail on a later iteration. continue-previousafter a loop checks the loop's last codon: a model mismatch is an error, and missing checkpointed files produces a warning.continue-previousafter a codon withexhaustWithPrompt, or after a loop whose last codon has it, is a load-time error because there is no remaining context to continue.
What a loop leaves behind#
A loop-level archiveOnSuccess runs once when the loop terminates, at rigArchive/<loopId>-loop/<path>. A codon's archiveOnSuccess inside a loop runs after each iteration, at rigArchive/<loopId>-<iteration>/<codonId>-<iteration>/<path>. Runtime IDs such as edit#0 are sanitized to edit-0 in the codon archive path. Archive entries are file globs (file-matching patterns), so use current-project/** to archive a tree rather than the bare directory name.
The tree below comes from a real fixture and shows the resulting archive directories: revise-0/edit-0 and revise-1/edit-1. From an agentRoot command, the archive is the sibling path ../rigArchive/, not a directory inside the agent root.
rigArchive/revise-0/edit-0/current-project/history.txt
rigArchive/revise-1/edit-1/current-project/history.txt
Sentinels declared on loop codons load fresh for each iteration with clean internal history. Their output files accumulate across iterations. Archive path and manifest details belong to execution-directory reference.
Loops also leave a record in the event stream. The loop.iteration.completed event is journaled in the server-state category and routed through sentinels. Its payload includes the loop ID, zero-based iteration, duration, cost, token count, isFinal, and an optional termination reason.
| id | category | journaled | sentinelRouted | payloadFields | receipts |
|---|---|---|---|---|---|
| loop.iteration.completed | server-state | true | true | loopId, iteration, durationMs, costUsd, tokensUsed, isFinal, terminationReason? | schemas/event-schemas.ts:612, schemas/event-schemas.ts:976, schemas/event-schemas.ts:1245, hankweave-runtime.ts:6035 |
In the captured run from the earlier fixture, the first event uses iteration: 0 with isFinal: false, followed by iteration: 1 with isFinal: true and terminationReason: "iteration_limit". The allowed termination reasons are iteration_limit, context_exceeded, sentinel_skip, and failure; budget is not one of them.
How to think about loop design#
Use continue-previous when iterations should accumulate context and build on earlier work. fresh is legal in an iterationLimit loop, but it resets the agent's memory each iteration; it is not allowed in a contextExceeded loop.
Long continue-previous chains in an iterationLimit loop can reach the context window mid-run. With the default autoCompact: false, the iteration that reaches the boundary fails; set autoCompact: true when the loop is expected to cross that window.
Try write → test → review, fill context then summarize, or clean the workspace per iteration, depending on what the next agent needs. We work through the handoffs in the BOUNDARY DRILL.