BetterIAM

AI agents that act for users

Register an AI agent with a sponsor and a ceiling, let a person delegate scoped and expiring access to it, have the agent act through short delegated sessions, require the person's confirmation for risky actions, and audit or stop the agent.

In this guide you give an AI support assistant its own identity in Acme and let Alice, a support engineer, delegate part of her access to it. The assistant never holds Alice's password or session: it opens short delegated sessions that act as Alice, bounded by what Alice agreed to, by the agent's own ceiling, and by its API key. Deleting a ticket is held back until Alice confirms that one call. You will build:

  • a ticket resource type and a role that lets Alice read, update, and delete tickets;
  • an agent identity with Alice as its sponsor, a boundary policy, and an API key;
  • a delegation from Alice to the agent with scopes, an expiry, a session cap, and a confirm list;
  • the agent's delegated session, checked by your API exactly like Alice's own requests;
  • a confirmation round trip (agent asks, Alice approves, the agent retries once);
  • the audit trail, the sponsor's kill switch, and revocation.

Prerequisites

  • The project from the Quickstart: better-iam.config.mjs, iam.ts, a migrated database, the organization Acme with its owner, and Alice, a member who accepted her invitation with a password. Export:

    export ACME_TENANT_ID=…          # the tenant.id of Acme
    export ACME_OWNER_EMAIL=owner@acme.test
    export ACME_OWNER_PASSWORD='another long password'
    export ALICE_EMAIL=alice@acme.test
    export ALICE_PASSWORD='alice password'
  • Node.js 22.12 or newer and tsx.

  • Optional: the MCP server from Secure an MCP server, to see the same delegation work through MCP tools.

The agent APIs (iam.api.agents, iam.api.delegations) are part of @better-iam/server and need no extra option. Every call below is also an HTTP route under /api/iam, shown as Route: in each step.

Declare the ticket resources

Decisions are about an action on a resource, so declare the ticket types and actions, and resolve each ticket's owner from your own storage. Skip this step if you already did it in the MCP guide.

tickets.mjs
// Stands in for your product's ticket table.
const tenantId = process.env.ACME_TENANT_ID;
export const tickets = new Map([
  ['T-1', { id: 'T-1', tenantId, title: 'Cannot export invoices', status: 'open' }],
  ['T-2', { id: 'T-2', tenantId, title: 'Duplicate of T-1', status: 'open' }],
]);
better-iam.config.mjs
import { IamError } from 'better-iam';
import { sqliteAdapter } from 'better-iam/adapter-sqlite';
import { findDocument } from './documents.mjs'; // your document lookup from the Quickstart
import { tickets } from './tickets.mjs';

const ticketActions = ['tickets:read', 'tickets:update', 'tickets:delete'];

export default {
  database: sqliteAdapter({ filename: './iam.db' }),
  secret: process.env.BETTER_IAM_SECRET,
  baseURL: process.env.BETTER_IAM_BASE_URL,
  authentication: {
    // Prints delegation-request and delegation-confirmation emails. Development only.
    sendEmail: async (message) => {
      console.log(message.template, message.to, message.payload);
    },
  },
  permissions: {
    resourceTypes: {
      document: { actions: ['documents:read', 'documents:write'], attributes: { ownerId: 'string' } },
      ticket: { description: 'A support ticket', actions: ticketActions },
      'ticket-queue': { description: "An organization's ticket queue", actions: ticketActions },
    },
  },
  resolveResource: async ({ type, id }) => {
    if (type === 'ticket-queue') return { type, id, tenantId: id }; // a queue's ID is its organization's ID
    if (type === 'ticket') {
      const ticket = tickets.get(id);
      if (!ticket) throw new IamError('NOT_FOUND', 'Ticket not found', 404);
      return { type, id, tenantId: ticket.tenantId };
    }
    if (type === 'document') {
      const document = await findDocument(id);
      return { type, id, tenantId: document.tenantId, attributes: { ownerId: document.ownerId } };
    }
    throw new IamError('NOT_FOUND', 'Unknown resource type', 404);
  },
};

