Model Routes
Model Routes
Section titled “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.
The --route flag
Section titled “The --route flag”--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.
What is checked before anything starts
Section titled “What is checked before anything starts”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.
What the session is given
Section titled “What the session is given”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.
The session ledger
Section titled “The session ledger”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.
grep '"route":"openrouter"' .claude/session-ledger.jsonl | tail -1Roles and failover (#1231)
Section titled “Roles and failover (#1231)”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.
Overrides
Section titled “Overrides”| 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. |
What bob route run does
Section titled “What bob route run does”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:
- Simulated down → fails over, nothing started.
- 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 asretired. - The command starts with the candidate’s environment:
ANTHROPIC_MODELon the subscription, the route’s variables otherwise, plusBOB_ROLE,BOB_ROLE_MODELand, on a fallback,BOB_ROLE_FALLBACK_FROM. Its output passes through as it is written. - 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.
Commands
Section titled “Commands”| 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).
Where each key lives
Section titled “Where each key lives”| 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.
Minting for another machine
Section titled “Minting for another machine”The management key stays on macbook-pro-16, so every other machine’s working key is minted there (#4299):
bob route mint --machine farm-01 # dry run: key bob-farm-01, its limit, farm-01:~/.config/bob/openrouter-api-keybob route mint --machine farm-01 --apply # mint it and store it on farm-01With --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:
bob route status # bob-farm-01 — $0.00 used of $15.00bob secrets check # OPENROUTER_API_KEY present, once the operator profile lists the fileClient projects
Section titled “Client projects”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.
Money and privacy
Section titled “Money and privacy”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.