Skip to content

Model Routes

A model route starts one process on a chosen model through a provider other than the Claude subscription (#3590, capability #3866). The first route is openrouter. A route is chosen per process, with a flag. With no flag, nothing on this page is read and a session runs on the subscription.

To use it, follow Start a Session on Another Model.

--route <name>[:<model slug>]
Form Model
--route openrouter The route’s declared default, anthropic/claude-sonnet-5.5.
--route openrouter:<slug> The model with that slug, when OpenRouter lists it.

Two commands take the flag:

Command What runs on the route
claude --route … The interactive session. The claude wrapper in bin/bob-shell.zsh hands the launch to scripts/route/route.js run.
bob groom --route … The headless groom. Its verdict line and its --json document (route, model) name the route and the model.

A route covers the whole process. One session cannot keep its main loop on the subscription and send only its subagents through the route.

Routes are declared in scripts/route/routes.json: the API address, the default model, the names of the two keys, and the key-name prefix.

route.js run and route.js check make these checks in order. A refusal ends the command with the exit code shown, and no session starts.

Order Check Refusal Exit
1 The project’s manifest declares _meta.tenant. refused: <project> is client tenant <id>'s project. No key is read and no request is made. 5
2 This machine holds a working key. no OpenRouter working key on this machine, naming bob route mint. 3
3 The key’s own state, one request (GET /v1/key). Rejected key: exit 3. Key at its limit: the OpenRouter key <label> is at its limit, with the amount. The request is never retried. 3, 4
4 The model slug is one the route lists (GET /v1/models). lists no model '<slug>', with the command that lists valid slugs. 2

bob groom --route reports any of these as --route <spec> refused and exits 2.

route.js run starts the command with these variables. They go to that one process; nothing is exported into the calling shell.

Variable Value
ANTHROPIC_BASE_URL https://openrouter.ai/api
ANTHROPIC_AUTH_TOKEN The working key.
ANTHROPIC_API_KEY Set and empty.
ANTHROPIC_MODEL, ANTHROPIC_DEFAULT_FABLE_MODEL, ANTHROPIC_DEFAULT_OPUS_MODEL, ANTHROPIC_DEFAULT_SONNET_MODEL, ANTHROPIC_DEFAULT_HAIKU_MODEL, CLAUDE_CODE_SUBAGENT_MODEL The model slug.
BOB_ROUTE, BOB_ROUTE_MODEL The route name and the model slug, for the session ledger.

The harness is still Claude Code: hooks, the commit gate and the state machine run whichever model answers. OpenRouter’s guide says models other than Anthropic’s may not work correctly in Claude Code.

Starting a routed session appends one entry to the project’s .claude/session-ledger.jsonl: a command invocation named route, carrying route and model and, under detail, the key’s label. Every later event of that session (a skill, an agent, a command) carries route and model too (schemas/session-ledger.schema.json). An event from an ordinary session carries neither.

Terminal window
grep '"route":"openrouter"' .claude/session-ledger.jsonl | tail -1

A route pins one model. A role names the kind of model a job needs, and scripts/route/routing.json (bob-routing/1, schemas/routing.schema.json) lists its candidates in the order they are tried. Each candidate is a model on a route: subscription (Claude Code’s own login, a model alias) or a route from routes.json (a model slug).

Role Alias Candidates, in order
default sonnet subscription sonnet · openrouter anthropic/claude-sonnet-5.5
reasoning opus subscription opus · openrouter anthropic/claude-opus-5.5 · openrouter anthropic/claude-sonnet-5.5
fast haiku subscription haiku · openrouter anthropic/claude-sonnet-5.5
cheap haiku subscription haiku · subscription sonnet · openrouter anthropic/claude-sonnet-5.5

The alias is what an agent or a command names in its model: frontmatter. make check (check-agents) fails a model: that is not a role’s alias or inherit. A concrete model id is stranded by an outage or a retirement; an alias follows the route, which sets every alias variable to the route’s model.

Override Scope Effect
routes.<name>.enabled: false in routing.json Every run Takes every candidate on that route out of every role.
BOB_ROUTING_DISABLE=<route>[,<route>…] One run The same, for that process only.
BOB_ROUTING_DOWN=<route>[:<model>][,…] One run Simulates an outage: the candidate fails over with class simulated, and nothing is started on it.

bob route run --role <role> -- <command> runs a headless command. A route is per process, so failover starts the command again. It never switches a running session, and an interactive session is not run through it. For each candidate in play, in order:

  1. Simulated down → fails over, nothing started.
  2. On a route other than the subscription, the checks above run. A refusal fails over as route_refused; a model the route does not list fails over as retired.
  3. The command starts with the candidate’s environment: ANTHROPIC_MODEL on the subscription, the route’s variables otherwise, plus BOB_ROLE, BOB_ROLE_MODEL and, on a fallback, BOB_ROLE_FALLBACK_FROM. Its output passes through as it is written.
  4. Exit 0 ends the run. A non-zero exit whose last 64 KB of output matches a failover class fails over to the next candidate. Any other non-zero exit is the command’s own failure: it is returned and nothing is restarted.

The failover classes are declared in routing.json under failover.classes, matched case-insensitively. They are Claude API error shapes only, so a GitHub rate limit or a failing test in the output never moves a run to another model.

Class Matches
limit Claude Code’s usage-limit message, credit balance is too low
rate_limit API Error: 429, rate_limit_error
outage API Error: 5xx, overloaded_error, "type": "api_error"
retired not_found_error, a 404 naming the model, a model that is deprecated or retired

Every failover prints one line on stderr, BoB: failover — role <role>: <route> <model> failed (<class>: <matched text>) — trying <next>, and appends one record to $BOB_HOME/route-failover.jsonl (BOB_ROUTING_LOG overrides): time, role, project, command, from, class, match, to. bob route failovers prints the newest. --result FILE writes a bob-failover/1 document with the candidate the command ran on and every attempt. --fresh-session-id gives each restart of a command that passes --session-id <id> a new id, recorded on its attempt.

bob groom --role <role> runs the groom this way. Its verdict line ends — role <role> on <route> <model>, adding (fallback: see bob route failovers) when it fell back. Its --json document carries role: {name, route, model, fallback}. route and model stay null on the subscription. --route and --role together are refused.

Exit codes of bob route run: 0 the command succeeded, 2 usage or an unknown role, 3 every candidate failed over or was out of play, otherwise the command’s own exit.

Command Does
bob route mint --machine <id> [--apply] [--replace] Creates the machine’s working key, named bob-<id>, with the monthly limit the machine declares, and stores it where that machine reads it: this Mac’s keychain, this machine’s key file off macOS, or, for another fleet machine, that machine’s key file over Tailscale SSH (see Minting for another machine). A dry run without --apply names the key, the limit and the store. An existing key of that name is left alone; --replace deletes it and mints again. A key that cannot be stored is deleted again. It runs where the management key is.
bob route status [--json] Prints the working key’s label, usage, limit and what is left this period.
bob route roles [--json] Lists every role, its alias and its candidates in order, marking any switched off.
bob route run --role <role> [--result FILE] [--fresh-session-id] -- <command> Runs a headless command on a role with failover.
bob route failovers [--limit N] [--json] Prints the newest failover records on this machine.
node scripts/route/route.js check --route <spec> [--json] Makes the checks above and prints the route, model and key label, starting nothing.

No command prints a key value. --json documents are bob-route/1.

Exit codes, shared by every route.js command: 0 ok, 1 the route’s API failed or a key could not be stored, 2 usage or an unknown route, model or machine, 3 no usable key, 4 key at its limit, 5 client project refused, 6 the target machine could not be reached over SSH, or its copy of the key could not be written or read back (mint for another machine).

Key Name Where What it can do
Management key OPENROUTER_MANAGEMENT_KEY The keychain of macbook-pro-16 only. Creates, limits, lists and deletes keys. It cannot call a model. Read by bob route mint only.
Working key OPENROUTER_API_KEY On a Mac, the keychain of the machine that uses it, account bob-<machine id>. On Linux (farm-01), the file ~/.config/bob/openrouter-api-key, mode 600. Calls models, up to its monthly limit, which OpenRouter enforces.

Each key is read from the environment variable of the same name first, then from the keychain. The working key is then read from the route’s key_file (~/.config/bob/openrouter-api-key, declared in scripts/route/routes.json). A key file that group or others can read is refused before any request is made, and the message names the fix, chmod 600 <path>. bob secrets check grades the same file when the operator profile lists it as a file secret. Both keys are named in secrets-inventory.json and in the operator profile’s secrets[], by name and location only.

A machine’s limit is declared on its entry in the operator repo’s machines.json:

"routes": { "openrouter": { "monthly_limit_usd": 15 } }

A machine with no declared limit cannot be minted a key.

The management key stays on macbook-pro-16, so every other machine’s working key is minted there (#4299):

Terminal window
bob route mint --machine farm-01 # dry run: key bob-farm-01, its limit, farm-01:~/.config/bob/openrouter-api-key
bob route mint --machine farm-01 --apply # mint it and store it on farm-01

With --apply the key is created at OpenRouter, then written to the target’s key file over Tailscale SSH. The command uses the same transport as the fleet audit, sshRun in scripts/fleet/audit.js, and the target is the machine’s tailscale_name in machines.json. The value goes on stdin, never in argv or a log. The file is written under umask 077, moved into place at mode 600, then read back. The key counts as stored only when the read-back returns the same value at mode 600.

If the SSH write or the read-back fails, the key is deleted at OpenRouter again and the command exits 6, naming the step (ssh-write or read-back). No orphan key is left spending. If the key already exists, the command checks over SSH whether the target holds it: exists means nothing to do, and exists-not-stored (exit 3) names the --replace command. If the target cannot be reached for that check, the command exits 6 and changes nothing.

Then, on the target:

Terminal window
bob route status # bob-farm-01 — $0.00 used of $15.00
bob secrets check # OPENROUTER_API_KEY present, once the operator profile lists the file

A project whose manifest declares _meta.tenant (a tenant id from the operator profile’s tenants[]) belongs to a client. A route refuses it before reading a key or making a request, so a client’s prompts and bill never pass through the operator’s account.

OpenRouter bills prepaid credits at the providers’ prices. A session on a frontier model spends credits quickly; the per-machine limit is the guard, and BoB never turns on automatic top-up. The account’s model-training setting stays off, which blocks providers that log or train on prompts.