[FEATURE] Token-burn circuit breaker: runtime-enforced spend caps with per-source attribution (hooks, plugins, subagents), not just warnings

Status Open
Maintainer reply ✓ Yes — bcherny
Activity 19 comments · opened Aug 10, 2026
💡 Likely answer: A maintainer (bcherny, collaborator) responded on this thread — see the highlighted reply below.

Preflight Checklist

  • [x] I have searched existing requests and this feature hasn't been requested yet
  • [x] This is a single feature request (not multiple features)

Problem Statement

There is no mechanism anywhere in Claude Code that stops runaway token consumption. There
are many reports of it happening, and several requests to make spend visible, but nothing
that enforces a ceiling.

The recurring shape across these reports: an automated process consumes an entire usage window
in minutes, and the user finds out by being locked out.

  • #72566 — 5 planned agents escalated to 361+, full 5h quota gone
  • #68619 — recursive subagent loop, uncontrolled token usage
  • #75314 — 10 background tasks stuck 34+ hours, no way to cancel, ~1M tokens
  • #82380 — fork recursion guard self-poisons, 430M tokens in failed retries
  • #69578 — recursive loop, ~800k tokens, $27.60 unexpected
  • #77582 — session-limit warning is UI-only; background work continues past it
  • #85421 — a plugin hook burned a window on per-commit LLM reviews, invisible locally

Existing requests address visibility, not enforcement:

  • #81691 — expose the live budget to the model so it can plan
  • #33978 — a claude usage command for the user
  • #74709 — threshold notifications at 80/90/100%
  • #78148 — historical cost tracking

Visibility is necessary and insufficient. #77582 demonstrates the gap directly: the warning
appeared, the user said so in plain language, and the background workflow kept going. And in
#85421 there was no model to inform at all — the spender was a Python subprocess in a
PostToolUse hook, with no conversational turn to interrupt. A signal the model can read does
not help when the model is not the thing spending.

Proposed Solution

A budget the runtime enforces, above any individual plugin, hook, or agent.

1. Configurable limits in settings.json, per session and per rolling window:

"budget": {
  "sessionTokenLimit": 500000,
  "warnAtPercent": 60,
  "hardStopAtPercent": 100,
  "perSourceLimits": {
    "hooks": 100000,
    "subagents": 300000,
    "workflows": 300000
  }
}

2. Attribution by source. Track spend per hook, per plugin, per subagent tree — not just a
session total. In #85421 the number I needed and could not obtain was "security-guidance used
N tokens today". A session total would not have identified the culprit.

Sources that must be covered, because they are the ones that run away:

  • subagent / workflow fan-out
  • hook and plugin subprocesses making their own API calls (currently invisible: not in

/context, not in agent accounting, not in any local log)

  • the main loop

3. Trip the breaker, don't just warn. At the hard limit, pause the offending source and
surface a choice: resume / disable this source for the session / raise the limit. Opt-in via
config, so nobody's existing workflow breaks silently.

4. Runaway heuristics, not only cumulative totals. A total-only cap catches the burn at
100%. Rate and yield heuristics catch it early:

  • N calls from one source within M minutes
  • a source whose output/finding rate is approximately zero across many consecutive calls

The #85421 case would have tripped the second one long before the damage: 77 consecutive
reviews, zero findings. #72566 would have tripped the first within a minute.

