← Back to Build Log

Cutting our Claude bill 60 percent by routing Sonnet at the worker level

By Caleb Gates

If you are running a multi-agent orchestrator on Claude Opus and have not looked at your model routing this month, you probably have 50 to 60 percent of the bill sitting on the table. We caught it in a weekend on Nark's bc-coordinator, the parallel pipeline that runs our profile authoring, scanner upgrades, and corpus auditing. The fix was two prompt changes and a small rule about JSON serialization. The harder part was admitting our mental model of Opus pricing was a year out of date.

Quick Answer: When you spawn a Claude subagent via the Agent tool, pass model: "sonnet" explicitly for any work that is pattern-completion against a documented schema. Sonnet 4.6 is quality-equivalent to Opus 4.7 on that class of task, but you pay about 44 percent of the rate (closer to 56 percent savings once you account for Opus 4.7's larger tokenizer). The parent agent that makes routing decisions can stay on Opus. To see the same audit and routing rules in our repo, the source doc is at /build/claude-model-routing-audit and the coordinator skill that enforces them ships with Nark.

What is bc-coordinator? It is Nark's internal orchestrator that runs eight independent streams in parallel: an audit stream that re-verifies our existing Nark Profiles against scan results, an onboard stream that creates new Profiles for npm packages, a scanner stream that ships analyzer upgrades, two deepen streams that fill in API coverage gaps, a drift stream that catches outdated semver ranges, an envelope-batch stream for SDK long-tail depth, and an opt-in cleanup stream that triages our vitest baseline. At full fan-out it spawns thirteen concurrent Claude contexts. Every dollar of inefficiency multiplies by thirteen.

What is the real Opus 4.7 to Sonnet 4.6 price ratio in 2026?

The Anthropic pricing docs as of June 25 2026 list Claude Opus 4.7 at $5 per million input tokens and $25 per million output tokens. Claude Sonnet 4.6 lists at $3 per million input and $15 per million output. Headline ratio is 1.67x, not the 5x most operators still carry in their head from the original Opus 4 era. Opus 4.7 was repriced down from Opus 4's $15 in $75 out. If your routing decisions were written when Opus was 5x Sonnet, your justification for spreading work across models is partly stale.

There is a second number that almost nobody quotes. The same docs note that Opus 4.7 uses a new tokenizer that may consume up to 35 percent more tokens for the same fixed text. That inflation hits both input and output. Same prompt encoded as more tokens, same response generated as more tokens. The effective ratio on real workloads:

SideHeadlineAfter 35 percent tokenizerEffective ratio
InputOpus $5 vs Sonnet $3, 1.67xOpus chunks ~35% more for identical text~2.25x
OutputOpus $25 vs Sonnet $15, 1.67xOpus generates ~35% more for identical content~2.25x

So the real Opus to Sonnet ratio in 2026 is closer to 2.25x. We confirmed this empirically. One bc-deepen-contract pass against the orb-billing Profile ran 138 thousand total tokens on Sonnet 4.6 for about $0.48, against an Opus 4.7 estimate of $1.08 on the same workload. 2.25x almost exactly.

What is Nark? Nark is a TypeScript scanner that catches missing error handling around npm packages. Run npx nark in your repo to scan against 165+ Nark Profiles, or wire the GitHub App into your pull request flow. Open source under AGPL-3.0. The orchestrator and routing changes described here are how we maintain the Profile corpus, not the product itself.

When should I use Sonnet vs Opus in a Claude agent system?

We bucketed every task type our orchestrator runs into four classes. The boundaries are not arbitrary. They came from looking at where Sonnet ships output that we cannot tell apart from Opus output, and where it does not.

Bucket A: mechanical walks, claim operations, queue state. Walking a directory tree of YAML files. Claiming the next pending package via a bash script. Updating a JSON index entry. Reading a state file and printing a snapshot. This is glorified find | grep | jq. Haiku 4.5 is fine here. Sonnet adds no quality. Opus is wasted.

Bucket B: pattern-following batch work with structured output. Writing per-method postconditions in YAML against a schema, given documented throw patterns. Triaging scanner violations into true positive or false positive buckets. Classifying a changelog into "extend semver range" vs "fork the profile" vs "rewrite the error model." Writing fixture TypeScript files. Most pull-request comment drafting against a template. Sonnet 4.6 is empirically as good as Opus 4.7 on these. Most of the volume in our orchestrator lives here.

Bucket C: strategic, creative, or cross-cutting judgment. Hacker News response drafting where voice is a feature. Sales outreach where blast radius is high. Architecture decisions like our multi-corpus precedence rule. Designing a new contract.yaml schema for a package whose docs are confusingly worded. Cross-surface product triage. Devils-advocate review where the entire point is catching subtle issues a cheaper model would miss. Opus earns its 1.67x to 2.25x premium here. The premium is cheap insurance against shipping something we cannot easily QA.

Bucket D: things Sonnet will quietly degrade on, watch out. Long-context synthesis past about 30 thousand tokens of relevant material. Complex schema constraints where the right answer requires holding many enums in mind. "Skip vs onboard" decisions where Sonnet is biased toward producing output even when "skip" is correct. We learned the schema constraint case the hard way in our first live run, more on that below.

How do I make a Claude subagent run on a different model than the parent?

The Agent tool has a model parameter that takes "sonnet", "opus", or "haiku". The resolution order is straightforward: explicit model: on the Agent call wins, then the agent definition's frontmatter, then inherit from parent. The single-level subagent nesting limit on Claude Code is a separate concern and does not interact with model selection. A parent can route every child to a different model. A child cannot itself spawn grandchildren, but the parent can stand in for that by spawning the grandchildren directly.

Our routing rule for the eight bc-coordinator streams now reads as a per-stream table at the top of the skill. The coordinator brain stays on Opus 4.7 because it makes layout, state, and partner-priority decisions across many docs every wave. Every spawned stream worker takes model: "sonnet". When the Sonnet routing does not work for a specific package family, the escape hatch is to override just that one spawn with model: "opus". We have not needed to use it yet.

Concretely, the call looks like this:

// Inside the coordinator brain (Opus 4.7), spawning a deepen stream worker.
Agent({
  subagent_type: "general-purpose",
  model: "sonnet",
  run_in_background: true,
  description: "deepen-stream-1 pass 4",
  prompt: deepenStreamPromptTemplate.replace("{WAVE_NUM}", "4"),
});

Per the audit, this single change drops coordinator burn by about 56 to 65 percent in steady state. Three other levers stack on top of it.

What broke when we shipped Sonnet routing to production

Two hours into the first live run we caught two schema regressions that Opus had never produced in any prior pass.

Cassandra-driver, function executeGraph, sources field. Sonnet inlined local file paths into sources: [...] instead of GitHub permalinks. The contract schema requires every source URL to match ^https://. Workspace validator flagged it as must match pattern "^https://". We scanned the entire 191-contract corpus and confirmed all three non-https sources came from this one Sonnet pass. Opus has consistently used proper permalink URLs back to v4.9.0 of the package.

Googleapis, function messages.watch, business impact enum. Sonnet invented two enum values that are not in the schema: production_behavior: silent_incorrect (the valid options are silent_failure, degraded_service, immediate_exception, delayed_failure) and user_experience: degraded (valid options are degraded_performance, lost_data, lost_transaction, service_unavailable, security_breach, authentication_failure). Plausible-sounding values, both wrong, both confidently emitted.

The pattern was clear after the second one. Sonnet 4.6 is more prone to schema-enum hallucination on complex business-impact subfields than Opus 4.7 is. It is not a quality failure in the general sense. It is a specific weakness in a specific class of constraint.

We patched both files, ran npm run validate to confirm 191 of 191 contracts now passed, and shipped a v3.0.1 update to the coordinator skill that added two preventive guardrails to the deepen-stream prompt:

  1. Explicit enumeration of the valid production_behavior, user_experience, incident_label, and severity enum values inline in the prompt. Sonnet anchors more reliably when the exact allowed strings are in its working context than when it has to remember them from a schema doc it read earlier in the session.
  2. A pre-commit gate that requires running cd nark-dev/nark-corpus && npm run validate and confirming "All N contracts valid" before any commit. This catches the failure at the worker level, before the bad contract ships.

Over the next two hours of live coordinator runtime we shipped twelve more commits across two corpus repos with zero new schema violations. The guardrail held.

The JSON serialization gotcha you only see at scale

A Sonnet subagent updating deepen-index.json re-serialized the entire 1300-line file with Python's default json.dump(... ensure_ascii=True). Every literal Unicode character in the source got rewritten as a \uXXXX escape sequence. The actual semantic change was 45 lines, updating one package entry. The raw diff was 1497 lines. Same data, totally unreadable, would have buried any real issue in the noise.

We restored the file, applied just the semantic update with ensure_ascii=False, and shipped a new workspace rule at .claude/rules/json-serialization.md that bans whole-file ASCII re-serialization on any shared JSON state file. The required directive now gets embedded in every stream prompt that writes JSON:

When writing JSON to any shared state file, preserve the file's original character encoding. Use json.dump(data, f, indent=2, ensure_ascii=False) in Python or default JSON.stringify(data, null, 2) in Node. Do not introduce \uXXXX escapes for characters that were literal Unicode in the source. Update only the entry you are responsible for. Do not reformat unrelated entries.

This is not Sonnet-specific. Opus would do the same thing if you let it. Haiku would too. The fix is making the expectation explicit at the spawn point, not picking a different model.

How does Claude prompt caching work for parallel agent orchestrators?

Anthropic's prompt cache hits on the prompt prefix. A cache hit costs 0.1x the base input rate. A cache miss costs 1x. The cache key is the literal token prefix of the prompt, ending at the first variable substitution. If your prompt starts with a wave number that changes every spawn, the cache prefix is essentially empty. You pay 1x on the whole prompt every time, even though the next 1200 lines are identical across waves.

Cache writes are not free. The 5-minute cache write costs 1.25x base input, and the 1-hour cache write costs 2x. The math works out to: one cache hit during the 5-minute window pays off the write; the 1-hour cache needs two hits to break even. For a parallel orchestrator that spawns the same stream every few minutes, 5-minute write is the safer bet. For an unattended overnight run that fires once an hour, 1-hour write wins. We use 5-minute because our wave cadence is sub-3-minute on the active streams.

Our stream prompts originally led with the variables:

You are bc-audit-stream running wave 42.
DATE: 2026-06-25
WORKSPACE: /Users/...
[1200 lines of stable instructions, schema constraints, edge cases]
Print STREAM_COMPLETE: audit-stream wave=42 when done.

We restructured them so the stable body is the cache prefix and the variables go in a Runtime context block at the end:

You are a bc-audit-stream worker.
Your job: Run one wave of bc-audit-batch (3 packages), then exit cleanly.
[1200 lines of stable instructions, schema constraints, edge cases]
Print STREAM_COMPLETE: audit-stream wave=<WAVE_NUM from runtime context> when done.

---
## Runtime context
WAVE_NUM: 42
DATE: 2026-06-25
STREAM_ID: audit-stream

The body is identical across waves of the same stream. That is what gets cached. Wave 2 and beyond pay 10 percent of the input cost on the stable portion. We estimate another 30 percent savings on input tokens on top of the Sonnet routing, in steady state.

We made this change as a spawn-time transformation rule documented in the coordinator skill rather than rewriting all eight prompt templates. The rule says: when the coordinator brain calls the Agent tool, strip the variable substitutions from the header of the prompt template, replace them with a stable header, append a Runtime context block at the end with the substituted values. This is forward-compatible. Any new stream added later inherits the cache benefit automatically.

We cannot measure cache hit ratio directly from inside Claude Code. There is no cache_read_input_tokens surface on the Agent tool's response in the harness we use. The validation we ran is functional: streams complete their waves and produce their STREAM_COMPLETE: markers normally. If the transformation were misapplied we would see workers fail mid-pass complaining about missing wave context. Zero such failures across the live runs. The cost confirmation is indirect, by looking at the Anthropic Console billing dashboard's cache-read column after the session.

What does a routed and cached session look like in production?

Two hours of live bc-coordinator runtime after shipping v3.0.0, v3.0.1, and v3.2.0:

  • 11 stream completions across audit, scanner, deepen-1, deepen-2, drift, and envelope-batch streams
  • 4 new package audits committed: @capacitor/core, @clerk/nextjs-v7, @clickhouse/client, plus the @alicloud triplet
  • 37 functions deepened on @workos-inc/node across four passes, taking coverage from 16.3 percent to 39.1 percent
  • 1 autonomous version fork: drift-stream created the ai-v7 extends-fork for Vercel AI SDK v7.0.0 without human intervention
  • 1 scanner upgrade implemented: nestjs-axios Observable handling (catchError + subscribe.error patterns), baseline vitest failure count dropped by 6
  • 39 operations added via envelope-batch on @aws-sdk/client-ecr (31 percent to 95 percent coverage in a single template-derived pass)
  • Both corpora validate 192 of 192 and 789 of 789 contracts clean
  • Every autonomous commit carries Co-Authored-By: Claude Sonnet 4.6 attribution, not Opus

Two schema regressions caught and patched at the workspace validator level before they shipped. JSON serialization rule held: every shared JSON state file got surgical updates, no cosmetic re-serializes. The numbers we ran on the audit doc's worked example predicted a 56 to 65 percent reduction in coordinator burn from Sonnet routing alone, with prompt caching layered on top adding roughly another 30 percent on input. We will know the exact final number once we have a full month of billing under the new layout.

What we deliberately did not do

We did not route bc-hn-responder, sales outreach drafting, full-product-triage, or the devils-advocate review skill to Sonnet. Those are explicitly Bucket C and Opus earns its premium on them. The blast radius of a wrong answer in a Hacker News reply or a customer email far outweighs the savings.

We did not set a workspace-wide default model in .claude/settings.json. Doing so would change the interactive /model behavior we rely on for ad-hoc work. The right granularity for routing is per-skill, not per-workspace.

We did not aggressively roll Haiku out. Audit recommended it for narrow Bucket A tasks like bc-next-package queue mechanics. The savings on those paths are real but small in absolute terms because the tasks are already sub-second and small-context. The risk-adjusted return on Haiku in our workload is lower than the routing and caching changes. We left it as a future option, not a deferred mistake.

We did not downgrade the coordinator brain itself. It re-reads the multi-corpus rule, the deepen index, the audit index, the partner-coverage targets, and the skill body on every wave. Routing the wrong child streams or making the wrong restart decision cascades. The 1.67x to 2.25x premium for keeping that decision on Opus is the cheapest insurance in the budget.

How to do this yourself if you are running Claude at scale

Three steps, in order, each independent of the others:

  1. Audit your model assignments first. Walk every skill, agent definition, and spawn call you have. Confirm what model each piece actually runs on today, not what you think it runs on. We discovered we had been running 100 percent Opus on everything because nothing had ever explicitly set a model, even on workers documented as "designed for Sonnet."
  2. Route stream workers to Sonnet by default for pattern-completion work. Keep your orchestrator brain on Opus. Set explicit model: "sonnet" on every Agent call that spawns a worker doing template-driven structured output. Run the regression test on one representative profile or workload before extending the rule.
  3. Restructure your prompts so variables go to the suffix. Anthropic's cache hits on the prompt prefix. If your stream prompts lead with wave numbers, timestamps, or stream identifiers, the cache prefix is broken. Move them to a Runtime context block at the end. The transformation is mechanical and stacks with the model routing change.

If you want to see the source code for what we shipped, the audit document is at .planning/quick/192-audit-claude-model-usage-opus-sonnet-hai/192-AUDIT.md in our open-source workspace, and the coordinator skill is .claude/skills/bc-coordinator/SKILL.md. We use the same model routing rules for everything Nark does today.

Try It Now

npx nark --tsconfig ./tsconfig.json

nark checks 165+ npm packages for correct error handling, resource cleanup, and Nark Profile coverage. The orchestrator described in this post is how we maintain the Profile corpus that nark scans against.