How I stopped my Claude Code subagents from secretly running on Fable instead of Sonnet

How I stopped my Claude Code subagents from secretly running on Fable instead of Sonnet
Guess which layer stopped holding.

I run Claude Code with a big session model, Fable or Opus 5, plus a small zoo of subagents doing the boring parts. Gateway agents, formatters, checkers. The kind of mechanical stuff you pin to a small model once, in the agent’s frontmatter, and then never think about again.

At some point the token consumption stopped matching my gut feeling of what I’d actually been doing that week. Nothing was broken. Nothing errored. Everything worked. It just cost more than it should have.

So I decided to take a deeper look at how Claude Code actually picks the model for a subagent. It turned out the pin I’d been trusting sits in the one layer you shouldn’t trust.

Disclaimer: This is what I run on my own machine, on my own projects. Hooks can block your own dispatches, that’s the whole point of this one, so if you wire it up wrong you’ll be staring at a blocked Task call and wondering why. Also, precedence behaviour in Claude Code changes between releases. Verify against the version you’re on.

Why you want subagents in the first place

Before the complaining starts, let me be clear about one thing: subagents are good. This post is not an argument against them, it’s an argument for pinning them properly.

The reason to use one isn’t that it’s a smaller model. It’s that it runs in its own context window. It goes off, does something loud and messy, and hands back a short answer. The noise never lands in your main session. You get the three lines that matter instead of the four megabytes they came from.

Which means the ideal subagent job looks like this: fetch a lot, filter, return a little. And that job needs a model that is obedient, not brilliant. Something like Sonnet does it all day. Running it on Fable or Opus 5 is paying frontier prices for grep with good manners.

My list of agents that should never touch a frontier model:

  • AWS, especially CloudWatch Logs. A log query for one request ID returns an ocean. You want the stack trace and the timestamp. This one alone justifies the whole pattern.
  • GitHub. Issues, PR diffs, CI status, “which commit touched this file”. Long output, small answer.
  • Honeybadger. Error occurrences, backtraces, “is this the same bug as last Tuesday”. Structured input, structured output.
  • Langfuse. Trace dumps are enormous and 95% of every trace is irrelevant to the question you’re asking about it.
  • Static analysis: RubyCritic, RuboCop and friends. The report is a hundred pages, the actionable part is nine lines.
  • Test runners. You do not need Fable to look at RSpec output and tell you which four specs are red. You need something that can read.

Pinning one looks like this, in .claude/agents/cloudwatch-digger.md:

---
name: cloudwatch-digger
description: Queries CloudWatch Logs and returns only the relevant lines
model: sonnet
tools: Bash, Read
---

One line. model: sonnet. That’s the whole pin, and that’s exactly why it hurts when it silently stops working. The agents you bother to pin are, by definition, the ones you dispatch most often and look at least.

The first uncomfortable thing

Claude Code resolves which model a subagent runs on in this order:

rank layer how you set it
1 environment variable shell / launch config
2 per-invocation parameter model on the dispatch itself
3 agent frontmatter model: in .claude/agents/<name>.md
4 session model whatever you started the session with

Four layers. And the one that everybody actually uses, the frontmatter pin, because it’s the one that’s documented, obvious and writable once, is rank 3 of 4.

That would be fine if rank 3 always held. It doesn’t. Across several releases the frontmatter layer has silently dropped out, and pinned agents fell straight through to rank 4: the session model. Which in my case is Fable.

So the cheap little agent you dispatch two hundred times a day quietly runs on the most expensive thing you have. And here’s the part I find genuinely annoying: there is no signal. No error, no warning, nothing in the transcript that looks different. The agent does its job. It just does it at a multiple of the price.

A crash is polite, it tells you. This doesn’t tell you anything. It shows up four weeks later as a number.

Recognising the pattern

I wasn’t the first one to run into this. There’s a whole class of upstream reports about frontmatter pins being ignored after an update, and the workaround people keep confirming is always the same: pass the model explicitly on the dispatch. That’s rank 2, one layer above frontmatter, and rank 2 has never been the layer that breaks.

