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).

GroupExample scopesSensitive?
profileprofile:read, profile:search, profile:writewrite only
memorymemory:read, memory:writewrite only
workspaceworkspace:files:read, workspace:files:writewrite only
connectorconnector:list, connector:execexec only
tooltool:executeyes
agentagent:list, agent:messagemessage only
billingbilling:readno

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:

ModeMeaning
persistentAlways allow — written to the key until revoked
sessionAllow for 1 hour, then re-confirm
one_timeAllow 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."
  }
}
CodeWhenWhat to do
UNAUTHORIZEDNo usable credentialAuthenticate
FORBIDDENThe key is unactivated, revoked, or missing the route’s scopeActivate it (/agents/connect), or request the scope (/agents/request-permission)
ACTIVATION_REQUIREDYou called /agents/request-permission with a key that is not active yetRun the activation flow first
NOT_FOUNDThe consent or permission request id does not existRestart the flow
CONFLICTThe consent is no longer pending (already approved, denied or consumed)Restart the flow
EXPIREDThe consent request timed outRestart the flow
INVALID_INPUTUnknown scope name, missing reason, malformed bodyFix 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/pod presenting the key (x-api-key or Authorization: Bearer). The response is 204. 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.