claude-code
Pinned Claude Code subagents fall through to Fable
Claude News
claude-codeA model pin written into a Claude Code subagent's frontmatter can stop holding silently, sending an agent pinned to Sonnet onto whatever model the session runs. The write-up on Thomas-witt traces it to Claude Code's four-layer model resolution, where frontmatter sits at rank 3 and the session model at rank 4.
At a glance
- The resolution order runs environment variable, then a model passed on the dispatch itself, then the agent's frontmatter, then the session model, so a dropped frontmatter pin falls straight through to the session.
- The fix is a PreToolUse hook of roughly nine lines that reads the dispatch payload with jq, parses the pin out of the agent file with awk, and blocks with exit code 2.
- Nothing errors when the pin drops, so agents built for CloudWatch queries, GitHub diffs, Langfuse traces and RSpec output keep working while billing at frontier rates until the token graph shows it weeks later.
The failure mode is expensive precisely because it is quiet. A crash gets fixed the same day, while a pin that quietly stops holding surfaces weeks later as a number on a token graph. Subagents built for log dumps and test output are dispatched constantly and inspected almost never, which appears to make them the worst place in a setup for a silent fallback to the most expensive model available.
Frontmatter sits at rank 3 of the four layers that decide a subagent's model
Claude Code resolves a subagent's model through four layers in order: an environment variable, a model parameter passed on the dispatch itself, the model key in the agent's frontmatter file under .claude/agents, and finally the session model the run started with.
The frontmatter pin is a single line, model: sonnet, sitting in a file such as .claude/agents/cloudwatch-digger.md alongside the agent's name, description and tool list. It is the documented way to fix a model, and it is rank 3 of the four.
Across several releases that layer has dropped out, and pinned agents fell through to rank 4, the session model, which in the author's setup is Fable. No error appears, no warning, and nothing in the transcript looks different: the agent completes its job at a multiple of the price.
A PreToolUse hook blocks pinned dispatches with exit code 2
Upstream reports of frontmatter pins being ignored after an update converge on one workaround: pass the model explicitly on the dispatch. That is rank 2, one layer above frontmatter, and it has not been the layer that breaks, according to the post.
Rather than rely on the orchestrator remembering, the post enforces it. A PreToolUse hook of about nine lines, .claude/hooks/enforce-subagent-model.sh, reads the tool payload with jq, pulls the pin out of the agent file with awk, and exits with code 2 when a pinned agent is dispatched without an explicit model.
The message goes to stderr, names the model to re-send, and Claude Code re-dispatches on its own. Wiring is a PreToolUse entry in .claude/settings.json with the matcher Task pointing at the script under $CLAUDE_PROJECT_DIR, and the file needs chmod +x. A dispatch of a pinned agent without a model should return the BLOCKED message, and the next dispatch should carry the pin.
Dispatches that carry an explicit model pass, even a different one
The guardrail targets omission, not choice. Any dispatch carrying an explicit model passes, including a deliberate send of a small agent to a large one, while an agent set to model: inherit or carrying no model key at all is treated as intending inheritance.
Built-in types such as Explore, Plan and general-purpose have no file in .claude/agents and nothing to enforce, so they pass too. An empty or null model is not counted as a choice: it falls through to frontmatter, the layer under suspicion, and is gated like an absent one.
An unparseable payload also passes: all three variables come back empty and the hook exits 0. The post frames that as deliberate, since this is a cost guardrail rather than a security boundary, and a hook that fails closed on payloads it cannot read gets switched off eventually.
Where the gate ends
Internal agent() spawns inside workflow tools do not pass through PreToolUse, so the hook covers Task and Agent dispatches only and anything a workflow tool starts sails past it. Those cases are handled with prose in reference documentation instead, which the post describes as not covered. No cleaner method for closing that gap is offered, and the author asks for one.
Comments
No comments yet. Be the first.
Join the conversation
Sign in with Google to leave a comment. Your name and avatar come from your Google profile, and the comment appears after moderation.
We only use your name and avatar from Google. We never store your email address.
