Agent Permissions
An API key starts with almost no access. Before an agent can read your data or act on your behalf, it declares the scopes it needs and you approve them from a sheet in the mobile app. Approved scopes are remembered on the key; sensitive actions can require fresh confirmation each time.
This is separate from getting a key in the first place — see
Device Activation for how a key is issued. A key created
through this flow starts unactivated: it does not authenticate at all
until you approve its first scope set, and every call it makes returns 403
until then.
Concepts
Scopes
A scope is a single, plain-English permission. Scopes are grouped by area, and each is either read (non-sensitive) or an action (sensitive — a write or a send).
| Group | Example scopes | Sensitive? |
|---|---|---|
profile | profile:read, profile:search, profile:write | write only |
memory | memory:read, memory:write | write only |
workspace | workspace:files:read, workspace:files:write | write only |
connector | connector:list, connector:exec | exec only |
tool | tool:execute | yes |
agent | agent:list, agent:message | message only |
billing | billing:read | no |
connector:list shows which apps are connected and reads nothing from them. connector:exec acts in your connected apps (and reads through them, for example repository listings); it covers every connected app, there are no per-app scopes. A bare tool:execute grant covers any skill. Legacy keys may carry a * (full access) scope.
Connecting or disconnecting an app is account management: from an agent or chat it needs a workspace owner or admin and a full-access credential (a signed-in session, or a key carrying *). A scoped key or OAuth token cannot do it, whatever else it holds.
When an agent calls /agents/connect without naming any scopes, the request
defaults to the smallest useful set: profile:read and agent:list. Keys
created by other routes carry the legacy * until an agent declares something
narrower and the user approves it.
Grant modes
When you approve a scope you also choose how long it lasts:
| Mode | Meaning |
|---|---|
persistent | Always allow — written to the key until revoked |
session | Allow for 1 hour, then re-confirm |
one_time | Allow once — a short-lived token consumed on first use |
The default is scope-aware: non-sensitive (read) scopes default to persistent; sensitive (write/send) scopes default to session. one_time is always opt-in.
Two flows
- Activation — an unactivated key declares the scopes it wants up front; you approve the set once. This brings the key to life.
- Escalation — an already-active key needs one more scope mid-task; it requests that single scope with a reason, and you approve it in the moment.
Both end the same way: the approved scopes land on the key, and the agent simply retries the call that failed.
Activation
1. Declare scopes
POST /api/v1/agents/connect
{ "requestedScopes": ["profile:read", "memory:read", "tool:execute"] }
If the key is already active and covers every requested scope, you get
200 { "status": "active", "scopes": [...] } and there is nothing to approve.
A key carrying the legacy * wildcard always shows the sheet anyway — the point
of an agent declaring its intent is letting you shrink an over-broad key.
Otherwise a consent is created and surfaced in your app, with 202 Accepted:
{
"status": "pending",
"consent_id": "…",
"link_code": "ABCD-EFGH",
"poll_url": "/api/v1/agents/connect/poll/…",
"poll_interval_seconds": 3
}
A push notification opens the approval sheet on your device. You can also enter the link_code manually — the app resolves it via POST /api/v1/agents/connect/by-code.
2. Approve on your device
The approval sheet lists each requested scope in plain English and flags the sensitive ones. Approving a sensitive scope takes a second, explicit confirmation in the app — there is no biometric or device-auth step. You approve or deny; read-only scopes can be batched under “Read your data.”
3. Poll for the result
GET /api/v1/agents/connect/poll/:consentId
Returns { "status": "pending" }, { "status": "denied" },
{ "status": "expired" }, or, once approved:
{
"status": "granted",
"scopes": ["profile:read", "memory:read", "tool:execute"]
}
scopes is re-read from the key, so it is the key’s effective set, not an
echo of what you asked for — the user may have approved a subset. The key has
flipped from unactivated to active; retry your original call with the same
x-api-key and nothing else.
An unknown consentId, or one belonging to a different key, is a 404
NOT_FOUND — the poll is scoped to the key that created it.
Escalation
When an active key hits a wall mid-conversation, it asks for the one scope it needs:
POST /api/v1/agents/request-permission
{
"scope": "connector:exec",
"mode": "session",
"reason": "Send the summary email you asked for"
}
reason is required, non-empty and ≤ 280 characters, and is shown to you in
the approval sheet. scope must be a known scope id; mode defaults to
session when omitted or unrecognised. A bad scope or reason is 400
INVALID_INPUT. If the key already has the scope you
get { "status": "already_granted", "grant_token": null }; otherwise 202:
{
"status": "pending",
"request_id": "…",
"poll_url": "/api/v1/agents/request-permission/poll/…",
"poll_interval_seconds": 2
}
The key must already be active. Calling this from an unactivated key is a
409:
{
"error": {
"code": "ACTIVATION_REQUIRED",
"message": "API key must be activated before requesting additional permissions.",
"details": { "activation_url": "/api/v1/agents/connect" }
}
}
Poll it:
GET /api/v1/agents/request-permission/poll/:requestId
Same statuses as activation. On approval the response carries the mode that was actually granted, plus an expiry for the time-boxed modes:
{
"status": "granted",
"mode": "session",
"expires_at": "2026-08-01T11:00:00.000Z"
}
A persistent grant answers { "status": "granted", "mode": "persistent" } and
is written onto the key. expires_at is null when the grant has no recorded
expiry.
After a grant
Retry the original call with the same x-api-key. A persistent grant has been
merged into the key’s own scope set; session and one_time grants are
recorded server-side against the key for their lifetime, so there is nothing for
the agent to carry between calls.
curl https://next.clanker.engineer/api/v1/skills/summarize/run \
-H "x-api-key: ck_live_xxxxxxxxxxxxx" \
-H "Content-Type: application/json" \
-d '{ "input": "..." }'
Enforcement & errors
The lifecycle gate is enforced at authentication: an unactivated key does
not authenticate at all — every call returns 403 until you approve it.
{
"error": {
"code": "FORBIDDEN",
"message": "API key has not been activated. Approve it to continue."
}
}
The permission family uses the standard envelope, like everything else on
this API. A credential that lacks the scope a route needs gets a 403 whose
message names the missing scope:
{
"error": {
"code": "FORBIDDEN",
"message": "This credential is missing the \"connector:exec\" scope."
}
}
| Code | When | What to do |
|---|---|---|
UNAUTHORIZED | No usable credential | Authenticate |
FORBIDDEN | The key is unactivated, revoked, or missing the route’s scope | Activate it (/agents/connect), or request the scope (/agents/request-permission) |
ACTIVATION_REQUIRED | You called /agents/request-permission with a key that is not active yet | Run the activation flow first |
NOT_FOUND | The consent or permission request id does not exist | Restart the flow |
CONFLICT | The consent is no longer pending (already approved, denied or consumed) | Restart the flow |
EXPIRED | The consent request timed out | Restart the flow |
INVALID_INPUT | Unknown scope name, missing reason, malformed body | Fix the request |
Match on error.code, never on error.message — the message is English prose
and may be reworded or translated at any time.
Keys minted through an OAuth app
An integration registered as an OAuth client can ask the user for the
apikey:pod scope. After consent it calls POST /api/v1/api-keys/pod with its
access token and receives a ck_ key for the consenting user, for a development
pod’s coding agents. The key is returned once, in key; nothing is shown to the
user. It carries a fixed, narrow scope set — library:read, library:write,
memory:read, memory:write, thread:read, trace:read, activity:read and
model:read — and never apikey:pod, so it cannot mint further keys. Its
workspace is the one bound at consent, otherwise the user’s personal workspace;
a different x-workspace-id is refused.
Such a key stays tied to the app and consent that created it:
- It stops working (
401) as soon as the OAuth client is deleted or disabled, or the user’s consent for it is revoked or narrowed. Granting consent again does not revive it; mint a new one. - It is treated like any other third-party credential. The
*scope is not honoured for it (on agent tools too), it cannot use the consent-request routes (/agents/connect,/agents/request-permission), and it has no credential self-service. - Minting again revokes only that app’s earlier pod keys in the workspace — not the user’s own keys, and not other apps’ keys.
- It can revoke itself, and only itself, with
DELETE /api/v1/api-keys/podpresenting the key (x-api-keyorAuthorization: Bearer). The response is204. An integration should call it on disconnect.
Revoking
Remove a single scope from a key at any time — from the app, or:
DELETE /api/v1/agents/:keyId/scopes/:scope
You can also revoke the whole key from Settings → External Agents.