Named, revocable API keys limited to the session lifecycle — how to create, scope, rotate and audit them.
A CRHQ instance has one master API key with full access — that key belongs to the operator and should never live inside a product. External products use scoped keys: named, revocable credentials limited to exactly what an integration needs.
In the UI: Settings → API Keys → Create Key. Pick a name (use the product's name — it appears in audit trails) and the scopes. The key value (crhq_sk_…) is shown exactly once — copy it immediately. Only its hash is stored; if the value is lost, revoke the key and create a new one.
Via API (master key required):
curl -X POST https://<instance>/api/settings/api-keys \
-H "x-api-key: $MASTER_KEY" -H "Content-Type: application/json" \
-d '{"name": "myproduct", "scopes": ["sessions:write", "sessions:read"]}'
# → 201 { "success": true, "key": "crhq_sk_…", "id": "…", "name": "myproduct", … }
| Scope | Grants |
|---|---|
sessions:write | Start turns, create sessions, send messages, rename or re-model a session, bind an agent, void queued messages |
sessions:read | Read a conversation, its metadata and queue; subscribe to its live WebSocket stream |
Everything else — settings, credentials, provider auth, agent and skill management, listing all sessions, deleting sessions — stays master-key-only. A scoped key calling outside its scope gets a 403 naming the key and the missing scope; the master key's behavior is completely unchanged.
Send it on every request, either way:
x-api-key: crhq_sk_…
Authorization: Bearer crhq_sk_…
For WebSocket streaming, send x-api-key as a header on the upgrade request. A key with sessions:read can subscribe to a session's stream; starting a turn over WebSocket requires sessions:write.
| Action | How |
|---|---|
| List keys (name, scopes, status, last used) | Settings → API Keys, or GET /api/settings/api-keys |
| Edit name or scopes | Applies immediately — the product does not need a new key |
| Revoke | Deactivates the key; every call it makes returns 401 from that moment. Reversible |
| Delete | Permanent. Any product using the key stops working immediately |
last_used_at is tracked per key, and every session created by a scoped key is attributed to it — so you can always answer "which product did this?"
A key with sessions:write can start agent turns, and an agent turn executes tools on the
server. That is the point of the platform — the agent reads files, runs commands and calls
skills to do its job. It also means the scope limits the HTTP surface, not what the agent can
do once running.
Treat a write-scoped key as equivalent to access to that server, and issue it only to products you would trust at that level.
This is the control that actually narrows the blast radius. When creating a key, choose "Only these agents" and select the purpose-built agent(s) your product needs:
curl -X POST https://<instance>/api/settings/api-keys \
-H "x-api-key: $MASTER_KEY" -H "Content-Type: application/json" \
-d '{"name":"myproduct","scopes":["sessions:write","sessions:read"],
"allowedAgents":["myproduct-worker"]}'
The key can then only run that agent — with the instructions, skills and tools you gave it.
Attempts to bind a different agent, or to start a turn on a session running one, return 403.
A session created by a restricted key is bound to an allowed agent automatically, so it can
never fall back to the default operator agent.
Keys created without an agent list (allowedAgents: null) may run any agent, including
privileged ones. The UI marks these "Any agent ⚠".
sessions:write it can run agent turns; with an agent allowlist, only the agents you nominated. Revoke it in Settings and issue a new one.API_KEY_OWNERSHIP_ENFORCE) locks each session to the key that created it — off by default on single-product instances.