Give Alice access to tickets

An agent acting for Alice can never do more than Alice can, so Alice needs the permissions first. The rest of this guide is one script, scripts/agent-demo.ts, that plays the owner, Alice, and the agent in turn; in production each part runs in a different place (an admin screen, Alice's browser, the agent's runtime).

scripts/agent-demo.ts
import { IamError } from 'better-iam';
import { iam } from '../iam';

const tenantId = process.env.ACME_TENANT_ID!;

async function signIn(email: string, password: string) {
  const result = await iam.api.auth.signIn({ tenantId, email, password });
  if ('mfaRequired' in result) throw new Error(`${email} must complete MFA (auth.verifyMfa)`);
  return result;
}

const ownerSignIn = await signIn(process.env.ACME_OWNER_EMAIL!, process.env.ACME_OWNER_PASSWORD!);
const owner = { token: ownerSignIn.token };
const aliceSignIn = await signIn(process.env.ALICE_EMAIL!, process.env.ALICE_PASSWORD!);
const alice = { token: aliceSignIn.token }; // a fresh sign-in: counts as a recent authentication
const aliceId = aliceSignIn.session.identityId;

const handler = await iam.api.roles.create(owner, {
  tenantId,
  name: 'Ticket handler',
  permissions: ['tickets:read', 'tickets:update', 'tickets:delete'],
});
await iam.api.bindings.create(owner, {
  tenantId,
  roleId: handler.id,
  subjectType: 'identity',
  subjectId: aliceId,
});

Route: POST /api/iam/roles/create, POST /api/iam/bindings/create

Register the agent

An agent is an identity of kind agent. Like a service account it holds API keys and never signs in. Unlike one, it has a sponsor, the person accountable for it: its credentials work only while the sponsor is an active member of the organization. Its boundary is a ceiling on everything it does, whatever its roles say and whoever it acts for. Creating agents needs iam:agents:create, and issuing a key with credentials.create needs iam:credentials:create, as for service accounts. The organization owner holds both.

scripts/agent-demo.ts (continued)
const agent = await iam.api.agents.create(owner, {
  tenantId,
  name: 'Support assistant',
  purpose: 'Triages and closes support tickets for the person it works for',
  model: 'claude-sonnet-5',
  provider: 'anthropic',
  protocols: ['mcp'],
  sponsorId: aliceId, // defaults to the caller when the caller is a person of the organization
  boundary: {
    version: 1,
    statements: [{ effect: 'allow', actions: ['tickets:*'], resources: ['ticket/*', 'ticket-queue/*'] }],
  },
  maxDelegatedSessionSeconds: 900, // 60 to 43200; 3600 by default
});

// The agent's own key: a biam_key_… bearer token. Its scopes also bound every delegated session it opens.
const { token: agentKeyToken } = await iam.api.credentials.create(owner, {
  tenantId,
  identityId: agent.id,
  name: 'production',
  scopes: ['tickets:read', 'tickets:update', 'tickets:delete'],
});
const agentKey = { token: agentKeyToken };
console.log('agent standing:', agent.standing); // ok

Route: POST /api/iam/agents/create, POST /api/iam/credentials/create

FieldDescription
sponsorIdThe accountable person: an active user of the same tenant.
model, providerWhat the agent runs on. Policies see them as principal.agentModel and principal.agentProvider (the provider is stored in lowercase).
purpose, urlShown to people deciding whether to delegate to the agent (agents.catalog).
protocolsInformational, such as mcp and a2a.
boundaryA ceiling over everything the agent does. Changes apply to live sessions at once.
delegablefalse refuses delegations and stops existing ones from being used. Default true.
maxDelegatedSessionSecondsThe longest delegated session the agent may hold: 60 to 43200 seconds, 3600 by default.
tokenAudiencesServices outside Better IAM the agent may present delegation tokens to (delegations.issueToken).

