← Blog

How to require approval before
an AI agent's tool call.

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. askInSlack is 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 deleteRepo tool writes their own version of the same pattern, slightly differently — maybe they forget the throw, 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 askInSlack returns false, 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 thrown Error('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 returns void and settles the callback; the outer awaited tools.closeTicket(...) call then rejects with ApprovalDeniedError, 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. closeTicket never runs until capability.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.

$ npx nominee-cli proof runs offline after npx installs the package
Star on GitHub Read the approval docs → Read the docs →