Routes each prompt to Haiku, Sonnet or Opus based on how hard it looks

A Claude Code mod that chooses the model for each prompt. Before a prompt is sent, a quick Haiku call rates how hard it is, and the turn then runs on Haiku, Sonnet or Opus accordingly.
At the prompt of a terminal session:
/plugin install auto-model --marketplace ecosys-technology/auto-model
From a shell:
claude plugin install auto-model --marketplace ecosys-technology/auto-model
In the desktop app, where /plugin does not run: click + next to the prompt box, then Plugins, Add marketplace with ecosys-technology/auto-model, then Add plugin.
Answer y to add the marketplace, then pick a scope. The mod is active right away.
haiku, sonnet or opus. When it is torn between two, it picks the stronger one.route: sonnet./route command| Command | Effect |
|---|---|
/route | Show the current setting and the last pick |
/route auto | Choose per prompt (default) |
/route haiku, /route sonnet, /route opus | Always use that model |
/route off | Stop routing; your /model choice applies |
The setting is kept across sessions.
/route to avoid it.hooks/register.ts (MODELS). Change them there if your account or provider uses different ones. The rubric that decides the tier is RUBRIC in the same file.claude plugin validate .
claude plugin test .hooks/register.ts 126 lines1import type { Register } from 'claude-code'
2
3type Tier = 'haiku' | 'sonnet' | 'opus'
4type Mode = 'auto' | 'off' | Tier
5
6const MODELS: Record<Tier, string> = {
7 haiku: 'claude-haiku-5-5',
8 sonnet: 'claude-sonnet-5-5',
9 opus: 'claude-opus-5-5',
10}
11
12const TIERS: readonly Tier[] = ['haiku', 'sonnet', 'opus']
13const MODES: readonly Mode[] = ['auto', 'off', ...TIERS]
14const MODE_KEY = 'mode'
15
16// Short follow-ups ("yes", "go on", "fix it") lean on the conversation, not
17// their own words, so they keep the model the previous prompt picked.
18const FOLLOW_UP_CHARS = 40
19
20// Origins that are the person typing; notifications and peers keep the route.
21const ROUTED_ORIGINS = new Set(['composer', 'bridge', 'sdk'])
22
23const RUBRIC = `You route prompts for a coding agent to the cheapest model that will do the job well.
24Answer with exactly one word: haiku, sonnet or opus.
25
26haiku: quick lookups, simple questions, explaining a short snippet, renaming, formatting, one-line fixes, running a known command.
27sonnet: ordinary coding work: implementing a feature, writing tests, fixing a bug with a clear cause, refactoring a few files, writing docs.
28opus: hard work: architecture and design decisions, debugging an unclear or intermittent failure, large multi-file changes, security review, performance work, tricky algorithms, anything ambiguous or high-stakes.
29
30When unsure between two, pick the stronger one.`
31
32export function parseTier(text: string): Tier | undefined {
33 const word = text.trim().toLowerCase().match(/haiku|sonnet|opus/)?.[0]
34
35 return word as Tier | undefined
36}
37
38export const register: Register = on => {
39 let current: Tier | undefined
40
41 const asMode = (stored: unknown): Mode =>
42 MODES.includes(stored as Mode) ? (stored as Mode) : 'auto'
43
44 on('session.start', async ($, e, next) => {
45 await $.command.register({
46 name: 'route',
47 description: 'Auto model routing: /route [auto|off|haiku|sonnet|opus]',
48 })
49 const mode = asMode(await $.store.get(MODE_KEY))
50 $.ui.status(mode === 'auto' ? 'route: auto' : `route: ${mode}`)
51
52 return next(e)
53 })
54
55 on('command.run', { command: 'route' }, async ($, e) => {
56 const arg = e.args.trim().toLowerCase()
57
58 if (arg === '') {
59 const mode = asMode(await $.store.get(MODE_KEY))
60 const now = current ? `, last pick ${current}` : ''
61
62 return { text: `Model routing is ${mode}${now}. Use /route auto|off|haiku|sonnet|opus.` }
63 }
64
65 if (!MODES.includes(arg as Mode)) {
66 return { text: `Unknown mode "${arg}". Use /route auto|off|haiku|sonnet|opus.` }
67 }
68
69 const mode = arg as Mode
70 await $.store.set(MODE_KEY, mode)
71 current = TIERS.includes(mode as Tier) ? (mode as Tier) : undefined
72 $.ui.status(mode === 'auto' ? 'route: auto' : `route: ${mode}`)
73
74 return { text: `Model routing set to ${mode}.` }
75 })
76
77 on('prompt.submit', async ($, e, next) => {
78 if (e.command || e.isMeta || !ROUTED_ORIGINS.has(e.origin.kind)) {
79 return next(e)
80 }
81
82 const mode = asMode(await $.store.get(MODE_KEY))
83
84 if (mode === 'off') {
85 current = undefined
86 $.ui.status('route: off')
87 return next(e)
88 }
89
90 if (mode !== 'auto') {
91 current = mode
92 $.ui.status(`route: ${mode} (pinned)`)
93 return next(e)
94 }
95
96 if (current !== undefined && e.text.trim().length < FOLLOW_UP_CHARS) {
97 $.ui.status(`route: ${current} (follow-up)`)
98 return next(e)
99 }
100
101 const reply = await $.model.complete({
102 model: 'haiku',
103 system: RUBRIC,
104 prompt: `<prompt>\n${e.text.slice(0, 6000)}\n</prompt>`,
105 maxTokens: 8,
106 timeoutMs: 6000,
107 })
108 const picked = reply.isAnswered ? parseTier(reply.text) : undefined
109
110 // A failed or unreadable answer leaves the session's own model in charge.
111 current = picked
112 $.ui.status(picked ? `route: ${picked}` : 'route: session model (router failed)')
113
114 return next(e)
115 }).catch(($, e, next) => next(e))
116
117 // The main loop's requests go to the routed model; subagents keep theirs.
118 on('turn.step', async function* ($, e, next) {
119 if (e.agentId !== undefined || current === undefined) {
120 return yield* next(e)
121 }
122
123 return yield* next({ ...e, model: MODELS[current] })
124 })
125}
126