Short answer: wrap the tool, not the prompt. Put the pause in the function the agent actually calls, make the pause block until a human your application authenticates decides, and make "denied" a real code path your agent handles — not an exception you forgot to catch. If the receipt itself must prove who approved, use nominee's durable resolution API with an explicit approver; the inline callback shown first records the decision, not that identity.
The version everyone starts with
An agent has a closeTicket tool. Someone decides closing tickets above a certain priority needs a human to sign off, so they add a check:
async function closeTicket({ ticketId, priority }) {
if (priority === 'high') {
const ok = await askInSlack(`Close ${ticketId}?`)
if (!ok) throw new Error('not approved')
}
return ticketService.close(ticketId)
}
This works, and for a single tool in a single file, it's not even a bad idea. The trouble starts when it's not one tool in one file.
Where it breaks
- It doesn't survive a restart.
askInSlackis presumably waiting on a promise. If the process redeploys, crashes, or the request times out while a human is still deciding, the pending approval is gone — not denied, not logged, just gone. Nobody finds out until the ticket that should have closed didn't, or the refund that shouldn't have gone out did because the retry skipped the check. - The check drifts per tool. The next engineer adding a
deleteRepotool writes their own version of the same pattern, slightly differently — maybe they forget thethrow, maybe their Slack message doesn't include enough context for the approver to make a real decision. Multiply by however many write-capable tools the agent has, and "requires approval" stops being one property of the system and becomes N slightly different implementations of it. - There's no record of the denials. If
askInSlackreturnsfalse, you get a thrown error in a log, if you're lucky enough to have logging on that path. Six months later, "did anyone ever try to close a high-priority ticket without approval, and who said no" is not a query you can run — it's an archaeology project through Slack history. - The agent can't reason about the difference between "denied" and "broke." A thrown
Error('not approved')looks identical to a thrownError('slack API down')to whatever's catching it three layers up.
None of this means the naive version is wrong to reach for first. If you have one tool, one approver, and you're prototyping — the eight lines above are the correct amount of engineering. The problem shows up at the second tool, or the first incident review.
Making the pause a property of the tool call, not of the tool's code
The fix is to stop writing the approval check inside each tool function, and instead declare "this tool needs a human" once, as policy, and let one engine enforce it consistently. With nominee, an ask rule plus guard() does that for a plain function:
import { Nominee, ask } from 'nominee'
const nominee = new Nominee({
policy: {
rules: [
ask('closeTicket', { reason: 'closing a ticket needs a second pair of eyes' }),
],
fallback: 'deny',
},
receipts: { key: process.env.NOMINEE_RECEIPT_KEY },
onApprovalRequest: async (req) => {
// req.detail is the exact tool input the approver is being asked about
const approved = await notifySlackAndWaitForClick(req.action, req.detail)
approved ? req.approve() : req.deny()
},
})
async function closeTicket({ ticketId }) {
return ticketService.close(ticketId)
}
const tools = nominee.guard({ closeTicket }, { user: 'agent_ops' })
const result = await tools.closeTicket({ ticketId: 'tkt_991' })
guard() wraps the plain function — closeTicket doesn't know or care that it's guarded. The policy name is just the object key, so this works with any tool-calling setup: a hand-rolled agent loop, a queue worker, an MCP server, whatever you already have. Run it, and here's what actually happens — no mocking, the real package:
? approval requested: closeTicket { ticketId: 'tkt_991' }
result: { ticketId: 'tkt_991', status: 'closed' }
#0 action.planned closeTicket d28c3c573c10
#1 policy.decision closeTicket ask 571f2f62a479
#2 approval.requested closeTicket af8efbf4a436
#3 approval.resolved closeTicket approved bfe606a126b9
#4 capability.issued closeTicket b6654c50a9ad
#5 capability.consumed closeTicket f03bc6448d90
#6 execution.started closeTicket 9de6c6d70377
#7 execution.succeeded closeTicket succeeded 7d4326bd9e44
What changed relative to the inline version:
- The pause is declared once, as a rule keyed by tool name — not re-implemented inside every function that needs one.
- Denial is a first-class outcome, not an exception with a string message you have to pattern-match on.
req.deny()itself returnsvoidand settles the callback; the outer awaitedtools.closeTicket(...)call then rejects withApprovalDeniedError, which is where your agent loop should catch and branch. - Every decision is receipted — including the ask itself, before anyone answered it, and including a denial, if that's what happens.
closeTicketnever runs untilcapability.consumed— that's not a comment describing the intent, it's the literal order execution happens in.
This inline callback receipts the ask and the approve-or-deny decision, but not the approver's identity: the resolution is recorded as coming via callback. When “who approved?” must be answerable from the chain, prepare the action, persist its pending id, and resolve it later with resolveActionApproval(id, { decision, approver, via }). That is the durable pattern described below.
The part the eight-line version and this version both have to answer: what if nobody answers in time?
The example above resolves inline, in one call, which is honest about a real limit: it assumes the process handling the request is still alive when the human clicks approve. For a Slack round-trip that might take thirty seconds, that's often fine. For an approval that might take an hour, or that needs to survive a redeploy in between, blocking one call is the wrong shape — you want to hand back a pending id and resume later, from a webhook, in a different process. That's a distinct problem with its own answer: see durable approval across an agent restart.
When you don't need this
If you have exactly one gated tool, one approver, and the process handling the approval genuinely won't restart mid-flight — the naive if block at the top of this post is a reasonable, low-ceremony answer, and adding a policy engine for one check is not obviously worth it. This is worth reaching for once you have more than a couple of gated tools, once approval decisions need a consistent receipt trail, or once denial needs to be something your agent can reason about rather than an error string it happens to catch. Use the durable resolver, not the inline callback, when that trail must name the approver.
For the deeper argument on why a native SDK's approval hook (Vercel AI SDK's toolApproval, for instance) and a policy-enforcement layer are solving related but different problems, see the AI SDK can pause a tool call — it can't tell you who's allowed to make it.
See it for yourself
Run the ask-and-approve proof in 10 seconds.