Managing AI Agents
Managing AI agents requires a role with the agents:read/agents:create/
agents:update/agents:delete scopes, and mandates require
mandates:read/mandates:manage — see Using o2idctl for how
to log in, and Managing Roles for how to create and
assign roles.
An AI agent is an AI agent identity, distinct from both a User (a
human account) and an Application (an OAuth client). A human authorizes
an AI agent to act on their behalf by creating a mandate — a scoped,
time-boxed grant — which is later redeemed for an access token through
Token Exchange's actor-token delegation (see
Mandates below).
Registering an AI agent
./o2idctl agents create --name "Research Agent" --scope users:read
./o2idctl agents list
Allowed scopes
Just like an application, an AI agent's tokens can never carry more than the RBAC scopes it's been explicitly granted, regardless of what the user it's acting for is otherwise permitted. New AI agents are created with no allowed scopes (deny-by-default).
./o2idctl agents create --name "Research Agent" \
--scope users:read --scope applications:read
Repeat --scope to grant more than one. Passing --scope to agents update replaces the full list — it isn't merged with the existing one, the
same replace-not-merge semantics as applications' --scope. Grant --scope "*" for every scope, present and future. See the API Reference for
the scope each management endpoint requires, and
Managing Roles for the role/scope model this
intersects with.
Listing, inspecting, updating, and deleting AI agents
./o2idctl agents get <id>
./o2idctl agents update <id> --name "Research Agent v2" --scope users:read
./o2idctl agents delete <id>
Registering a public key (for unattended AI agents)
An AI agent that needs to run unattended — with no human present to approve each token request — proves its own identity with an asymmetric key instead of a shared secret. Register the AI agent's RSA public key as a JSON Web Key (JWK):
./o2idctl agents update <id> --public-key ./agent-public-key.jwk.json
The file must be a JWK with "kty": "RSA" — any other key type is
rejected. There's currently no way to clear a registered key back to
"none" via update. An AI agent with no registered key can never
authenticate on its own at all — including as an actor in a mandate
redemption (see Mandates below), since that also needs the
AI agent to first prove its own identity via this same key.
O2ID never sees the private key — it only ever stores the public half. The
AI agent process holds the private key and uses it to sign a short-lived
private_key_jwt assertion (RFC 7523) each time it authenticates — see
below for how it uses this key to get its own token, with no human and no
mandate involved. That token is then presented as actor_token to redeem
a mandate (see below).
Getting a token as an unattended agent
An AI agent running unattended, with no human present, uses the
client credentials grant to get a token for
itself — authenticating with the signed private_key_jwt assertion above
instead of a client secret. This is the only way an unattended AI agent
gets a token: it has no client_secret to fall back on, and acting as
a specific human — via redeeming a mandate — needs
that human's own live access token, which by definition isn't available
when no human is present.
Token endpoint: POST /oauth2/token
| Parameter | Required | Description |
|---|---|---|
grant_type | Yes | Must be client_credentials |
client_id | Yes | The AI agent's own agentId, not an application's clientId |
client_assertion | Yes | A signed private_key_jwt (see below) |
client_assertion_type | Yes | Must be urn:ietf:params:oauth:client-assertion-type:jwt-bearer |
scope | No | Space-delimited list of requested scopes |
The AI agent signs the assertion with the private key matching the public key registered above:
| Claim | Value |
|---|---|
iss | The AI agent's own agentId |
sub | Same as iss — RFC 7523 requires them to be equal |
aud | This server's token endpoint, e.g. https://your-o2id-host/oauth2/token |
exp | A few minutes from now — O2ID rejects assertions with an implausibly long lifetime |
Signed with RS256. O2ID verifies the signature against the AI agent's
registered JWK and confirms iss == sub equals the AI agent named by
client_id. An AI agent with no registered key can't authenticate this
way at all.
curl -s -X POST http://localhost:8080/oauth2/token \
-H "Content-Type: application/x-www-form-urlencoded" \
--data-urlencode "grant_type=client_credentials" \
--data-urlencode "client_id=${AGENT_ID}" \
--data-urlencode "client_assertion=${SIGNED_JWT}" \
--data-urlencode "client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer" \
--data-urlencode "scope=users:read"
The granted scope is clamped to the AI agent's own allowedScopes — there
is no application, mandate, or human role scope involved in this path at
all, since client_credentials has no concept of a resource owner.
Mandates
A mandate is what a human creates to authorize an AI agent: "this AI agent may act as me, with these scopes, until this time." It's the record redeemed to actually mint a delegated access token.
"Until this time" doesn't imply the mandate has to be set up long in
advance. Since POST /agents/{id}/mandates always authorizes the agent to
act as whoever calls it (see below), a human who is present right now can
create a mandate and redeem it via token exchange in the same
interaction — for example, scoped narrowly and expiring in a few minutes,
covering only the one action just requested. A mandate created this way
is identical to one set up days ahead; only the timing differs. Because a
mandate can only ever be created for the caller themself, granting
mandates:manage broadly — to every ordinary user, not just
administrators — is safe: nobody can use it to authorize an agent to act
as anyone but themselves.
There is currently no o2idctl command for mandates — manage them directly
through the API (see the API Reference). {id} here is the AI
agent's internal id from agents create/agents list — the same one
agents get/update/delete take — not its agentId, which only
ever appears in OAuth requests (the agent's own client_id, or the sub
claim of its actor_token):
curl -X POST "$API_URL/agents/<id>/mandates" \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{"scope": ["users:read"], "expiresAt": "2026-08-01T00:00:00Z"}'
POST /agents/{id}/mandates always authorizes the AI agent to act as the
caller — there is no request field for naming a different subject. List
an AI agent's mandates with GET /agents/{id}/mandates, and revoke one
with DELETE /agents/{id}/mandates/{mandateId} (idempotent — revoking an
already-revoked mandate is not an error).
Redeeming a mandate
A mandate is redeemed through Token Exchange's
actor-token delegation — POST /oauth2/token with grant_type set to
urn:ietf:params:oauth:grant-type:token-exchange, the human's own live
access token as subject_token, and the AI agent's own access token (from
getting a token as an unattended agent
above) as actor_token:
curl -X POST "$API_URL/oauth2/token" \
-H "Content-Type: application/x-www-form-urlencoded" \
--data-urlencode "grant_type=urn:ietf:params:oauth:grant-type:token-exchange" \
--data-urlencode "client_id=${CLIENT_ID}" \
--data-urlencode "subject_token=${HUMAN_TOKEN}" \
--data-urlencode "subject_token_type=urn:ietf:params:oauth:token-type:access_token" \
--data-urlencode "actor_token=${AGENT_TOKEN}" \
--data-urlencode "actor_token_type=urn:ietf:params:oauth:token-type:access_token" \
--data-urlencode "scope=users:read"
There is no mandate_id parameter — the mandate is looked up implicitly
by (AI agent, human) pair, matching whichever agent actor_token
identifies and whichever human subject_token identifies. If no active
mandate authorizes that pair, the exchange fails with invalid_grant.
The granted scope is the intersection of, at the moment of exchange:
- The front-door application's
allowedScopes - The AI agent's own
allowedScopes - The mandate's
scope(fixed at creation time — can only narrow what the creating human could grant then, never widen it) - The acted-for human's current, live role scopes
That last one means revoking the human's role revokes every AI agent acting for them too, immediately, on the very next exchange. See Token Exchange for the full mechanics, including how a second AI agent can further re-delegate an already-delegated token, nesting the chain rather than replacing it.
See the CLI Reference for the full agents
command list.