agent.standing is ok, suspended, expired, deleted, sponsor-missing, or sponsor-inactive. Anything but ok means every credential of the agent is refused.

Delegate access with a scope and an expiry

A delegation lets one agent act for one person. Alice grants it from her own session, which must be a recent sign-in (RECENT_AUTH_REQUIRED otherwise). The delegated sessions it allows act as Alice and are limited by:

  1. the delegation's scope: scopes (an action list, wildcards allowed) or a policy document;
  2. the agent's boundary;
  3. the agent key's scopes (or session policy) and the authority of whoever issued the key;
  4. an optional scope-down policy the agent passes when it opens a session;

and by Alice's own permissions, because the session's identity is Alice. confirm lists actions Alice confirms one call at a time.

scripts/agent-demo.ts (continued)
const delegation = await iam.api.delegations.grant(alice, {
  tenantId,
  agentId: agent.id,
  scopes: ['tickets:read', 'tickets:update', 'tickets:delete'],
  confirm: ['tickets:delete'], // each delete needs Alice's approval
  expiresInSeconds: 7 * 86_400, // 5 minutes to 1 year; 30 days by default
  maxSessionSeconds: 900, // cap on each delegated session
});
console.log('delegation:', delegation.status); // active

Route: POST /api/iam/delegations/grant

FieldTypeDefaultDescription
agentIdstringrequiredThe agent that may act for the person.
scopesstring[]noneActions the agent may take for the person; an alternative to policy.
policyPolicyDocumentnoneA full policy document as the scope, for resource limits or conditions.
confirmstring[]noneAction patterns the person confirms one call at a time (at most 50).
expiresInSecondsnumber30 daysLifetime of the delegation: 5 minutes to 1 year.
maxSessionSecondsnumbernoneCap on each delegated session, on top of the agent's maxDelegatedSessionSeconds.
handoff{ agents?, depth? }offLets the agent hand part of the delegation on to other agents (depth 1 to 3).
spend{ period, maxCostUsd?, maxTokens?, maxRequests? }noneCaps AI model use under the delegation per minute, hour, day, or month.

One pending request or active delegation may link an agent and a person at a time (DELEGATION_EXISTS otherwise).

Act as the person with a delegated session

The agent opens a short delegated session with its own key. The returned biam_dlg_… token is a bearer credential whose identity is Alice, with the agent and the delegation recorded alongside. Every use re-checks the whole chain: the delegation, Alice, the agent and its sponsor, and the agent's key.

scripts/agent-demo.ts (continued)
const assumed = await iam.api.delegations.assume(agentKey, {
  tenantId,
  delegationId: delegation.id,
  durationSeconds: 600, // at most the agent's and the delegation's caps; 900 or less by default
  sessionName: 'triage-run-1', // appears in decisions as principal.sessionName
});
const delegated = { token: assumed.token };
console.log(assumed.session.identityId === aliceId, assumed.session.agentId === agent.id); // true true

const check = (action: string, id: string) =>
  iam.authorize({ ...delegated, tenantId, action, resource: { type: 'ticket', id } });

console.log('close T-1:', (await check('tickets:update', 'T-1')).allowed); // true
console.log('delete T-2 before confirmation:', (await check('tickets:delete', 'T-2')).allowed); // false

Route: POST /api/iam/delegations/assume

Your API does not need to know that an agent is calling: it checks the delegated token exactly like Alice's own session. The agent sends it as Authorization: Bearer biam_dlg_…:

routes/tickets.ts
import { iam } from '../iam';
import { tickets } from '../tickets.mjs';

