Asks an Otari gateway which model each Claude Code subagent should run on, and starts it there.

Claude Code plugin for Otari, the open-source LLM gateway. When Claude Code spawns a subagent, the plugin asks the gateway which model that subagent should run on, and starts it there.
Switching the model inside a running conversation cold-starts its prompt cache. A new subagent has no cache yet, so the moment it is spawned is where a cheaper model is free. Claude Code can take Otari's pick at that moment even when its own requests go to Anthropic directly.
In a Claude Code session:
/plugin marketplace add mozilla-ai/otari
/plugin install otari-router@otari
From a terminal, the same two steps are claude plugin marketplace add mozilla-ai/otari and claude plugin install otari-router@otari.
The install asks for two values, and both are required:
| Option | What it is |
|---|---|
otari_url | Base URL of the gateway, such as https://otari.example.com. Plain http only for a gateway on this machine, since the key travels in a header. |
otari_api_key | The key each recommendation is billed to. Stored in secure storage, never in a settings file. |
Change them later with /plugin configure otari-router. The plugin is active in the session that installed it and in every session after.
To remove it: claude plugin uninstall otari-router@otari.
One hooks module with two hooks:
agent.spawn: sends the spawn (subagent type, task, parent model, the model the caller asked for) to POST /api/v1/routing/recommend and starts the subagent on the model the gateway recommends. A fork is not asked about: Claude Code keeps a fork on its parent's model. When the gateway does not answer within three seconds, or answers with an error, the subagent starts on the model it would have had anyway, and a line in the transcript says so.turn.complete: when a subagent finishes, logs the model it ran on and its token counts, so the pick can be checked against what it cost.Each spawn leaves a dim transcript line such as Explore agent-… starts on claude-haiku-4-5, Otari's choice (jev-1.13.0 chose haiku with 81%).
claude plugin validate plugins/otari-router
claude plugin test plugins/otari-router
To run the checkout's copy in your own sessions, add the repository folder as a marketplace, claude plugin marketplace add /path/to/otari, and install from it. A plugin installed from a folder is read in place, so an edit reaches an open session with /reload-plugins. Claude Code lays editor typings into .claude-plugin/types/ under the plugin when it loads; that folder is gitignored.
A plugin installed from GitHub is a copy pinned by version in plugin.json. A change to the plugin bumps that version, and users pick it up with claude plugin update otari-router@otari.
hooks/register.ts 99 lines1import type { Register } from 'claude-code'
2
3// Otari decides every subagent's model. This module only carries the question
4// there and the answer back. Where Otari listens and the key the request
5// carries are the plugin's two options; the manifest requires both, so
6// Claude Code collects them at install time and loads nothing without them.
7const RECOMMEND_PATH = '/api/v1/routing/recommend'
8const DEADLINE_MS = 3000
9
10type Recommendation = { model: string; reason?: string }
11
12// The key travels in a header, so plain http is for a gateway on this machine
13// only. A remote http URL is refused at spawn time rather than at load: a
14// module that fails to load says so in the debug log alone, while a refused
15// spawn leaves a line in the transcript until the option is fixed.
16const plainHttpToRemoteHost = (base: string): boolean => {
17 let url: URL
18 try {
19 url = new URL(base)
20 } catch {
21 return false // not a URL at all; the fetch reports that itself
22 }
23 const host = url.hostname
24 const local = host === 'localhost' || host.endsWith('.localhost') || host === '[::1]' || isLoopbackV4(host)
25 return url.protocol === 'http:' && !local
26}
27
28// The URL parser has already turned an IPv4 host into its dotted quad, so a
29// loopback address is four numeric labels led by 127. A name such as
30// 127.example.com keeps its last label and is a remote host.
31const isLoopbackV4 = (host: string): boolean => {
32 const labels = host.split('.')
33 return labels.length === 4 && labels[0] === '127' && labels.every((label) => /^\d{1,3}$/.test(label) && Number(label) <= 255)
34}
35
36export const register: Register = (on, options) => {
37 const base = String(options.otari_url).replace(/\/$/, '')
38 const key = String(options.otari_api_key)
39 const transportProblem = plainHttpToRemoteHost(base) ? 'otari_url must use https unless it points at this machine' : undefined
40
41 on('agent.spawn', async ($, e, next) => {
42 // Claude Code ignores `model` for a fork, which always inherits the
43 // parent's model along with its context. Asking would change nothing.
44 if (e.subagentType === 'fork') return next(e)
45 if (transportProblem !== undefined) throw new Error(transportProblem)
46
47 const question = $.http.fetch(`${base}${RECOMMEND_PATH}`, {
48 method: 'POST',
49 headers: { 'content-type': 'application/json', authorization: `Bearer ${key}` },
50 body: JSON.stringify({
51 harness: 'claude-code',
52 session_id: await $.session.id(),
53 tool_use_id: e.tool_use_id,
54 agent_type: e.subagentType,
55 description: e.description,
56 prompt: e.prompt,
57 parent_model: e.parentModel,
58 requested_model: e.model ?? null,
59 }),
60 })
61 const deadline = $.clock.sleep(DEADLINE_MS).then(() => {
62 throw new Error(`no answer within ${DEADLINE_MS} ms`)
63 })
64 const response = await Promise.race([question, deadline])
65 if (!response.ok) throw new Error(`HTTP ${response.status}`)
66
67 const recommended = JSON.parse(response.text) as Partial<Recommendation>
68 if (typeof recommended.model !== 'string' || recommended.model === '') throw new Error('the answer names no model')
69
70 const spawned = await next({ ...e, model: recommended.model })
71 if (spawned.deny !== undefined) return spawned
72 const why = recommended.reason === undefined ? '' : ` (${recommended.reason})`
73 $.ui.log(`${e.subagentType} ${spawned.agentId ?? ''} starts on ${spawned.model}, Otari's choice${why}`)
74 return spawned
75 }).catch(($, e, next) => {
76 // Otari did not decide: unreachable, slow, an error, or a bad answer. The
77 // subagent still starts, on the model it would have had without this mod,
78 // and the transcript says so. To refuse the spawn instead, return
79 // `{ deny: reason }` here when `next.called` is false.
80 if (!next.called) {
81 $.ui.log(`otari-router: Otari did not decide (${next.error.message}); ${e.subagentType} starts on its usual model`)
82 }
83 return next(e)
84 })
85
86 on('turn.complete', async ($, e, next) => {
87 if (e.agentId !== undefined && e.usage !== undefined) {
88 const u = e.usage
89 const seconds = Math.round(e.durationMs / 1000)
90 $.ui.log(
91 `subagent ${e.agentId} ran on ${u.model}: in ${u.input_tokens}, out ${u.output_tokens}, ` +
92 `cache read ${u.cache_read_input_tokens}, cache write ${u.cache_creation_input_tokens}, ${seconds}s` +
93 (e.isAborted ? ', aborted' : ''),
94 )
95 }
96 return next(e)
97 })
98}
99