AI Agents (On-behalf-of)
Let an AI agent act on behalf of a human user with RFC 8693 Token Exchange and a first-class agent identity.
AI Agents (On-behalf-of)
An agent identity in Avnology ID is a first-class machine identity that is owned by a human user. It lets an AI agent, assistant, or autonomous workflow act on behalf of that user with:
- Time-limited scope — exchanged tokens are short-lived and audience-scoped.
- Attribution — the exchanged token carries both the user (
sub) and the agent (act.sub), so every downstream API call is auditable to both parties. - Revocability — revoking the agent identity or the user's session revokes every token the agent has ever minted.
This page shows the end-to-end flow: create an agent, exchange a user token for an on-behalf-of token, then verify it on the resource server.
What an agent identity is
An agent identity is a service account with owner_id set to the delegating user. It shares the machine-identity model with plain service accounts (API keys, scopes, revocation) but adds the human-owner backlink so token-exchange flows can bind exchanged tokens to act.sub = agent and sub = user.
User (human) ──owns──▶ Agent (service account)
├── owns agents ├── owner_id = user:jane
├── issues user access tokens ├── api_keys[]
└── delegates scope └── scopes[] (subset of user's)The shape already exists — owner_id is part of ServiceAccount in the public API. No new resource type; the docs + SDK helper just make the "agent" intent first-class.
End-to-end flow
┌──────────────────────────┐
│ Avnology ID │
│ (api-id.avnology.net) │
└──────────────────────────┘
▲ ▲
1. User signs in, gets access token │ │
──────────────────────────────────────▶ │ │
│ │
2. Admin creates agent (service account, │ │
owner_id = user, scopes = subset) │ │
──────────────────────────────────────▶ │ │
│ │
3. Admin issues API key for the agent ◀───┘ │
│
4. Agent holds: user's access token + its own API key │
│
5. Agent exchanges: subject=user_token, │
actor=agent ────────────────────────────────────▶ │
│
6. Avnology ID issues an access token with ◀───┘
sub=user, act.sub=agent, scope=intersection
│
7. Agent calls downstream API with the exchanged token
Prerequisites
| Requirement | Description |
|---|---|
| User access token | Obtained from a regular login or OAuth code exchange. |
| Agent identity | A service account created by an admin with owner_id pointing at the delegating user. |
| Agent credential | Either the agent's API key (server-side) or a signed assertion (advanced). |
token_exchange scope | The OAuth client used by the agent must include token_exchange. |
| Audience | The exchanged token's aud claim determines which resource server accepts it. |
1. Create the agent identity (admin)
An admin creates the agent for the delegating user. This is the standard service-account create flow; the only special field is owner_id.
import { AvnologyId } from "@avnology/sdk-typescript";
const admin = new AvnologyId({
baseUrl: "https://api-id.avnology.net",
apiKey: process.env.AVNOLOGY_ADMIN_KEY!,
});
const agent = await
2. Agent exchanges user token for an on-behalf-of token
The auth.exchangeForAgent helper wraps RFC 8693 with sensible defaults for the agent-on-behalf-of-user pattern. It sets the grant type, the subject-token type, and the actor-token type so you only supply the identifiers.
from avnology_id import AvnologyId
async with AvnologyId(
base_url="https://api-id.avnology.net",
client_id="app_abc123",
access_token=AGENT_API_KEY,
import avnologyid "github.com/avnology/sdk-go"
agent := avnologyid.NewClient(
"https://api-id.avnology.net",
avnologyid.WithClientID("app_abc123"),
avnologyid.
curl -X POST https://api-id.avnology.net/v1/oauth/token \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "grant_type=urn:ietf:params:oauth:grant-type:token-exchange" \
-d "subject_token=USER_ACCESS_TOKEN" \
-d "subject_token_type=urn:ietf:params:oauth:token-type:access_token" \
-d "actor_token=sa_agent_abc123" \
-d "actor_token_type=urn:avnology:params:oauth:token-type:agent-id" \
-d "requested_token_type=urn:ietf:params:oauth:token-type:access_token" \
-d "scope=documents:read calendar:read" \
-d
import { AvnologyId } from "@avnology/sdk-typescript";
const agent = new AvnologyId({
baseUrl: "https://api-id.avnology.net",
clientId: process.env.AVNOLOGY_CLIENT_ID!, // OAuth client with token_exchange scope
accessToken: process.env.AGENT_API_KEY
from avnology_id import AvnologyId
async with AvnologyId(
base_url="https://api-id.avnology.net",
client_id="app_abc123",
access_token=AGENT_API_KEY,
import avnologyid "github.com/avnology/sdk-go"
agent := avnologyid.NewClient(
"https://api-id.avnology.net",
avnologyid.WithClientID("app_abc123"),
avnologyid.
curl -X POST https://api-id.avnology.net/v1/oauth/token \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "grant_type=urn:ietf:params:oauth:grant-type:token-exchange" \
-d "subject_token=USER_ACCESS_TOKEN" \
-d "subject_token_type=urn:ietf:params:oauth:token-type:access_token" \
-d "actor_token=sa_agent_abc123" \
-d "actor_token_type=urn:avnology:params:oauth:token-type:agent-id" \
-d "requested_token_type=urn:ietf:params:oauth:token-type:access_token" \
-d "scope=documents:read calendar:read" \
-d
import { AvnologyId } from "@avnology/sdk-typescript";
const agent = new AvnologyId({
baseUrl: "https://api-id.avnology.net",
clientId: process.env.AVNOLOGY_CLIENT_ID!, // OAuth client with token_exchange scope
accessToken: process.env.AGENT_API_KEY
from avnology_id import AvnologyId
async with AvnologyId(
base_url="https://api-id.avnology.net",
client_id="app_abc123",
access_token=AGENT_API_KEY,
import avnologyid "github.com/avnology/sdk-go"
agent := avnologyid.NewClient(
"https://api-id.avnology.net",
avnologyid.WithClientID("app_abc123"),
avnologyid.
curl -X POST https://api-id.avnology.net/v1/oauth/token \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "grant_type=urn:ietf:params:oauth:grant-type:token-exchange" \
-d "subject_token=USER_ACCESS_TOKEN" \
-d "subject_token_type=urn:ietf:params:oauth:token-type:access_token" \
-d "actor_token=sa_agent_abc123" \
-d "actor_token_type=urn:avnology:params:oauth:token-type:agent-id" \
-d "requested_token_type=urn:ietf:params:oauth:token-type:access_token" \
-d "scope=documents:read calendar:read" \
-d
import { AvnologyId } from "@avnology/sdk-typescript";
const agent = new AvnologyId({
baseUrl: "https://api-id.avnology.net",
clientId: process.env.AVNOLOGY_CLIENT_ID!, // OAuth client with token_exchange scope
accessToken: process.env.AGENT_API_KEY
from avnology_id import AvnologyId
async with AvnologyId(
base_url="https://api-id.avnology.net",
client_id="app_abc123",
access_token=AGENT_API_KEY,
import avnologyid "github.com/avnology/sdk-go"
agent := avnologyid.NewClient(
"https://api-id.avnology.net",
avnologyid.WithClientID("app_abc123"),
avnologyid.
curl -X POST https://api-id.avnology.net/v1/oauth/token \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "grant_type=urn:ietf:params:oauth:grant-type:token-exchange" \
-d "subject_token=USER_ACCESS_TOKEN" \
-d "subject_token_type=urn:ietf:params:oauth:token-type:access_token" \
-d "actor_token=sa_agent_abc123" \
-d "actor_token_type=urn:avnology:params:oauth:token-type:agent-id" \
-d "requested_token_type=urn:ietf:params:oauth:token-type:access_token" \
-d "scope=documents:read calendar:read" \
-d
The response is a standard TokenSet — accessToken, tokenType: "Bearer", expiresIn, optional refreshToken.
3. Verify on the resource server
On your downstream API, verify the JWT locally using @avnology/backend. The act claim lets you branch on "is this an agent call?" and log both identities for audit.
import { JWTVerifier } from "@avnology/backend";
const verifier = new JWTVerifier({
issuer: "https://api-id.avnology.net",
audience: "https://api.acme.example",
});
export async function handler
Security notes
- Never trust the
agentIdthe client sends. Avnology ID re-reads the service account by ID and confirms itsowner_idmatches the user insubject_tokenbefore minting the exchanged token. A mismatch returnsAVNOLOGY_ERROR_CODE_OAUTH_TOKEN_EXCHANGE_DENIED. - Scope is always narrowed. The exchanged token's granted scopes are the intersection of the user's scopes, the agent's allowed scopes, and the scopes requested in the exchange. You cannot widen privilege via exchange.
actchains. If the agent itself calls another service that exchanges again, RFC 8693 requires the new token to preserve the originalactchain. Avnology ID does this automatically.- Revocation cascades. Revoking the agent service account, rotating its API key, or signing the user out invalidates future exchanges immediately. Already-issued short-lived tokens expire on their own.
References
- RFC 8693 — OAuth 2.0 Token Exchange
/docs/guides/token-exchange— generic delegation + impersonation/docs/guides/impersonation— admin acting as user (different pattern)/docs/concepts/hosted-vs-embedded— where the agent's client should live