Big shoutout to everyone who bothered to file those issues with reproductions. Silent cost regressions are exactly the kind of bug nobody files, because nobody notices.

Which leaves an obvious problem: “just always pass the model explicitly” means the orchestrator has to remember it, every single time, forever. An orchestrator that reliably remembers a thing forever is not something I’ve met.

So don’t remember. Enforce.

Phase 1: A PreToolUse hook

PreToolUse runs before a tool call goes through and can block it with exit code 2. So: if a subagent is pinned in its frontmatter, and the dispatch carries no explicit model, refuse the dispatch and say exactly what to re-send.

These few lines of code can save you a lot of tokens, because they remind Claude Code to use the model you actually chose for your subagents.

.claude/hooks/enforce-subagent-model.sh:

#!/bin/bash
{ read -r T; read -r S; read -r M; } < <(jq -r '.tool_name//"",.tool_input.subagent_type//"",.tool_input.model//""')
case $T in Task|Agent) ;; *) exit 0;; esac
[ -n "$S" ] && [ -z "$M" ] || exit 0
P=$(awk '{sub(/\r$/,"")} NR==1&&$0=="---"{f=1;next} f&&$0=="---"{exit} f&&/^model:[ \t]/{gsub(/["'"'"']/,"",$2);print $2;exit}' \
     "${CLAUDE_PROJECT_DIR:-.}/.claude/agents/$S.md" 2>/dev/null)
case $P in ""|inherit) exit 0;; esac
echo "BLOCKED: '$S' is pinned (model: $P) but this dispatch has no explicit 'model'. Re-dispatch with model: \"$P\". A deliberate different model also passes, but it must be explicit." >&2
exit 2

Nine lines. jq reads the hook payload from stdin, awk reads the pin out of the agent’s frontmatter, and the message on stderr goes back to Claude Code, which then re-dispatches correctly on its own.

Don’t forget:

chmod +x .claude/hooks/enforce-subagent-model.sh

Phase 2: Wiring it up

In .claude/settings.json:

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Task",
        "hooks": [
          { "type": "command", "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/enforce-subagent-model.sh" }
        ]
      }
    ]
  }
}

Test it by dispatching a pinned agent without a model. You should get the BLOCKED message, and the very next dispatch should carry the pin.

What it lets through, on purpose

The interesting part of a guardrail is its exceptions:

  • Any dispatch that carries an explicit model, even a different one. What I’m guarding against is omission, not choice. If I deliberately send a small agent to a big model, that’s a decision, and decisions are allowed. Falling into a model by accident is not.
  • Unpinned agents. model: inherit or no model key at all means inheritance is the intent. Fine, pass.
  • Built-in types (Explore, Plan, general-purpose, …). No file in .claude/agents, nothing pinned, nothing to enforce.
  • Anything unparseable. If jq can’t read the payload, all three variables come back empty and the hook exits 0.

That last one is deliberate: this is a cost guardrail, not a security boundary. A hook that fails closed on a payload it doesn’t understand will eventually wedge a session at the worst possible moment, and then I’ll disable it, and then I’ll be back where I started.

An empty or null model, by the way, is not treated as a choice. It falls straight through to frontmatter, the exact layer under suspicion, so it gets gated like an absent one.

One thing to know

Workflow-tool internal agent() spawns don’t go through PreToolUse. The hook covers Task and Agent dispatches only. Anything spawned inside a workflow tool sails right past it. I cover those with prose in my reference docs instead, which is a nice way of saying they’re not covered. Know where your gate ends.

That’s it

If you’re running Claude Code on Fable or Opus 5 and your token graph looks steeper than your week felt, check your pinned agents before you check anything else. Everything that’s supposed to be cheap is worth verifying, because the failure mode here doesn’t announce itself. It just quietly bills you.

And if you’re not using subagents at all yet, that’s a whole other topic, and honestly a bigger one than this post. Let me know if you’d like to read it and I’ll write it up.

If you find a cleaner way to close the workflow-tool gap, let me know as well!