Skip to content
Talk to our solutions team

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.

A connection is an agent principal acting for one user under a grant. Creating one writes four rows in a single transaction:

RowEntityHolds
Agent registry entryprincipalid A…, ptyp: agent, realm agent
Agentiamagentsthe user it acts for, its grant, its expiry, the scopes’ tools
Grantprincipal_grantthe user as grantor, the roles the scopes confer, the scope names, the expiry
App keyapi_keythe 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.

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]
KeyDefaultMeaning
enabledfalseTurns app connections on for the tenant
token_ttl5mAgent token lifetime. At most 15m
keys.default_ttl720hApp key lifetime when the user asks for none. Capped at max_ttl
keys.max_ttl2160hLongest lifetime a user may ask for
scopes.<name>.descriptionnoneText the user reads when choosing scopes
scopes.<name>.requiresrequiredUser roles, any one of which allows granting the scope
scopes.<name>.rolesrequiredRoles the connection receives for the scope
scopes.<name>.toolsnoneOperation 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.
  • requires and roles list role names explicitly. Wildcards are refused.
  • roles never includes iam-service or superadmin.
  • admin is conferred only by a user who holds admin themselves.

App connections also need the iamagents entity, which realmconfig.enable.agents controls and which is on by default.

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
FieldTypeRequiredMeaning
namestringyesWhat the user calls the connection, up to 200 characters
scopesarray of stringsyesDeclared scope names
expires_in_daysintnoLifetime in days. Absent or 0 uses keys.default_ttl
Terminal window
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.

POST /auth/token
Authorization: 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.

Terminal window
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 requires roles;
  • 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.

An agent token is an access token (type: user) whose subject is the agent.

ClaimValue
subthe agent id, A…
ptypagent
realmagent
aut{sub, ptyp}: the user the agent acts for
grt{id, exp, scopes}: the grant id, the grant’s expiry and its scope names
rolesthe grant’s roles
audthe requested audience, when one was passed
amr["apikey"]
tenant, iss, jti, iat, nbf, expas 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.

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:

BindingValue for an agent
user.ptypagent
user.realmagent
user.grant_idthe grant id
user.is_derived / user.is_standingtrue / false
user.authority.sub / .ptyp / .presentthe 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.

RouteWhoPurpose
GET /auth/appsthe userTheir own connections, newest first
GET /auth/apps/:idthe owner, or adminOne connection
DELETE /auth/apps/:idthe owner, or adminRevoke
POST /auth/apps/:id/rotatethe ownerReplace the key; the new key is returned once and the old one stops working
GET /auth/apps/:id/activitythe owner, or adminThe connection’s events, newest first; ?limit= up to 500, default 100
GET /auth/admin/appsadminEvery connection in the tenant; ?acting_for=U… filters by user
DELETE /auth/admin/apps/:idadminRevoke 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.

EventOutcomeDetail
app.connection.createdsuccessscopes, expires_at
app.token.issuedsuccessaudience, scopes
app.token.refusedrefusedreason (the refusal code), audience
app.key.rotatedsuccess
app.connection.revokedsuccessrevoked_by

The events are rows in user_eventlog with the agent id as userid and the user it acts for in meta.acting_for.

StatusCodeRouteCause
401auth_required/auth/apps*No user token
403standing_principal_required/auth/apps*, POST /auth/apikeyThe caller is an agent or another derived identity
403impersonation_not_allowed/auth/apps*The token is an impersonated session
403apps_disabledcreate, scopes, rotate, exchangeapps.enabled is not true, or the iamagents entity is disabled
401user_unavailable/auth/apps*, exchangeThe user’s identity row cannot be read
403 / 401user_inactive/auth/apps* / exchangeThe user’s row is inactive or locked
400bad_requestcreateBody is not valid JSON
400missing_name / name_too_longcreatename is empty or over 200 characters
400unknown_scopecreateNo scopes, or a scope that is not declared
403scope_not_grantablecreateThe user holds none of the scope’s requires roles
400bad_expiry / ttl_exceeds_policycreateexpires_in_days is negative, or over keys.max_ttl
403hook_deniedcreateAn auth-flow hook refused the connection
404app_not_found/auth/apps/:id*No such connection, or not the caller’s
403admin_required/auth/admin/apps*The caller does not hold admin
400not_a_key_connectionrotateThe connection has no key to rotate
409connection_revoked / connection_expiredrotateThe connection is no longer active
400bad_limitactivitylimit is not a positive integer
401apikey_invalidexchangeUnknown, wrong, expired or revoked key; one answer for all four
401connection_revoked / connection_expiredexchangeThe grant has ended
403grant_exceeds_authorityexchangeA scope’s requires role is gone, or the product withdrew or changed the scope
400app_keyDELETE /auth/apikey/:idThe id names an app key; revoke its connection instead