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 for capabilityTtlMs (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
}
Persist the original input yourself. The durable action record stores only 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
This is where step 1's persistence comes back. 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:

StateWho keeps it
Action id, input hash, approval decision, capability, outcome, receiptsnominee's durable action store (nominee-postgres in production)
The original input, and which function to call with itYour 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:

AdapterWhat 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 contractPOST /refund catches ActionPendingError and returns 202 with the actionId, approvalId, and the original input echoed back so the caller can re-supply it.
  • Authenticated steps 2–4POST /approve and POST /deny require a Bearer credential (APPROVER_CREDENTIAL); POST /refund/resume takes the action id plus the same input, checks resumed.status === 'ready', and calls executeCapability.
  • 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.
See the same pause-and-resume live at nominee.dev/agent — a Cloudflare Durable Object that hibernates while it waits for your approval, then resumes the same hash-chained receipt log and fetches a fresh GitHub token only at that moment.

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.