export async function closeTicket(request: Request, ticketId: string): Promise<Response> {
  const ticket = tickets.get(ticketId);
  if (!ticket) return new Response('Not found', { status: 404 });
  // Throws ACCESS_DENIED (403) or UNAUTHENTICATED (401); the HTTP layer turns them into JSON errors.
  await iam.require({
    headers: request.headers,
    tenantId: ticket.tenantId,
    action: 'tickets:update',
    resource: { type: 'ticket', id: ticket.id },
  });
  const { identity, session } = await iam.authenticate({ headers: request.headers });
  // Better IAM audits refusals; record the allowed change, and which agent made it, in your own logs.
  console.log(`ticket ${ticket.id} closed for ${identity.id}`, session.agentId ? `by agent ${session.agentId}` : '');
  ticket.status = 'closed';
  return Response.json(ticket);
}

Through the MCP gate from Secure an MCP server, the same token works as is: the gate reports caller.sessionKind: 'delegated' with agentId and delegationId.

A delegated session never counts as a recent sign-in, never acts as an owner or root administrator, cannot manage delegations (except handing work on when allowed) or mint further credentials, and lasts at most the delegation, the key that opened it, and the session caps. At most 20 live sessions may exist per delegation.

Ask the person to confirm a risky action

tickets:delete is in the delegation's confirm list, so the delegated session is refused it until Alice approves that action on that exact ticket. The agent files the request with its delegated session; Alice answers from her own session; the approval then opens one call, made within validSeconds.

scripts/agent-demo.ts (continued)
// The agent asks (it should read delegation.confirm first, with delegations.get):
const pending = await iam.api.delegations.requestConfirmation(delegated, {
  tenantId,
  action: 'tickets:delete',
  resource: { type: 'ticket', id: 'T-2' },
  reason: 'T-2 duplicates T-1, which I just closed',
  validSeconds: 120, // how long the approval stays usable: 30 to 3600; 300 by default
});

// Alice sees it in her inbox (or in the delegation-confirmation email) and approves:
const inbox = await iam.api.delegations.listConfirmations(alice, { tenantId, status: 'pending' });
await iam.api.delegations.decideConfirmation(alice, { tenantId, confirmationId: inbox[0]!.id, approve: true });

// The agent polls its request, then retries:
const decided = await iam.api.delegations.getConfirmation(delegated, { tenantId, confirmationId: pending.id });
console.log('confirmation:', decided.status); // approved
console.log('delete T-2 after approval:', (await check('tickets:delete', 'T-2')).allowed); // true
console.log('delete T-2 again:', (await check('tickets:delete', 'T-2')).allowed); // false: one approval, one call

Route: POST /api/iam/delegations/requestConfirmation, POST /api/iam/delegations/listConfirmations, POST /api/iam/delegations/decideConfirmation, POST /api/iam/delegations/getConfirmation

A real agent polls until the person decides; requests wait up to 30 minutes, and asking again for the same action and resource returns the pending request:

agent/wait-for-confirmation.ts
import { iam } from '../iam';

export async function waitForConfirmation(
  delegated: { token: string },
  tenantId: string,
  confirmationId: string,
): Promise<boolean> {
  for (;;) {
    const request = await iam.api.delegations.getConfirmation(delegated, { tenantId, confirmationId });
    if (request.status === 'approved' && !request.expired) return true;
    if (request.status !== 'pending' || request.expired) return false; // rejected, used, or lapsed
    await new Promise((resolve) => setTimeout(resolve, 5_000));
  }
}

Single checks (iam.authorize, iam.require) use the approval up; batch and listing checks (authorizeMany, listAccessible) see it without using it. The MCP gate files the confirmation request for the agent automatically and tells the model to retry.

To let people answer in your product, build an inbox with the React hooks from better-iam/react (inside an IamProvider; see React):

app/confirmation-inbox.tsx
import { useConfirmations } from 'better-iam/react';

export function ConfirmationInbox({ tenantId }: { tenantId: string }) {
  const { pending, approve, reject } = useConfirmations({ tenantId });
  return (
    <ul>
      {pending.map((item) => (
        <li key={item.id}>
          {item.agent.name} wants to {item.action} on {item.resource.type}/{item.resource.id}: {item.reason}
          <button onClick={() => approve(item.id)}>Approve</button>
          <button onClick={() => reject(item.id)}>Reject</button>
        </li>
      ))}
    </ul>
  );
}

