An unattended agent run can spend money faster than you can watch it. Budgets are how a Hankweave workflow carries spending limits in the program itself: the author records which steps are expensive, which are critical, and what partial work is still worth keeping, and the operator sets the dollar and time envelope for a particular run. This page walks through that contract in the order you will meet it – who sets which limits, the four resources a budget can cap, how dollars reach individual codons, what happens when a cap trips, how loops and resumed runs interact with budgets, and how to catch a bad configuration before any tokens are spent.
Why is cost a program construct and not a dashboard?#
A dashboard can tell us what a run spent, but it cannot carry spending decisions into the next version or the next handoff. When we author a workflow, we already know which steps are expensive, which are critical, and what partial output would still be useful. Putting those decisions in the workflow definition keeps them versioned and reviewable alongside the work they govern, and leaves the operator free to choose the spending and time limits for a particular run.
The contract divides responsibility between two parties. A hank is the workflow definition; a codon is one step in that definition. The author describes the shape of the work – its expensive and critical steps – and sets policies for partial output. The operator owns the wallet, clock, and context envelope around a run. The runtime combines those responsibilities into a resolved plan.
The contract is concrete: the author can set shares and onExceeded: "fail" when partial output is worse than none; the operator can set an envelope such as --max-cost 5.00 and --max-time 600. The runtime resolves the applicable ceiling for each axis, derives per-codon limits, and shows that plan before tokens are spent. The author's shape cannot raise the operator's envelope, and a CLI ceiling does not bypass the author's onExceeded: "fail" policy.
The diagram below shows how the two inputs flow into enforcement. Author and operator settings resolve into ceilings, which become per-codon budgets; a metered-message check and a time watchdog can each request a SIGTERM, and onExceeded decides the outcome.
Read the diagram as text
author: { shares, onExceeded } ─┐
operator: { maxDollars, maxTime } ─┴→ resolved ceilings
↓
per-codon budgets
↙ ↘
metered messages time watchdog
↓ ↓
limit check ─────────────→ SIGTERM request
↓
onExceeded outcome
Reading the figure top to bottom gives the three verbs this page uses throughout: a budget allocates shares, a cap trips, and onExceeded decides whether the codon is marked completed or failed. We can let savings flow forward under proportional allocation, design a loop to add value one iteration at a time, and inspect the resolved plan with --validate before spending. A cheap first run is the practical starting point while you learn what the work needs:
# A 50-cent ceiling for a first safety run
bunx hankweave@0.11.0 hank.json data/ --max-cost 0.50 -m haiku
That command caps the whole run at fifty cents on a small model, so a misconfigured workflow fails cheaply. The -m haiku override takes effect only in a plan built by a fresh run, so point it at a new execution and output directory (for example --start-new --execution exec-cheap -o out-cheap) rather than resuming an existing one.
Budgets also change what a spending limit can buy. Without loops, cost-to-benefit can be a step function: $9.99 buys nothing useful while $10.03 buys 10 units. A cost-driven loop lets each iteration add marginal value, so the operator can choose a point on that curve rather than betting on a single threshold.
One complete captured execution of the anchor workload used five Haiku codons and two pi/baseten/deepseek-ai/DeepSeek-V4-Pro judgment codons. Its configured per-codon caps sum to $9.00; the capture reports $0.7265 in tracked codon cost for that complete execution. The configured sum is a ceiling, not a measured total or price promise, and the tracked figure covers codons in that capture rather than provider health checks or sentinel calls.
The enforcement boundary matters as much as the numbers. Dollar caps are checked incrementally as metered assistant messages arrive. A cost delta is accumulated, the cap trips when the accumulated value reaches the limit, and the running agent receives a SIGTERM request. That can interrupt a codon after an already-billable message; a cap is not a prepaid spending guarantee. Time uses a separate watchdog described below. A provider estimate that lands exactly on a dollar cap is still an edge case: the >= comparison operates on floating-point accumulation, so provider-side rounding precision determines the boundary result.
Two further limits bound this contract. For a model the registry has no pricing data for, each metered message adds $0 of tracked cost, so a dollar cap never trips and preflight warns. A model whose pricing data exists but is zero – zai's glm-5.2-highspeed and glm-5.3-highspeed – behaves the same but draws no warning. Provider rate limits, database-read limits, and customer-facing API-call counts are not budget axes.
You can still use a dashboard. By keeping cost decisions in the workflow definition, we also carry them through versioning and handoffs, rather than depending on monitoring configuration left in another environment.
Who sets what – the two-party contract#
With the rationale in place, the next question is which party controls which field. The author sets the shape: child codon and loop IDs can receive proportional shares, and a step that must not produce partial output can set onExceeded: "fail". The operator sets the envelope with --max-cost <dollars> and --max-time <seconds>, or with budget.maxDollars and budget.maxTimeSeconds in hankweave.json.
For ordinary runtime settings, the precedence order is CLI, then HANKWEAVE_RUNTIME_* environment variables, then hank overrides, then hankweave.json, then defaults. Budget ceilings are special: hankweave.json and hank-override maxDollars/maxTimeSeconds combine with min() to form the effective global ceiling, while an explicit --max-cost or --max-time replaces that result and can loosen it. HANKWEAVE_RUNTIME_* parsing does not provide budget settings. This is distinct from model selection and from the persisted execution plan; see Hanks for the broader configuration discussion.
The following tables list the fields each party can set, starting with the author-controlled fields on a hank 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. |
Codons accept a narrower set of fields – hard caps rather than allocation policy:
| field | type | default | required | constraints | description |
|---|---|---|---|---|---|
maxDollars | number | no | > 0 | Max cost in USD. Execution stopped when exceeded. | |
maxTimeSeconds | number | no | > 0 | Max wall-clock time in seconds. | |
maxOutputTokens | integer | no | > 0 | Max output tokens. | |
maxContextTokens | integer | no | > 0 | Max context window tokens (high-water mark of input+output per turn). Useful for capping context growth independent of cost. | |
onExceeded | enum | no | complete | fail | Override hank-level onExceeded for this codon. |
A loop uses the same container shape as a hank override for its dollar and time settings:
| 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. |
On the operator side, the runtime configuration exposes the two global budget fields:
| field | type | default | required | constraints | description |
|---|---|---|---|---|---|
maxDollars | number | no | > 0 | Global cost budget in USD. Effective global = min(runtime, hank). | |
maxTimeSeconds | number | no | > 0 | Global wall-clock time limit in seconds. Effective = min(runtime, hank). |
Both operator flags are documented in the shipped --help as well as parsed, so the resolved plan at --validate confirms their effect but is not the only evidence that they are active. The JSON equivalents are budget.maxDollars and budget.maxTimeSeconds.
The budgets scenario shows the author side of this contract in practice: a $0.20 proportional pool split into equal shares, with the hank defaulting to onExceeded: "complete" and write-second overriding that policy to "fail".
"overrides": {
"budget": {
"maxDollars": 0.2,
"maxTimeSeconds": 600,
"allocation": "proportional",
"shares": {
"write-line": 0.5,
"write-second": 0.5
},
"onExceeded": "complete"
[since 0.6.1] The resolved plan has shown the global ceiling, allocation mode, per-codon dollar caps with their source, time limits, and onExceeded policies before any tokens are spent, at both --validate and run startup. The validation section below shows what it looks like.
Which resource are you capping?#
So far we have talked about "limits" generically. A budget actually has four currencies. Dollars and time can be set at hank, loop, and codon levels. Output tokens and context tokens are codon-level only.
| Currency | Field | Accepted levels | What it caps |
|---|---|---|---|
| Dollars | maxDollars | hank, loop, codon | USD cost, fungible across codons |
| Time | maxTimeSeconds | hank, loop, codon | Wall-clock seconds |
| Output tokens | maxOutputTokens | codon | Model-generated output tokens |
| Context tokens | maxContextTokens | codon | High-water mark of context fill |
Each currency behaves differently once set. Under shared and proportional allocation, maxDollars is fungible: money one codon does not spend can remain available to later codons. Under proportional-strict, unspent shares evaporate. Cost tracking requires the model to have provider-registry pricing.
maxTimeSeconds is a wall-clock watchdog, and enforcement is active. Whenever a codon or loop time limit is set, a one-second interval timer checks elapsed time, and the same check runs on every cost update. When the limit is crossed, the codon runner requests the codon's interruption. A dated observation recorded one codon running roughly 24,923 seconds against a 2,400-second cap, with the budget error surfacing alongside another failure; that overrun is an unexplained observation from an earlier release, not the design. For unattended work, an external process deadline remains a separate safety boundary.
maxOutputTokens caps model-generated output for one codon. Context handling is separate: continuationMode: "fresh" starts a new session, while continuationMode: "continue-previous" continues the previous codon's session and conversation history.
maxContextTokens tracks the high-water mark of inputTokens + outputTokens per turn, not a cumulative total. A turn here is one assistant-message round trip. This limit is useful when context-window fill affects quality independently of dollar cost.
Provider quota, database-read limits, and customer-facing API-call counts are not additional currencies.
How dollars are allocated among codons#
Of the four currencies, only dollars are divided among children. A container budget can allocate its dollar pool in three modes: shared, proportional, and proportional-strict. The default is shared.
| Mode | How children receive dollars | What happens to savings |
|---|---|---|
shared | Children draw from one remaining pool in execution order; the next codon receives what remains. | A cheaper earlier codon leaves more for later codons. |
proportional | Named children receive shares before work begins. | Unspent dollars flow back to the pool for later codons. |
proportional-strict | Named children receive the same kind of share. | Unspent dollars evaporate; the partition stays strict. |
For example, when a child has a $2 share and spends $1, proportional returns the unused dollar to the pool, while proportional-strict lets it evaporate.
shares maps child codon or loop IDs to fractions greater than 0 and at most 1; a share of 0 fails validation. It is meaningful only with proportional or proportional-strict. Setting it without one of those allocation modes is a preflight error. Proportional allocation also requires maxDollars on the container, because there must be a total to divide.
Given those inputs, the resolution chain for a codon is:
- Find the effective ceiling and subtract prior spending for the remaining pool.
- Apply the allocation mode's math.
- A codon with a share gets
min(allocation, codon.maxDollars). - A capped codon without a share ignores the mode allocation and gets
min(maxDollars, remaining global budget), or onlymaxDollarswhen no global ceiling exists. - A codon without a cap gets the mode's limit – the whole remaining pool under
shared– and is uncapped only when there is also no global ceiling.
The resolved limit never exceeds the codon's own maxDollars; for an unshared capped codon, that cap is the binding limit. onExceeded at the codon level overrides the hank or loop container; when neither level sets it, the policy defaults to complete.
The anchor workload's three normalize codons each carry a $0.50 hard cap. Those configured caps are separate from the workload's $9.00 sum of per-codon caps and from its measured tracked cost.
Time is not allocated proportionally. Codons execute sequentially, so dividing a time budget by shares has no useful meaning. Time remains a shared watchdog at the container level and a hard cap at the codon level.
What happens when a cap trips#
A metered cost or watchdog check can request an interrupt while the codon is running. After that interrupt, onExceeded decides the codon's outcome; the policy is separate from the interrupt mechanism.
| Policy | Result | When the result fits |
|---|---|---|
complete (default) | The codon is marked completed, partial output survives, and downstream codons still run. | Best-effort work where partial output is useful. |
fail | The codon is marked failed non-retriably with reason budget exceeded; its onFailure policy determines what follows. | All-or-nothing work where partial output is worse than none. |
The default is a soft-cap policy: "completed" describes the runtime outcome, not proof that the agent finished the task. A codon whose cap tripped carries budgetExceeded metadata whether it ended completed or failed; under the default the event reports success. That object identifies the currency that tripped, its limit, and the amount used: { currency, limit, used }.
The outcome is settled before the codon's codon.completed event: under fail the codon is marked failed with a Budget exceeded: … reason before that event fires, and the run-level onFailure policy follows the event.
If the cap trips before a required handoff is written, complete can leave the next codon with no file, truncated JSON, or an old artifact. A later model may improvise a plausible replacement instead of exposing the missing work. Put a deterministic boundary check in the consumer's rig, before its agent starts, and check required fields and input coverage rather than only file existence. See Start with a guarded handoff.
Keeping useful partial work is separate from allowing the pipeline to continue. For a required producer, choose onExceeded: "fail" with an intentional onFailure policy such as "abort"; failure status does not itself erase work already written. Checkpoints govern what recovery can restore. If best-effort completion is intentional, define the partial-result contract and make the boundary check reject anything the consumer cannot safely use. Prompt instructions alone cannot enforce that contract after interruption.
See Events for the complete event shapes and surrounding contract. The two events most relevant here are codon.completed, which carries the budgetExceeded? flag, and budget.summary, which reports the ceiling, allocation, and per-codon rows:
| id | category | journaled | sentinelRouted | payloadFields | receipts |
|---|---|---|---|---|---|
| codon.completed | server-state | true | true | codonId, success, cost, duration, exitStatus, failureReason?, failureIgnored?, budgetExceeded? | schemas/event-schemas.ts:672, schemas/event-schemas.ts:1075, schemas/event-schemas.ts:1349, hankweave-runtime.ts:3792 |
| id | category | journaled | sentinelRouted | payloadFields | receipts |
|---|---|---|---|---|---|
| budget.summary | server-state | true | true | ceiling, allocation, rows, totals | schemas/event-schemas.ts:836, schemas/event-schemas.ts:1093, schemas/event-schemas.ts:1381, hankweave-runtime.ts:7086 |
budget.summary is a runtime state-based summary built from the budget and the run's codons. It is emitted only from a graceful shutdown of a run with a budget configured, before that shutdown's terminal event; a run failed by a codon failure ends without one. It is not a provider invoice and is not a guaranteed retry-inclusive equivalent of every billing record.
When the outcome is failure instead, onFailure is the follow-up policy for a failed codon: abort, retry, or ignore. abort keeps a retriable failure active in an interactive run, where a client can retry, and shuts down for a non-retriable failure; a headless run has no client to retry, so it records the failure and shuts down either way. retry retries only retriable failures until maxAttempts and shuts down for a non-retriable failure or exhausted attempts; ignore continues. A retry configuration's delayMs has a maximum of 60,000 milliseconds.
Two configurations deserve an early warning. onExceeded: "fail" with onFailure: "retry" still triggers a preflight warning, but a budget-exceeded failure is non-retriable: the run shuts down rather than automatically retrying the same allocation. A fail-policy codon late in a shared pool may find that earlier codons have exhausted the pool before it runs.
Budgets inside loops#
Loops change what a budget means, because the same codons run repeatedly against one pool. A loop-level dollar budget is one pool shared across all iterations. The loop ends early when the finishing body codon's own budget or the loop's budget is exceeded: the planner drops the remaining codons of the current iteration and creates no further iterations.
Whether the run continues past the loop depends on how that codon ended. Under the default onExceeded: "complete", or a failure under onFailure: "ignore", the run moves on. Under onExceeded: "fail", the codon fails non-retriably, and the default onFailure: "abort" fails and shuts down the run instead of continuing past the loop.
A codon-level maxDollars inside a loop means "per iteration." A $1.00 codon cap provides $1 for each iteration, not $1 for the whole loop; the loop-level budget controls the aggregate. When such a cap trips, it ends the whole loop, not only that iteration.
shared is usually the useful allocation mode inside a loop. The number of iterations is often unknown, so assigning a proportional share to each iteration is rarely meaningful. An iteration limit remains a separate safety valve for how many iterations may run.
A loop-level maxTimeSeconds is a watchdog across all iterations. When elapsed loop time exceeds the limit, the runtime requests the current codon's interruption and the loop terminates. If a codon's time cap is longer than its parent loop's time cap, preflight warns because the loop will terminate first.
One reporting caveat: budget exhaustion is not a terminationReason value on loop.iteration.completed. The field is optional; the runtime sets it only on a loop's final iteration and only to context_exceeded or iteration_limit. sentinel_skip and failure are allowed by the schema but never sent, so do not report "budget exceeded" as a termination reason either.
| id | category | journaled | sentinelRouted | payloadFields | receipts |
|---|---|---|---|---|---|
| loop.iteration.completed | server-state | true | true | loopId, iteration, durationMs, costUsd, tokensUsed, isFinal, terminationReason? | schemas/event-schemas.ts:724, schemas/event-schemas.ts:1090, schemas/event-schemas.ts:1359, hankweave-runtime.ts:6415 |
What survives when a run resumes?#
A resumed run does not forget earlier spending. Dollar use is hydrated from prior run costs in the state file: if $6 was spent against a $10 budget, $4 remains for the resumed run.
Elapsed wall-clock time is hydrated from prior run timestamps too. If 45 minutes have elapsed against a 60-minute budget, 15 minutes remain; the runtime offsets the hank start time by the prior elapsed duration.
Run-level budget settings are read again when a continuation run starts, but per-codon budgets in the persisted execution plan remain frozen. Raising the applicable run-level maxDollars can increase the remaining pool; editing a saved codon's cap in hank.json does not replace that cap on resume. Use the change/resume table before choosing between another attempt and --start-new.
Retry attempts during one codon execution add to that codon's running total. When the codon completes, the retry-only accumulator is cleared; that does not erase the completed codon's cost from the spending map. Costs from already completed codons remain immutable there and hydrate the next run. Each codon.completed event carries its own attempt's cost plus the immediately preceding failed attempt's, so summing every event double-counts retries.
Budget state is persisted in .hankweave/state.json; see State file for its storage contract. The state manager writes the file atomically. A resumed run combines prior dollar and time usage, current run-level settings, and saved per-codon definitions.
Catching budget misconfigurations before tokens are spent#
Everything so far can be inspected before it costs anything. --validate runs the budget checks before any tokens are spent and prints the resolved budget table. Errors block execution; warnings inform without blocking. The table gives the global ceiling, allocation mode, per-codon limits with source attribution, time limits, and onExceeded policies.
The captured plan below starts with a $0.20 hank ceiling and proportional allocation. Each codon has a $0.05 hard cap against a $0.10 share, and the table notes both the cap and the share it came from.
Budget
─────────────────────────────────────────────────────────────────
Global ceiling: $0.20 (hank)
Time limit: 600s (hank)
Allocation: proportional (unspent flows to later codons)
Codon Model Max Dollars Max Time On exceeded
───── ───── ─────────── ──────── ───────────
write-line Claude Ha… $0.05 (codon cap, share was $0.10)120s (cap) completes
write-second Claude Ha… $0.05 (codon cap, share was $0.10)120s (cap) ⚠ fails run
A tighter operator ceiling changes the global limit to $0.10 and shows each share as 50% of $0.10; the codon caps and policies stay as the author set them. The operator-tightened plan is captured separately:
Budget
─────────────────────────────────────────────────────────────────
Global ceiling: $0.10 (--max-cost, hank wanted $0.20)
Time limit: 600s (hank)
Allocation: proportional (unspent flows to later codons)
Codon Model Max Dollars Max Time On exceeded
───── ───── ─────────── ──────── ───────────
write-line Claude Ha… $0.05 (50% of $0.10) 120s (cap) completes
write-second Claude Ha… $0.05 (50% of $0.10) 120s (cap) ⚠ fails run
Errors block execution#
| Error | What it means |
|---|---|
Shares sum to more than 1.0 | More than the whole budget is allocated. |
| Shares reference unknown IDs | A share key names no codon or loop. |
| Shares without proportional allocation | shares is set without proportional or proportional-strict. |
Proportional allocation without maxDollars | There is no total to divide. |
Warnings inform without blocking#
The validator warns about cases such as:
- A codon or loop cap is greater than its proportional allocation.
- Budget remains unallocated and no child is available to receive it.
exhaustWithPromptis combined with a time budget.- A dollar budget names a model without provider-registry pricing; each metered message adds
$0and the dollar budget never trips. - Shares total 100% but this codon has no share, so its effective budget is
$0and it immediately trips. onExceeded: "fail"is combined withonFailure: "retry".- A fail-policy codon comes late in a shared pool that earlier codons may exhaust.
- A codon time cap is greater than its loop time cap.
The pricing warning is narrower than it looks: it fires only for a model with no cost data at all, and only for a dollar budget set in the hank's overrides.budget, a loop, or the codon. A zero-priced model draws no warning, and a ceiling set only by --max-cost or hankweave.json produces none.
These checks distinguish a configuration that blocks the run from one that is valid but likely to surprise you. The resolved plan is the pre-spend inspection point; the runtime's later summary is a state-based report, not an invoice.