Agent Identity: Security Beyond API Keys
A shared API key cannot tell you which agent acted. Bounded grants, an approval loop, and a revocation that lands everywhere replace it.
Most teams hand their agents a raw provider key — OpenAI, Stripe, Twilio — and move on. The key works, the demo ships, and the security model is now "whoever holds this string is us."
The failure is not that the key might leak. It is that the key answers the wrong question. When something goes wrong you need to know which agent did this, and a shared credential cannot tell you. Revoking it takes down every agent that shares it, so nobody revokes it.
An identity is a thing you can bound#
Give each agent its own identity and the interesting question becomes what that identity is allowed to do beyond its own key's authority.
import { Anima } from "@anima-labs/sdk";
const anima = new Anima({ apiKey: process.env.ANIMA_API_KEY! });
const agent = await anima.agents.create({
orgId: process.env.ANIMA_ORG_ID!,
name: "Sales Outreach Agent",
slug: "sales-outreach",
});The agent gets its own ak_-prefixed key. Everything it does is attributable to that
identity, and nothing it does is attributable to any other.
A grant names a procedure, not a capability#
Permissions are not a bag of feature flags. A grant is a row naming one API procedure the agent may reach, with the terms attached:
| Field | What it holds |
|---|---|
procedurePath | The dotted procedure, e.g. agent.delete. Null when the scope is every read |
scope | PROCEDURE for one call, READS to cover read-only procedures at once |
state | ASK, ALWAYS_ALLOW, or NEVER |
expiresAt | When the grant lapses. Null for no expiry |
usesRemaining | Null for unlimited, 1 for single-use |
argumentDigest | Binds a single-use grant to one exact call |
That last row is the one worth dwelling on.
Approving an operation is not approving every instance of it#
A grant that names agent.delete and stops there has approved deleting an agent. The
owner who clicked approve was looking at a request to delete agent abc. Nothing in a
procedure-level grant prevents it being spent on agent xyz.
So a single-use grant also stores a SHA-256 of the canonically-serialised input. The approval is bound to the arguments the owner actually read. Change the arguments and the digest no longer matches.
usesRemaining is spent only after the call succeeds, which matters for the same
reason: decrementing on attempt would burn an approval on an unrelated validation error
and send the agent back to a human for a call that never happened.
Ask, approve, retry#
When an agent reaches a procedure it has no authority for, it is not simply refused. The request is filed for a human, and the agent is told to try again later.
The server does not act on the agent's behalf
A grant records what an agent may do. It is never a queued action that the platform executes once approved — the agent re-issues its own call. Approval changes what is permitted, not what has already been set in motion.
That distinction keeps the audit trail honest. Every entry is something an agent did, under authority it held at the time, rather than something the platform did while wearing the agent's name.
Two things a grant can never buy#
Bounded authority stops being bounded if a grant can be spent acquiring unbounded authority. Two floors exist for that, and they are enforced in deliberately different ways:
- A grant may not be used to mint a credential. The check lives inside the minting primitive itself, not in a list of procedure names, so no route added later can reach around it.
- A grant may not be asked for at a procedure that decides approval requests. This one is a list, because "decides who may do what" is a property of specific procedures rather than something you can put a check inside.
Both exist to prevent the same outcome: an agent approving its own requests. Filing a request to approve requests would also be structurally undecidable — a queue entry that can only be answered by another queue entry, leaving the owner an item they cannot act on. Those procedures need a human holding real master authority, and calling one without it returns a flat refusal.
Revocation is one write, and it lands everywhere#
An agent has three states: ACTIVE, SUSPENDED, DELETED.
await anima.agents.update(agent.id, { status: "SUSPENDED" });The status is read where the principal is minted, during authentication — not on the send paths. That placement is the whole point. A suspended agent does not merely stop sending email; it stops doing anything, across REST, the voice WebSocket, the browser extension bridge and MCP, because all of them resolve through the same authentication path.
A status column nothing reads is not a control
This gate was added after the column already existed. Operators could set an agent to Suspended, see it displayed as Suspended, and have it counted as suspended in compliance reports — while its key kept authenticating on every channel. A lifecycle field is only a containment mechanism once something on the request path asks.
Attribution, finally#
Because every action carries an agent identity rather than a shared key, the audit log answers the question the shared key never could:
for await (const entry of anima.audit.list(orgId, { agentId: agent.id })) {
console.log(entry);
}None of this makes an agent safe to run unattended. It makes an agent's blast radius something you chose, rather than something you discover afterwards — and that is the difference between an incident you can bound and one you can only apologise for.