useDelegations({ tenantId }) gives the same page the agents acting or asking to act for the person, with grant, approve, deny, and revoke.

Audit, stop, and revoke

Every audit event of a delegated session names Alice as the actor and carries agentId and delegationId in its sessionContext, covered by the tenant's hash chain. delegations.activity shows Alice what happened under her delegation, newest first: its lifecycle and every audited action of the agent's sessions. agents.activity shows the sponsor (or an administrator with iam:agents:read) everything the agent did, for anyone.

scripts/agent-demo.ts (continued)
const trail = await iam.api.delegations.activity(alice, { tenantId, delegationId: delegation.id, limit: 20 });
for (const event of trail) console.log(event.action, event.outcome, event.resourceId);
// newest first: tickets:delete deny T-2 (the second delete), delegation:confirm, delegation:confirmation-request,
// tickets:delete deny T-2, delegation:assume, delegation:grant

// The sponsor's kill switch: no permission needed, effective at once.
await iam.api.agents.suspend(alice, { tenantId, agentId: agent.id, reason: 'Demo finished' });
try {
  await check('tickets:read', 'T-1');
} catch (error) {
  if (error instanceof IamError) console.log('after suspend:', error.code); // UNAUTHENTICATED
}

// Bring it back (a sponsor may resume only an agent they suspended), then end the delegation for good.
await iam.api.agents.resume(alice, { tenantId, agentId: agent.id });
await iam.api.delegations.revoke(alice, { tenantId, delegationId: delegation.id, reason: 'No longer on call' });

Route: POST /api/iam/delegations/activity, POST /api/iam/agents/activity, POST /api/iam/agents/suspend, POST /api/iam/agents/resume, POST /api/iam/delegations/revoke

LeverWhoEffect
agents.suspendThe sponsor (no permission), or iam:agents:updateThe agent's keys are refused and its live delegated sessions end at once. Keys are kept for agents.resume.
agents.suspendAlliam:agents:update, no recent sign-inEmergency stop for every agent of the tenant, or those of one sponsorId, provider, or model.
delegations.revokeThe person, the sponsor, the agent, or iam:delegations:revokeEnds the delegation and its sessions at once, and the hand-offs below it.
credentials.revokeiam:credentials:revoke and a recent sign-inEnds one key, the sessions it opened, and the hand-offs made with it.
agents.deleteiam:agents:delete and a recent sign-inEnds keys and sessions, revokes every delegation to the agent, and keeps a tombstone for the audit trail.

Audit actions to watch: agent:create, agent:suspend, agent:resume, agent:sponsor-change, delegation:grant, delegation:request, delegation:approve, delegation:deny, delegation:assume, delegation:revoke, delegation:confirmation-request, delegation:confirm, and delegation:reject. Access analysis flags agents without an active sponsor, delegations that allow every action, unused delegations, and agents refused 20 or more times in a day (agent-denials), a sign of a looping or prompt-injected agent.

Verify it works

Run the script:

npx tsx scripts/agent-demo.ts
agent standing: ok
delegation: active
true true
close T-1: true
delete T-2 before confirmation: false
confirmation: approved
delete T-2 after approval: true
delete T-2 again: false
tickets:delete deny T-2
delegation:confirm allow …
delegation:confirmation-request allow …
tickets:delete deny T-2
delegation:assume allow …
delegation:grant allow …
after suspend: UNAUTHENTICATED

Then check from another angle:

  • iam.api.sts.getCallerIdentity({ token: assumed.token }) (before the revocation) reports agentId and delegationId for the delegated session.
  • iam.api.delegations.listMine(alice, { tenantId }) lists the delegation with status: 'revoked' and revokedBy.
  • iam.api.audit.list(owner, { tenantId, action: 'delegation:assume' }) shows each session the agent opened.

Troubleshooting

Next steps

Was this page helpful?

Better IAM is created by Sean Filimon

Last updated

On this page