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:<app>:read, connector:<app>:execexec only
tooltool:executeyes
agentagent:list, agent:messagemessage only
billingbilling:readno

<app> covers the connected apps (e.g. gmail, calendar, drive, notion, github). A bare tool:execute grant covers any skill. Legacy keys may carry a * (full access) scope.

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 mobile sheet lists each requested scope in plain English. Sensitive scopes are gated behind biometric / device auth. 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:gmail: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://clanker.net/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:gmail: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.

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.