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:<app>:read, connector:<app>:exec | exec only |
tool | tool:execute | yes |
agent | agent:list, agent:message | message only |
billing | billing:read | no |
<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:
| 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 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."
}
}
| 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.
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.