Sets the main loop's reasoning effort per turn from how complex the prompt looks; /route pins or releases it

Mods for Claude Code, plus the status line they pair with.
Picks the reasoning effort for each turn from your prompt, so you don't pay high effort for "what time is it in Tokyo?".
? or starting with what, how, *is*…)Only the main conversation is routed. Subagents keep their own effort.
/route shows the current mode, /route low|medium|high pins an effort, and /route auto switches back to routing by prompt.
The effort chosen for each turn is written to /tmp/claude-effort-router/<session id>, so a status line script can display it.
At the prompt of a terminal session:
/plugin install effort-router --marketplace apiglue/claude-mods
Answer y to add the marketplace, then pick a scope.
From a shell:
claude plugin install effort-router --marketplace apiglue/claude-mods
In the desktop app, where /plugin does not run: click + next to the prompt box, then Plugins, Add marketplace with apiglue/claude-mods, then Add plugin.
statusline/statusline-command.sh is a truecolor status line that shows:
~/.p10k.zsh (e.g. rooster (main !1)), and current folderOpus 5.5 (low). With effort-router installed, that is the effort the mod chose for the turn rather than the session's setting.It needs jq. A status line is a setting, not something a plugin can install, so clone this repo and point statusLine in ~/.claude/settings.json at the script:
"statusLine": {
"type": "command",
"command": "bash /path/to/claude-mods/statusline/statusline-command.sh"
}
A git pull in the clone picks up changes, since the setting runs the file straight from it.
hooks/register.ts 70 lines1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import type { Effort } from '../types'
5import { classify } from './classify'
6
7const pinned = atom({ plugin: 'effort-router', key: 'pinned' } as const, null)
8const current = atom({ plugin: 'effort-router', key: 'current' } as const, null)
9
10const EFFORTS: readonly Effort[] = ['low', 'medium', 'high']
11const isEffort = (word: string): word is Effort =>
12 (EFFORTS as readonly string[]).includes(word)
13
14// The status line script reads the turn's choice from here, by session id.
15const CHOICE_DIR = '/tmp/claude-effort-router'
16
17export const register: Register = on => {
18 on('session.start', async ($, e, next) => {
19 await $.command.register({
20 name: 'route',
21 description: 'Pin reasoning effort (low, medium, high), or "auto" to route by prompt',
22 })
23
24 return next(e)
25 })
26
27 on('command.run', { command: 'route' }, async ($, e) => {
28 const word = e.args.trim().toLowerCase()
29
30 if (word === '') {
31 const pin = await read($, pinned)
32 return { text: pin ? `Effort pinned at ${pin}.` : 'Effort routed by prompt.' }
33 }
34 if (word === 'auto') {
35 await update($, pinned, () => null)
36 return { text: 'Effort routed by prompt again.' }
37 }
38 if (isEffort(word)) {
39 await update($, pinned, () => word)
40 return { text: `Effort pinned at ${word} until /route auto.` }
41 }
42
43 return { text: `Unknown "${word}". Use low, medium, high or auto.` }
44 })
45
46 // Decide once per prompt, so every step of the turn runs at the same effort.
47 on('prompt.submit', async ($, e, next) => {
48 if (e.command || e.isMeta) {
49 return next(e)
50 }
51
52 const pin = await read($, pinned)
53 const choice = pin ? { effort: pin, reason: 'pinned' } : classify(e.text)
54 await update($, current, () => choice)
55 await $.fs.write(`${CHOICE_DIR}/${await $.session.id()}`, choice.effort)
56
57 return next(e)
58 }).catch(($, e, next) => next(e))
59
60 // Main loop only: a subagent keeps the effort its definition asks for, and a
61 // model that takes no effort (e.effort absent) is left alone.
62 on('turn.step', async function* ($, e, next) {
63 const choice = e.agentId === undefined && e.effort !== undefined
64 ? await read($, current)
65 : null
66
67 return yield* next(choice ? { ...e, effort: choice.effort } : e)
68 })
69}
70hooks/classify.ts 27 lines1import type { Choice } from '../types'
2
3const HEAVY =
4 /\b(refactor|migrat|implement|build|design|architect|debug|diagnos|investigat|fix|rewrite|review|optimi[sz]|plan)\w*/i
5const QUESTION =
6 /^(what|where|who|when|why|how|is|are|does|do|can|explain|show|list|look at)\b/i
7const LONG = 800
8const SHORT = 200
9
10// Words over cleverness: a wrong guess costs one turn, and /route overrides it.
11export const classify = (text: string): Choice => {
12 const prompt = text.trim()
13 const heavy = HEAVY.exec(prompt)
14
15 if (heavy) {
16 return { effort: 'high', reason: `"${heavy[0].toLowerCase()}"` }
17 }
18 if (prompt.length > LONG) {
19 return { effort: 'high', reason: 'long prompt' }
20 }
21 if (prompt.length < SHORT && (prompt.endsWith('?') || QUESTION.test(prompt))) {
22 return { effort: 'low', reason: 'question' }
23 }
24
25 return { effort: 'medium', reason: 'default' }
26}
27types/index.d.ts 10 lines1export type Effort = 'low' | 'medium' | 'high'
2
3export type Choice = { effort: Effort; reason: string }
4
5declare module 'claude-code' {
6 interface PluginState {
7 'effort-router': { pinned: Effort | null; current: Choice | null }
8 }
9}
10