← Blog

How to make an agent's pending approval
survive a restart.

Short answer: don't hold the approval as a pending promise in process memory. Persist a stable action id the moment the approval is requested, return control to the caller, and resolve the action later — from a webhook, a cron job, a completely different process — by looking that id back up. If you're awaiting anything across the gap between "asked" and "answered," that's the bug, no matter how it's implemented.


Why the obvious version doesn't survive

The obvious way to pause for an approval is to await it:

async function cancelOrder({ orderId }) {
  const approved = await waitForSlackApproval(orderId) // could take an hour
  if (!approved) throw new Error('denied')
  return orders.cancel(orderId)
}

This is fine for an approval that resolves in seconds inside one request-response cycle. It stops being fine the moment the wait is long enough that something else happens first: the container redeploys, the serverless function's execution limit is hit, the process crashes and gets rescheduled. Whatever was holding that await — the closure, the pending promise, the callback waiting on a websocket — is gone. Not denied, not logged as abandoned. Gone, along with any way to tell the difference between "still waiting" and "never happened."

The tell that you have this bug: if you can't answer "which of our agent's pending approvals are still open right now" with a database query — only by asking whether the relevant process is still running — the state lives in memory, not in something durable.


The shape that survives: a persisted id, not a held promise

nominee's action lifecycle is built around exactly this split. prepareAction() either resolves with an execution capability immediately, or comes back — synchronously, without blocking — with a pending_approval status and a durable action id. run(), the higher-level call, surfaces that same moment as a typed error instead of a status you have to check:

import { Nominee, allow, ask, ActionPendingError } from 'nominee'

const RECEIPT_STREAM = 'order-agent'
const nominee = new Nominee({
  policy: {
    rules: [allow('order.read'), ask('order.cancel', { reason: 'cancellations over 2 days old need a human' })],
    fallback: 'deny',
  },
  actionTtlMs: 7 * 24 * 60 * 60 * 1000, // match your maximum approval SLA
  actionStore, // durable — see below
  receipts: { store: receiptStore, stream: RECEIPT_STREAM, key: process.env.NOMINEE_RECEIPT_KEY }, // durable — see below
})

try {
  await nominee.run({ tool: 'order.cancel', input: { orderId }, user: session.userId }, () =>
    orders.cancel(orderId),
  )
} catch (err) {
  if (err instanceof ActionPendingError) {
    await jobs.save({ actionId: err.actionId, orderId }) // your own durable job row
    return res.status(202).json({ actionId: err.actionId })
  }
  throw err
}

Nothing here is awaiting the human. The request handler returns immediately with a 202 and an id. That id is the entire handoff — it's what a webhook, a cron sweep, or a different service instance uses to pick the thread back up, with zero shared in-memory state:

// in a completely separate process, minutes or hours later — a webhook handler:
await nominee.resolveActionApproval(actionId, {
  decision: 'approved',
  approver: 'ravi@acme-ops.example',
  via: 'dashboard',
})

const resumed = await nominee.resumeAction(actionId)
const result = await nominee.executeCapability(resumed.capability, { orderId }, ({ input }) =>
  orders.cancel(input.orderId),
)

To prove that's not just a comment, here's two independent Nominee instances — no shared memory, no closures crossing between them, only a shared store — standing in for two separate processes either side of a restart, run against the real package:

// process A: plans the action, hits ActionPendingError, persists the id, exits
process A: action pending, id=act_f7ec76c256fe2832d5c47bdc77d072cb093f — persisting id and returning 202
--- process A exits / redeploys here ---

// process B: brand-new Nominee instance, only knows the id from the job row
process B: resumed status = ready
process B: executed -> { orderId: 'ord_512', cancelled: true }

Process B never held a promise from process A. It looked up the same durable store by id and picked up exactly where the action left off.


The catch: two stores have to be durable, not one

This is the part worth being precise about, because it's easy to fix half of it and assume you're done. The example above passes a shared actionStore to both instances, so the action's lifecycle state (its status, its approval, its capability) survives the "restart." Each instance's local nominee.receipts view contains only the entries that instance appended, though:

process A receipts: action.planned, policy.decision, approval.requested
process B receipts: approval.resolved, capability.issued, capability.consumed, execution.started, execution.succeeded

Those are two incomplete local views, not two chains. The configuration already points both instances at the same atomic receipt store and exact order-agent stream, so the durable ledger is one chain containing plan, ask, approve, and execute regardless of which process appended each entry. await nominee.verifyDurableReceipts() loads and verifies that complete shared stream; inspecting only nominee.receipts does not. If you omit receipts.store, only the action state is durable and the audit trail really does split into process-local chains. If you omit the durable action store, you have a durable log of a workflow that cannot resume. nominee-postgres supplies both stores. Production mode enforces the pair: new Nominee({ production: true, ... }) refuses to construct unless the action store, atomic receipt store, and strict receipt delivery are configured.


Two more things this pattern buys you for free

  • Capability rotation on retry. If your webhook or job runner retries resumeAction() before the first capability is consumed, the old one is invalidated and a fresh one issued — a duplicate delivery can't accidentally execute the action twice through two different capabilities.
  • Provider-native polling for the same call. If your approval comes from a provider that tracks its own pending state (Auth0 CIBA, for instance) rather than your webhook, resumeAction() polls it once and returns pending_approval again if it's still open — same resume call, same durable id, whether the approval mechanism is your own dashboard or someone else's.

For the fuller production checklist — idempotency keys downstream, external receipt anchoring, alerting on stuck actions — see the production runbook, and nominee.dev/agent for a deployed reference: a Cloudflare Durable Object agent whose receipt chain resumes across hibernation and whose credential is fetched fresh only at resume, not held across the gap.


When you don't need this

If every approval in your system resolves inside one request — a synchronous confirm dialog, a same-session click — the await version at the top of this post is simpler and correct; don't add a durable action store for a pause that never outlives the request handling it. This pattern earns its place once an approval can plausibly outlive the process asking for it: anything measured in minutes to days, anything routed through Slack or email, anything that has to survive a deploy landing mid-wait. Pending actions expire after 24 hours by default; the example sets actionTtlMs to seven days, so set a bounded TTL that actually covers your approval SLA (or pass expiresInMs for a particular action). If your agent's tokens are also going stale during that same pause, that's a related and separate problem — see your AI agent's OAuth token refresh is probably broken.

See it for yourself

Run the local ask-and-receipt proof in 10 seconds.

$ npx nominee-cli proof runs offline after npx installs the package
Star on GitHub See the deployed durable pause-and-resume demo → Read the production runbook → Read the docs →