Picks effort for each prompt by itself and asks before switching the model.

A Claude Code mod that sets the effort for every prompt by itself and asks you before it switches the model.
The model question is one click: Switch to Sonnet, Stay on Opus or Stop asking. If you say Stay, the same suggestion won't come back for the next 5 prompts. A switch sticks until a prompt calls for a different model, so the model doesn't flip back and forth (every switch starts a fresh prompt cache, which costs extra).
The status line always shows what's in force, for example autoroute: Sonnet · low (light).
At the prompt of a Claude Code terminal session:
/plugin install autoroute --marketplace 1kta4/claude-autoroute
Answer y to add the marketplace, then pick the user scope so it works in every project.
From a shell:
claude plugin install autoroute --marketplace 1kta4/claude-autoroute
In the desktop app: click + next to the prompt box, then Plugins, Add marketplace with 1kta4/claude-autoroute, then Add plugin.
| Command | What it does |
|---|---|
/autoroute | Shows the model, effort and last guess |
/autoroute off / on | Turns routing off or on |
/autoroute reset | Goes back to the session's own model and effort |
/autoroute ask off / ask on | Switches the model without asking, or asks again |
Open /config and look for the autoroute rows:
| Setting | Default |
|---|---|
| Model for light tasks | sonnet |
| Model for deep tasks | opus |
| Effort for light / everyday / deep tasks | low / medium / high |
| Ask before switching the model | on |
A model can be haiku, sonnet, opus, fable or a full model id such as claude-sonnet-5-5. Short names map to ids in hooks/register.ts (MODEL_IDS). Update that map when new models come out.
prompt.submit hook reads each prompt you send and sorts it with keyword rules (hooks/classify.ts). It runs locally and costs nothing.turn.step hook sends each request of the main conversation with the chosen model and effort./model still shows the session's own model. The status line shows what autoroute actually sends.The keyword rules are a starting point. Edit DEEP and LIGHT in hooks/classify.ts to match how you write.
A Claude Code build with function-hook mods. Tested with Claude Code 2.1.308. On older builds, run claude update.
MIT
hooks/register.ts 189 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Effort, Route, Tier } from '../types'
5import { classify } from './classify'
6
7const route = atom({ plugin: 'autoroute', key: 'route' } as const, {
8 model: null,
9 effort: null,
10 tier: null,
11} as Route)
12
13// After "Stay", the same suggestion waits this many prompts before coming back.
14const QUIET_PROMPTS = 5
15
16// A model request takes a full id, not an alias, so the short names map here.
17const MODEL_IDS: Record<string, string> = {
18 haiku: 'claude-haiku-5-5',
19 sonnet: 'claude-sonnet-5-5',
20 opus: 'claude-opus-5-5',
21 fable: 'claude-fable-5-1',
22}
23
24const FAMILIES = Object.keys(MODEL_IDS)
25
26const modelId = (name: string) => MODEL_IDS[name.trim().toLowerCase()] ?? name.trim()
27
28const family = (model: string) =>
29 FAMILIES.find(name => model.toLowerCase().includes(name)) ?? model
30
31const title = (model: string) => {
32 const name = family(model)
33
34 return name.charAt(0).toUpperCase() + name.slice(1)
35}
36
37const HELP = [
38 '/autoroute show what is in force',
39 '/autoroute on | off turn routing on or off',
40 '/autoroute reset go back to the session model',
41 '/autoroute ask on | off ask before a model switch, or not',
42].join('\n')
43
44async function showStatus($: EngineInterface, isOn: boolean, isMuted: boolean) {
45 if (!isOn) {
46 $.ui.status('autoroute: off')
47
48 return
49 }
50
51 const now = await read($, route)
52 const model = now.model ?? (await $.session.model())
53 const effort = now.effort ?? 'default'
54 const tier = now.tier ? ` (${now.tier})` : ''
55 const muted = isMuted ? ', model fixed' : ''
56 $.ui.status(`autoroute: ${title(model)} · ${effort}${tier}${muted}`)
57}
58
59export const register: Register = (on, options) => {
60 const lightModel = modelId(String(options.lightModel ?? 'sonnet'))
61 const deepModel = modelId(String(options.deepModel ?? 'opus'))
62 const efforts: Record<Tier, Effort> = {
63 light: String(options.lightEffort ?? 'low') as Effort,
64 standard: String(options.standardEffort ?? 'medium') as Effort,
65 deep: String(options.deepEffort ?? 'high') as Effort,
66 }
67
68 let isOn = true
69 let isAsking = options.askBeforeSwitch !== false
70 // "Stop asking": effort still follows each prompt, the model stays put.
71 let isMuted = false
72 let prompts = 0
73 let declined: { model: string; until: number } | null = null
74
75 on('session.start', async ($, e, next) => {
76 await $.command.register({
77 name: 'autoroute',
78 description: 'Show or change automatic model and effort routing',
79 argumentHint: 'on | off | reset | ask on | ask off',
80 })
81 await showStatus($, isOn, isMuted)
82
83 return next(e)
84 })
85
86 on('command.run', { command: 'autoroute' }, async ($, e) => {
87 const args = e.args.trim().toLowerCase()
88
89 if (args === 'on' || args === 'off') {
90 isOn = args === 'on'
91 isMuted = false
92 } else if (args === 'reset') {
93 await update($, route, () => ({ model: null, effort: null, tier: null }))
94 isMuted = false
95 declined = null
96 } else if (args === 'ask on' || args === 'ask off') {
97 isAsking = args === 'ask on'
98 } else if (args !== '' && args !== 'status') {
99 return { text: HELP }
100 }
101
102 await showStatus($, isOn, isMuted)
103 const now = await read($, route)
104 const model = now.model ?? (await $.session.model())
105
106 return {
107 text: [
108 `Routing is ${isOn ? 'on' : 'off'}; asks before a model switch: ${isAsking ? 'yes' : 'no'}.`,
109 `Model: ${model}${now.model ? ' (switched by autoroute)' : ' (session model)'}.`,
110 `Effort: ${now.effort ?? 'session default'}${now.tier ? `, last prompt read as ${now.tier}` : ''}.`,
111 ].join('\n'),
112 }
113 })
114
115 on('prompt.submit', async ($, e, next) => {
116 // Commands, hidden prompts and prompts typed into a running turn pass as they are.
117 if (!isOn || e.command || e.isMeta || e.turnId) {
118 return next(e)
119 }
120
121 prompts += 1
122 const tier = classify(e.text)
123
124 if (tier === null) {
125 return next(e)
126 }
127
128 const now = await read($, route)
129 const sessionModel = await $.session.model()
130 const current = now.model ?? sessionModel
131 const target = tier === 'light' ? lightModel : tier === 'deep' ? deepModel : null
132 let model = now.model
133
134 const isNew = target !== null && family(target) !== family(current)
135 const isQuiet =
136 declined !== null && declined.model === target && prompts < declined.until
137
138 if (isNew && target !== null && !isQuiet && !isMuted) {
139 let isSwitching = !isAsking
140
141 if (isAsking) {
142 const yes = `Switch to ${title(target)}`
143 const no = `Stay on ${title(current)}`
144 const mute = 'Stop asking'
145 const answer = await $.ui
146 .ask(`This looks like a ${tier} task. Switch to ${title(target)} from now on?`, {
147 options: [yes, no, mute],
148 header: 'Model',
149 })
150 .catch(() => no)
151
152 isSwitching = answer === yes
153
154 if (answer === mute) {
155 isMuted = true
156 } else if (!isSwitching) {
157 declined = { model: target, until: prompts + QUIET_PROMPTS }
158 }
159 }
160
161 if (isSwitching) {
162 // Going back to the session's own family drops the override.
163 model = family(target) === family(sessionModel) ? null : target
164 declined = null
165 }
166 }
167
168 await update($, route, () => ({ model, effort: efforts[tier], tier }))
169 await showStatus($, isOn, isMuted)
170
171 return next(e)
172 })
173
174 // Every request of the main loop goes out with the routed model and effort.
175 on('turn.step', async function* ($, e, next) {
176 if (!isOn || e.agentId !== undefined) {
177 return yield* next(e)
178 }
179
180 const now = await read($, route)
181
182 return yield* next({
183 ...e,
184 model: now.model ?? e.model,
185 effort: now.effort ?? e.effort,
186 })
187 })
188}
189hooks/classify.ts 84 lines1import type { Tier } from '../types'
2
3// Work that rewards a stronger model and more thinking.
4const DEEP = [
5 /\barchitect/i,
6 /\bdesign\b/i,
7 /\brefactor/i,
8 /\bmigrat/i,
9 /\bsecurity\b/i,
10 /\bRLS\b/,
11 /\bpolic(y|ies)\b/i,
12 /\bdebug/i,
13 /\bwhy (does|is|do|did|are|would)\b/i,
14 /\brace condition/i,
15 /\bdeadlock/i,
16 /\bconcurren/i,
17 /\bperformance\b/i,
18 /\boptimi[sz]/i,
19 /\bplan\b/i,
20 /\breview\b/i,
21 /\binvestigat/i,
22 /\bschema\b/i,
23 /\bauth(entication|orization)?\b/i,
24 /\bstack ?trace\b/i,
25 /\bTraceback\b/,
26 /\bException\b/,
27 /\bfrom scratch\b/i,
28 /\btrade-?offs?\b/i,
29 /\barhitektur/i,
30 /\bmigracij/i,
31 /\bsigurnost/i,
32 /\bzašto\b/i,
33]
34
35// Chores a lighter model does just as well.
36const LIGHT = [
37 /\brename\b/i,
38 /\btypos?\b/i,
39 /\bcommit\b/i,
40 /\bpush\b/i,
41 /\bpull\b/i,
42 /\bformat\b/i,
43 /\blint\b/i,
44 /\bwhat (is|does|are)\b/i,
45 /\bshow me\b/i,
46 /\bwhere is\b/i,
47 /\btranslate/i,
48 /\bspelling\b/i,
49 /\bbump\b/i,
50 /\bgit status\b/i,
51 /\bupdate (the )?(readme|version|docs?|changelog)\b/i,
52 /\brun (the )?(tests?|build|app|server)\b/i,
53 /\bpreimenuj/i,
54]
55
56// "ok", "yes, do it": carries on whatever came before, so nothing changes.
57const CARRY_ON =
58 /^(ok(ay)?|yes|yeah|yep|sure|go( ahead)?|do it|continue|keep going|thanks?|thank you|ty|y|no|nope|k)\b[\s.,!]*(do it|go ahead|please|thanks?)?[\s.!]*$/i
59
60const count = (patterns: RegExp[], text: string) =>
61 patterns.filter(pattern => pattern.test(text)).length
62
63// null means the prompt says too little to move anything.
64export const classify = (text: string): Tier | null => {
65 const trimmed = text.trim()
66
67 if (trimmed.length < 4 || CARRY_ON.test(trimmed)) {
68 return null
69 }
70
71 const deep = count(DEEP, trimmed)
72 const light = count(LIGHT, trimmed)
73
74 if (deep >= 2 || (deep >= 1 && trimmed.length > 200) || trimmed.length > 1200) {
75 return 'deep'
76 }
77
78 if (deep === 0 && light >= 1 && trimmed.length < 300) {
79 return 'light'
80 }
81
82 return 'standard'
83}
84types/index.d.ts 17 lines1export type Tier = 'light' | 'standard' | 'deep'
2
3export type Effort = 'low' | 'medium' | 'high' | 'xhigh' | 'max'
4
5export type Route = {
6 // The model every main-loop request goes to; null keeps the session's own.
7 model: string | null
8 effort: Effort | null
9 tier: Tier | null
10}
11
12declare module 'claude-code' {
13 interface PluginState {
14 autoroute: { route: Route }
15 }
16}
17