The sar CLI
Everything the console does, from a shell — and the one thing it does that the console cannot.
sar drives the same public API the console does. Nothing in it reaches around the API into the
database, which is what makes it usable against a deployment you do not operate.
Connecting
Five global options, each with an environment variable behind it, so a shell can be configured once and every command inherits it.
| Option | Variable | Default |
|---|---|---|
--url | ROUTER_URL | http://127.0.0.1:8787 |
--tenancy | ROUTER_TENANCY | The one the session key opens |
--api-key | ROUTER_API_KEY | — |
--session-key | ROUTER_SESSION_KEY | — |
--registration-token | ROUTER_REGISTRATION_TOKEN | — |
Every flag is weighed before any variable is, so a credential named on the command line wins over one left in the shell even when the two are of different kinds.
export ROUTER_URL=https://api.perdurance.dev
export ROUTER_TENANCY=acme
export ROUTER_API_KEY='sar_ab12cd34_…'The CTX_* variables belong to the service and ROUTER_* to this client. The two lists never
mix, so it is always clear which side of the connection a setting is about.
Which credential a command wants
This is the part that surprises people. Commands do not all take the same kind of credential.
| Wants | Commands |
|---|---|
| A registration token | register |
| Nothing | login |
| A session | password, whoami, logout |
| An API key | submit |
| Either a session or an API key | everything else |
submit is the one place a session is refused by kind rather than by role: sending a request is
something a machine credential does, and holding a browser session should not be enough to spend
your provider budget.
Getting started from a shell
# once per organisation, with a registration token from the deployment.
# `register` creates the tenancy, so it has to be told what to call it.
sar register --tenancy acme --email [email protected]
# thereafter
sar login --email [email protected]
sar namespaces create --slug prod --name Production
sar backends create --namespace prod --name openai --kind openai_compat
sar rules create --namespace prod --priority 100 --pattern '*' --backend <BACKEND_ID>
sar keys create --name my-serviceEvery command that answers a value takes --json, which writes the router's own field names
instead of the aligned table. login, register, keys create and requests submit all do,
so a script can capture the session key, the API key or the request id it was given.
A provider key is never an argument
backends create prompts for the credential, or reads it from standard input with
--credential -. There is no way to pass one inline, deliberately: an argument sits in the
shell's history and is visible in ps to everyone on the machine.
pass show openai/prod | sar backends create --namespace prod \
--name openai --kind openai_compat --credential ---credential takes two words and nothing else. - reads the key from standard input, as
above. caller creates a backend that stores no key at all and forwards the credential each
submission brought:
sar backends create --namespace prod \
--name anthropic-passthrough --kind anthropic --credential callerThe router refuses that unless it was started with --credential-passthrough, and answers
422 passthrough_disabled when it was not. Claude Code and the Agent
SDK has what it is for. Leaving
--credential out prompts for a key with the terminal's echo off.
Sending a request
sar submit --namespace prod --dialect openai --file body.jsonThe body travels exactly as written — sar does not reformat it. The router canonicalises
what it is given before hashing it, so a re-sent file whose object keys moved still deduplicates;
a changed value, a reordered array or different numeric spelling does not. See
Idempotency.
--file - reads from standard input:
jq -n '{model:"gpt-4.1",messages:[{role:"user",content:"Say hello."}]}' \
| sar submit --namespace prod --file ---dialect is openai (the default) or anthropic, and it decides which vendor route the body
is sent to rather than being sniffed from the body.
Which shape the answer takes is read off what came back rather than worked out from the body, so a request that asked for a stream and was refused before reaching the provider still prints as one document.
Working with stored requests
# fire-and-forget: prints the id and returns
sar requests submit --namespace prod --file body.json
# read one back, whatever it has reached so far
sar requests get 01J8F2ZK9QX3M4NBVWT7 --namespace prodsar submit holds the connection and gives you the provider's answer. sar requests submit
walks away with an id. Same body, same deduplication.
The id is positional; the namespace is not, because a request id is only meaningful inside one.
Replaying a stream
# replay from the first chunk, then follow it live if it is still running
sar requests get $ID --namespace prod --stream
# pick up after the last chunk you already have
sar requests get $ID --namespace prod --stream --from 25--from takes the sequence number of the last chunk in hand and is the CLI's Last-Event-ID.
It requires --stream, since there is nothing to resume in a whole document.
Everything else
| Command | Does |
|---|---|
register | Create a tenancy and its owner from a registration token |
login | Sign in, opening a session or taking an API key |
password | Replace the password this account signs in with |
whoami | Show what the credential in hand stands for |
logout | End the session this key opened |
keys | Mint, list and end API keys |
users | Invite people into the tenancy and see what they hold |
ownership | Move ownership of the tenancy |
namespaces | List and create namespaces |
grants | Read and write what a member may do in a namespace |
backends | Configure the providers a namespace sends to |
rules | Decide which backend a model name reaches |
submit | Send a body and take the provider's answer on the connection |
requests | submit without holding a connection, and get one back |
usage | Report what a namespace has spent |
storage | Report what a namespace is holding |
sar <command> --help has the arguments for each.
whoami first
When something is refused and you are not sure why, sar whoami says which tenancy the
credential opens and what it holds. Most 403s are answered by it.
Errors and status codes
Every status the submission and retrieval routes return, the stable code that comes with it, and what to do about each — including the 504 that does not mean your request failed.
Deployment
What a Perdurance deployment is made of: one container image, one Postgres, and a handful of pointers. Every figure it charges or enforces lives in the database and changes without a restart. Dedicated deployments run in our cloud or in yours, and we operate both.