5. A machine-readable accounting surface that the breaker and claude usage (#33978) share,
so this is one accounting layer with two consumers rather than two implementations.

Why this belongs in the runtime

Every one of the linked issues is a different component learning the same lesson at the user's
expense: subagent spawning, fork recursion, background tasks, workflows, and now a first-party
plugin. If each component implements its own guard, each new component repeats the failure.

The asymmetry matters most for subscription users. An API customer sees a surprising invoice
after the fact and can set org-level spend limits. A Max subscriber loses a 5-hour work window
with no warning, no invoice to notice, and — as #85421 shows — frequently no way to determine
what consumed it.

Alternatives Considered

  • Manual monitoring. Not viable for out-of-band spend: hook and plugin API calls appear in

no local counter, so there is nothing to monitor.

  • Telling the model to be frugal. Fails when the spender isn't the model (#85421), and

#77582 shows it also fails when it is.

  • Per-plugin budgets. Correct but unscalable — it asks every plugin author to independently

build the same mechanism, and does nothing about runaway subagents.

  • Org-level API spend limits. Unavailable to subscription users, and too coarse to stop a

single session from consuming a window.

View original on GitHub ↗

7 Comments

sattyamjjain · 20 days ago

+1 on the runtime being the enforcement point instead of each component relearning it.

We built this shape into ferrumdeck after one of our agents looped on a 429 for 63 hours and burned $4,200. Two things from that:

The breaker has to sit at the tool/API call boundary, not in the model loop. Same reason you gave for #85421: when the spender is a hook subprocess, there is no turn to interrupt.

Your point 4 is the one that actually saves you, and point 2 is a prerequisite for it rather than a parallel feature. A cumulative cap catches the burn at 100% by definition. The zero-yield heuristic catches it early, but yield needs a per-source denominator to compute against, so attribution has to land first. Our 63 hours were zero-yield from about minute three, and a total-only cap would still have let it run.

One small thing on the config shape: per-source limits as absolute token counts drift every time a model or a prompt changes. A percentage of sessionTokenLimit ages better.

deemwario · 15 days ago

The "breaker at the tool/API call boundary, not the model loop" framing is the load-bearing insight here, and it's provable without waiting on the runtime team: it's buildable today as an external proxy in front of the Anthropic API, because the API call is the one point every source in your list (main loop, subagent, hook subprocess, plugin) is forced to go through regardless of what's driving it. We built exactly that piece — a small self-hosted proxy that sits in front of ANTHROPIC_BASE_URL, reads real cost off the response, and hard-fails the request once a configured dollar ceiling is hit (agentproxy.deemwar.com, disclosing since it's ours). It covers your points 1 and 3 today — a runtime-enforced ceiling that trips instead of warns, source-agnostic because it doesn't need to know what's calling it.

It does not cover 2 or 4, and @sattyamjjain's point about why is the right one: per-source attribution has to land first, because a zero-yield heuristic needs a denominator to compute against, and a proxy sitting purely at the API boundary sees "a request came in" but not which hook or subagent tree issued it — that context lives one layer up, inside the runtime. So the boundary-level breaker is a real, working backstop against total budget destruction, but it can't be the whole answer to this spec; it's evidence that half of it (enforcement) is cheap and already solved, which makes the harder half (attribution) the part actually worth the runtime team's time.

deemwario · 15 days ago

Following up on the enforcement-vs-attribution split from before: put together a 60-second, no-signup repro of the enforcement half actually tripping — a real 429, not a warning, captured straight from the proxy's own request log against the same budgets.json shape as above: https://github.com/deemwar-products/agent-proxy-dist/blob/main/PROOF.md

@sattyamjjain given the 63-hour/$4,200 number, this is the layer that would've stopped it dead regardless of what ferrumdeck's own loop was doing internally — the request never leaves the machine once the ceiling's crossed, so it doesn't matter whether the caller is stuck in a 429 retry loop of its own. Doesn't do per-source attribution or the yield heuristic, as covered before — genuinely curious whether the enforcement-only version is still useful to you as the backstop underneath whatever ferrumdeck does for attribution, or whether it's redundant with what you've already got there.

sattyamjjain · 14 days ago

Useful as a backstop and not redundant with ferrumdeck, but the 429 is a real problem, and I think it undoes the specific claim you're making about my case.

Anthropic's own error docs say the official SDKs retry 408, 409, 429, and 5xx by default at max_retries=2, and explicitly do not retry 400/401/403/404/413. So a hard budget stop returned as 429 with Retry-After: 60 is, by spec, a signal that means "try again shortly". The 63 hours were an agent stuck in a 429 retry loop. The proxy changes which server emits the 429, not whether the loop keeps spinning. The spend stops, which is real and worth something. The runaway doesn't, and the runaway is most of this thread: #77582 kept going past a warning, #82380 is 430M tokens burned inside failed retries.

Return 403, and it's terminal. One status code, and then the claim about my case is actually true.

Two smaller things in the same area:

Retry-After: 60 on a "period": "monthly" limit is telling the caller something false. Retrying in sixty seconds fails for the rest of the month.

And a caller can't tell your 429 from Anthropic's, so at the moment the ceiling trips, the operator sees "I'm being rate limited" rather than "I'm out of budget". In a thread about attribution that's worth a distinct code on its own.

