My Agent Orchestrator Burned 1-2M Opus Tokens Per Task. Here's the Postmortem. An engineer's Claude Code orchestration skill burned 1-2 million Opus tokens per task due to stacked multipliers: subagents inherited the expensive Opus model, fresh contexts caused cache-prefix misses, and parallel fan-out triggered simultaneous cold writes. The postmortem reveals the model tax was the smallest factor, with cache misses and agent fan-out dominating costs, and outlines a redesign with an enforcement layer. I built an orchestration skill for Claude Code that delegated everything to subagents. It worked. It also cost somewhere on the order of 1-2 million Opus tokens per task - including tasks whose final diff was a handful of lines. Nothing was broken. Every individual decision was defensible. Three modest multipliers stacked, and then the whole stack ran on every single request. This is the postmortem, the redesign, and the enforcement layer I should have written first. The design goal was context hygiene. The main session gets polluted fast - it accumulates file contents, tool output, and dead ends, and its judgment degrades as the window fills. So: don't let it do any work. Make it a coordinator, and give every unit of real work a fresh context. That produced four rules: And the trigger was broad - essentially any actionable request. "do this," "implement," "fix," "build," "change." Read those four rules again with a cost lens instead of a correctness lens. That is the whole postmortem. model optional The subagent dispatch tool takes a model parameter. My skill never set it. Omitted, it inherits from the parent session - which was Opus 4.8. So every subagent, including the ones whose entire job was "read this file and summarize it," ran on the most expensive tier available. Here's what that actually costs at list prices: | Model | Input $/MTok | Output $/MTok | vs. Opus | |---|---|---|---| Claude Opus 4.8 claude-opus-4-8 | $5.00 | $25.00 | 1× | Claude Sonnet 4.6 claude-sonnet-4-6 | $3.00 | $15.00 | 0.6× | Claude Haiku 4.5 claude-haiku-4-5 | $1.00 | $5.00 | 0.2× | I want to flag something here, because I got it wrong in my own first write-up of this incident: Opus is not 5× Sonnet. It's about 1.7×. It is exactly 5× Haiku. If you're building a tiering story, the Opus→Sonnet move is a 40% cut, and the Opus→Haiku move on genuinely trivial work is an 80% cut. Which means the model tax was the smallest of my three multipliers. I'd been blaming it for the whole bill. It wasn't even close. This is the expensive one, and it took me longest to see because the symptom "agents re-read the repo" sounds like a token-count problem when it's actually a cache-prefix problem. Prompt caching is a prefix match . The cache key comes from the exact bytes of the rendered prompt, in the order tools → system → messages , up to each cache control breakpoint. One byte different at position N and everything from N onward is a miss. The economics of that: So a cached read is a 90% discount, and a cold write carries a 25% premium . The gap between best case and worst case on the same tokens is roughly 12× . Now put a fresh subagent in that picture. A fresh subagent is a new prefix. It does not inherit the parent's cached prompt unless its system , tools , and model are byte-identical to the parent's - and mine weren't, because each phase got its own tailored instructions. Every subagent I spawned paid a cold write on the entire repo context it had been told to "include ALL" of. It gets worse when you parallelize. A cache entry only becomes readable once the first response starts streaming. Fire five subagents simultaneously with identical prefixes and all five pay full freight - none of them can read what the others are still writing. My design had a rule that guaranteed maximum context per agent, a rule that guaranteed a fresh prefix per agent, and a fan-out pattern that guaranteed simultaneous cold writes. Three rules, one bill. Five-plus agents per task as a floor - one per phase, more when a phase parallelized. On top of that, "loop until clean" gave the review phase no termination bound other than the reviewer's own judgment about its own output. A reviewer that finds one nit per pass runs forever. A reviewer that finds nothing on pass one still costs a full agent. None of these is outrageous alone. Multiply them: | Factor | Multiplier | |---|---| | Opus instead of Sonnet on work that didn't need it | ~1.7× | | Cold cache write instead of cache read on repo context | ~12× | | 5+ agents per task, before review loops | ~5× | | Review loop iterations | ~1-3× | That's a 100× to 300× band against a baseline of "one well-cached Sonnet agent does the work." Applied to every actionable request, because the trigger was broad. The 1-2M number stops being surprising. It's what the architecture was specified to do. Because there is no backpressure anywhere in that loop. The model cannot see cumulative session spend. It has no running total, no budget, no signal that agent 14 is different from agent 2. The API does offer a task budget output config.task budget , but that governs a single agentic request - thinking, tool calls, and output within one loop. It does not span a session's worth of independent subagent dispatches, which is precisely where my spend lived. So the only thing standing between me and a 1.5M-token bill was the model's own restraint, mediated through a prompt. Prompts are preferences. Preferences degrade under context pressure, and they get compacted away entirely on long sessions. That was the actual bug. Not the model tier, not the cache: I put a budget policy somewhere it could not be enforced. Same delegation model, corrected against each root cause: Better. Still a prompt. The real fix is that none of the above is left to judgment. It runs as a PreToolUse hook - a process the harness executes before the tool call, whose verdict the model does not get a vote on. Register it in settings.json against the subagent dispatch tool: { "hooks": { "PreToolUse": { "matcher": "Task", "hooks": { "type": "command", "command": "node /path/to/.claude/hooks/cost-guard.js", "statusMessage": "Checking dispatch budget..." } } } } The hook receives JSON on stdin: { "session id": "abc123", "transcript path": "/home/user/.claude/projects/.../transcript.jsonl", "cwd": "/home/user/my-project", "permission mode": "default", "hook event name": "PreToolUse", "tool name": "Task", "tool input": { "subagent type": "general-purpose", "prompt": "...", "model": null }, "tool use id": "toolu 01ABC..." } And answers on stdout: { "hookSpecificOutput": { "hookEventName": "PreToolUse", "permissionDecision": "allow | deny | ask | defer", "permissionDecisionReason": "shown to the model and the user", "updatedInput": { "...": "replaces the tool's arguments before it runs" } } } updatedInput is what makes this more than a bouncer. The hook can rewrite the call rather than just refusing it. Five guards: | Guard | Decision | What it stops | |---|---|---| | Model downgrade | allow + updatedInput | Opus-by-default. Rewrites Opus and model-less dispatches to Sonnet. | | Dispatch circuit-breaker | ask past 10, deny past 25 | Runaway fan-out across a whole session. | | Nested-dispatch guard | deny | Agents spawning agents - exponential, not linear. | | Concurrency cap | deny beyond 5 in flight | Simultaneous cold cache writes. | | Context-bloat gate | deny | Tool calls that would flood the context window. | Here's the core of it: bash /usr/bin/env node // .claude/hooks/cost-guard.js - PreToolUse, matcher: "Task" const fs = require "fs" ; const os = require "os" ; const path = require "path" ; const MODEL FLOOR = "sonnet"; const ASK AFTER = 10; const REFUSE AFTER = 25; function decide decision, reason, updatedInput { const out = { hookEventName: "PreToolUse", permissionDecision: decision, permissionDecisionReason: reason, }; if updatedInput out.updatedInput = updatedInput; console.log JSON.stringify { hookSpecificOutput: out } ; process.exit 0 ; } // Counters must live on disk: each hook invocation is a separate process with // no memory of the last one. session id is the only stable key we get, and it // goes into a path, so it is validated rather than trusted. function counterPath sessionId { if /^ A-Za-z0-9 - {1,64}$/.test sessionId return null; return path.join os.tmpdir , cost-guard-${sessionId}.json ; } function bumpDispatchCount sessionId { const file = counterPath sessionId ; if file return 1; let state = { dispatches: 0 }; try { state = JSON.parse fs.readFileSync file, "utf8" ; } catch { // First dispatch of the session, or an unreadable file. Either way we // start from zero rather than failing the user's tool call. } state.dispatches = state.dispatches || 0 + 1; fs.writeFileSync file, JSON.stringify state , "utf8" ; return state.dispatches; } let raw = ""; process.stdin.on "data", chunk = raw += chunk ; process.stdin.on "end", = { let payload; try { payload = JSON.parse raw ; } catch { // A guard that crashes must not become a guard that blocks. Exit 0 with no // JSON and the normal permission flow applies. process.exit 0 ; } const input = payload.tool input || {}; const n = bumpDispatchCount payload.session id ; if n REFUSE AFTER { decide "deny", Dispatch ${n} exceeds the hard cap of ${REFUSE AFTER} for this session. + Do the remaining work directly, or start a fresh session. ; } if n ASK AFTER { decide "ask", This is subagent ${n} this session soft cap ${ASK AFTER} . + Each one pays a cold prompt-cache write. Approve? ; } // updatedInput REPLACES tool input wholesale - it is not a patch. Spreading // the original first is what keeps prompt and subagent type alive; drop // the spread and you dispatch an agent with no instructions. if input.model || input.model === "opus" { decide "allow", Model ${input.model ? "'opus'" : "unset would inherit Opus "} rewritten + to '${MODEL FLOOR}'. Set model explicitly to override. , { ...input, model: MODEL FLOOR } ; } process.exit 0 ; // no opinion; normal permission flow applies } ; Three implementation notes that cost me time: updatedInput replaces, it does not merge. Return { model: "sonnet" } and you have just dispatched a subagent with no prompt. Spread the original input first. This is the single easiest way to turn a cost guard into an outage. Hooks are separate processes. There is no in-memory counter to increment. Anything cumulative has to be persisted, and session id from stdin is the natural key. Validate it before putting it in a path. Never fail closed on your own bug. Exit 0 with no JSON output and the normal permission flow applies. A malformed payload, an unreadable state file, a disk error - none of those should block the user's work. Exit 2 is the deliberate block; everything else stays out of the way. The concurrency cap needs a PostToolUse counterpart to decrement the in-flight count, and the nested-dispatch guard needs a reliable "am I inside a subagent" signal - check what session id and transcript path actually look like inside a subagent on your Claude Code version before you rely on either. Those two are the most harness-coupled of the five. I keep coming back to one line from this: A budget rule in a prompt is a preference. A budget rule in a PreToolUse hook is an invariant. The prompt version degrades exactly when you need it most - deep in a long session, under context pressure, after compaction has quietly dropped the paragraph where you wrote it down. The hook version runs identically on dispatch 1 and dispatch 24, and it does not need to be persuaded. That distinction isn't really about cost. It's about which layer a constraint belongs in. Anything you would be upset to discover the model overrode - spend caps, destructive commands, push targets, credential access - does not belong in a system prompt. It belongs in a process the model cannot argue with. I still delegate. Pure delegation on tasks that are genuinely large and parallel is the right shape, and the context-hygiene argument that motivated v1 was never wrong. What was wrong was believing an architecture and forgetting to price it - and then writing the safeguards in the one place they could not be enforced. If you're running a similar setup: check whether your dispatches set model explicitly, and check whether your subagents share a prefix with their parent. Those two questions found most of my bill.