Subagent model routing is broken — all mechanisms resolve to parent model (Opus)

Status Open
Maintainer reply None cached
Activity 17 comments · opened Apr 5, 2026

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

  1. Start Claude Code on a Max plan with Opus as the main model
  2. Note the "Sonnet only" usage % on the dashboard
  3. Spawn subagents with any of the above mechanisms
  4. 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:

  1. CLAUDE_CODE_SUBAGENT_MODEL env var (highest priority)
  2. Per-invocation model parameter
  3. Subagent definition's model frontmatter
  4. 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

View original on GitHub ↗

17 Comments

github-actions[bot] · 4 months ago

Found 3 possible duplicate issues:

  1. https://github.com/anthropics/claude-code/issues/18346
  2. https://github.com/anthropics/claude-code/issues/41045
  3. https://github.com/anthropics/claude-code/issues/24668

This issue will be automatically closed as a duplicate in 3 days.

  • If your issue is a duplicate, please close it and 👍 the existing issue instead
  • To prevent auto-closure, add a comment or 👎 this comment

🤖 Generated with Claude Code

zadr007 · 4 months ago

This is not a duplicate of the suggested issues:

  • #18346 reports the opposite direction (sets model: opus, gets Sonnet). Our issue is: set model: sonnet, get Opus. Same root cause likely, but distinct symptom and different reproduction.
  • #41045 is about agent teams on AWS Bedrock ignoring modelOverrides in the tmux spawner. We're on the Anthropic API using subagents, not agent teams.
  • #24668 is about the Task tool model param being ignored specifically when team_name is 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 model param, custom agent frontmatter, CLAUDE_CODE_SUBAGENT_MODEL env var (both full ID and alias), and settings.json env 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.

tndv · 4 months ago

Just discovered and came to report this as well, confirmed with claude running opus 4.7 itself after doing a deep dive - using model: haiku or model: sonnet frontmatter does not work, everything runs on opus. Is my usage going to be adjusted to compensate for this?

 Weighted cost overage: ~7.2×. Using Anthropic's public API price ratios (opus 15× haiku / 5× sonnet on input,
   15× / 5× on output, 0.1× for cache read) as a proxy for Max-plan rate-limit consumption:
  - Actual (all opus): ~8.80M opus-equivalent units
  - Expected (declared models): ~1.22M opus-equivalent units
  - Excess attributable to bug: ~7.58M units

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?

unthingable · 4 months ago

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.

blwfish · 3 months ago

This is quite a surprise. At first I thought that I was mis-interpreting the logs. This definitely interferes with optimization.

a-inglese · 3 months ago

This definitely needs a bump, it's a serious issue cost wise for the users and I suppose also performance wise for Anthropic

blwfish · 3 months ago

Update: direct Agent(model:) calls appear fixed as of v2.1.146 (2026-05-21) on Mac Desktop / Max5

The v2.1.146 changelog includes: "Fixed CLAUDE_CODE_SUBAGENT_MODEL not being forwarded to child processes in multi-agent sessions." On that version, model: "haiku" and model: "sonnet" in direct Agent tool invocations both resolve correctly — confirmed by inspecting subagents/agent-*.jsonl transcripts showing claude-haiku-4-5-20251001 and claude-sonnet-4-6 respectively.

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.

PrakharSrivastav · 3 months ago

Reproduction confirming this bug

Environment:

  • Claude Code CLI: 2.1.150 (claude --version)
  • Parent session model: Opus 4.7 (claude-opus-4-7)
  • Plan: Max
  • Sub-agent type: built-in general-purpose

Minimal reproduction recipe:

  1. Note your "Sonnet only" weekly usage on the Anthropic dashboard.
  1. Start a Claude Code session on Opus and paste this prompt verbatim:

> Use the Task tool to spawn 3 parallel sub-agents. Each must have subagent_type: "general-purpose" and model: "sonnet" set on the Agent call.
>
> Task each sub-agent to create exactly 15 text files numbered 1.txt through 15.txt in 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>.output for the model field — you'll see claude-sonnet-4-6 for all three.

  1. Wait for the 3 sub-agents to complete (~30–60 sec total).
  1. Refresh the Anthropic dashboard.

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.

