When the approval outlives the request
The call throws. The action lives on. An ask that
cannot be settled inside the current request does not hang a connection — it
returns a durable action id immediately, and a human can approve it minutes,
days, or a restart later. Here is the whole path.
Why the call throws
Every official adapter and nominee.run() route through the
decision-bound lifecycle. When the policy says ask, nominee first
tries to settle the approval inline — your onApprovalRequest
callback calling req.approve() within the request, or a strategy
that blocks on a decision (Auth0 CIBA). When it cannot — the callback only
notifies, the push is still pending, or the process is going away — the call
throws ActionPendingError immediately and the pending action is
durably recorded.
That is the intended shape of this path: no hung connection, no in-memory state. What survives is a durable action record. Two things to know about it:
-
It stores the input as
inputHash— a SHA-256 of the canonical JSON — not the input itself. The raw arguments are never in nominee's action store. -
It expires. Actions live for
actionTtlMs(default 24 hours), and the one-use capability you get at resume time lives forcapabilityTtlMs(default 5 minutes).
To resume across processes or replicas, construct the Nominee
with a durable action store — nominee-postgres provides one. The
in-memory store is process-local and fine for single-process demos.
The four steps
Setup: a policy where refunds above a threshold need a human, and your tools
wrapped with guard() (or any adapter's
nomineeTool()):
import { Nominee, allow, ask, ActionPendingError } from 'nominee'
const nominee = new Nominee({
policy: {
rules: [
allow('orders.read'),
allow('refund.issue', { when: ({ input }) => input.amount <= 50 }),
ask('refund.issue', { when: ({ input }) => input.amount <= 500 }),
deny('refund.issue'),
],
fallback: 'deny',
},
onApprovalRequest: (req) => notifySlack(req), // notifies; does NOT settle inline
})
const tools = nominee.guard(
{
'orders.read': readOrder,
'refund.issue': issueRefund,
},
{ user: 'alice' },
)
Step 1Catch the throw — persist the action id and the input
const input = { orderId: 'ord_1', amount: 200 }
try {
await tools['refund.issue'](input)
} catch (error) {
if (error instanceof ActionPendingError) {
// error.actionId — durable; error.approvalId — the approval inside it
await db.pendingActions.save({
actionId: error.actionId,
input, // your copy of the exact arguments you called with
})
return { status: 'waiting_for_approval', actionId: error.actionId }
}
throw error // PolicyDeniedError and others propagate unchanged
}
inputHash — nominee deliberately never keeps your raw
arguments. Your application is the only copy of input, and step 4
needs it back, byte for byte.
Step 2Resolve the approval — later, from anywhere
Minutes later, from a webhook, an admin UI, or a different process entirely.
The resolver records an approval.resolved receipt with the
approver's identity and the mechanism:
// your approval endpoint — authenticate the approver first
await nominee.resolveActionApproval(actionId, {
decision: 'approved', // or 'denied'
approver: '[email protected]',
via: 'web',
})
Step 3Resume — this does not execute the tool
const resumed = await nominee.resumeAction(actionId)
// { status: 'ready', action, capability } — a one-use capability, nothing runs yet
resumeAction() only issues the capability. If the approval has not
been settled yet it returns { status: 'pending_approval', approvalId }
(poll again later); a denied or expired action returns
{ status: 'denied' | 'expired' } with no capability and the tool
never runs. The capability is consumed once and expires quickly — call step 4
right away.
Step 4Execute — re-supply the original input and your tool function
const result = await nominee.executeCapability(
resumed.capability,
input, // the SAME arguments the approver reviewed — from your persistence
({ input, token }) => issueRefund(input), // YOUR tool function, again
)
// the action is marked succeeded; a failure is recorded the same way
executeCapability re-fingerprints the input you hand it and compares
it to the hash the approver signed off on. A different amount, a different order,
a missing field: AuthorizationInputChangedError, a deny receipt on
the chain, and the tool never runs.
You persist the input — that is not an omission
The durable action record keeps inputHash, policy version, user,
resource, tenant, and approval state. It does not keep your arguments, and it
cannot run your tool for you: nominee does not know what
issueRefund is. So the out-of-band path splits the state in two:
| State | Who keeps it |
|---|---|
| Action id, input hash, approval decision, capability, outcome, receipts | nominee's durable action store (nominee-postgres in production) |
| The original input, and which function to call with it | Your application — next to the action id, in your own table or cache |
Storing the hash instead of the arguments keeps user data out of nominee's store and receipt log, and it makes the argument-binding check at step 4 meaningful. If nominee held the input itself, "the input matches" would be a comparison against a copy nominee made. The input you re-supply is the application's attestation — at execution time — that these are the arguments that were reviewed.
Why re-supplying the input is the point
A capability is bound to the canonical hash of the exact arguments policy and
the approver saw. executeCapability fingerprints the input you pass
at execution and refuses — with a deny receipt — if it differs, so a tampered
queue, a buggy retry, or an agent that "adjusts the amount" after approval can
never widen the decision. The same guarantee closes the loop on the pause
itself: when an action names a resource, your
authorizer is consulted again after capability consumption, so a
permission revoked mid-approval also fails closed.
Per-adapter behavior
All adapters route through the same decision-bound lifecycle; what differs is how the pending state surfaces:
| Adapter | What ask does when it outlives the request |
|---|---|
nominee-ai (Vercel AI SDK / Cloudflare Agents) |
Throws ActionPendingError out of the tool's execute. Catch it where the call is made (or in your model's tool-error handling), persist, resume. |
nominee-eve (Vercel Eve) |
Same portable path — ActionPendingError out of execute. An Eve-native eveApproval gate is independent and still applies on top. |
nominee-openai (OpenAI Agents SDK) |
Maps ask into the SDK's native resumable approval: the run pauses, and on resume the adapter verifies the approved tool-call id and seals it as approval evidence. If execute runs without verifiable framework approval, the portable path applies instead. |
nominee-mastra (Mastra) |
Portable path by default (nativeApprovals: false): ActionPendingError. With nativeApprovals: true, ask maps to Mastra's native pause/resume for agent tools; calls without Mastra's toolCallId marker (workflows, direct execution) fail closed to the portable path. |
nominee-mcp (MCP server SDK) |
registerNomineeTool returns { isError: true, structuredContent: { nominee: 'pending_approval', actionId, approvalId } } — MCP has no approval-resume protocol, so the id travels in the tool result. The low-level nomineeMcpHandler() throws ActionPendingError at a custom transport. |
nominee-langchain (LangChain JS) |
Portable path only — LangChain has no first-class resumable tool-approval primitive. invoke() rejects with ActionPendingError. |
In every case the recovery is the same four steps, and denied calls never
reach execute.
The canonical example
examples/support-refund-agent
is the reference implementation of this path: an Express app with
nominee-ai tools, production: true, and
PostgresControlStore durable stores.
- Policy by amount — refunds ≤ $50 run, ≤ $500 ask, larger ones are denied before the refund function runs.
-
Step 1 as an HTTP contract —
POST /refundcatchesActionPendingErrorand returns202with theactionId,approvalId, and the originalinputechoed back so the caller can re-supply it. -
Authenticated steps 2–4 —
POST /approveandPOST /denyrequire a Bearer credential (APPROVER_CREDENTIAL);POST /refund/resumetakes the action id plus the same input, checksresumed.status === 'ready', and callsexecuteCapability. - Proof — the test suite covers all three policy outcomes, HTML escaping, and the approver credential checks, and the run walkthrough above drives the resume flow manually.
Coming from the examples: dedicated approval walkthroughs for
nominee-langchain and nominee-mastra are on the way;
until then, read the ask sections of the
nominee-langchain
and nominee-mastra
READMEs alongside this page. MCP's durable-approval notes also live in
docs/integrations/mcp.md.