SLOPSHOPPER

nadir-route

Right-size every prompt and every subagent spawn. A UserPromptSubmit hook asks Nadir's free decision API which tier the prompt needs and, when a cheaper tier…

toaststatus
★ 2v0.11.0no licenseupdated 2026-10-17NadirRouter/nadir-route-plugin
A shopper browsing a rack in a slop shop
README

nadir-route

Right-size Claude Code's subagent spawns. A PreToolUse hook on the Agent tool asks Nadir's free decision API which model the task actually needs, then rewrites model in the spawn's input.

Since 0.8.0 it also right-sizes the prompt itself: a UserPromptSubmit hook asks the same decision API which tier the user's prompt needs, priced against the model the session runs, and when a cheaper tier suffices it asks Claude to delegate that work to a subagent on it. Routing no longer waits for anyone to ask for subagents; complex prompts change nothing, and the main thread keeps its model and its warm cache.

0.8.1 routes spawns issued by a session's first turn, which 0.8.0 mostly left on the session model. The hook reads that model off the session transcript, and Claude Code writes the turn there a few milliseconds after the hook starts, so an inherited spawn now waits up to a second for it.

0.8.2 routes a session's first prompt too. Claude Code states no model on that prompt and the transcript has no turn to read one off yet, so 0.8.1 stayed silent there, which left every session that opens with its task, and every claude -p, unrouted. With no readable model it now offers only simple work to Haiku, the cheapest alias, which cannot be a move up from any session model; medium waits for the next prompt, once the model is on record.

0.8.3 routes Claude Code's Explore searches. Explore is the read-only search agent; since Claude Code 2.1.280 it inherits the session model (capped at Opus), and nearly every real search brief rates complex, so every search ran on the frontier model. An Explore spawn that names no model now takes the rung below on complex (Sonnet under an Opus session, never level or up). NADIR_EXPLORE_COMPLEX=inherit turns that off.

0.8.4 extends that to Explore briefs the classifier cannot see whole (65% of real ones were over the prefilter): they take the same rung without a decision, since no tier under that ladder sits above it. It also stops the prompt router classifying background-task notifications, which reach it as queued prompts.

0.8.5 writes a local route log, ~/.nadir/route-log.jsonl: what Nadir decided for each prompt and spawn, and which model the main thread and each subagent then ran on. Metadata only, never prompt text; NADIR_ROUTE_LOG=off stops it. Follow it live:

python3 ~/.claude/plugins/cache/nadir/nadir-route/*/scripts/route_log.py -f

0.8.8 never moves a spawn above the model its agent definition sets: a Haiku agent that names no model stays on Haiku when Nadir says medium.

0.9.0 lets Claude decide. The prompt hook no longer gives an order. It gives Nadir's read (the tier odds), its pick and the options, and Claude chooses: do the work itself, or hand it to one of five worker agents this plugin ships, each a fixed model and effort:

WorkerModelEffort
nadir-route:haikuHaikunone (Haiku takes no effort setting)
nadir-route:sonnet-lowSonnetlow
nadir-route:sonnet-mediumSonnetmedium
nadir-route:sonnet-highSonnethigh
nadir-route:opus-mediumOpusmedium

0.9.1 adds size. Nadir's turn-size head estimates how many tool calls a prompt will take, and the note says so. When no cheaper model fits the whole task but the task is long (8+ tool calls), the note still speaks: keep the judgment on your model and hand routine, well-specified parts to a worker. A session's first prompt now offers Sonnet workers too, not only Haiku.

0.9.2 prices it. When Nadir knows your session model, it estimates what the task costs done in place and handed off, and the note says both. In clear cases (a cheaper model fits the whole task, a confident rating, a long task that is cheaper to hand off, and a prompt that reads as a whole task) the note asks Claude to hand the work off, unless it needs this conversation's context.

0.9.3: confidence decides who decides. When Nadir is confident and its costing says handing off is cheaper, the note is an order; below that, Claude decides from the information. The costing counts your warm prompt cache, so a warm session that makes staying cheaper keeps the work where it is.

0.10.0 can switch the main thread's model. With the switch_main_model option (on by default; set it to false to keep every turn on your own model), the prompt hook gives main-session advice and a hooks module runs a turn's main-thread requests on the cheaper model Nadir suggested, only when its cache check prices the switch below staying on your warm model, so a long warm session mostly stays. The cheaper model is claude-haiku-4-5 unless NADIR_CLAUDE_MODELS or ANTHROPIC_DEFAULT_HAIKU_MODEL names another; under Bedrock, Vertex or a custom ANTHROPIC_BASE_URL nothing switches until you list your models. Needs a Claude Code build with hooks modules.

0.11.0 reads the session, not just the prompt. Each prompt records how many tool calls the previous turn made (counts only, kept in ~/.nadir/context) and sends the session's turn sizes with the decision, so a terse "ok, next" in the middle of a big job is sized as part of that job. Harness messages that arrive as prompts (<side-sessions-event> and <ci-monitor-event>, as well as <task-notification>) are no longer sent for a decision.

Workers are how a subagent's effort gets set at all: Claude Code's Agent tool has no effort field, and it tells Claude to set model only when you ask for one. A worker Claude picks is left exactly as chosen; the spawn hook still logs Nadir's tier beside it, so the dashboard shows both.

Nadir is a decision engine here, not a gateway. Your prompts and completions go straight from Claude Code to Anthropic on your own auth; Nadir is consulted out of band with the spawn's task text and never sees the request, the response, or a provider key. There is no added latency on the token stream.

claude plugin marketplace add https://getnadir.com/marketplace.json
claude plugin install nadir-route@nadir

No key required. Add one to attribute decisions to your account and get a savings figure on the dashboard (see below).

What it does

Nadir's bucketthe hook writeseffect
simplehaikuyour cheapest model
mediumsonnetyour default working model
complexnothingthe spawn keeps the model the session already runs

Three rules, all deliberate:

  • complex is never rewritten. The top tier stays on the model you chose, including which Opus generation. Nadir right-sizes the cheap end; it does not move you off your frontier model.
  • It only writes harness aliases. Agent's model parameter is an enum (haiku, sonnet, opus, fable), and Claude Code converts a schema-invalid hook rewrite into a denied tool call. Aliases also resolve through your own ANTHROPIC_DEFAULT_*_MODEL config, so a routed spawn lands in your family and your generation rather than one this plugin hardcoded.
  • It never moves a spawn up on its own. If the agent already asked for something cheaper than the routed tier, that stands. So does a cheaper model set in the agent's own definition (a Haiku agent such as caveman:cavecrew-investigator or the built-in claude-code-guide), which is what a spawn naming no model runs on. The one exception is an explicit policy pin from your account, which is a standing instruction from you and so wins in either direction. A pin naming a full model id (claude-sonnet-5) cannot be expressed on the alias enum at all, so it falls through to the tier ladder rather than doing nothing.

Your main thread is never touched, by construction: the hook fires only on spawns.

Measured end to end on Claude Code 2.1.220 — a trivial read bucketed medium and the subagent started on claude-sonnet-5 (from an Opus inherit); a multi-region failover design bucketed complex, the hook emitted nothing, and the subagent ran on claude-opus-5.

Configuration

Set these in ~/.claude/settings.json under env, or export them.

VarDefaultMeaning
NADIR_ROUTE_DISABLEunset1 turns the hook off
NADIR_BASELINE_MODELunsetthe model your sessions run on, e.g. claude-opus-5, which inherited spawns are routed and priced against. Unset, the hook reads it off the session transcript; set it to pin one, or for sessions that persist no transcript, where an inherited spawn waits up to a second for one and then keeps its model
NADIR_API_KEYunsetattributes decisions to your account and surfaces them on the dashboard (Logs), with the session each belongs to and the model the turn actually ran. The plugin also asks for it as its api_key option when you enable it (kept in your OS keychain); this env var wins over that. Keyless calls store no row at all, by design, so your dashboard stays empty
NADIR_LOOP_WORKERhaikuthe worker a scheduled tick (a /loop or scheduled-task prompt that already ran in the session) is handed to; any shipped worker name: haiku, sonnet-low, sonnet-medium, sonnet-high, opus-medium
NADIR_CLAUDE_LADDER{"simple":"haiku","medium":"sonnet"}retune the table above. Map a tier to inherit to leave it alone; {"simple":"inherit","medium":"inherit"} is audit mode — decisions recorded, nothing changed
NADIR_AGENT_POLICY{"subagent":"auto"} keylessraw JSON role policy. Pin a value ({"subagent":"haiku"}) instead of letting the router pick
NADIR_TIMEOUT5seconds the decision call may take. Do not lower to 2: a 10-way parallel fan-out measures 1.9–2.3s per call, so a 2s cap loses most decisions in exactly the traffic this is for
NADIR_BUCKET_URLhttps://api.getnadir.com/v1/bucketendpoint

Requires python3 on PATH for JSON handling. Without it every path exits silently and spawns proceed unrouted.

Fail-open, and how to tell

Every failure path exits 0 with no stdout — disabled, empty prompt, unparseable input, bad policy JSON, network error, timeout, non-200, missing decision field, a model equal to the one already requested, or a rejected key. Claude Code reads that as "no decision" and the spawn proceeds exactly as the agent intended. Only exit code 2 blocks a tool call, and this script cannot produce one.

The cost of that design is that "working" and "doing nothing" look identical, so check explicitly rather than assuming:

hook=$(python3 -c 'import json, os; d = os.environ.get("CLAUDE_CONFIG_DIR") or os.path.expanduser("~/.claude"); print(json.load(open(d + "/plugins/installed_plugins.json"))["plugins"]["nadir-route@nadir"][0]["installPath"])')
echo '{"tool_name":"Agent","tool_input":{"prompt":"rename a variable in one file","description":"rename var","subagent_type":"Explore"}}' \
  | NADIR_BASELINE_MODEL=claude-opus-5 sh "$hook/scripts/route-spawn.sh"

Expect JSON containing "model":"haiku". The first line finds the version Claude Code actually runs: an update leaves the previous version's directory in the plugin cache, so a glob over it can pick the old hook. The baseline stands in for the session transcript a real spawn is read against: without either, an inherited spawn keeps its model by design and this prints nothing. With it, empty output means it is not routing. An invalid NADIR_API_KEY produces exactly the same silence as an unreachable network, so if you are keyed, re-run the same check with NADIR_API_KEY= — if it starts working, your key is being rejected.

Inside a session, Claude Code's PostToolUse tool_response.resolvedModel names the model a subagent actually started on.

Precedence traps

  • CLAUDE_CODE_SUBAGENT_MODEL, if set to anything but inherit, outranks the per-invocation model this hook writes. The hook now detects this and exits before calling the API, so it spends no decision — and books no savings — on a rewrite that is guaranteed to be discarded.
  • The hook does beat a subagent's frontmatter model, including model: inherit.
  • This is a nudge, not a control. Claude Code's Agent permission rules match the agent type, not the model, so there is no local way to enforce a ceiling.

Alternative install

One command, no plugin, same hook — it merges into settings.json, backs it up, and verifies routing is live before it exits:

curl -fsSL https://getnadir.com/install/claude-code.sh | sh

For tiering work that is not a subagent spawn (batch items, "which model should this use", delegate-vs-inline cost), the full skill documents the whole /v1/bucket contract: npx skills add https://getnadir.com

Source

Mirrored from integrations/agent-hooks/claude-code in the Nadir monorepo, which is the source of truth. Issues: <https://getnadir.com>

Source 1 files
hooks/model_switch.ts 59 lines
1import type { Register } from 'claude-code'
2
3// The plugin's `switch_main_model` option, on by default. On, route_user_prompt.py
4// runs in recommend mode and logs a `suggested` model only when Nadir's cache check
5// prices switching below staying on the warm model; this module then runs that
6// turn's main-thread requests on it. Subagents, other sessions, stale rows and
7// strict-mode rows (a worker agent's name, not a model) are left alone, and an
8// unreadable log leaves the session model. Transcript, tools and messages never change.
9const FRESH_MS = 120_000
10const SCAN_ROWS = 200
11const MODEL_ID = /^claude-[a-z0-9.-]+$/
12
13type Row = {
14  ts?: string; harness?: string; event?: string; mode?: string
15  session?: string | null; suggested?: string | null
16}
17
18export function suggestedModel(log: string, sessionId: string, now: number): string | undefined {
19  const lines = log.trim().split('\n')
20  for (let i = lines.length - 1; i >= 0 && i >= lines.length - SCAN_ROWS; i--) {
21    let row: Row
22    try { row = JSON.parse(lines[i] ?? '') } catch { continue }
23    if (row.harness !== 'claude-code' || row.event !== 'prompt' || row.session !== sessionId) continue
24    if (!row.ts || now - Date.parse(row.ts) > FRESH_MS) return undefined
25    const model = row.mode === 'recommend' ? row.suggested : undefined
26    return model && MODEL_ID.test(model) ? model : undefined
27  }
28  return undefined
29}
30
31export const register: Register = (on, options) => {
32  if (options.switch_main_model !== true) return
33  let decided: { turnId: string; model: string | undefined } | undefined // one decision per turn
34
35  on('turn.step', async function* ($, e, next) {
36    if (e.agentId) return yield* next(e) // subagents: the PreToolUse spawn hook covers them
37    if (decided?.turnId !== e.turnId) {
38      let model: string | undefined
39      try {
40        const home = await $.env.get('HOME')
41        const log = (await $.env.get('NADIR_ROUTE_LOG')) || `${home}/.nadir/route-log.jsonl`
42        const [text, id, now] = await Promise.all([$.fs.read(log), $.session.id(), $.clock.now()])
43        model = suggestedModel(text, id, now)
44      } catch {
45        model = undefined
46      }
47      decided = { turnId: e.turnId, model }
48    }
49    const { model } = decided
50    if (!model || model === e.model) {
51      $.ui.status(undefined)
52      return yield* next(e)
53    }
54    $.ui.status(`nadir: ${model}`)
55    if (e.index === 0) $.ui.toast(`Nadir: this turn runs on ${model}`)
56    return yield* next({ ...e, model })
57  })
58}
59