Subagent model routing is broken — all mechanisms resolve to parent model (Opus)
Bug description
Subagent model routing does not work. All documented mechanisms for routing subagents to a different model (e.g. Sonnet) are silently ignored — subagents always run on the parent session's model (Opus).
Tested mechanisms (all failed)
| # | Mechanism | Doc reference |
|---|---|---|
| 1 | Agent(model: "sonnet") per-invocation param | Subagent docs |
| 2 | Custom agent .claude/agents/*.md with model: sonnet frontmatter | Subagent docs |
| 3 | CLAUDE_CODE_SUBAGENT_MODEL=claude-sonnet-4-6 env var | Model config docs |
| 4 | CLAUDE_CODE_SUBAGENT_MODEL=sonnet env var (alias) | Same |
| 5 | settings.json top-level "env": { "CLAUDE_CODE_SUBAGENT_MODEL": "sonnet" } | Model config docs |
Reproduction
- Start Claude Code on a Max plan with Opus as the main model
- Note the "Sonnet only" usage % on the dashboard
- Spawn subagents with any of the above mechanisms
- Check dashboard — Sonnet stays flat, Opus session usage increases
We ran 5 separate tests (15 subagents total, 3 per test). Sonnet stayed at 2% across all tests while Opus increased each time.
Expected behavior
The subagent docs document a resolution order:
CLAUDE_CODE_SUBAGENT_MODELenv var (highest priority)- Per-invocation
modelparameter - Subagent definition's
modelfrontmatter - Main conversation's model (fallback)
At least one of these should actually route the subagent to the specified model.
Impact
Max plan users have a separate "Sonnet only" quota that is currently unusable from Claude Code. All subagent work (search, exploration, scaffolding) consumes the shared Opus weekly limit instead, leading to faster throttling.
Related issues
- #8932 — general-purpose agent sets model as 'sonnet' rather than 'inherit'
- #5456 — Sub-agents Don't Inherit Model Configuration in Task Tool
- #37823 — Allow per-agent-type model overrides in settings
- #26179 — Subagents should default to Sonnet, not inherit Opus
- #19174 — Ambiguity regarding default model behavior for Subagents
Environment
- Claude Code desktop app (Windows 11)
- Opus 4.6 (1M context) as main model
- Max plan (5x)
- Tested 2026-04-05
17 Comments
Found 3 possible duplicate issues:
This issue will be automatically closed as a duplicate in 3 days.
🤖 Generated with Claude Code
This is not a duplicate of the suggested issues:
model: opus, gets Sonnet). Our issue is: setmodel: sonnet, get Opus. Same root cause likely, but distinct symptom and different reproduction.modelOverridesin the tmux spawner. We're on the Anthropic API using subagents, not agent teams.modelparam being ignored specifically whenteam_nameis provided (agent teams). Already closed as duplicate.What makes this issue unique: We systematically tested all 5 documented mechanisms for subagent model routing — per-invocation
modelparam, custom agent frontmatter,CLAUDE_CODE_SUBAGENT_MODELenv var (both full ID and alias), andsettings.jsonenv injection — and verified each with Anthropic dashboard metrics (Sonnet-only quota). None of them work.The closest issue (#18346) only covers one mechanism and lacks dashboard verification. Our issue provides a complete test matrix that demonstrates the entire model resolution chain documented at https://code.claude.com/docs/en/sub-agents#choose-a-model is non-functional.
👎 to prevent auto-closure.
Just discovered and came to report this as well, confirmed with claude running opus 4.7 itself after doing a deep dive - using
model: haikuormodel: sonnetfrontmatter does not work, everything runs on opus. Is my usage going to be adjusted to compensate for this?My usage is currently over 7x higher than it should be because of this bug, and I'm thankful I decided to look into this as soon as I did. Is this going to be adjusted or compensated when the bug is fixed?
Adding a reproduction: Explore subagents self-initiated by the parent session were labeled as Sonnet 4.6 in the UI but ran as claude-opus-4-6 (the parent's model). The user did not request subagent delegation — CC chose to spawn them autonomously, compounding the cost impact since the user has no control over either the delegation decision or the model routing.
CLI version: current (2.1.x), parent model: claude-opus-4-6, requested: sonnet.
This is quite a surprise. At first I thought that I was mis-interpreting the logs. This definitely interferes with optimization.
This definitely needs a bump, it's a serious issue cost wise for the users and I suppose also performance wise for Anthropic
Update: direct
Agent(model:)calls appear fixed as of v2.1.146 (2026-05-21) on Mac Desktop / Max5The v2.1.146 changelog includes: "Fixed
CLAUDE_CODE_SUBAGENT_MODELnot being forwarded to child processes in multi-agent sessions." On that version,model: "haiku"andmodel: "sonnet"in direct Agent tool invocations both resolve correctly — confirmed by inspectingsubagents/agent-*.jsonltranscripts showingclaude-haiku-4-5-20251001andclaude-sonnet-4-6respectively.This fix may not cover the
resolveTeammateModel()path (custom agent frontmatter + subagent_type), which the changelog doesn't mention. The full-model-ID workaround noted above likely still applies there.Reproduction confirming this bug
Environment:
2.1.150(claude --version)claude-opus-4-7)general-purposeMinimal reproduction recipe:
> Use the Task tool to spawn 3 parallel sub-agents. Each must have
subagent_type: "general-purpose"andmodel: "sonnet"set on the Agent call.>
> Task each sub-agent to create exactly 15 text files numbered
1.txtthrough15.txtin a dedicated scratch directory:> - Worker 1 →
/tmp/sonnet-test/worker1/> - Worker 2 →
/tmp/sonnet-test/worker2/> - Worker 3 →
/tmp/sonnet-test/worker3/>
> Each file should contain a single line
workerN file M(where N is the worker number and M is the file number).>
> After all 3 sub-agents complete, verify by running:
>
for w in worker1 worker2 worker3; do echo "$w: $(ls -1 /tmp/sonnet-test/$w/ | wc -l)"; done>
> Then grep each sub-agent's transcript file at
/private/tmp/claude-501/<workspace>/<session>/tasks/<agentId>.outputfor themodelfield — you'll seeclaude-sonnet-4-6for all three.Expected (if routing worked): "Sonnet only" weekly % moves upward.
Observed: "Sonnet only" stays at 0%; parent session's "Current session" gauge increases by ~2% (Opus billed).
Dashboard readings from the test (just-now refresh):
| Metric | Before sub-agents | After sub-agents | Delta |
|---|---|---|---|
| Current session | 22% | 24% | +2% |
| Weekly "All models" | 31% | 31% | 0% |
| Weekly "Sonnet only" | 0% | 0% | 0% |
Sub-agents completed the task correctly (45/45 files written, content verified). The work happened — but it ran on Opus, not Sonnet.
Transcript metadata is misleading:
\
jq\/ \grep\against the sub-agent transcript JSONL files returns:\
\\\"model":"claude-sonnet-4-6"
\
\for all three sub-agents. The metadata records the requested model from the \
model\parameter, but actual routing + billing goes to the parent's model. This makes the bug hard to notice — you can grep transcripts and convince yourself Sonnet was used, while the dashboard says otherwise.Real-world impact:
On a Max plan, the "Sonnet only" weekly quota stays at 0% no matter how aggressively you try to push sub-agent work onto Sonnet, because all delegation mechanisms (per-invocation \
model\parameter, custom agent frontmatter, \CLAUDE_CODE_SUBAGENT_MODEL\env var) silently route to the parent session's model. The Sonnet quota Max users pay for is genuinely unreachable from sub-agents.Workaround that does work: switch the parent session model to Sonnet via \
/model sonnet\. Sub-agents then inherit Sonnet correctly. The cost is losing Opus quality on the main session — not a substitute for the documented per-sub-agent routing.Hoping for visibility on this from the Claude Code team — affects every Opus-parent + Sonnet-delegate workflow.
Repro: plugin agents +
CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1— frontmatter ignored on 2.1.202, full-model-ID workaround also failsEnvironment
2.1.202(latest release as of posting), macOS (darwin), Anthropic API/Maxclaude-sonnet-5CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1set in usersettings.jsonenv— this routes allAgenttool spawns through the teammate spawn path~/.claude/plugins/cache/.../agents/hvn-scout.md) withmodel: haikufrontmatter (verified well-formed, identical in repo and installed cache)claude -psessions; model confirmed from subagent transcripts (~/.claude/projects/<project>/subagents/agent-*.jsonl), not model self-reportResults
| Test | Expected | Actual |
|---|---|---|
| Spawn plugin agent, no
modelparam, frontmattermodel: haiku| haiku |claude-sonnet-5(parent model) — reproduced in 2 independent sessions || Same, frontmatter changed to full ID
claude-haiku-4-5-20251001| haiku |claude-sonnet-5— workaround failed || PreToolUse hook injecting
model: haikuviahookSpecificOutput.updatedInputon the Agent call | haiku |claude-haiku-4-5-20251001✅ |Two data points beyond what's in the thread:
--model haiku, failing to parse, falling back — per theresolveTeammateModel()trace above), a full ID should have worked. It didn't, which suggests frontmatter model is dropped (or overridden) on the teammate path before the subprocess flag is even built — not merely passed through unresolved.Agent(model:)calls reported above does not cover this: with the agent-teams flag enabled, plugin-agent frontmatter is still ignored on 2.1.202.Working mitigation (the only mechanism that controlled subagent model on this setup): a PreToolUse hook on the
Agenttool that injects the desiredmodelinto the tool input viahookSpecificOutput.updatedInput. Aliases resolve fine on that path — transcript shows the injectedhaikurunning asclaude-haiku-4-5-20251001.Source-level trace of the teammate-path drop (follow-up to my repro above, v2.1.202)
Traced the installed v2.1.202 binary (Bun-compiled; the embedded minified
src/entrypoints/cli.jsis readable in the executable). Minified local names below; offsets omitted since they vary per build. Three findings that narrow where the fix needs to land, plus one correction to earlier speculation in this thread (mine included).1. The teammate branch looks the agent definition up by EXACT
subagent_typematch — no normalization — and a miss silently drops the frontmatter model.The Agent tool handler routes to the teammate spawn when teams context + a
nameparam are present. On that branch:So frontmatter (
gvereturnsagentDef.modelfor non-built-in agents) IS threaded — but only if that exact-matchfindsucceeds. The non-teammate path, by contrast, normalizes the requested type (there's dedicated telemetrytengu_subagent_type_normalizedand an ambiguity error). The teammate branch has none of that: anysubagent_typestring that doesn't literally equal the storedagentType(e.g. plugin agents are stored namespaced asplugin:name) yieldsCe = undefined→model: void 0→ teammate default → parent model, with no error and no log — and the spawn still works, because the child only receives--agent-type <string>and resolves the definition itself. This produces identical failures for alias and full-ID frontmatter, which matches my repro above.2. Downstream, the teammate resolver never falls back to frontmatter and always forces
--modelon the child.gsn's fallback chain (teammateDefaultModelsetting → hardcoded opus id) never returnsundefined, and the result is appended to the child's command line as an explicit--model <id>unconditionally — so even if the child could resolve its own frontmatter at startup, the parent's flag overrides it.3. A second silent-inherit path exists on BOTH spawn paths: allowlist rejection. Both resolvers validate against
availableModelsand quietly substitute the parent/default on failure, logging:Subagent model "X" is not in the availableModels allowlist; inheriting the parent model insteadTeammate model "X" is not in the availableModels allowlist; using the default teammate model insteadAnyone reproducing this issue can grep session logs for these lines to tell mechanism (3) apart from mechanism (1).
Why the PreToolUse
updatedInputworkaround works: the injected value lands in the tool-param slot (m), which short-circuits ahead of the definition lookup in them ?? gve(...)expression. One sharp edge for anyone copying that workaround:updatedInputis re-validated against the tool's input schema (inputSchema.safeParse, onlyunrecognized_keystolerated), and the Agent tool'smodelschema isv.enum(["sonnet","opus","haiku","fable"])— injecting"inherit"(or a full model ID) gets the spawn denied, so only concrete alias tiers are injectable.Correction: earlier comments (mine included) attributed this to
resolveTeammateModel()skippingparseUserSpecifiedModel(). Neither exists as a callable identifier in 2.1.202 — those names only appear in esbuild export-name tables. The behavior lives in the minified functions quoted above.Suggested fix shape: (a) resolve
subagent_typein the teammate branch with the same normalization the normal path uses (or reuse the normal path's resolution wholesale), and (b) don't force--modelon the spawned child when neither env, tool param, nor frontmatter requested one.Caveat: all of this is static reading of a minified build — happy to re-verify against a fix.
I am sorry people, this is NOT A BUG!! This is an Anthropic BILLING FEATURE.
This is still broken in version 2.1.220.
Still not working for me... it seems "claude" main agent is expected to pass the model. And if not it chooses the session model automatically. This is clearly a bug that should be an easy fix.
Even letting the agent do it explicitly doesnt work
Broken as of v2.1.212
Additional data point from v2.1.233 (Windows), which may narrow this down:
the same failure occurs here, but intermittently within a single session —
which suggests this is not purely a config-resolution bug.
Launch:
claude --model opusplan --permission-mode planCLAUDE_CODE_SUBAGENT_MODEL=sonnetvia settings.jsonenv(verified present inside the subagent process:
echo $CLAUDE_CODE_SUBAGENT_MODEL->sonnet)| # | time | requested model | actual model |
|---|------|-----------------|--------------|
| 1 | 07:53 | (none) | claude-sonnet-5 |
| 2 | 07:59 | (none) | claude-opus-5 |
| 3 | 08:00 | (none) | claude-opus-5 |
| 4 | 08:02 | sonnet | claude-opus-5 |
| 5 | 08:03 | (none) | claude-opus-5 |
| 6 | 08:04 | haiku | claude-opus-5 |
| 7 | 09:04 | (none) | claude-sonnet-5 |
| 8 | 09:16 | (none) | claude-sonnet-5 |
No configuration change, no restart, no
/modelchange between #1 and #8.Spawns #4 and #6 passed an explicit
modelparameter to the Task tool; therequest is recorded correctly in
subagents/agent-<id>.meta.json(
{"agentType":"Explore",...,"model":"haiku"}) while every assistant line inthe corresponding
agent-<id>.jsonlrecords"model":"claude-opus-5".The wrong model actually served the request — this is not a logging artifact.
During the failure window the injected system prompt still told the subagent:
> You are powered by the model named Sonnet 5. The exact model ID is claude-sonnet-5.
but the server-generated
signatureon the thinking block decodes to the real model:...Mg1jbGF1ZGUtb3B1cy01OAFCCHRoaW5raW5n...
base64 -> "laude-opus-5" "thinking"
Since
signatureis produced server-side and is tamper-evident, this confirmsthe API served
claude-opus-5while the harness had resolved (and announced)claude-sonnet-5. The two halves of the request disagree.Ruled out by controlled comparison, all on 2.1.233:
opusplan— a separate session with--model opusplan+ plan mode enabledinteractively produced parent=opus / subagent=sonnet (correct).
"permissionMode":"plan"and"agentSetting":"orchestrator".--model opussession spawned 3 sequential subagents,all correctly sonnet.
Exploreand a user-defined agent.Repro is not deterministic here, but the mismatch between the injected system
prompt and the thinking-block signature makes affected runs easy to detect
after the fact:
grep -o '"model":"[^"]"' ~/.claude/projects/<project>/<session>/subagents/agent-.jsonl | sort
| uniq -c
Note that asking the subagent which model it is running as is useless — it
reads the injected system prompt and reports Sonnet even when Opus served it.
Confirming @TomeSq's signature method, plus a script so anyone here can run it
The
signatureobservation above is the most useful thing in this thread — it's the only signal that the harness cannot have produced — so it's worth mechanising. Below: one decode detail that matters, a standalone script, my numbers, and a false-positive trap.Decode the length prefix, not the string
The model id sits in the blob as a protobuf length-delimited string, so the byte before
claude-is its length. Reading greedily gives youclaude-opus-58— that trailing8is0x38, the next field's tag byte, not part of the id. Take exactlyraw[i-1]bytes. The base64 also needs slicing at four offsets, since the blob isn't aligned to the id and only some alignments decode that region cleanly.Script
NO-SIGNATUREmeans the subagent emitted no thinking blocks — that's absence of proof, not a pass.Three legs — and why this thread keeps arguing about the transcript
There are three records, two of which share a filename stem. I think that's the source of the long-running disagreement here over whether
agent-*.jsonlholds the requested or the routed model:Both sides were right about a different file. Only the third leg is authoritative.
My numbers — v2.1.229, Windows, Max plan
84 subagents across 4 runs, 368 signature blocks, 0 declared-vs-served mismatches. 8 served genuine
claude-sonnet-5(requested via a workflow script's per-agentmodeloption); the other 76 correctly inheritedclaude-opus-5.I can't reproduce the failure on this build. Reporting that as a data point, not as a contradiction of @TomeSq — their table shows it's intermittent within one session, so a clean sweep is evidence about those 84 spawns and nothing else. Worth saying plainly given I opened this issue on the opposite result: the one mechanism I can measure works here, and the ones I can't measure are still being reported broken through 2.1.233. Not covered by my run: agent frontmatter,
CLAUDE_CODE_SUBAGENT_MODEL(shell or settings.json), agent teams.Trap: don't grep for the allowlist string
The
"is not in the availableModels allowlist"line from the source trace above is a real signal, but grepping transcripts for it no longer works. My own notes file quotes the string, so every session where an agent read that file matches: 18 hits here, all quotes, zero real emissions. Check the string appears outside amessagebody before counting it.Negative control
Before trusting the zero: I rewrote a real Opus transcript to declare
claude-sonnet-5, kept the original signatures, and confirmed the check flags it. A detector that has never fired isn't evidence of anything.Correction to my script above — it passes the exact failure this issue is about
The script I posted compares
declaredagainstservedand never uses therequestedleg it prints. That answers "did the harness mislabel its transcript?" — not "did routing honour the request?", and the second one is this issue.Rebuilding @TomeSq's spawn #6 as a fixture —
meta.jsonrequestinghaiku, transcript and signature bothclaude-opus-5— my script printsOKand exits 0. The failure is invisible to a declared-vs-served comparison by construction: the harness faithfully records whichever model it was handed, so declared and served agree precisely when the request was dropped.Two verdicts are needed, not one:
| verdict | condition | meaning |
|---|---|---|
|
IGNORED| a model was requested, a different family was served | the routing bug ||
MISMATCH| declared disagrees with served | harness mislabelled its own transcript |Requests arrive as aliases (
haiku) and signatures carry full ids (claude-haiku-4-5-20251001), so both legs have to be reduced to a family before comparing:Use that in place of the
tag = 'OK' if decl == srv else ...line in the earlier script.What still stands, and is now stronger. Re-ran the corrected check over the same 84 subagents: still 0 failures. Under the stricter comparison that says more than the original pass did — the 8 Sonnet agents provably requested Sonnet and were served it, which a declared-vs-served check cannot establish either way.
Why I'm flagging this rather than quietly editing. The single negative control I ran forged the
declaredfield, so it only ever exercised the leg that already worked, and it passed the entire time the check was blind. If you're writing your own verifier, build the fixture from the failure you're hunting — @TomeSq's spawn #6 is the right shape and is fully specified in their comment.Still unread by any of the above: a fourth leg, the injected system prompt's
You are powered by the model named X. @TomeSq's failing run had announced disagreeing with declared, so three legs do not cover the space and a clean three-leg run does not rule that out.