AI agents
Treat AI agents as accounts with a responsible sponsor, a ceiling on what they may do, and short delegated sessions when they act for a person.
An AI agent that files tickets, schedules meetings, or edits documents needs credentials. Handing it a person's session means nobody can tell afterwards whether a person or the agent acted, and the agent can do everything the person can. Giving it an ordinary service account means nobody is answerable for it, and it keeps working long after the person who set it up has left. Better IAM treats agents as accounts of their own, with three safeguards:
- A sponsor. Every agent has a person accountable for it. Its credentials work only while that sponsor is an active member of the same organization. When the sponsor leaves, offboarding hands the agent to their successor, or the agent stops until an administrator names a new sponsor. No agent outlives the person answerable for it.
- A ceiling. The agent's
boundarypolicy caps everything it does, whatever its roles say and whoever it acts for. - Delegation. A person can let an agent act for them, within a scope and for a limited time. The agent then opens short delegated sessions that act as the person, never with more than the person has.
An agent is an identity of kind agent. Like a service account it holds API keys and never signs in, and like every
identity it can have roles, groups, and policies.
Register an agent
const agent = await iam.api.agents.create(admin, {
tenantId,
name: 'Support triage',
purpose: 'Labels and routes incoming support tickets',
model: 'claude-sonnet-5',
provider: 'anthropic',
protocols: ['mcp'],
sponsorId: alice.id, // defaults to the caller when the caller is a person of the organization
boundary: {
version: 1,
statements: [{ effect: 'allow', actions: ['tickets:*'], resources: ['ticket/*'] }],
},
});
// Keys work as for service accounts: typed `biam_key_…` tokens, bounded by the issuer's authority.
const { token } = await iam.api.credentials.create(admin, {
tenantId,
identityId: agent.id,
name: 'production',
scopes: ['tickets:read', 'tickets:update'],
});| Field | What it is for |
|---|---|
sponsorId | The accountable person, an active user of the same organization. |
model, provider | What the agent runs on. Policies see them as principal.agentModel and principal.agentProvider; the provider is stored in lowercase. |
purpose, url | Shown to people deciding whether to let the agent act for them. |
protocols | An informational list, such as mcp and a2a. |
boundary | The ceiling over everything the agent does. Changes apply to live sessions at once. |
delegable | false refuses new delegations and stops existing ones from being used until it is turned back on. |
maxDelegatedSessionSeconds | The longest delegated session the agent may hold (60 to 43200; 3600 by default). |
Managing agents needs iam:agents:create, :read, :update, and :delete (deleting also needs a recent sign-in),
and the plan limit agents caps how many an organization may register. agents.get reports the agent's standing:
ok, suspended, expired, deleted, sponsor-missing, or sponsor-inactive. Anything but ok means every
credential of the agent is refused, and keys are only issued to an agent whose standing is ok. People choosing an
agent to delegate to see agents.catalog: the active, delegable agents of their organization with their purpose,
model, and sponsor.
The kill switch
An agent that misbehaves must be stoppable by the person responsible for it, at once, without filing a ticket. A
sponsor manages their agents from their own session, with no extra permission: agents.listMine lists them, and
agents.suspend stops one immediately.
await iam.api.agents.suspend(aliceSession, { tenantId, agentId, reason: 'Looping on the wiki' });When something goes wrong across the board, such as a compromised model provider or a runaway release, an administrator can stop every agent of the organization at once:
// Every agent running on one provider's models, for example during that provider's security incident:
await iam.api.agents.suspendAll(admin, { tenantId, reason: 'Provider incident', provider: 'anthropic' });agents.suspendAll needs iam:agents:update but no recent sign-in, so it works in an emergency. It suspends every
active agent (or only those of one sponsorId, provider, or model), each exactly as agents.suspend would, and is
audited as agent:suspend-all plus one agent:suspend per agent. Agents come back one at a time with agents.resume.
The console's Agents page has a Stop all agents now button for it.
A suspended agent's keys are refused, and its live delegated sessions and session tokens end immediately. Keys are
kept, so agents.resume restores the agent as it was. A sponsor can resume only an agent they suspended themselves;
lifting an administrator's suspension takes iam:agents:update and a recent sign-in. Deleting an agent ends its keys
and sessions, revokes every delegation to it, and leaves a tombstone so audit records stay readable.
Letting an agent act for a person
A delegation lets one agent act for one person. The delegated session's identity is the person, so decisions use the person's grants, with three ceilings on top:
- the delegation's scope (
scopesas an action list, or apolicydocument); - the agent's boundary;
- an optional scope-down
policythe agent passes when it opens a session;
and the limits of the agent key that opened the session (its scopes or session policy, and its issuer's authority), as for session tokens. So an agent can never do more than the person could, more than the person agreed to, or more than the agent and its key are allowed in general. Delegated sessions never count as a recent sign-in, never act as an owner or root administrator, cannot manage delegations or mint further credentials, and cannot obtain stateless assertions.
The person grants it from their own, recently authenticated session:
const delegation = await iam.api.delegations.grant(aliceSession, {
tenantId,
agentId: agent.id,
scopes: ['tickets:read', 'tickets:update'],
expiresInSeconds: 30 * 86_400, // 5 minutes to 1 year; 30 days by default
maxSessionSeconds: 900, // optional cap on each delegated session
});One pending request or active delegation may link an agent and a person at a time. To act, the agent opens a short delegated session with its key:
const { token } = await iam.api.delegations.assume(agentKey, {
tenantId,
delegationId: delegation.id,
durationSeconds: 600,
sessionName: 'triage-run-42',
});
// token is a `biam_dlg_…` bearer credential that acts as Alice, with the agent recorded alongside.
await iam.require({ token, tenantId, action: 'tickets:update', resource: { type: 'ticket', id: 'T-1' } });A delegated session lasts from 60 seconds up to the agent's maxDelegatedSessionSeconds and the delegation's
maxSessionSeconds (15 minutes or less by default), never past the delegation or the key that opened it, and at most 20 live
sessions may exist per delegation. Every use re-validates the whole chain: the delegation, the person, the agent and
its sponsor, and the agent's key. If any link breaks, the session is refused with UNAUTHENTICATED.
The person, the agent's sponsor, the agent itself, or an administrator with iam:delegations:revoke can revoke a
delegation, which ends its sessions at once. Offboarding or deleting the person revokes every delegation they gave.
People see their delegations with delegations.listMine, agents see theirs the same way with their key, and
administrators list them with delegations.list. Its permission, iam:delegations:read, also lets administrators
read any delegation with delegations.get and delegations.activity.
Confirming sensitive actions one at a time
Some actions are too consequential to hand over for a month: deleting a document, sharing it outside the company,
sending money. A delegation can hold such actions back until the person confirms each call. Pass confirm (action
patterns) when granting, requesting, or approving. A delegated session is then refused those actions until the person
approves that action on that exact resource, and the approval opens it for a few minutes. It is a human-in-the-loop
check in the spirit of OpenID CIBA: the agent asks, and the person answers from wherever they are.
await iam.api.delegations.grant(aliceSession, {
tenantId,
agentId: agent.id,
scopes: ['documents:*'],
confirm: ['documents:delete', 'documents:share'],
});
// The agent, with its delegated session, asks before deleting:
const request = await iam.api.delegations.requestConfirmation(delegated, {
tenantId,
action: 'documents:delete',
resource: { type: 'document', id: 'q3-draft' },
reason: 'You asked me to clean up the drafts folder',
validSeconds: 120, // how long the approval stays usable: 30 to 3600; 300 by default
});
// Alice gets a `delegation-confirmation` email and answers from her own session:
await iam.api.delegations.decideConfirmation(aliceSession, {
tenantId,
confirmationId: request.id,
approve: true,
});
// The agent polls delegations.getConfirmation, then retries the delete.A request waits up to 30 minutes for a decision, and asking again for the same action and resource returns the pending
request. Only actions that match confirm can be requested. An unconfirmed call is refused like any denied decision:
callers see ACCESS_DENIED, and the recorded reason is CONFIRMATION_REQUIRED. So agents should read the delegation's
confirm list (delegations.get) and ask first. delegations.listConfirmations lists the requests a person has to
answer, or an agent's own.
Capping what an agent spends for you
An agent that calls AI models on your behalf spends money on your behalf, and a loop can spend a lot of it overnight.
A person can cap what an agent spends for them with spend on grant, request, or approve: maxCostUsd,
maxTokens, and/or maxRequests per minute, hour, day, or month (at least one limit).
await iam.api.delegations.grant(aliceSession, {
tenantId,
agentId: agent.id,
scopes: ['inference:invoke', 'documents:read'],
spend: { period: 'day', maxCostUsd: 5 },
});Every model call made under the delegation, or under a hand-off below it, counts against the
cap like a budget. Past it, calls are refused with BUDGET_EXCEEDED until the window resets, and the first refusal is
audited as inference:budget-exceeded on delegation:{id}. The delegation's summary reports the cap with what the
current window has used (spend.usedCostUsd, usedTokens, usedRequests, resetsAt), so a consent screen can show
"$0.42 of $5 today". approve can change a requested cap or remove it with spend: null, and a hand-off may carry a
tighter cap of its own. The agent's own budgets and the organization's budgets apply as well.
Handing work on to other agents
Agents increasingly call other agents: an assistant asks a research agent for sources, a planner hands a step to a specialist. Sharing the assistant's own credential would hide who did what; asking the person for a second delegation every time would be tedious. When the person allows it, the agent acting for them can hand part of its delegation on. The other agent then acts for the same person, never with more than the handing agent had.
// Alice lets her assistant hand work on, only to the research agent, one level deep:
const delegation = await iam.api.delegations.grant(aliceSession, {
tenantId,
agentId: assistant.id,
scopes: ['documents:*'],
handoff: { agents: [researcher.id], depth: 1 }, // omit agents for any delegable agent; depth 1 to 3
});
// The assistant, in its delegated session, hands a narrower part on:
const handoff = await iam.api.delegations.handoff(
{ token: assistantSession.token },
{ tenantId, agentId: researcher.id, scopes: ['documents:read'], reason: 'Find sources for the report' },
);
// It passes handoff.id to the researcher (for example in an A2A message), which opens its own sessions for Alice:
const research = await iam.api.delegations.assume(researcherKey, { tenantId, delegationId: handoff.id });The person sets handoff when granting. An agent can ask for it in delegations.request, but hand-offs widen what
happens in the person's name, so a requested hand-off counts only when the person states handoff themselves when
approving. A plain approval, or null, leaves hand-offs out. A hand-off is a delegation of its own (requestedBy: 'handoff', with parentId
and the chain of agents above it),
so the receiving agent acts with its own key and every action is attributed to it. Its use is bounded by its own
scope and every delegation above it, by the ceiling of every agent in the chain, and by the limits of the session that
handed it on (that session's scope-down policy, its key's scopes, and the key's issuer). The whole chain is checked on
every use: if a delegation above ends, or an agent in the chain is suspended or loses its sponsor, the hand-off stops at
once. Revoking a delegation revokes the hand-offs below it, and the person's confirm list travels down the chain.
No agent may appear twice in a chain, a delegation holds at most 20 live hand-offs, and each lasts an hour by default
(expiresInSeconds, never past the delegation above). A hand-off is also bound to the API key the handing agent acted
with: it ends when that key is revoked or expires, so revoking a compromised agent's key stops everything it handed on.
Opening a session under a delegation whose parent, agent, or key has ended fails with DELEGATION_INACTIVE. The
handing agent's own budgets count the hand-off's model calls too.
Hand-offs are audited as delegation:handoff and show in the person's delegations.listMine, where they can revoke
each one; when an agent revokes a hand-off from its delegated session, revokedBy names the agent.
principal.delegationChain lists the agents from the person's own delegate to the acting one, so a policy can keep an
agent away from some data however the work reached it:
{
"effect": "deny",
"actions": ["documents:read"],
"resources": ["document/hr-*"],
"conditions": { "ArrayContains": { "principal.delegationChain": ["<research agent id>"] } }
}What did the agent do?
People who let an agent act for them want to see what it did. delegations.activity shows the person everything that
happened under a delegation, newest first: its lifecycle (grant, request, approval, sessions opened, confirmations,
revocation) and every allowed or denied action of the agent's sessions acting for them, including everything done
under hand-offs below it. agents.activity shows the
sponsor, or an administrator with iam:agents:read, everything an agent did, with its own keys and for anyone it acted
for.
const trail = await iam.api.delegations.activity(aliceSession, { tenantId, delegationId, limit: 50 });Policies for agents
Decisions tell agents apart: principal.kind is agent for an agent's own key, principal.delegated is true while an
agent acts for a person, and principal.agentId, principal.agentSponsorId, principal.agentModel,
principal.agentProvider (lowercase), and principal.delegationId describe the agent involved. In a delegated session
principal.kind is the person's (user), and principal.mfa, principal.owner, and principal.rootAdmin are
always false, so test principal.delegated to single out agents acting for people.
{
"version": 1,
"statements": [
{
"sid": "NoAgentsInBilling",
"effect": "deny",
"actions": ["billing:*"],
"resources": ["*"],
"conditions": { "Bool": { "principal.delegated": true } }
},
{
"sid": "AgentsNeverDelete",
"effect": "deny",
"actions": ["documents:delete"],
"resources": ["*"],
"conditions": { "StringEquals": { "principal.kind": "agent" } }
}
]
}The agent keys other than principal.delegated are absent when no agent is involved, so guard deny statements that
use them with Exists, as the policy linter suggests (see
missing keys).
Audit
Every event recorded for a delegated session names the person as the actor and carries agentId and delegationId in
its sessionContext, covered by the audit chain. Agents record agent:create,
agent:suspend, agent:resume, and agent:sponsor-change, and deleting one is recorded as iam:agents:delete and
identity:delete with kind: 'agent'. Delegations record delegation:grant, delegation:request,
delegation:approve, delegation:deny, delegation:assume, delegation:revoke, delegation:confirmation-request,
delegation:confirm, and delegation:reject; delegations revoked because their person was offboarded or deleted write
no separate delegation:revoke. sts.getCallerIdentity shows agentId and delegationId for delegated sessions.
Access analysis reports the risky cases: agents without an active sponsor
(agent-without-sponsor, high), agents with full administrator access (agent-admin, high), delegable agents without
a ceiling (unbounded-agent, low), delegations that allow every action (broad-delegation, medium), active
delegations unused for the dormant window (unused-delegation, low), and delegations that let the agent hand work on
to any agent (open-handoff, medium when hand-offs may go more than one level deep, otherwise low). It also flags
agents refused 20 or more times in the last day, with their own keys or acting for people (agent-denials, medium):
a sign of an agent stuck in a loop or following instructions injected into something it read. Check what it did with
agents.activity and suspend it if in doubt.
The framework integrations pass delegated sessions through like any other credential. In @better-iam/next,
apiRoute principals carry session.kind: 'delegated' with session.agentId and session.delegationId, and an
agent's own key shows identity.kind: 'agent'.
Consent screens in React
People need a place to see which agents act for them, answer requests, and confirm held-back actions.
@better-iam/react has the hooks for building it into your own product:
useDelegations: the agents acting or asking to act for the signed-in person, withgrant,approve,deny, andrevoke;useConfirmations: actions waiting for the person's confirmation, withapproveandreject;useAgentCatalog: the agents a person may delegate to;useModels: the AI models the caller may use.
function AgentInbox({ tenantId }: { tenantId: string }) {
const { requests, approve, deny } = useDelegations({ tenantId });
const { pending, approve: confirm, reject } = useConfirmations({ tenantId });
return (
<>
{requests.map((request) => (
<Request key={request.id} request={request} onApprove={() => approve(request.id)} onDeny={() => deny(request.id)} />
))}
{pending.map((item) => (
<Confirm key={item.id} item={item} onApprove={() => confirm(item.id)} onReject={() => reject(item.id)} />
))}
</>
);
}Protecting MCP servers
Agents reach tools through the Model Context Protocol. @better-iam/mcp (also better-iam/mcp) puts tool-level
authorization in front of any MCP server that speaks Streamable HTTP. The gate:
- authenticates the caller and answers unauthenticated requests with a
WWW-Authenticatechallenge. When you give itmetadata, the challenge points at the server's protected resource metadata (RFC 9728), which the gate then serves; - refuses
tools/callrequests the caller may not make, as an MCP tool error so the model sees why; - removes the tools the caller may not use from
tools/listanswers, in JSON and in event streams. A tool whoseresourceis a function and that has nolistAsis always listed, because there are no arguments to decide on yet.
import { createMcpGate } from 'better-iam/mcp';
const gate = createMcpGate({
iam,
tenantId: acmeTenantId, // the organization that runs this server; decisions are made there
tools: {
search_tickets: { action: 'tickets:read', resource: { type: 'ticket', id: 'index' } },
close_ticket: {
action: 'tickets:update',
resource: (args) => ({ type: 'ticket', id: String(args.id) }),
listAs: { type: 'ticket', id: 'any' },
scopes: ['tickets.write'], // for OAuth callers
},
ping: { public: true },
},
unlisted: 'deny', // tools without a rule are hidden and refused
oauth: resourceGuard.verifier, // optional: also accept OAuth access tokens from @better-iam/oauth
metadata: {
resource: 'https://mcp.acme.test/mcp',
authorizationServers: ['https://iam.acme.test/oauth'],
},
});
export default {
fetch: (request: Request) => gate(request, (forwarded, caller) => mcpServer.handle(forwarded, caller)),
};tenantId is required and names the organization that runs the server: every decision is made in that organization,
so an agent from another organization cannot grant itself access by writing policies in its own. A function
(caller) => string | undefined picks the organization per caller, and undefined refuses the caller. OAuth access
tokens issued for another organization are refused.
Better IAM credentials (a person's session, a service account or agent key, or a delegated session) are decided by the
policy engine, action on resource. OAuth access tokens are decided by the tool's scopes; see
MCP authorization for issuing them. Unless a tool is public, one without
scopes is refused to OAuth callers and one without action to Better IAM credentials. Batched tool requests are
answered with a JSON-RPC 400. The second argument of next tells the server who called.
When an agent acting for a person calls a tool whose action the person
confirms one call at a time, the gate files the confirmation request
itself and answers with a tool error telling the model to call again after the approval (the request ID is in
_meta['better-iam/confirmationId']). Agents built on any MCP client get the human-in-the-loop flow without extra
code; confirmations: false turns it off. createMcpAuthorizer offers the same decisions (authenticate, canCall,
visibleTools) for tool handlers written directly against an MCP SDK.
Agent-to-agent (A2A)
In the Agent2Agent protocol, agents find each other through an agent card, a JSON
document at /.well-known/agent-card.json that names the agent, its skills, and where to reach it. Anyone can publish
a card that claims anything, so an agent deciding whether to hand work to another needs to know two things: is this
card really from the organization it names, and is there an accountable person behind the agent? @better-iam/a2a
answers both, and protects your own A2A servers the way the MCP gate protects tool servers.
Attested agent cards
With the a2a server option, Better IAM signs an agent's card and vouches for it: the organization it belongs to, that
a person sponsors it, whether people can delegate to it, and its model.
export const iam = betterIam({
// ...
a2a: {
signingKeys: [cardSigningJwk], // Ed25519 or ES256 private JWKs, each with a kid
jwksUrl: 'https://iam.example.com/a2a/jwks.json', // where you serve iam.a2a.jwksResponse()
cardLifetimeSeconds: 3600, // 300 to 604800
},
});agents.signCard({ tenantId, agentId, card }) signs a card. The agent can call it with its own key (an unscoped one;
a key with scopes is refused), and so can its sponsor, or an administrator with iam:agents:update. The agent must be
in good standing and have a registered url, and the card's url (and every additionalInterfaces[].url and
supportedInterfaces[].url) must be on that origin. Better IAM sets provider to the organization's name and the
agent's registered origin, ignoring whatever the card said, adds the attestation extension
urn:better-iam:a2a:attestation:v1
(the agent, its organization, that it is sponsored, whether it is delegable, its model, provider, and protocols, and
when the attestation was issued and expires), and signs the canonical card (RFC 8785) as a detached JWS. Each signature
is audited as agent:card-sign.
createCardAttestor keeps a served card signed: it signs on first use, signs again when less than a fifth of the
attestation's lifetime is left, and keeps serving the previous card if signing fails while that card is still valid.
import { createCardAttestor } from '@better-iam/a2a';
const card = createCardAttestor({
card: { name: 'Triage', url: 'https://triage.example.com/a2a', skills: [/* ... */] },
sign: (card) => iam.api.agents.signCard({ token: process.env.AGENT_KEY }, { tenantId, agentId, card }),
});The agent directory
Before an agent can hand work to a helper, it has to find one. Every card an agent has signed is also its entry in the
organization's directory: agents.directory lists the current attested cards of the tenant's agents in good standing,
optionally only those offering a skill (by skill ID or tag) or speaking a protocol. Any credential of the tenant can
read it: a person, an agent's key, or an agent acting for someone and looking for a helper. Entries leave the directory
when their attestation expires or the agent is suspended or deleted, and each entry's card is the signed card itself,
ready for verifyAgentCard.
const [translator] = await iam.api.agents.directory(delegatedSession, { tenantId, skill: 'translate' });
// { agentId, name, card, attestation, expiresAt }Verifying other agents
Before delegating work, an agent checks the other side's card:
import { discoverAgent } from '@better-iam/a2a';
const { card, attestation } = await discoverAgent('https://triage.example.com', {
// Each trusted Better IAM deployment (its issuer: base URL plus API path), with where its card keys are published:
trustedIssuers: { 'https://iam.example.com/api/iam': 'https://iam.example.com/a2a/jwks.json' },
tenantId: 'acme', // optional: only agents of this organization
});A deployment's issuer is its base URL plus the API path (https://iam.example.com/api/iam for
baseURL: 'https://iam.example.com' and the default basePath), unless it sets a2a.issuer. trustedIssuers binds
each deployment's keys to the issuer a card names, so a key trusted for one deployment can
never vouch for a card claiming to come from another; use it whenever you trust more than one deployment. (keys and
trustedJwksUrls are the simpler forms.) Key sets are fetched again, at most every 30 seconds, when a signature names
a key that is not known yet, so key rotations need no restart.
discoverAgent fetches the card without following redirects, with a size limit and a timeout, and requires its url
to be on the origin it came from. verifyAgentCard(card, options) accepts only cards signed by a trusted key with
exactly one current attestation, and checks issuers, tenantId, and origin when you pass them. Every failure
throws AgentCardError with a reason.
Protecting an A2A server
createA2aGate sits in front of an A2A JSON-RPC server:
import { createA2aGate } from '@better-iam/a2a';
const gate = createA2aGate({
iam,
tenantId: acmeTenantId, // the organization that runs this server, as for the MCP gate
card,
message: { action: 'triage:use', resource: { type: 'agent', id: 'triage' } },
skills: {
summarize: { action: 'triage:use', scopes: ['triage'] },
escalate: { action: 'tickets:escalate', resource: { type: 'queue', id: 'support' } },
},
taskAdminAction: 'triage:operate',
});
export default { fetch: (request: Request) => gate(request, (forwarded, caller) => a2aHandler(forwarded, caller)) };- The card is served without authentication; everything else needs a Better IAM credential (a person's session, an
agent key, or a delegated session) or, with
oauth, an access token decided by each rule'sscopes. message/sendandmessage/streamare decided by the skill the message names (metadata.skillId), or by themessagerule. A refusal is JSON-RPC error-32050with HTTP 403. Skills without a rule are refused unlessunlistedSkills: 'message', and a request naming two different skills is refused as ambiguous.- Tasks are private to the caller who started them: anyone else gets "task not found" (
-32001) when reading, cancelling, or continuing one, or naming it inreferenceTaskIds, unless they are allowedtaskAdminAction. Conversations (contextId) are private too: a context counts as the caller's only once the server's answers named it for them, and any other is refused (-32602). Passtasksto share task and conversation ownership between server instances. - The authenticated extended card lists only the skills the caller may use.
- An agent acting for a person whose delegation holds the action back
gets
-32051witherror.data.confirmationId; the person has been asked, and the retry passes once they approve. - Only
POSTis accepted (405 otherwise, unlessotherHttpMethods: 'allow'); other JSON-RPC methods (unlessotherMethods: 'allow') and batches are refused; unexpected errors answer-32603with HTTP 500 after youronError.
createA2aAuthorizer offers the same decisions for servers written directly with an A2A SDK.
Next steps
Better IAM is created by Sean Filimon
Last updated
Billing and spendnew
Usage meters, rate cards, and spend by person, team, department, project and organization, with budgets, plans and subscriptions, and Stripe-style invoicing.
Model access and budgetsnew
Decide who may call which AI models, cap what people, teams, and agents spend, meter every call, and keep provider keys away from callers with a gateway.