vietairs · 1 month ago

Repro: plugin agents + CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1 — frontmatter ignored on 2.1.202, full-model-ID workaround also fails

Environment

  • CLI: 2.1.202 (latest release as of posting), macOS (darwin), Anthropic API/Max
  • Parent session model: claude-sonnet-5
  • CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1 set in user settings.json env — this routes all Agent tool spawns through the teammate spawn path
  • Agent under test: marketplace-installed plugin agent (~/.claude/plugins/cache/.../agents/hvn-scout.md) with model: haiku frontmatter (verified well-formed, identical in repo and installed cache)
  • Method: fresh headless claude -p sessions; model confirmed from subagent transcripts (~/.claude/projects/<project>/subagents/agent-*.jsonl), not model self-report

Results

| Test | Expected | Actual |
|---|---|---|
| Spawn plugin agent, no model param, frontmatter model: 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-5workaround failed |
| PreToolUse hook injecting model: haiku via hookSpecificOutput.updatedInput on the Agent call | haiku | claude-haiku-4-5-20251001 ✅ |

Two data points beyond what's in the thread:

  1. The full-model-ID frontmatter workaround does not fix this path. If the failure were only unresolved aliases (subprocess getting --model haiku, failing to parse, falling back — per the resolveTeammateModel() 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.
  1. The 2.1.146 fix for direct 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 Agent tool that injects the desired model into the tool input via hookSpecificOutput.updatedInput. Aliases resolve fine on that path — transcript shows the injected haiku running as claude-haiku-4-5-20251001.

vietairs · 1 month ago

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.js is 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_type match — no normalization — and a miss silently drops the frontmatter model.

The Agent tool handler routes to the teammate spawn when teams context + a name param are present. On that branch:

let Ce = t ? c.options.agentDefinitions.activeAgents.find((De) => De.agentType === t) : void 0;
// ...
model: m ?? (Ce ? gve(Ce, c.options.mainLoopModel) : void 0),  // m = tool-call model param

So frontmatter (gve returns agentDef.model for non-built-in agents) IS threaded — but only if that exact-match find succeeds. The non-teammate path, by contrast, normalizes the requested type (there's dedicated telemetry tengu_subagent_type_normalized and an ambiguity error). The teammate branch has none of that: any subagent_type string that doesn't literally equal the stored agentType (e.g. plugin agents are stored namespaced as plugin:name) yields Ce = undefinedmodel: 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 --model on the child.

function H4f(e, t) {            // e = payload model (see above), t = mainLoopModel
  let r = process.env.CLAUDE_CODE_SUBAGENT_MODEL;
  if (r && r !== "inherit") { let n = $o(r); if ($a(n)) return n; return R4o(r), gsn(t) }
  if (e === "inherit") return t ?? gsn(t);
  if (e !== void 0 && !$a(e)) return R4o(e), gsn(t);
  return e ?? gsn(t)
}

gsn's fallback chain (teammateDefaultModel setting → hardcoded opus id) never returns undefined, 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 availableModels and quietly substitute the parent/default on failure, logging:

  • Subagent model "X" is not in the availableModels allowlist; inheriting the parent model instead
  • Teammate model "X" is not in the availableModels allowlist; using the default teammate model instead

Anyone reproducing this issue can grep session logs for these lines to tell mechanism (3) apart from mechanism (1).

Why the PreToolUse updatedInput workaround works: the injected value lands in the tool-param slot (m), which short-circuits ahead of the definition lookup in the m ?? gve(...) expression. One sharp edge for anyone copying that workaround: updatedInput is re-validated against the tool's input schema (inputSchema.safeParse, only unrecognized_keys tolerated), and the Agent tool's model schema is v.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() skipping parseUserSpecifiedModel(). 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_type in the teammate branch with the same normalization the normal path uses (or reuse the normal path's resolution wholesale), and (b) don't force --model on 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.

auraDancer · 1 month ago

I am sorry people, this is NOT A BUG!! This is an Anthropic BILLING FEATURE.

SerayaEryn · 1 month ago

This is still broken in version 2.1.220.

rufreakde · 16 days ago

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

Write in your global Claude.md to always explicitly use the Model specified in the /agents/*.md file when invoking/creating an agent
AgarwalPragy · 14 days ago

Broken as of  v2.1.212

TomeSq · 14 days ago

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 plan
CLAUDE_CODE_SUBAGENT_MODEL=sonnet via settings.json env
(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 /model change between #1 and #8.
Spawns #4 and #6 passed an explicit model parameter to the Task tool; the
request is recorded correctly in subagents/agent-<id>.meta.json
({"agentType":"Explore",...,"model":"haiku"}) while every assistant line in
the corresponding agent-<id>.jsonl records "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 signature on the thinking block decodes to the real model:

...Mg1jbGF1ZGUtb3B1cy01OAFCCHRoaW5raW5n...
base64 -> "laude-opus-5" "thinking"

Since signature is produced server-side and is tamper-evident, this confirms
the API served claude-opus-5 while 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 enabled

interactively produced parent=opus / subagent=sonnet (correct).

  • plan mode — both the working and failing sessions log

"permissionMode":"plan" and "agentSetting":"orchestrator".

  • spawn order — a --model opus session spawned 3 sequential subagents,

all correctly sonnet.

  • agent type — the failures span Explore and 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.

zadr007 · 12 days ago

Confirming @TomeSq's signature method, plus a script so anyone here can run it

The signature observation 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 you claude-opus-58 — that trailing 8 is 0x38, the next field's tag byte, not part of the id. Take exactly raw[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

import base64, glob, json, os, re, sys

def served(sig):
    out = set()
    for off in range(4):
        c = sig[off:]
        try: raw = base64.b64decode(c + '=' * (-len(c) % 4))
        except Exception: continue
        for m in re.finditer(rb'claude-', raw):
            i = m.start()
            if i and 3 <= raw[i-1] <= 60:
                cand = raw[i:i+raw[i-1]]
                if re.fullmatch(rb'claude-[a-z0-9\-]+', cand):
                    out.add(cand.decode())
    return out

root = sys.argv[1] if len(sys.argv) > 1 else os.path.expanduser('~/.claude/projects')
for path in sorted(glob.glob(f'{root}/**/agent-*.jsonl', recursive=True)):
    decl, srv = set(), set()
    for line in open(path, encoding='utf-8'):
        try: msg = json.loads(line).get('message') or {}
        except Exception: continue
        if not isinstance(msg, dict): continue
        if msg.get('model'): decl.add(msg['model'])
        for b in msg.get('content') or []:
            if isinstance(b, dict) and b.get('signature'):
                srv |= served(b['signature'])
    if not decl: continue
    req = None
    meta = path[:-6] + '.meta.json'
    if os.path.exists(meta): req = json.load(open(meta, encoding='utf-8')).get('model')
    tag = 'OK' if decl == srv else ('NO-SIGNATURE' if not srv else '*** MISMATCH ***')
    print(f'{tag:16} requested={req} declared={sorted(decl)} served={sorted(srv)}  {os.path.basename(path)}')

NO-SIGNATURE means 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-*.jsonl holds the requested or the routed model:

agent-<id>.meta.json    "model":"sonnet"       <- REQUESTED (alias; key absent when none passed)
agent-<id>.jsonl        message.model          <- DECLARED  (what the harness resolved)
agent-<id>.jsonl        content[].signature    <- SERVED    (server-side, tamper-evident)

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-agent model option); the other 76 correctly inherited claude-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 a message body 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.

zadr007 · 12 days ago

Correction to my script above — it passes the exact failure this issue is about

The script I posted compares declared against served and never uses the requested leg 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.json requesting haiku, transcript and signature both claude-opus-5 — my script prints OK and 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:

FAMILIES = ('sonnet', 'opus', 'haiku', 'fable')

def family(model):
    if not model:
        return None
    text = model.lower()
    for name in FAMILIES:
        if name in text:
            return name
    return None                       # unrecognised, incl. "inherit" -> nothing requested

def verdict(requested, declared, served):
    if not served:
        return 'NO-SIGNATURE'         # absence of proof, not a pass
    wanted = family(requested)
    if wanted and wanted not in {family(m) for m in served}:
        return 'IGNORED'              # check this FIRST
    if set(declared) != set(served):
        return 'MISMATCH'
    return 'OK'

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 declared field, 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.