Test double for onbudget's end-to-end tests: answers every model request from a script, so nothing is sent and nothing is spent, and reports a scripted cost…

Give a Claude Code task a spending limit, in dollars or as a share of your 5-hour or weekly usage; Claude sees what's left as it works, and onbudget stops it before the limit runs out.
/onbudget $3 Implement authentication
/onbudget 10% 5h Build this application
/onbudget week Write the migration
Install (Claude Code 2.1.287 or later):
claude plugin marketplace add sebastiandrazsky/onbudget
claude plugin install onbudget@onbudget
What you see, rendered by Claude Code with a scripted model: Claude Code 2.1.306 in an 80-column terminal, with the test suite's scripted model answering in place of Claude, at no cost. The three passages are copied from the screen as drawn, and nothing in them is edited.
⚠ onbudget: tier MEDIUM · $0.21 left · budget $0.50
⏺ [onbudget] Stopped: $0.07 of $0.12 is used, and the next request could cost up
to $0.07, so it was not sent.
This notice comes from the onbudget plugin, not from Claude. The work was
interrupted: treat anything Claude has not reported as complete and verified
as unfinished.
To go on: /onbudget off (no budget), or /onbudget <budget> <instructions> (a
new budget).
⏺ onbudget: Claude ended the task with STATUS: COMPLETE, at $0.20 of
$0.50. The budget is still open: a follow-up prompt draws on it, and
/onbudget off removes it.
The first line is Claude Code's status line under the prompt: the tier, what is left before the stop, and the budget. The second passage is a stop, under a $0.12 budget. The third ends a task.
~/.claude/plugins/store/onbudget_*.json), a status line, and, only with ONBUDGET_LOG=1, an event log in its own folder. Nothing else.When you don't need it: in claude -p, Claude Code's own --max-budget-usd did as well as onbudget in our runs (Claude Code 2.1.304, n = 2 against 3). And at the 5-hour limit, Claude Code may let a response in progress finish under its Wrap-Up Allowance.
/cost counts them, read from Claude Code's own ledger, subagents included. onbudget does no pricing of its own.10% 5h is ten points of the 5-hour window, not ten percent of what is left. 5h is what the 5-hour window has left below 99% when you set the budget. A weekly budget (week, N% week) is counted in the weekly window's own points unless you set usdPerPointWeek, and is good to about a point.The benchmark: seven real bug fixes in Python libraries (boltons, falcon, markdown-it-py, pluggy, pydantic-settings and two in werkzeug), given in one prompt to one headless session and scored by upstream tests the model never saw. eval/TASKS.md names each task's repository, commits and hidden tests. Claude Opus 5.5 at medium effort; REPORT.md has the method, both rounds' full tables and the exceptions, and eval/ the raw data. That evidence, the harness included, is at the tag v0.1.0: from 0.1.1 it is not on main, so an install does not carry it.
At $2.47 on Claude Code 2.1.304 (the release current on 2026-10-20):
| n | Tasks complete, of 7 | Within $2.47 | Ended by itself, with a report | Cost, each run | |
|---|---|---|---|---|---|
| onbudget | 3 | 5, 5, 5 | 3 of 3 | 3 of 3 | $2.00, $1.86, $1.97 |
Plain, --max-budget-usd 2.47 | 2 | 5, 6 | 2 of 2 | 2 of 2 | $2.06, $2.25 |
| Budget sentence in the prompt | 2 | 5, 5 | 2 of 2, one only because a safety cap refused its next request | 1 of 2 | $2.38, $2.10 |
--max-budget-usd run did most of its work in seven subagents that Claude Code ran on Claude Sonnet 5.5 ($1.32 of its $2.25); the other, Opus only, finished 5 of 7.eval-freeze-3). git diff eval-freeze-3 -- hooks .claude-plugin shows these changes: weekly sizing (a weekly budget counts the window's points unless usdPerPointWeek is set); display text (the command's answers, the status line, the end-of-task line and the manifest's descriptions); the event log, which is off unless ONBUDGET_LOG=1 and goes only to log/events.jsonl in the plugin's own folder, where round two's build wrote it to a file the variable named; the variable ONBUDGET_EFFORT, removed, so that the effort option alone sets the cap (default medium, as before); an icon; from 0.1.1, the version number and the manifest's listing fields; and, from 0.1.2, other sessions' expired records gone through once a day instead of at every session start. Rendered side by side with tests/e2e/modeltext.py, what the model receives is byte for byte the same under dollar and 5-hour budgets, which is what round two ran, and differs only under weekly budgets.eval-freeze), plain Claude Code under a cap split the work across subagents in six of eight runs and was cut off mid-work; that round's table is in REPORT.md.~/CLAUDE.md in context, a pointer to a writing-style skill.--max-budget-usd works in print mode and the SDK only and is checked after each response, and its documentation says spend can pass it. onbudget checks before each request and also works in a terminal session.<total_tokens> countdown is undocumented and set by the server, padded to 15 million tokens. It has been in Claude Code builds since at least 2.1.200, on by default since at least 2.1.235, and 2.1.304 sends it only for models with a certain capability. It is not a task budget; while a budget is open onbudget leaves it out of new requests (see How it works).--task-budget flag and the SDK's taskBudget (alpha) set an API-side budget in tokens; onbudget does not use them.Budgets on the weekly window already exist: redline (sankalpgunturi/redline, 80% 7d), markwpearce/rainy-day-plugin, nikhilsitaram/claude-caliper's usage-guard (--window 7d, stopping at 99% by default), MohammadMD1383/claude-usage-plugin and michael-L-i/agent-budget. HeyCubit/effortless lowers effort near the limit. ccdwyer/budget-governor (a mod), cost-guard and budgetclaw cap dollars in interactive sessions. They enforce by locking tools, blocking prompts, ending the process (Keesan12/martin-loop) or proxying the API (metahumansworld/SIwithin). As far as both Anthropic marketplaces and a GitHub search showed on 2026-10-21, onbudget is the only one that refuses the individual model request from inside Claude Code, and the only one that publishes an evaluation with its raw data.
onbudget needs Claude Code 2.1.287 or later: from that release the mods API it is built on (function hooks in plugins) is on by default. Its test suites passed on Claude Code 2.1.288, 2.1.304 and 2.1.306 on macOS; GitHub Actions runs the validations, the in-engine tests, run.py and wire.py on Linux with the release current at each push.
claude plugin marketplace add sebastiandrazsky/onbudget
claude plugin install onbudget@onbudget
Inside a Claude Code session the same two commands start with /plugin instead of claude plugin. To stay on this release until you choose to move, add the marketplace pinned to its tag instead:
claude plugin marketplace add sebastiandrazsky/onbudget#v0.1.2
From a local checkout, for one session: claude --plugin-dir /path/to/onbudget.
Type /onbudget. It should answer onbudget: no budget is set and name its version and the Claude Code builds it was tested on.
Nothing outside the plugin enforces a budget. If the plugin does not run, requests are neither metered nor refused, and ONBUDGET is ignored, so a headless task runs with no budget at all. That happens when Claude Code starts in safe mode (--safe-mode) or --bare mode, which load no plugins; when an organisation's managed settings turn plugins or mods off; when Anthropic switches the mods API off; and when Claude Code unloads the plugin (see "The hooks worker" under Limitations). A plugin that runs before onbudget can also skip its hooks; from 2.1.304 the transcript names the plugin and the event when it does. A request whose hook does not run is not checked, though its cost is in the ledger the next check reads. A hook that runs and fails, or runs out of time, is different: while a budget is set, the request is not sent, and the plugin says so ([onbudget] Not sent: the budget check could not be completed).
How to tell. In a terminal session, /onbudget is offered when you type /onb, and once a budget is set a status line under the prompt shows the tier and what is left. Without the plugin, /onbudget is not offered and, typed anyway, is sent to Claude as an ordinary prompt. In a headless run, claude -p "/onbudget" prints onbudget: no budget is set without a model request when the plugin is running. To check one particular run, start it with ONBUDGET_LOG=1: as the session starts, the plugin writes log/events.jsonl in its own folder, beginning with a session.start row. For an install from the marketplace that folder is plugins/cache/onbudget/onbudget/0.1.2 under your Claude config directory. Delete the file before the run; if the run does not write it again, the plugin did not run.
/onbudget $3 Implement authentication
The task is sent with a short protocol appended, which you can read in the transcript: the budget, how to work under it, and how to report at the end. From then on:
onbudget: tier MEDIUM · $1.60 left · budget $3.00. When one of the account's windows is closer than the budget it is named, as in tier HIGH · $61.16 left (5h limit) · budget $500.00. Under a budget counted in points the figures are points, as in tier HIGH · 78 pts left · budget 81 pts (week), and a budget that has stopped reads STOPPED · $2.91 of $3.00 · /onbudget off. /onbudget gives the full reading.[budget] spent $1.23 of $3.00 | left before the stop $1.60 (53%) | tier MEDIUM. "Left before the stop" is exactly what the stop rule still allows, so the meter and the status line read $0.00 when the next request will be refused.medium while the budget is set. Your setting comes back when the budget is removed.Follow-up prompts draw on the same budget. Once the budget has stopped the session, a further prompt is turned away with the reason, and nothing is spent on it. /onbudget off removes the budget, and /onbudget <budget> <instructions> replaces it with a new one.
/onbudget 10% 5h Build this application
/onbudget 5% week Fix these bugs
A 5-hour point is worth a different number of dollars on every plan, so onbudget learns the rate from your own usage: in every session where the plugin is loaded it records how far each window moved and how much the session cost. A 5-hour percentage budget set after it has seen three points of movement is converted into dollars at that rate, and the dollars are enforced as a dollar budget is. Before a rate is known, the budget is enforced on the window reading itself, in whole points, with a 3% minimum. /onbudget shows the rate.
A weekly percentage budget is always enforced on the weekly window's own reading, in whole points, with a 3% minimum, unless you set usdPerPointWeek (see Configuration): the weekly rate onbudget learns errs far low (see Limitations), and converting with it would have given at most about two fifths of the share named.
/onbudget 5h Refactor the parser
/onbudget week Write the migration
The budget is the points between the window's reading and 99% when you give the command. For 5h they are converted to dollars when a rate is known; for week, only when you have set usdPerPointWeek. Otherwise the budget is counted in the window's own points, and onbudget stops about two points short of 99%, because the reading may not yet show the last requests. onbudget keeps one point back from the account's limit because a reading can be up to a point below the true figure. A session with no reading yet (a new session, or a headless run) sizes the budget from the first one, which arrives with Claude's first answer, so that first request goes out before the budget is known. If the window resets while the task runs, the budget stays what it was when you set it.
The meter follows whichever comes first, the budget or one of the windows, and names the window when it is the closer one: left before the stop $0.40 (17%), set by the account's 5-hour limit. The status line marks it (5h limit). A budget larger than what a window has left says so when you set it and runs against the window. A window with nothing left refuses the budget in one line, and a new session that has no reading of its own uses the last reading seen on this machine for that check, as long as that window has not reset since.
Accounts that report no usage window (an API key, a gateway) have no such limits: dollar budgets work as described, and budgets counted in a window cannot be enforced. A gateway's own spend limit, which Claude Code also reports, is not used.
ONBUDGET='$3' claude -p "Implement authentication"
ONBUDGET='5h' claude -p "Build this application"
ONBUDGET applies to the first prompt of the session, once. If the value cannot be used, the prompt is dropped with the reason; the task is not run without its budget. When the budget stops a headless run, the run's result is the plugin's notice, which begins [onbudget] Stopped:. In claude -p, set the budget with ONBUDGET: the form claude -p "/onbudget <budget> <task>" sets the budget but can exit before the task is sent (exit code 0, nothing run, nothing spent), a known issue in 0.1.0 to 0.1.2.
The ledger. Spend is the growth of the session's own cost ledger since the budget was set. Each request is charged the ledger's growth since the previous request completed, and no more than its growth since the request was sent, so a request is not charged for the ones that ran beside it or for what was spent between requests. Two requests that finish at the same instant cannot be told apart: the first to read the ledger is charged both, the total stays exact, and the reserve errs high.
The stop. A request cannot be priced before it is sent, so the plugin holds back what the dearest request so far has cost: refuse when spent + reserve × (requests in flight + 1) ≥ budget. A window of the account is a limit of the same shape. With a rate, its room is converted into the task's dollars from the reading that came with the latest answer; without one, each request is reckoned to move the window by a point, or by the largest step seen between two readings.
The meter. Claude is told the budget once, with the task, and shown the meter after every tool result. The tier is HIGH from 70% left, MEDIUM from 30%, LOW from 10%, and CRITICAL below that or when two requests' worth or less is left at the cost of the dearest so far (two points, for a budget counted in points). Claude has therefore read CRITICAL at least twice before a request can be refused, on one condition: requests one at a time, none costing more than the dearest before it. At small budgets that condition often fails. In the acceptance of 2026-10-21, the three live runs that came near the stop ($0.12, $0.15 and $0.25, on Claude Code 2.1.304 with Claude Opus 5.5) each read CRITICAL once, not twice, because a request cost more than any before it; none passed its budget. The protocol asks Claude to stop adding scope at LOW, to leave the tree in its best verified state at CRITICAL, to verify before it claims anything, and to end with what is complete, what is unfinished and what was not verified. After a compaction the protocol is sent again.
The status line. It is for you; Claude reads the meter, not this line. Claude Code cuts a plugin's status line at the terminal's width: after onbudget: it shows the width less 17 characters, 63 in an 80-column terminal (measured on 2.1.304 and 2.1.306). onbudget's line is at most 60 characters, its fields in order of importance (the tier, what is left before the stop, the budget, and the account limit when one is closer), so it shows whole from 77 columns, and in a narrower terminal the budget is cut first.
Claude Code's countdown. While a budget is open, onbudget leaves Claude Code's <total_tokens> countdown out of new requests, because it is not the task's budget. This is partial: the copy in the system prompt stays. Its effect on the model was never measured. An attachment already sent keeps the answer it was sent with, and a resumed session gets the same answers again, so the prompt cache's prefix does not change. A countdown under a million tokens could be a budget you set yourself, and is kept; so is any countdown when CLAUDE_CODE_TOTAL_TOKENS_REMINDER is set.
Effort. Each request's effort is lowered to the cap and never raised. Nothing in your settings is changed, so there is nothing to restore.
PRIVACY.md says the same as a privacy statement, with what the event log records.
claude plugin validate --strict .claude-plugin/plugin.json lists every engine call the plugin can make:
./register.ts calls: $.clock.after (via submitLater), $.clock.now, $.command.register, $.env.get (via load), $.fs.exists (via log), $.fs.write (via log), $.prompt.submit (via submitLater), $.session.id, $.session.model (via calModel, rateFor), $.session.usage, $.session.version, $.store.delete, $.store.get (via calModel, load, otherRecords, prune, startReadings), $.store.keys (via otherRecords, prune), $.store.set, $.ui.log (via say), $.ui.status (via showStatus)
./register.ts env writes: nothing
./register.ts env reads: CLAUDE_CODE_TOTAL_TOKENS_REMINDER, ONBUDGET, ONBUDGET_LOG
ONBUDGET: a budget for the first prompt of a headless run (ONBUDGET='$3' claude -p "..."). A budget for one run is not something to save as a setting, so it stays a variable.ONBUDGET_LOG: 1 turns on the event log (below); off when unset, and any other value leaves it off.CLAUDE_CODE_TOTAL_TOKENS_REMINDER: Claude Code's own setting, read only to respect it. When you have set it, onbudget keeps Claude Code's token countdown in requests instead of leaving it out.The effort cap and the dollars per point are options (see Configuration), not variables.
plugins/store/onbudget_*.json: the budget of each session that has one, with the first line of its task (at most 120 characters, shown by /onbudget), calibration records (dollars spent and points moved per session, window and model, nothing else), the newest reading of each window with its reset time, and, per budgeted session, which countdowns it left out, with the day it last went through other sessions' records. Records older than 30 days are deleted when a session starts: calibration records at every start, other sessions' budgets and flags at the first start of each day.ONBUDGET_LOG=1, one JSON-lines file in the plugin's own folder, log/events.jsonl, continued in log/events.1.jsonl, log/events.2.jsonl, ... every 200 rows. Each Claude Code process that logs starts it again. The path is fixed in the code: no variable or setting can point it anywhere else. It stays until you delete it./onbudget.It writes nothing else: no build, start-up, settings or instructions file, and nothing in your project.
<!-- readme.py: prompts --> Only while a budget is set. These texts are rendered
hooks/register.ts 110 lines1// The world for onbudget's end-to-end tests. Loaded beside the plugin in a real Claude Code process, it
2// - answers every model request from a script and never calls next, so no request leaves the machine;
3// - answers $.session.usage() with a scripted cost ledger and scripted usage windows.
4// Env: MOCK_SCRIPT (a json file: a list of replies, one per model request of the main thread; when it runs out the
5// reply is the text "mock done"), MOCK_AGENT_SCRIPT (optional json file: one list of replies per subagent, given out in
6// the order the subagents make their first request), MOCK_LOG (json path: what the mock saw), MOCK_LEDGER (json path:
7// the ledger and readings, kept across processes so a resumed session can be given a ledger that survives or one that
8// starts over).
9// A reply: { bash?: command to call the Bash tool with, agents?: prompts to start one subagent each with, in one
10// answer (Claude Code runs them side by side), text?: answer text, cost?: dollars it adds to the ledger,
11// five_hour?: number, seven_day?: number (the readings that arrive with this answer),
12// sleep_ms?: how long the answer takes (for interrupting a request in flight, or keeping one in flight) }
13// A compaction is answered here too (a one-line summary), since the engine's own would need the model.
14// MOCK_STATUS (optional): a status line of the mock's own, set at the start, to measure how much of one Claude Code shows.
15import type { Register } from 'claude-code'
16
17type Reply = { bash?: string; agents?: string[]; text?: string; cost?: number; five_hour?: number; seven_day?: number; sleep_ms?: number }
18let script: Reply[] | undefined
19let agentScripts: Reply[][] = []
20const agents = new Map<string, { script: Reply[]; i: number }>()
21let i = 0
22let ids = 0
23let world: { cost: number; five_hour?: number; seven_day?: number } | undefined
24const seen: unknown[] = []
25
26async function load($: any): Promise<void> {
27 if (script) return
28 script = JSON.parse(String(await $.fs.read(await $.env.get('MOCK_SCRIPT'))))
29 const forAgents = await $.env.get('MOCK_AGENT_SCRIPT')
30 if (forAgents) agentScripts = JSON.parse(String(await $.fs.read(forAgents)))
31 world = { cost: 0 }
32 const kept = await $.env.get('MOCK_LEDGER')
33 if (kept && (await $.fs.exists(kept))) world = JSON.parse(String(await $.fs.read(kept)))
34}
35
36async function note($: any, row: unknown): Promise<void> {
37 seen.push(row)
38 await $.fs.write(await $.env.get('MOCK_LOG'), JSON.stringify(seen))
39 const kept = await $.env.get('MOCK_LEDGER')
40 if (kept) await $.fs.write(kept, JSON.stringify(world))
41}
42
43export const register: Register = (on: any) => {
44 on('session.start', async ($: any, e: any, next: any) => {
45 try { const text = await $.env.get('MOCK_STATUS'); if (text) $.ui.status(text) } catch { /* ignore */ }
46 return next(e)
47 })
48
49 on('turn.step', async function* ($: any, e: any, next: any) {
50 await load($)
51 let reply: Reply
52 if (e.agentId) {
53 let a = agents.get(e.agentId)
54 if (!a) agents.set(e.agentId, (a = { script: agentScripts[agents.size] ?? [], i: 0 }))
55 reply = a.script[a.i++] ?? { text: 'agent done' }
56 } else reply = script![i++] ?? { text: 'mock done' }
57 await note($, { kind: 'request', index: e.index, agentId: e.agentId ?? null })
58 if (reply.sleep_ms) await $.clock.sleep(reply.sleep_ms, { signal: next?.signal })
59 world!.cost += reply.cost ?? 0
60 if (reply.five_hour !== undefined) world!.five_hour = reply.five_hour
61 if (reply.seven_day !== undefined) world!.seven_day = reply.seven_day
62 await note($, { kind: 'step', index: e.index, agentId: e.agentId ?? null, effort: e.effort ?? null, model: e.model, messageCount: e.messageCount, reply })
63 if (reply.agents) {
64 const uses = reply.agents.map(prompt => ({ name: 'Agent', input: { description: 'mock subagent', prompt, subagent_type: 'general-purpose' } }))
65 for (const [k, u] of uses.entries()) {
66 yield { kind: 'tool', index: k, id: `toolu_mock_${++ids}`, name: u.name }
67 yield { kind: 'input', index: k, json: JSON.stringify(u.input) }
68 }
69 yield { kind: 'stop', stopReason: 'tool_use', usage: null }
70 return { turnId: e.turnId, index: e.index, answer: '', toolUses: uses, stopReason: 'tool_use', usage: null }
71 }
72 if (reply.bash !== undefined) {
73 const input = { command: reply.bash, description: 'mock' }
74 yield { kind: 'tool', index: 0, id: `toolu_mock_${++ids}`, name: 'Bash' }
75 yield { kind: 'input', index: 0, json: JSON.stringify(input) }
76 yield { kind: 'stop', stopReason: 'tool_use', usage: null }
77 return { turnId: e.turnId, index: e.index, answer: '', toolUses: [{ name: 'Bash', input }], stopReason: 'tool_use', usage: null }
78 }
79 const text = reply.text ?? 'mock done'
80 yield { kind: 'text', index: 0, text }
81 yield { kind: 'stop', stopReason: 'end_turn', usage: null }
82 return { turnId: e.turnId, index: e.index, answer: text, toolUses: [], stopReason: 'end_turn', usage: null }
83 })
84
85 // The ledger and the usage windows, as scripted, in place of the engine's own.
86 on('session.usage', async ($: any, e: any, next: any) => {
87 const r = await next(e)
88 await load($)
89 const rateLimits = (['five_hour', 'seven_day'] as const).filter(k => world![k] !== undefined).map(k => ({ kind: k, percentUsed: world![k], resetsAt: '2030-01-01T00:00:00.000Z' }))
90 return { value: { ...r.value, cost: { usd: world!.cost }, rateLimits } }
91 })
92
93 on('session.compact', async ($: any, e: any) => {
94 await note($, { kind: 'compact', trigger: e.trigger, messages: e.messages.length })
95 return { messages: [{ role: 'user', text: 'Summary of the earlier conversation: the user set a task; work is in progress.', toolUses: [] }] }
96 })
97
98 // What reached the model beside each tool result (the meter line), and each prompt as it entered.
99 on('tool.call', async ($: any, e: any, next: any) => {
100 const r = await next(e)
101 try { await note($, { kind: 'tool', tool: e.tool, agentId: e.agentId ?? null, context: r?.context ?? null, isError: r?.isError ?? false, deny: r?.deny ?? null }) } catch { /* ignore */ }
102 return r
103 })
104 on('prompt.submit', async ($: any, e: any, next: any) => {
105 const r = await next(e)
106 try { await note($, { kind: 'prompt', text: r?.text ?? null, context: r?.context ?? null, drop: r?.drop ?? null, origin: e.origin }) } catch { /* ignore */ }
107 return r
108 })
109}
110