Appearance
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
--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
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
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
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.
bash
grep '"route":"openrouter"' .claude/session-ledger.jsonl | tail -1Roles 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
| 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
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
| 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
| 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:
json
"routes": { "openrouter": { "monthly_limit_usd": 15 } }A machine with no declared limit cannot be minted a key.
Minting for another machine
The management key stays on macbook-pro-16, so every other machine's working key is minted there (#4299):
bash
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-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:
bash
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 fileClient 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
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.