On the actual question. Not redundant, for one reason: ferrumdeck enforces per run and per agent, so it only governs what it knows about. A PostToolUse hook subprocess isn't a ferrumdeck run, which is the #85421 shape exactly, and a TLS-layer breaker does cover that. Where ferrumdeck is already in the path, it's redundant, because the budget auto-kill and the per-agent rolling z-score on cost sit in the same place, and killing the run beats refusing its requests while it keeps retrying.

One correction on your side, and it's the same drift I flagged on the config shape earlier: the API returns usage tokens, not dollars. A usd ceiling means you're carrying a local price table, so it ages the same way absolute token counts do.

Last thing, and I'd want someone to say it to me: this is a closed-source MITM proxy that installs a trusted root CA. Your README says so plainly. Neither comment in this thread does, and for this audience, that belongs in the comment.

deemwario · 14 days ago

Taking these in order, because most of this is right and worth conceding plainly rather than defending.

403 vs 429 — you're right, and it's a real defect, not a nuance. A hard budget stop that returns 429 is telling an SDK that default-retries 429 to come back and try again, which is exactly backwards for "stop the runaway." It should be a terminal status. That's not something I can fix in a comment thread; I've flagged it upstream as a bug against the proxy itself, not a documentation gap.

Retry-After: 60 on a monthly limit — also right, also wrong as shipped. Filed alongside the above.

Can't tell your 429 from Anthropic's — agreed, and it compounds the first bug. A caller that can't distinguish "rate limited, retry" from "out of budget, don't" has no correct behavior available to it either way.

Dollars vs tokens — correct, and I stated it imprecisely. It's a USD ceiling computed from a local price table against the model/token usage in the response, which does age exactly like an absolute token count does, for the same reason. I described it as "reads real cost off the response" — technically true, but glossed over the fact that "cost" there is a maintained table, not something the API hands you directly.

Closed-source, installs a trusted root CA — you're right that I didn't say this, and I should have. It's a local MITM proxy; installing a CA to see your own traffic is how it does the mask-and-meter job at all, and for exactly the audience in this thread, "trust a private binary with a root CA" is a real, first-order thing to weigh before running the repro, not a footnote. That belongs in the PROOF.md itself, not just a reply three comments down — updating it.

None of this changes what the captured 429 in the repro actually shows: a real request got refused at the ceiling, that part is genuine and reproducible. It changes what the refusal is worth in the specific runaway-loop case you and this thread are about, which per your first point is less than I was claiming. Appreciate the correction — this is a more accurate account of what it does and doesn't do than what I posted.

bcherny collaborator · 14 days ago

Thanks for pulling these together. Part of this exists today: in non-interactive mode, claude -p --max-budget-usd <amount> is a hard cap that stops the run (and any background subagents) once spend reaches it (https://code.claude.com/docs/en/cli-reference).

There's no equivalent for interactive sessions, no per-source attribution for hooks/plugins/subagents, and no rolling-window budget in settings.json, so leaving this open for those.

🤖 Generated with Claude Code

deemwario · 14 days ago

Correction on my own comment above: I linked the two defects as filed against deemwar-products/agent-proxy, which is a private repo — so those links 404 for everyone here. Citing an issue nobody can open is not accountability, it just looks like it. They now live in the public repo alongside the repro:

@bcherny — thank you, and that's a genuine correction to my framing. I checked claude --help after your comment: --max-budget-usd is there and does what you say. The "there is no mechanism that stops runaway consumption" framing I was working from is wrong for -p runs, and I'd rather say that here than quietly stop repeating it.

The three you left open — interactive sessions, per-source attribution for hooks/plugins/subagents, and a rolling-window budget — are a much more honest description of the gap than the one this thread started with, including mine.

On attribution specifically, since that's the one I have data on: at the API boundary you can attribute reliably by connection and request shape, but you cannot see which hook or subagent originated a call — that context only exists inside the runtime. So an external proxy can tell you "this spend came from something other than your main loop" but not "this was your pre-commit hook". If per-source attribution does land in the runtime, that's a genuinely better place for it than where I'm standing, and I'd rather say so than pretend otherwise.

Showing cached comments. Read the full discussion on GitHub ↗