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
Three 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 |
| — | ROUTER_API_KEY | — |
| — | ROUTER_SESSION_KEY | — |
| — | ROUTER_REGISTRATION_TOKEN | — |
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 you@example.com
# thereafter
sar login --email you@example.com
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-serviceA 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 -Sending a request
sar submit --namespace prod --dialect openai --file body.jsonThe body travels exactly as written — sar does not reformat it. That matters more than it
looks: the router hashes the bytes it was given, so a re-sent file deduplicates only if it is
byte-identical. 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 this API returns, 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.

