App connections
An app connection lets a user give an external application, such as an AI agent, access to a
product without sharing a password. The user signs in with any method their realm allows, creates a
connection with the scopes they choose, and gives the application the returned app key. The
application exchanges the key at POST /auth/token for a short-lived agent token and calls the
product’s APIs with it.
Every call runs as the connection’s agent, on behalf of the user who created it. The user and the tenant’s admins can list connections, see their activity, rotate their keys and revoke them.
How a connection is built
Section titled “How a connection is built”A connection is an agent principal acting for one user under a grant. Creating one writes four rows in a single transaction:
| Row | Entity | Holds |
|---|---|---|
| Agent registry entry | principal | id A…, ptyp: agent, realm agent |
| Agent | iamagents | the user it acts for, its grant, its expiry, the scopes’ tools |
| Grant | principal_grant | the user as grantor, the roles the scopes confer, the scope names, the expiry |
| App key | api_key | the key’s hash, bound to the agent |
The grant is authoritative. The key carries no authority of its own: every token it buys takes its roles from the grant, and revoking the grant revokes the key with it. Only a user’s own token creates connections and API keys, so an agent never extends its own reach.
Declaring scopes: iam/apps.yaml
Section titled “Declaring scopes: iam/apps.yaml”App connections are off until the product enables them in iam/apps.yaml. A tenant can override
the file with a /apps.yaml tenant extension row, which replaces scalars and replaces scopes by name.
apps: enabled: true token_ttl: 5m # agent token lifetime keys: default_ttl: 720h # 30 days max_ttl: 2160h # 90 days scopes: orders.read: description: Read your orders requires: [customer, sales-manager] roles: [order-reader] tools: [orders_search, orders_get] tickets.write: description: Create and update your support tickets requires: [customer] roles: [ticket-writer]| Key | Default | Meaning |
|---|---|---|
enabled | false | Turns app connections on for the tenant |
token_ttl | 5m | Agent token lifetime. At most 15m |
keys.default_ttl | 720h | App key lifetime when the user asks for none. Capped at max_ttl |
keys.max_ttl | 2160h | Longest lifetime a user may ask for |
scopes.<name>.description | none | Text the user reads when choosing scopes |
scopes.<name>.requires | required | User roles, any one of which allows granting the scope |
scopes.<name>.roles | required | Roles the connection receives for the scope |
scopes.<name>.tools | none | Operation names an integration exposes for the scope; stored on the connection and returned with it |
A user may grant a scope when they hold at least one of its requires roles. The connection then
receives the scope’s roles, which need not be roles the user holds: this is how a scope gives an
application less than the user’s own access. The product decides that mapping; the user only picks
scope names.
Scope declarations follow these rules, checked when the file is loaded:
- Scope names use lowercase letters, digits and
_ . : -, and start with a letter. requiresandroleslist role names explicitly. Wildcards are refused.rolesnever includesiam-serviceorsuperadmin.adminis conferred only by a user who holdsadminthemselves.
App connections also need the iamagents entity, which realmconfig.enable.agents controls and
which is on by default.
Creating a connection
Section titled “Creating a connection”The user calls these routes with their own access token, from any sign-in flow.
GET /auth/apps/scopes[ { "name": "orders.read", "description": "Read your orders", "grantable": true, "tools": ["orders_search", "orders_get"] }, { "name": "tickets.write", "description": "Create and update your support tickets", "grantable": false }]grantable reflects the caller’s current roles.
POST /auth/apps| Field | Type | Required | Meaning |
|---|---|---|---|
name | string | yes | What the user calls the connection, up to 200 characters |
scopes | array of strings | yes | Declared scope names |
expires_in_days | int | no | Lifetime in days. Absent or 0 uses keys.default_ttl |
curl -s localhost:5030/auth/apps \ -H 'Content-Type: application/json' \ -H 'Authorization: Bearer <access token>' \ -H 'X-Customer: acme' -H 'X-Product: shop' -H 'X-Env: prod' -H 'X-Tenant: main' \ -d '{"name":"Claude","scopes":["orders.read"],"expires_in_days":30}'201:
{ "id": "A01J9Z3K7Q2W8E5R6T4Y1U0I3O", "grant_id": "G01J9Z3K7Q9P2L4K6J8H0G2F4D", "name": "Claude", "kind": "key", "acting_for": "U01HSDZ0RMEC0WNF5APJ8T9C78X", "scopes": ["orders.read"], "tools": ["orders_search", "orders_get"], "created_at": "2026-10-02T09:30:00Z", "expires_at": "2026-11-01T09:30:00Z", "status": "active", "key": "kisak_01J9Z3K7QA1S2D3F4G5H6J7K8L.Zm9vYmFyYmF6cXV4…"}The roles that decide what a user may grant are read from their identity row at the time of the
call, so a role removed since sign-in is no longer grantable. The route takes a user’s own token:
an agent token is refused with 403 standing_principal_required and an impersonated session with
403 impersonation_not_allowed. Creating a connection is rate-limited under the auth_apikey
profile.
Exchanging the app key
Section titled “Exchanging the app key”POST /auth/tokenAuthorization: ApiKey kisak_<key-id>.<secret>Authorization: Bearer kisak_… is accepted as well, for clients that send a fixed header. No body.
The optional ?audience=<recipient> sets the token’s aud.
curl -s -X POST 'localhost:5030/auth/token?audience=https://shop.example/agent' \ -H 'Authorization: ApiKey kisak_01J9Z3K7QA1S2D3F4G5H6J7K8L.Zm9vYmFyYmF6cXV4…' \ -H 'X-Customer: acme' -H 'X-Product: shop' -H 'X-Env: prod' -H 'X-Tenant: main'{ "token": "v4.public.…", "token_type": "Bearer", "expires_in": 300 }Cache the token and exchange again at about 80% of expires_in, or after a 401. One exchange per
token lifetime is enough; POST /auth/token is rate-limited under the auth_token profile.
Every exchange checks the connection again before minting:
- app connections are enabled for the tenant;
- the grant is neither revoked nor expired;
- the user the agent acts for still exists and is active;
- every scope on the grant is still declared, and the user still holds one of its
requiresroles; - the grant’s roles are still the roles those scopes confer.
A failed check refuses the token, and the user reconnects to grant the current scopes. Revocation therefore reaches a running application within one token lifetime.
The app key is a credential for this exchange only. Every other route refuses it as a credential; call them with the agent token.
The agent token
Section titled “The agent token”An agent token is an access token (type: user) whose subject is the agent.
| Claim | Value |
|---|---|
sub | the agent id, A… |
ptyp | agent |
realm | agent |
aut | {sub, ptyp}: the user the agent acts for |
grt | {id, exp, scopes}: the grant id, the grant’s expiry and its scope names |
roles | the grant’s roles |
aud | the requested audience, when one was passed |
amr | ["apikey"] |
tenant, iss, jti, iat, nbf, exp | as on every access token |
{ "iss": "iam", "sub": "A01J9Z3K7Q2W8E5R6T4Y1U0I3O", "tenant": "acme:prod:shop:main", "type": "user", "ptyp": "agent", "realm": "agent", "aut": { "sub": "U01HSDZ0RMEC0WNF5APJ8T9C78X", "ptyp": "user" }, "grt": { "id": "G01J9Z3K7Q9P2L4K6J8H0G2F4D", "exp": 1793525400, "scopes": ["orders.read"] }, "roles": ["order-reader"], "aud": "https://shop.example/agent", "amr": ["apikey"], "jti": "01J9Z3M2…", "iat": 1790933400, "nbf": 1790933400, "exp": 1790933700}exp never passes the grant’s expiry. Scope names ride in grt.scopes; the token has no top-level
scope claim, which on a service token means CEPT authority. A tenant’s token.claims and
token.claims_from are not copied onto agent tokens: an agent’s authority is its grant’s roles.
Writing rules that admit a connection
Section titled “Writing rules that admit a connection”Entity access rules and row-level security see the agent’s id as user.id and the user it acts for
as user.acting_for. For a user’s own token, user.acting_for is their own id. An ownership rule
written with user.acting_for admits the owner and their connections; one written with user.id
admits the owner only.
access: actions: read: { language: expr, expression: '"customer" in user.roles || "order-reader" in user.roles' } rls: read: { language: expr, expression: "row.owner_id == user.acting_for" }The rest of the caller is available too:
| Binding | Value for an agent |
|---|---|
user.ptyp | agent |
user.realm | agent |
user.grant_id | the grant id |
user.is_derived / user.is_standing | true / false |
user.authority.sub / .ptyp / .present | the user it acts for / user / true |
Use user.acting_for rather than user.authority.sub for ownership. A user’s own token has no
authority, so row.owner_id == user.authority.sub compares the column with an empty value for every
direct caller. To keep agents out of an entity entirely, require user.ptyp == "user" or
user.is_standing.
The full binding list is on Access rules and Row-level security.
Managing connections
Section titled “Managing connections”| Route | Who | Purpose |
|---|---|---|
GET /auth/apps | the user | Their own connections, newest first |
GET /auth/apps/:id | the owner, or admin | One connection |
DELETE /auth/apps/:id | the owner, or admin | Revoke |
POST /auth/apps/:id/rotate | the owner | Replace the key; the new key is returned once and the old one stops working |
GET /auth/apps/:id/activity | the owner, or admin | The connection’s events, newest first; ?limit= up to 500, default 100 |
GET /auth/admin/apps | admin | Every connection in the tenant; ?acting_for=U… filters by user |
DELETE /auth/admin/apps/:id | admin | Revoke any connection |
A connection reports status as active, revoked or expired, with last_used_at set from the
latest successful exchange. A connection that belongs to someone else answers 404 app_not_found
to anyone but an admin.
Revoking stamps the grant revoked, switches the agent off and deletes the key. The grant and agent
rows remain, so actions taken through the connection stay attributable. Tokens already issued run
out within their lifetime. Listing and revoking keep working while apps.enabled is false, so a
tenant that turns the feature off can still clean up.
Rotation is rate-limited under the auth_apikey profile. An admin can revoke a user’s connection
but cannot rotate it, so an admin never holds a user’s key.
Activity
Section titled “Activity”| Event | Outcome | Detail |
|---|---|---|
app.connection.created | success | scopes, expires_at |
app.token.issued | success | audience, scopes |
app.token.refused | refused | reason (the refusal code), audience |
app.key.rotated | success | |
app.connection.revoked | success | revoked_by |
The events are rows in user_eventlog with the agent id as userid and the user it acts for in
meta.acting_for.
Errors
Section titled “Errors”| Status | Code | Route | Cause |
|---|---|---|---|
| 401 | auth_required | /auth/apps* | No user token |
| 403 | standing_principal_required | /auth/apps*, POST /auth/apikey | The caller is an agent or another derived identity |
| 403 | impersonation_not_allowed | /auth/apps* | The token is an impersonated session |
| 403 | apps_disabled | create, scopes, rotate, exchange | apps.enabled is not true, or the iamagents entity is disabled |
| 401 | user_unavailable | /auth/apps*, exchange | The user’s identity row cannot be read |
| 403 / 401 | user_inactive | /auth/apps* / exchange | The user’s row is inactive or locked |
| 400 | bad_request | create | Body is not valid JSON |
| 400 | missing_name / name_too_long | create | name is empty or over 200 characters |
| 400 | unknown_scope | create | No scopes, or a scope that is not declared |
| 403 | scope_not_grantable | create | The user holds none of the scope’s requires roles |
| 400 | bad_expiry / ttl_exceeds_policy | create | expires_in_days is negative, or over keys.max_ttl |
| 403 | hook_denied | create | An auth-flow hook refused the connection |
| 404 | app_not_found | /auth/apps/:id* | No such connection, or not the caller’s |
| 403 | admin_required | /auth/admin/apps* | The caller does not hold admin |
| 400 | not_a_key_connection | rotate | The connection has no key to rotate |
| 409 | connection_revoked / connection_expired | rotate | The connection is no longer active |
| 400 | bad_limit | activity | limit is not a positive integer |
| 401 | apikey_invalid | exchange | Unknown, wrong, expired or revoked key; one answer for all four |
| 401 | connection_revoked / connection_expired | exchange | The grant has ended |
| 403 | grant_exceeds_authority | exchange | A scope’s requires role is gone, or the product withdrew or changed the scope |
| 400 | app_key | DELETE /auth/apikey/:id | The id names an app key; revoke its connection instead |
Continue with
Section titled “Continue with”- API keys and service tokens: the other key the service issues
- Tokens: the claim set and verification
- Authorization model: how rules see the caller
- Access rules and Row-level security
- Error codes