Approvals (human in the loop)
How approval: 'human' grants produce approval-required decisions, how the replay-safe token binds an approval to one call, and how each runtime surfaces and resumes the approval.
Some actions should be permitted in principle but confirmed by a person each time: refunds, deletions, publishing, anything an agent might do a thousand times before anyone notices. PermDock models this as a third decision outcome rather than a denial with a note, so every adapter can translate it into the runtime's own approval vocabulary and resume safely once a human says yes. See decisions; the API and the resolve flow are on the approvals adapter. Who may approve lives on the grant.
The grant
const clerk = role("clerk", [
allow(permissions.filing.pay, {
approval: {
by: [roles.admin, assurance({ amr: ["mfa"] })],
distinct: true,
},
}),
]);approval: 'human' marks a grant that any authenticated person other than the requester may resolve: neither the actor nor the principal can approve. { by } names the eligible approvers with the same grantee selectors as to (role, assurance, authenticated, relation(), an array as intersection), plus user(id) for one named person, and refuses the principal and the actor the same way. distinct defaults to true; { distinct: false } lets the principal approve their own request (a user confirming their own agent's call), and permdock doctor PD024 lists every grant that sets it. The actor is refused either way.
Three more fields shape the request the grant produces:
allow(permissions.payout.release, {
approval: {
by: roles.finance,
quorum: 2, // two distinct finance approvers
ttl: "30m", // the ask expires after half an hour
escalation: { after: "10m", to: roles.cfo }, // after ten minutes the CFO may approve too
},
});quorum(an integer, default 1) is how many distinct approvers the request needs. Each approval is recorded as{ by, at }on the request'sapprovals, the same principal counts once, and the request stayspending(so it cannot be consumed) until the quorum is met. One rejection ends it.ttl(a duration such as'30m'or'2d') caps how long the request stays open. The window comes from the call'sttl(resumeDecision), else the store'sttl, else one hour; the grant can shorten it, never extend it.escalationnames, with the same selectors asby, who may approve in addition tobyonceafterhas passed since the request was created. It widens the approver set; it does not change the quorum or remove anyone.
definePolicy refuses a quorum below 1 or a ttl or after that is not a duration.
Stages
mode orders several approver sets. 'any' (the default) is the single by set above. 'all' and 'sequential' take stages, each with its own by and quorum; by and quorum are then refused at the top level.
allow(permissions.expense.pay, {
approval: {
mode: "sequential",
stages: [
{ by: relation(permissions.expense, "manager") }, // the expense's manager first
{ by: relation(permissions.report, "approver", { through: ["report"] }) }, // then the report's approver
{ by: roles.finance, quorum: 2 },
],
},
});'all'needs every stage's quorum, in any order.'sequential'opens a stage only when the previous one is complete.- Each signature records its
stage. One approver signs once per request, so one person never completes two stages. escalation.tomay approve any open stage onceafterhas passed. A stage may carry its ownescalation(stages: [{ by, quorum, escalation }]), which widens only that stage.
Any of several approvers, and permission holders
A list in by (or allOf(...)) is a conjunction: the approver must match every item. anyOf(...) matches an approver who matches at least one item, and a list inside it is an all-of group. holder(permission) names whoever holds that permission in the request's tenant through any role, custom roles included:
allow(permissions.payment.send, {
approval: { by: anyOf(user("cfo-id"), holder(permissions.payment.approve)) },
});holder() is checked when the verdict is given, from the approver's own instance: approvalsHandler(store, { permdockFor }) (and resolveApproval(..., { permdockFor })) builds it with permdockFor(approver, request), and approverPermissions(request, instance) lists the permission keys it grants without a row as verdict.permissions. An instance for another tenant or an anonymous one holds nothing; without permdockFor, or when it throws, holder() matches nobody. A custom store receives the same facts on verdict.permissions and passes them to assertApprover(request, by, requireDistinct, now, relations, permissions); it never derives them.
Relation approvers
relation(resource, name) in by, a stage or escalation.to names whoever holds that relation on the requested row, such as the manager on an expense. It is checked when the verdict is given, by the request's resource id, so a manager who changes between the ask and the answer is the one who may approve. definePolicy refuses a relation approver on a collection permission, on a resource other than the permission's own or the end of its through links, on an undeclared relation, and in an activation approval. The approvals handler reads the relation through its relations source (approvals adapter); without one, a relation approver matches nobody.
When the grant matches, decide returns:
{
outcome: ("approval-required", grant, reason, token);
}If the grant does not match, the outcome is denied as usual; approval is never offered for something the subject could not do even with approval. If a deny grant applies, it wins. can returns false for approval-required because a boolean caller cannot ask anyone; only decide and the adapters see the third outcome.
The replay-safe token
Decision.token is a hash of the permission key, the resource id (from the resource's id field), a digest of the call's data when there is no id (a collection action such as refund.create, or a row without its id field), the principal's id, tenant and issuer, the actor's id and kind, and the fingerprint of the matched grant's conditions. Roles, memberships, assurance and claims are not hashed: the resume re-runs decide, so a role lost in between is a denial. It exists so an approval given for one call cannot be attached to another: approving "delete post p_42 for user u_123 via agent A" must not authorise deleting p_43, or deleting p_42 as a different user, or the same action by a different agent. This is the problem the AI SDK's experimental_toolApprovalSecret addresses for its own approval replies; PermDock computes the binding itself so the guarantee holds in every runtime.
Properties:
- Deterministic for the same inputs, so a resumed request can recompute and compare.
- Includes
actor, so an approval obtained by one agent cannot be spent by another. - Includes a SHA-256 of the canonical JSON of the validated data (keys sorted,
undefinedmembers dropped) when the resource id would be*. An approvedrefund.createfor{ amount: 5, to: 'c_1' }does not cover{ amount: 5000, to: 'c_attacker' }: the retried call recomputes a different token and is a newapproval-required. - Does not include a timestamp; expiry lives on the
ApprovalRequestrecord in the store, not in the token. - Includes the row's
versionfield only for a grant withapproval: { staleOn: 'resource-change' }(below). - Carries no secret material and reveals nothing beyond what the caller already knows.
On resume, the adapter recomputes the token from the actual call being made and compares it with the approved token. A mismatch is denied with a reason of kind approval. A signed permdock-approval+jwt never replaces this comparison: it proves who resolved the request, the token proves what was approved.
Approvals that go stale
By default the token binds the row's id, not its content: an approval for "pay invoice inv_1" stays valid if the amount changes between the ask and the resume. For actions where the approver approves the content (a payment amount, a contract text, a deploy target), the grant binds the approval to the row's version:
const permissions = definePermissions({
invoice: resource(Invoice, { actions: ["pay"], version: "updatedAt" }),
});
allow(permissions.invoice.pay, {
approval: { by: roles.finance, staleOn: "resource-change" },
});versiononresource()names the row field that changes whenever the row does: anupdatedAttimestamp or a revision counter. Its value (aDateas ISO text, a string, a number) joins the token input; a row without the field hashes asnull.staleOn: 'resource-change'needs an instance action on a resource that declaresversion.definePolicythrows otherwise, and a hosted grant that sets it on such a resource is dropped asinvalid.- On resume,
decideruns on the current row, so a changed version recomputes a different token and the approval no longer applies. When the presented token names a pending or approved request for the same permission, resource and principal, the resume isdeniedwith reasonstale-approval, and the old record is not consumed. The next call without that token is a newapproval-requiredwith a new token, and the approver sees the row as it is now. - Grants without
staleOnhash exactly what they hashed before, so their tokens do not change.
The store
Between the ask and the answer, the pending approval is an ApprovalRequest record in an ApprovalStore; the API is on the approvals adapter and the record shape on wire formats. An adapter without a store denies every resume (Eve and the OpenAI Agents SDK adapter default to memoryApprovalStore()). Rules the store and the adapters hold:
- The approver comes from authentication, never from the resume request, and is never the request's actor: an agent cannot approve its own call. Nor is it the request's principal, unless the grant sets
distinct: false;requireDistinctApproveron the handler refuses the principal even then. - Only
pendingbecomesapprovedorrejected;expiredis terminal. A resume against anything butapprovedisdenied. - An approval is spent once. A resume that enforces the call (
protect, agent tool execution, MCP) calls the store's atomicconsume, which setsconsumedAt; a second resume isdeniedwith detailapproval-consumed. A store withoutconsumecannot resume. The decision endpoint only inspects, so a UI check does not spend the approval. createis idempotent per token: a pending, approved or rejected record is kept until it expires, so a consumed or rejected call cannot be asked again beforeexpiresAt.- A request with a tenant is resolvable only by an approver with a membership in that tenant.
- A
quorumcounts principals, not clicks: the same approver is refused a second time, so one person can never satisfy a quorum of two, whatever role they hold. - A verdict the application vouches for (
vouchApproval,verdict.vouched) skips the eligibility checks ofapprovers, the tenant membership and the quorum, because the application's own rules decided them, and resolves the request at once. It still refuses an unauthenticated approver, the actor, the principal unlessdistinct: false, a repeated approver and a request that is not pending or has expired, and the request records the rule's name. Only server code can set it:approvalsHandlernever reads it from a body. expiresAtis the shorter of the store's window and the grant'sttl; the escalation window opens atcreatedAtplusescalation.afterand the store checks it at resolve time, so an approver who was not eligible when the request was created can become eligible without any write.- A
simulate()plan produces one request perapproval-requiredstep; approving the plan approves each token, and each step is still re-checked at execution. - The ask, the answer and the resumed decision are three
on('decision')records sharingtoken.
Applications that need approvals to survive a restart implement the interface over their database (a Drizzle recipe is on the adapter page). PermDock Cloud implements the same interface with an inbox UI and delivery (Cloud adapter).
Surfaces
| Runtime | How approval-required is surfaced | How it resumes |
|---|---|---|
AI SDK 7 generateText / streamText / ToolLoopAgent | toolApproval returns 'user-approval' | The SDK's re-check of the same toolCallId is denied unless the store holds an approved, unconsumed record for the recomputed token; the record is consumed before the tool runs |
AI SDK 7 WorkflowAgent | needsApproval(permissions.post.delete) suspends the durable workflow | Workflow resumes; the adapter recomputes and compares token |
| Claude Agent SDK | canUseTool denies with the reason and token and records the request in the store; permissionRequestHook answers the SDK's own prompt with the same decision | A reviewer resolves the request in the store; the agent's retry recomputes token and consumes the approved record once |
| Eve | approval.request returns "user-approval"; the session parks at session.waiting | approval.response checks the responder against the store and approvers; on resume the adapter recomputes and compares token (Eve adapter) |
| OpenAI Agents SDK | needsApproval returns true; the run returns interruptions and a serialisable RunState | resolveInterruptions applies the store's verdict with state.approve / state.reject; the token is recomputed before execute (OpenAI adapter) |
| MCP | An MRTR input_required result with a URL-mode elicitation/create request to approval.at and the token as requestState, when the client takes URL elicitations; otherwise a structured tool result (isError) carrying the reason and token | A reviewer resolves the request in the store; the retried call re-runs decide, compares and consumes |
| HTTP adapters | 403 Problem Details with type .../approval-required, permission, token | Client retries the same request with a PermDock-Approval: <token> header; the kernel requires an approved record, re-runs decide and compares |
Terminal (permdock/terminal) | A y/N prompt only for a grant with distinct: false and no actor; otherwise the request is recorded in store and the CLI exits 75 (77 with no store) | The rerun recomputes token and consumes the approved record once (terminal adapter) |
| A2A | Skill returns a structured "approval required" result with token | The task enters input-required; the caller retries with the token |
| WebMCP | Tool handler returns a structured refusal with token; page renders its own approval UI | Page re-invokes the guarded route with the token |
| React / Next.js UI | usePermission reports allowed: false; decide on the server shows approval-required for rendering an approval button | Server action re-checks with the token |
| Chat UIs over AG-UI | The backend emits the approval request (reason, token, what to ask) as an AG-UI human-in-the-loop or custom event; the UI renders the control | The answer flows back on the same stream; the adapter in the backend recomputes and compares token (agent frameworks) |
| Slack, Microsoft Teams, Discord through the Vercel Chat SDK | The store's approval event triggers requestApproval from a chat/workflow step; the card carries the reason and names the approvers | The Chat SDK verifies the platform signature and returns the responder's user.id; the app maps it to a Subject and calls store.resolve, which applies the actor rule; the resumed call recomputes token (approvals adapter, Delivery) |
| Other frameworks (LangGraph.js, Mastra, Inngest AgentKit, Google ADK) | Recipes, not adapters: decide in the framework's before-tool hook; approval-required parks as an interrupt, suspend or waiting step | The durable step resumes with the token; the same store and token check apply (agent frameworks) |
The AI SDK adapter fails closed: denied maps to 'denied', approval-required maps to 'user-approval', and 'not-applicable' is never returned, in contrast to @ai-sdk/policy-opa, which executes tools on unrecognised decisions (vercel/ai#19978).
Resume flow
The second decide matters: the policy, the resource and the subject are re-evaluated at resume time, so a revocation between the ask and the answer (a CAEP session-revoked, a role change, the post being reassigned) turns the approval into a denial. The token proves the human approved this call; the re-check proves the call is still permitted.
What an approval does not do
- It does not grant a permission the subject lacks. Approval sits on top of a matching grant.
- It does not outlive its request. Each call needs its own approval, which is consumed on use; the record expires at
expiresAt, and asimulate()plan is approved step by step with no plan-level token. - It is not remembered. A rejection applies to one call; the next call asks again, and the OpenAI SDK's
alwaysRejectis never set. - It does not decide who may approve beyond the grant and the actor rule.
approval.by,approval.mode,approval.stages,approval.distinct,approval.quorumandapproval.escalationon the grant, tightened by any matching approval policy entries, are copied to theApprovalRequestasapproversand enforced byApprovalStore.resolveandapprovalsHandler. Adapterapproverslists (Eve, OpenAI, Chat SDK) are an extra restriction; both must pass.requireDistinctApproveron the handler is an application-wide floor: with it on,distinct: falseon a grant no longer lets the principal approve. - It is never given by a link alone. An emailed or chat link carries only the token and opens a page where the approver authenticates; the verdict goes through
approvalsHandlerwith that session's subject. A link that approves without a session would make possession of the message the approver's identity (approvals).
Why
- Approvals can bind content, not only identity. An approval is a person's judgement about what they saw. When the row can change between the ask and the resume, someone could get a small refund approved and then raise the amount before the retry. The version field is the cheapest stable signal of "the row changed" that every database already has, and putting it in the token reuses the existing comparison instead of adding a second check. It is opt-in per grant because most approvals are about the action on a row ("delete this post"), and forcing re-approval on every unrelated edit would train approvers to click yes. The resume says
stale-approvalrather than a generic mismatch, so the client can tell the user to ask again instead of retrying the same token. - A call without a row id binds its arguments. A collection action has no row to name, so a token of key, subject and actor alone would let one approval cover every later call of that action until it is consumed, and an agent could swap the arguments after the human said yes. Hashing the validated data costs one digest per approval-required decision and needs no new field on the record. Rows with an id keep binding the id, with
staleOnfor content, because a row's content changes for reasons unrelated to the approval. - Relation approvers are checked at the verdict, outside the store.
relation()answers "does this subject relate to this row", which needs a relation reader at resolve time; the store has none and stays synchronous. The approvals handler asks itsRelationSourcewhich relation approvers the approver satisfies for the request's resource id and passes those facts toresolveasverdict.relations. A store never derives them, so without the facts a relation approver matches nobody. Reading at the verdict, not at the ask, means the current holder approves: a manager who left the team the day before cannot approve. - Stages are ordered sets, not a workflow engine. Two-step sign-off (a line manager, then finance) is common enough to live on the grant, where
permdock diffand a policy review see it.sequentialrefuses a later stage until the earlier one is complete, so finance never signs something the manager has not seen. Branching, parallel paths with different outcomes and delegation of a stage are left to the application. - Quorum, ttl and escalation are grant fields, not store configuration. How many people must agree, how long an ask may wait and who may step in when nobody answers are statements about the action, the same as who may approve, so they live next to
byon the grant, appear in the catalog and inpermdock diff, and travel with the request asapprovers. A store setting would apply to every permission at once and would be invisible to a policy review. The request records each approval as{ by, at }rather than a counter so an audit can see who agreed, and the quorum counts distinct principals because a quorum one person can satisfy alone is a single approval with extra clicks. Escalation only widens the approver set, because a workflow where the fallback approver replaces the first set would let anyone wait out the timer to pick their approver. Recurring windows and per-approver weights are left out: both are approval workflow design, which an application or PermDock Cloud builds on these primitives. - Four eyes is the default, not an option. A user who can approve their own request turns an approval into a confirmation dialog: the person who wants the refund is the person who says yes. So the principal is refused unless the grant opts out. Where a confirmation is what the product wants (the user approving their own agent's call in a chat),
approval: { distinct: false }says so on the grant and PD024 keeps the list visible in review. Separation between the agent and its user was already guaranteed by the actor rule; this closes the one between the user and themselves.
Elevated access
Two elevated-access features ride the same ApprovalRequest machinery (elevated access). Role activation with activation: { approval } returns approval-required from permdock.activate; once resolved, the granted decision carries the via: 'elevated' membership to write under elevation. Support access asks a tenant owner to consent: the request's membership is the via: 'support' row, and on consume the app writes it. Both reuse the replay-safe token and four-eyes default above; neither lets PermDock write the membership.
Sources
- AI SDK 7 tool approvals:
toolApprovaloutcomes andWorkflowAgentneedsApproval. - AI SDK
needsApprovaldeprecation commit. - AI SDK policy-based tool approvals and the fail-open issue.
- MCP 2026-07-28 release: stateless elicitation via multi-round-trip requests.
- Eve human-in-the-loop:
approvalrequest and response policies,session.auth.initiatorandcurrent. - OpenAI Agents SDK human-in-the-loop:
needsApproval,interruptions,RunStateserialisation. - Vercel Chat SDK
requestApproval(6 August 2026): durable approval cards for Slack, Teams and Discord with signature-verified responders.
Last updated on
OWASP Top 10 for Agentic Applications
How PermDock features map to the OWASP Top 10 for Agentic Applications (December 2025), with detailed coverage of ASI02 Tool Misuse and ASI03 Identity and Privilege Abuse.
Delegation
The principal, actor and delegation model, the attenuation invariants that keep an agent from exceeding its user, how adapters fill the agent half of the subject, and how RAR authorization_details are emitted and verified.