{"slug": "my-agent-orchestrator-burned-1-2m-opus-tokens-per-task-here-s-the-postmortem", "title": "My Agent Orchestrator Burned 1-2M Opus Tokens Per Task. Here's the Postmortem.", "summary": "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.", "body_md": "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.\n\nNothing was broken. Every individual decision was defensible. Three modest multipliers stacked, and then the whole stack ran on every single request.\n\nThis is the postmortem, the redesign, and the enforcement layer I should have written first.\n\nThe 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.\n\nThat produced four rules:\n\nAnd the trigger was broad - essentially any actionable request. \"do this,\" \"implement,\" \"fix,\" \"build,\" \"change.\"\n\nRead those four rules again with a cost lens instead of a correctness lens. That is the whole postmortem.\n\n`model`\n\noptional\nThe subagent dispatch tool takes a `model`\n\nparameter. My skill never set it. Omitted, it inherits from the parent session - which was Opus 4.8.\n\nSo every subagent, including the ones whose entire job was \"read this file and summarize it,\" ran on the most expensive tier available.\n\nHere's what that actually costs at list prices:\n\n| Model | Input $/MTok | Output $/MTok | vs. Opus |\n|---|---|---|---|\nClaude Opus 4.8 (`claude-opus-4-8` ) |\n$5.00 | $25.00 | 1× |\nClaude Sonnet 4.6 (`claude-sonnet-4-6` ) |\n$3.00 | $15.00 | 0.6× |\nClaude Haiku 4.5 (`claude-haiku-4-5` ) |\n$1.00 | $5.00 | 0.2× |\n\nI 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.\n\nWhich 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.\n\nThis 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.\n\nPrompt caching is a **prefix match**. The cache key comes from the exact bytes of the rendered prompt, in the order `tools`\n\n→ `system`\n\n→ `messages`\n\n, up to each `cache_control`\n\nbreakpoint. One byte different at position N and everything from N onward is a miss.\n\nThe economics of that:\n\nSo 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×**.\n\nNow 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`\n\n, `tools`\n\n, and `model`\n\nare 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.\n\nIt 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.\n\nMy 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.\n\nFive-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.\n\nA reviewer that finds one nit per pass runs forever. A reviewer that finds nothing on pass one still costs a full agent.\n\nNone of these is outrageous alone. Multiply them:\n\n| Factor | Multiplier |\n|---|---|\n| Opus instead of Sonnet on work that didn't need it | ~1.7× |\n| Cold cache write instead of cache read on repo context | ~12× |\n| 5+ agents per task, before review loops | ~5× |\n| Review loop iterations | ~1-3× |\n\nThat'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.\n\nThe 1-2M number stops being surprising. It's what the architecture was specified to do.\n\nBecause there is no backpressure anywhere in that loop.\n\nThe 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`\n\n), 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.\n\nSo 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.\n\nThat was the actual bug. Not the model tier, not the cache: **I put a budget policy somewhere it could not be enforced.**\n\nSame delegation model, corrected against each root cause:\n\nBetter. Still a prompt.\n\nThe real fix is that none of the above is left to judgment. It runs as a `PreToolUse`\n\nhook - a process the harness executes before the tool call, whose verdict the model does not get a vote on.\n\nRegister it in `settings.json`\n\nagainst the subagent dispatch tool:\n\n```\n{\n  \"hooks\": {\n    \"PreToolUse\": [\n      {\n        \"matcher\": \"Task\",\n        \"hooks\": [\n          {\n            \"type\": \"command\",\n            \"command\": \"node /path/to/.claude/hooks/cost-guard.js\",\n            \"statusMessage\": \"Checking dispatch budget...\"\n          }\n        ]\n      }\n    ]\n  }\n}\n```\n\nThe hook receives JSON on stdin:\n\n```\n{\n  \"session_id\": \"abc123\",\n  \"transcript_path\": \"/home/user/.claude/projects/.../transcript.jsonl\",\n  \"cwd\": \"/home/user/my-project\",\n  \"permission_mode\": \"default\",\n  \"hook_event_name\": \"PreToolUse\",\n  \"tool_name\": \"Task\",\n  \"tool_input\": { \"subagent_type\": \"general-purpose\", \"prompt\": \"...\", \"model\": null },\n  \"tool_use_id\": \"toolu_01ABC...\"\n}\n```\n\nAnd answers on stdout:\n\n```\n{\n  \"hookSpecificOutput\": {\n    \"hookEventName\": \"PreToolUse\",\n    \"permissionDecision\": \"allow | deny | ask | defer\",\n    \"permissionDecisionReason\": \"shown to the model and the user\",\n    \"updatedInput\": { \"...\": \"replaces the tool's arguments before it runs\" }\n  }\n}\n```\n\n`updatedInput`\n\nis what makes this more than a bouncer. The hook can **rewrite the call** rather than just refusing it.\n\nFive guards:\n\n| Guard | Decision | What it stops |\n|---|---|---|\n| Model downgrade |\n`allow` + `updatedInput`\n|\nOpus-by-default. Rewrites Opus and model-less dispatches to Sonnet. |\n| Dispatch circuit-breaker |\n`ask` past 10, `deny` past 25 |\nRunaway fan-out across a whole session. |\n| Nested-dispatch guard | `deny` |\nAgents spawning agents - exponential, not linear. |\n| Concurrency cap |\n`deny` beyond 5 in flight |\nSimultaneous cold cache writes. |\n| Context-bloat gate | `deny` |\nTool calls that would flood the context window. |\n\nHere's the core of it:\n\n``` bash\n#!/usr/bin/env node\n// .claude/hooks/cost-guard.js - PreToolUse, matcher: \"Task\"\nconst fs = require(\"fs\");\nconst os = require(\"os\");\nconst path = require(\"path\");\n\nconst MODEL_FLOOR = \"sonnet\";\nconst ASK_AFTER = 10;\nconst REFUSE_AFTER = 25;\n\nfunction decide(decision, reason, updatedInput) {\n  const out = {\n    hookEventName: \"PreToolUse\",\n    permissionDecision: decision,\n    permissionDecisionReason: reason,\n  };\n  if (updatedInput) out.updatedInput = updatedInput;\n  console.log(JSON.stringify({ hookSpecificOutput: out }));\n  process.exit(0);\n}\n\n// Counters must live on disk: each hook invocation is a separate process with\n// no memory of the last one. session_id is the only stable key we get, and it\n// goes into a path, so it is validated rather than trusted.\nfunction counterPath(sessionId) {\n  if (!/^[A-Za-z0-9_-]{1,64}$/.test(sessionId)) return null;\n  return path.join(os.tmpdir(), `cost-guard-${sessionId}.json`);\n}\n\nfunction bumpDispatchCount(sessionId) {\n  const file = counterPath(sessionId);\n  if (!file) return 1;\n  let state = { dispatches: 0 };\n  try {\n    state = JSON.parse(fs.readFileSync(file, \"utf8\"));\n  } catch {\n    // First dispatch of the session, or an unreadable file. Either way we\n    // start from zero rather than failing the user's tool call.\n  }\n  state.dispatches = (state.dispatches || 0) + 1;\n  fs.writeFileSync(file, JSON.stringify(state), \"utf8\");\n  return state.dispatches;\n}\n\nlet raw = \"\";\nprocess.stdin.on(\"data\", (chunk) => (raw += chunk));\nprocess.stdin.on(\"end\", () => {\n  let payload;\n  try {\n    payload = JSON.parse(raw);\n  } catch {\n    // A guard that crashes must not become a guard that blocks. Exit 0 with no\n    // JSON and the normal permission flow applies.\n    process.exit(0);\n  }\n\n  const input = payload.tool_input || {};\n  const n = bumpDispatchCount(payload.session_id);\n\n  if (n > REFUSE_AFTER) {\n    decide(\n      \"deny\",\n      `Dispatch #${n} exceeds the hard cap of ${REFUSE_AFTER} for this session. ` +\n        `Do the remaining work directly, or start a fresh session.`\n    );\n  }\n\n  if (n > ASK_AFTER) {\n    decide(\n      \"ask\",\n      `This is subagent #${n} this session (soft cap ${ASK_AFTER}). ` +\n        `Each one pays a cold prompt-cache write. Approve?`\n    );\n  }\n\n  // updatedInput REPLACES tool_input wholesale - it is not a patch. Spreading\n  // the original first is what keeps `prompt` and `subagent_type` alive; drop\n  // the spread and you dispatch an agent with no instructions.\n  if (!input.model || input.model === \"opus\") {\n    decide(\n      \"allow\",\n      `Model ${input.model ? \"'opus'\" : \"unset (would inherit Opus)\"} rewritten ` +\n        `to '${MODEL_FLOOR}'. Set model explicitly to override.`,\n      { ...input, model: MODEL_FLOOR }\n    );\n  }\n\n  process.exit(0); // no opinion; normal permission flow applies\n});\n```\n\nThree implementation notes that cost me time:\n\n** updatedInput replaces, it does not merge.** Return\n\n`{ model: \"sonnet\" }`\n\nand 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`\n\nfrom stdin is the natural key. Validate it before putting it in a path.\n\n**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.\n\nThe concurrency cap needs a `PostToolUse`\n\ncounterpart to decrement the in-flight count, and the nested-dispatch guard needs a reliable \"am I inside a subagent\" signal - check what `session_id`\n\nand `transcript_path`\n\nactually 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.\n\nI keep coming back to one line from this:\n\n**A budget rule in a prompt is a preference. A budget rule in a PreToolUse hook is an invariant.**\n\nThe 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.\n\nThat 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.\n\nI 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.\n\n*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.*", "url": "https://wpnews.pro/news/my-agent-orchestrator-burned-1-2m-opus-tokens-per-task-here-s-the-postmortem", "canonical_source": "https://dev.to/akashy/my-agent-orchestrator-burned-1-2m-opus-tokens-per-task-heres-the-postmortem-2k7g", "published_at": "2026-08-04 18:19:42+00:00", "updated_at": "2026-08-04 18:46:59.344086+00:00", "lang": "en", "topics": ["artificial-intelligence", "large-language-models", "ai-agents", "developer-tools", "ai-infrastructure"], "entities": ["Claude Code", "Claude Opus 4.8", "Claude Sonnet 4.6", "Claude Haiku 4.5"], "alternates": {"html": "https://wpnews.pro/news/my-agent-orchestrator-burned-1-2m-opus-tokens-per-task-here-s-the-postmortem", "markdown": "https://wpnews.pro/news/my-agent-orchestrator-burned-1-2m-opus-tokens-per-task-here-s-the-postmortem.md", "text": "https://wpnews.pro/news/my-agent-orchestrator-burned-1-2m-opus-tokens-per-task-here-s-the-postmortem.txt", "jsonld": "https://wpnews.pro/news/my-agent-orchestrator-burned-1-2m-opus-tokens-per-task-here-s-the-postmortem.jsonld"}}