# Loops
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.

Why can't the agent decide when it's done?
Diagram as text
```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
```
```text
# 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.
> **VersionNote:** Since 0.8.0, compaction (automatic context summarization) is off by default; the context boundary is therefore a meaningful stop signal rather than an automatically absorbed event.
## 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` | | yes | minItems 1 | Array of codons to execute in each iteration |
| `budget` | `object` | | no | | Budget scope for this loop. |
| `archiveOnSuccess` | `array` | | 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.
```json
{
"$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](/0.10.0/files/concepts/codons) and the [hank JSON reference](/0.10.0/files/reference/hank-json).
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.
```text
╭────────────────────────────────────────────────────────────────────╮
│ Hankweave v0.10.0 │
│ darwin arm64 • node v23.8.0 │
╰────────────────────────────────────────────────────────────────────╯
Calculating data signature for validation...
> Validating configuration: /fixtures/scenarios/loops/hank.json
Data source: /fixtures/scenarios/loops/data
Execution path: ~/.hankweave-executions/validation-
✓ 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
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
```
```text
{"id":"","timestamp":"","type":"codon.started","data":{"codonId":"append-a#0","codonName":"Codon A appends","sessionId":"","startTime":""}}
{"id":"","timestamp":"","type":"codon.completed","data":{"codonId":"append-a#0","success":true,"cost":"","duration":"","exitStatus":{"type":"success"}}}
{"id":"","timestamp":"","type":"codon.started","data":{"codonId":"append-b#0","codonName":"Codon B appends","sessionId":"","startTime":""}}
{"id":"","timestamp":"","type":"codon.completed","data":{"codonId":"append-b#0","success":true,"cost":"","duration":"","exitStatus":{"type":"success"}}}
{"id":"","timestamp":"","type":"loop.iteration.completed","data":{"loopId":"append-loop","iteration":0,"durationMs":"","costUsd":"","tokensUsed":"","isFinal":false}}
{"id":"","timestamp":"","type":"codon.started","data":{"codonId":"append-a#1","codonName":"Codon A appends","sessionId":"","startTime":""}}
{"id":"","timestamp":"","type":"codon.completed","data":{"codonId":"append-a#1","success":true,"cost":"","duration":"","exitStatus":{"type":"success"}}}
{"id":"","timestamp":"","type":"codon.started","data":{"codonId":"append-b#1","codonName":"Codon B appends","sessionId":"","startTime":""}}
{"id":"","timestamp":"","type":"codon.completed","data":{"codonId":"append-b#1","success":true,"cost":"","duration":"","exitStatus":{"type":"success"}}}
{"id":"","timestamp":"","type":"loop.iteration.completed","data":{"loopId":"append-loop","iteration":1,"durationMs":"","costUsd":"","tokensUsed":"","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` | | 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](/0.10.0/files/concepts/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 `contextExceeded` loop requires every body codon to use `continuationMode: "continue-previous"`. A `fresh` codon resets context between iterations, so the validator rejects that combination at load time.
* Consecutive `continue-previous` codons 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; choose `retry` or `ignore` when that is the intended policy.
* A loop codon's `rigSetup` operations produce a validation warning unless they set `allowFailure: true`. Setup that creates a resource can succeed on iteration 0 and fail on a later iteration.
* `continue-previous` after a loop checks the loop's last codon: a model mismatch is an error, and missing checkpointed files produces a warning.
* `continue-previous` after a codon with `exhaustWithPrompt`, or after a loop whose last codon has it, is a load-time error because there is no remaining context to continue.
> **Pitfall:** The default `onFailure: "abort"` applies to each loop-body codon. Use `retry` with a bounded `retryConfig`, or `ignore`, when a transient failure should not stop the whole loop.
## What a loop leaves behind
A loop-level `archiveOnSuccess` runs once when the loop terminates, at `rigArchive/-loop/`. A codon's `archiveOnSuccess` inside a loop runs after each iteration, at `rigArchive/-/-/`. 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.
```text
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](/0.10.0/files/reference/execution-directory).
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](/0.10.0/files/author/designing-codons-and-handoffs).