TL;DR
I run a fully autonomous coding agent that handles everything from quick lint fixes to multi-file refactors, and for months I pointed every single task at the same model. Then I spent a month splitting work across Claude Opus, Sonnet, and Haiku by task type instead of habit. My monthly API spend dropped about 35%, average task latency dropped too, and — this is the part that surprised me — quality on the hard tasks actually went up, because I stopped burning my best model's attention on busywork. Here's the routing logic I landed on, what broke along the way, and the one rule I wish I'd known on day one.
The Problem
My agent setup runs dozens of small coding tasks a day: fixing a broken test, renaming a variable across a module, writing a one-line bug fix, but also occasionally something gnarly like "redesign the retry logic for this queue consumer" or "figure out why this race condition only happens under load."
For the longest time I ran all of it through one model — whatever the current top-tier Claude model was at the time. My reasoning was lazy but felt safe: "just use the best one, then I never have to think about it."
Two things eventually forced me to reconsider:
- The bill. A chunk of my daily task volume was trivial — rename this, add this import, fix this lint warning — and I was paying top-tier-model prices for tasks a much cheaper model could do in one shot.
- The queue. When ten small tasks and one genuinely hard task all got the same model, the hard task waited in the same line as the easy ones. Nothing about my routing said "this one needs more attention," so nothing got more attention.
The real problem wasn't cost or speed in isolation — it was that I was treating "best model for everything" as a strategy, when it's actually the absence of one. A model that's excellent at holding a huge architectural decision in its head for twenty minutes is not obviously the right tool for renaming a variable, and I was never actually testing that assumption.
How I Solved It
I split my task queue into three buckets and matched each to a model tier: Haiku for volume, Sonnet for the default workhorse, Opus for anything where being wrong is expensive.
Bucket 1: Haiku — mechanical, low-ambiguity, high-volume
Things that go here: lint fixes, import sorting, renaming a symbol across files, writing a commit message from a diff, classifying whether a PR touches tests vs. source, summarizing a log file. The defining trait isn't "small" — it's low ambiguity. There's basically one correct answer and the model doesn't need to weigh trade-offs to get there.
def pick_model(task):
if task.category in ("lint_fix", "rename", "commit_message", "log_summary"):
return "claude-haiku-4-5"
if task.category in ("architecture", "race_condition", "security_review"):
return "claude-opus-4-8"
return "claude-sonnet-5" # default workhorse
This is deliberately dumb — a category lookup, not a smart classifier deciding in real time. I tried building a "meta-agent" that used a model call to decide which model to route to, and it was a waste of a model call for anything where the category was obvious from the task type alone. Static rules beat a dynamic router for the 80% of tasks where the answer doesn't change.
Bucket 2: Sonnet — the default workhorse
Everything that isn't obviously mechanical or obviously high-stakes lands here: normal feature implementation, most bug fixes, writing tests for existing code, routine refactors. This is my default — if I'm not sure which bucket a task belongs in, it goes to Sonnet, not up to Opus "just in case." That single habit change (defaulting down, not up) accounted for more of the cost savings than the Haiku bucket did.
Bucket 3: Opus — expensive-to-be-wrong
This bucket is small on purpose: architectural decisions, debugging intermittent failures where the fix needs to address a root cause instead of papering over a symptom, anything touching auth or data integrity, and tasks where the agent will operate with minimal supervision for an extended stretch. The shared trait is that a wrong answer here doesn't just cost a retry — it costs hours of downstream cleanup, or ships a bug that's expensive to trace back.
flowchart LR
A[Incoming task] --> B{Category known?}
B -- mechanical/high-volume --> C[Haiku]
B -- default/unclear --> D[Sonnet]
B -- architecture/security/root-cause --> E[Opus]
C --> F[Result]
D --> F
E --> F
The escalation path
The part that actually made this safe to ship was a fallback rule, not the routing table itself: if a Haiku or Sonnet task fails validation twice, it escalates one tier up automatically. "Fails validation" means the test suite still fails, the diff doesn't apply cleanly, or a follow-up check flags the change as only partially done. Without this, a misclassified task just burns retries at the wrong tier and never gets the extra reasoning it actually needed. With it, my cheap tier gets to be aggressively cheap, because I'm not betting the whole task on getting the classification right the first time.
def run_task(task, attempt=0):
model = escalate(task.model, attempt) if attempt > 0 else pick_model(task)
result = call_model(model, task)
if not validate(result, task) and attempt < 2:
return run_task(task, attempt + 1)
return result
def escalate(model, attempt):
order = ["claude-haiku-4-5", "claude-sonnet-5", "claude-opus-4-8"]
idx = min(order.index(model) + attempt, len(order) - 1)
return order[idx]
What the numbers actually looked like
I tracked this for four weeks before and after switching to tiered routing, same workload mix both times as best I could control for it:
| Metric | Single-model baseline | Tiered routing |
|---|---|---|
| Monthly API spend | 100% (baseline) | ~65% |
| Median task turnaround | ~42s | ~27s |
| Tasks requiring escalation | n/a | ~9% |
| Opus share of total task volume | 100% | ~11% |
The median turnaround drop surprised me more than the cost drop. I'd assumed latency was mostly about task complexity, but a lot of it was actually queueing — Haiku and Sonnet both respond faster per call than Opus, so routing the 89% of tasks that didn't need Opus off of it sped up the whole pipeline, not just those individual tasks.
The 9% escalation rate is the number I watch most closely now. If it creeps up, it usually means my category list has drifted from what my actual task mix looks like — a sign I need to update the routing table, not evidence that tiered routing itself is failing.
Lessons Learned
Default down, not up. The biggest cost win wasn't the Haiku bucket — it was making Sonnet the default for "unclear" tasks instead of reflexively reaching for the top-tier model whenever I wasn't sure. Most tasks I thought needed the best model didn't.
Ambiguity is the right axis, not size. I originally tried routing by "how many lines will this touch," and it routed badly — a one-line fix to a subtle race condition is small but not low-ambiguity. Once I switched to "does this have one clearly correct answer," the routing got a lot more accurate.
A dumb static router beats a smart dynamic one for most tasks. I burned real money on an early version where a model call decided which model to route to. For the ~80% of tasks with an obvious category, that's a model call spent deciding something a lookup table already knew.
Escalation-on-failure is what makes cheap tiers safe. Without automatic escalation, routing a task to Haiku is a bet you make once with no recovery. With it, it's a cheap first attempt with a safety net — which is the only way I was comfortable sending anything to the cheapest tier at all.
The hard tasks got better, not just cheaper. This was the real surprise. Once Opus was only handling maybe 10% of total volume instead of 100%, I stopped feeling like I was "wasting" capacity on it — which meant I started giving the hard tasks more context, more constraints, more of the surrounding code instead of terse descriptions. The model didn't get smarter; I got less stingy with the inputs because I wasn't stretching it across everything.
Track the escalation rate, not just the cost. Cost savings will make tiered routing look successful even when the categories are wrong, because Haiku is cheap even when it fails and retries. The escalation rate is the metric that actually tells you whether your routing table matches your real task mix — I check mine weekly now, and a rising number is my signal to revisit the category list before it turns into a quality problem I don't notice until later.
What's Next
I'm working on making the routing categories self-updating — right now "architecture" vs. "routine refactor" is a hand-maintained list of task-type strings, and it drifts as my codebase and workflows change. I'd rather have the categories derived from outcomes (which task types actually needed escalation historically) than from my initial guesses, which were wrong often enough that this whole post exists.
I'm also curious whether the same three-tier split holds up for non-coding agent tasks — research summarization, data extraction — or whether the ambiguity axis needs to be redefined per domain. If you've tried multi-tier routing for anything outside of coding agents, I'd genuinely like to hear how you drew the lines.
Wrap-up / CTA
If you're running a Claude Code agent and still pointing every task at the same model, try splitting your next 20 tasks into "mechanical," "normal," and "expensive-to-be-wrong" before you run them — you'll probably be surprised how few land in that last bucket. If this was useful, follow me here on Dev.to — I'm writing this whole build-in-public series as I go, mistakes included.
Top comments (0)