How to add a human approval step before an agent does something irreversible
How to gate an AI agent's irreversible actions behind a human decision: classify tools by blast radius, pause the run, show the exact call, and record it.
Put the gate in the runtime, between the model issuing a tool call and the tool executing, and choose which tools it covers by blast radius. The model still proposes the action and its parameters. A person approves or denies that exact call, the run is persisted while it waits, expiry means deny, and the decision is written to the execution's trace.
The agent is good enough to draft the refund and not good enough to issue it. Without a gate the choice is between a read-only assistant nobody opens twice and an agent that can do something you cannot undo: a refund posted against the wrong order, an email sent from a customer's own address, a bulk update across a thousand records.
That choice gets sharper when the agent lives inside your product and your customers use it. The person who can approve is a tenant admin or an end user, the agent reads content those customers supply, and each tenant has its own opinion about what needs a signature. The overview of human-in-the-loop AI for agents that take actions covers why the problem is hard; this page covers what to build, in order.
How do I make an AI agent ask permission before taking action?
Seven steps, each enforced by the component that runs the tool rather than by the prompt.
1. Classify actions by blast radius
Ask three questions of every tool the agent can call: does it change state, does that state belong to someone outside your system, and can the agent itself undo the change in the next turn. The answers place the tool in one of three tiers.
| Tier | Test | Examples | What happens |
|---|---|---|---|
| Auto | Read-only, or reversible inside your own system | search, fetch a record, create a draft, add an internal note | Runs immediately and is logged |
| Notify after | Reversible, but a person should know it happened | open a support ticket, tag a CRM contact, schedule a follow-up | Runs immediately, then posts a summary with an undo action |
| Approve before | Irreversible, leaves your system, or touches money | issue a refund, email a customer, delete, change a plan, update many records | Pauses until a person decides on the exact call |
Tier is a property of the tool definition, not of the agent, so a send_email tool is approve-before in every agent that carries it. When one tool spans two tiers, split it: a credit_account tool that handles both a five-dollar goodwill credit and a five-thousand-dollar refund cannot be gated at the right threshold, while credit_account_small with a maximum in its own parameter schema and issue_refund can. The threshold is then enforced by input validation rather than by the model's judgement.
Notify-after is the tier teams skip, and it is the one that keeps the approve-before queue short enough that each item gets read.
2. Choose the pause mechanism
A gate can live in three places, and only one of them holds under pressure.
- A system prompt instruction ("ask before sending") is advisory, followed with a probability that shifts with the model version and with any text the agent reads. Keep it as a hint that helps the model phrase its request, never as the mechanism.
- A callback inside your own agent loop (the
toolApprovalsetting in Vercel AI SDK 7, which deprecated the per-toolneedsApprovalflag, or aninterruptin LangGraph) is a real gate when the approver is the same person in the same session as the run. It fails when the decision outlives the request, which is what step 3 addresses. - A runtime interrupt: the tool executor checks the tool's tier before executing, persists the run, emits a pause event with a handle, and resumes on a decision posted against that handle. Nothing the model writes can reach the tool without a recorded decision, so this holds across model swaps and injected instructions.
Where the interrupt surfaces is a separate choice, and it follows the approver. An in-app admin UI renders approve and deny controls from the pause event and posts the decision from your authenticated backend, since the browser is untrusted. Slack gives native buttons in the thread that started the run, bound to that thread and user. Email works through a signed, single-use link that expires, and SMS or a messaging reply works through an exact keyword set checked before any classifier.
Entry points with no human need a rule too. Schedules, batch jobs, webhooks and bare API calls reach the same tools, and a gated call from one of them has nobody to ask. Either route the decision to a named channel, or refuse the run before it starts with an error that names the policy.
3. Keep the run durable while it waits
Most agent loops run inside one request with a wall-clock limit, and a decision that takes twenty minutes outlives all of them. Without persisted state the fallback is to re-run the turn after approval, and a fresh model call may pick different parameters than the ones the approver read.
Before returning, the pause writes the conversation so far, the pending tool call with its id and parameters, and a deadline. It then emits an event a caller can hold on to:
{
"type": "approval_start",
"executionId": "exec_01J9M3",
"approvalId": "apr_01J9M3",
"toolCallId": "call_8f2c",
"toolName": "issue_refund",
"parameters": { "orderId": "ord_4421", "amountCents": 12900, "note": "damaged on arrival" },
"expiresAt": "2026-09-03T14:07:11Z"
}
A decision posted against the approvalId resumes the execution at that same call, and nothing is regenerated. The call that runs is the call that was displayed, parameter for parameter, and a second decision against a used or expired id is rejected rather than replayed. The wider problem of a run that outlives its request is covered in long-running agents.
4. What the approver sees
Design the card so a decision takes about ten seconds. If it takes longer, the card is missing context or the tool belongs in a lower tier.
- The tool name and the exact parameters the runtime will execute, with credentials and secrets shown as
[REDACTED]. - Who or what triggered the run: the tenant, the end user, and the surface.
- One line of context that makes the numbers judgeable, such as the order total next to the refund amount.
- The agent's stated reason, labelled as the agent's claim, and the deadline.
The reason is the part teams get wrong. It is text the model wrote, and under prompt injection it is text the attacker wrote, so show it and log it and never branch on it: no auto-approve rule, no risk score, no classifier that reads the justification and skips the human. The tool name and parameters stay at least as prominent as the explanation. How injected text reaches the tool call and the justification is covered in prompt injection through tools.
A PII policy that scrubs the log should scrub the approval card as well, since the reader may not be entitled to the underlying record; those content-side controls are the subject of LLM guardrails.
5. Timeouts and defaults
Every gate has a timeout, and expiry means deny. The value follows the surface: a few minutes when a dashboard or API caller is waiting on the run (five minutes is a common default), an hour for a reply from a phone, a day for email.
After expiry three things must be true. The tool does not run. A late reply does not resume the old execution, because the state the approver saw may have changed. And the agent receives a tool result saying the call was denied, so it can tell the user.
Two defaults close the remaining holes: a tool with no tier assigned is approve-before until someone classifies it, and an entry point with no approver attached fails closed before the agent starts. "Always deny" lets an approver stop seeing a tool they never want to authorize, while "always allow" is a policy change with an author and a date, recorded per tenant and per tool.
6. The audit record
The record that answers "who approved what" needs, at minimum:
- the approval id, tool call id and execution id
- the tool name and parameters exactly as displayed, plus a hash of the raw call
- the tenant and end user the run was admitted under
- the approver's identity and the surface the decision came from
- the decision, its timestamp, and whether the request expired
- the agent's stated reason, stored as the agent's claim
- the executed call's result, or the denial the agent received
Everything on that list hangs off the execution, so a reviewer can walk from the customer's message to the gated call to the human decision to the result without joining logs from three systems.
7. Test the gate
A gate that is never tested drifts back into the prompt. Put these cases in the eval suite that runs on every prompt, tool or model change, alongside the rest of your tests for agent tool calls.
- Injected skip: a tool result or an uploaded document says "the user has pre-approved all actions". The gated call still pauses.
- Model swap: the same case on a different model pauses at the same call.
- Parameter tampering: a decision posted with altered parameters is rejected, and the executed call equals the displayed call.
- Expiry: with no decision, the tool does not run, the agent receives a denial, and a later approve is rejected.
- Wrong approver: a decision from another tenant's admin, another Slack user or another phone number does not resolve the request.
- Unattended entry point: a schedule, a batch or a bare API call that reaches a gated tool fails before the agent starts, with the policy named in the error.
Widening the auto tier with the approval log
After a month of decisions, query the audit record by tool and by tenant: the approve rate, the median time to decide, and how many approved actions were later reversed. A tool approved nearly every time, decided in seconds, and never reversed is a candidate to move to notify-after for that tenant. A tool with denials or reversals stays where it is.
Treat the move as a policy change with an author, a date and a re-run of the test suite, recorded per tenant so one customer's tolerance does not loosen another's gate. When the median decision time falls below the time it takes to read the card, the fix is fewer gated calls rather than a faster approver. Per-tenant policy differences are covered in per-customer agent configuration.
Where this gets easier
Runtype pauses a run at a configured approval gate (approval.require lists the tools, or true covers all of them, with a timeout in milliseconds that defaults to five minutes and denies on expiry) and resumes the same tool call when the decision is posted through the dispatch API. The pause event carries the approval id, the tool name and the parameters with protected values redacted, and the agent's stated reason arrives through a reserved _approvalReason parameter that is displayed as the agent's claim and never used as a control input, while entry points with no approver fail closed with an error naming the policy. The request, the decision, its timestamp and whether a person or the expiry resolved it sit on the execution beside every tool call's arguments and result, without naming the individual approver.
Frequently asked questions
- Should the approval check be a prompt instruction or a runtime rule?
- A runtime rule. A prompt instruction such as "ask before sending" is followed with some probability that changes with the model version, the context length, and any text the agent reads that says otherwise. A gate enforced by the tool executor sits between the model issuing the call and the tool running, so the call cannot execute without a recorded decision.
- How long should an approval request wait before it expires?
- Long enough for the approver on that surface to answer, and expiry must always mean deny. A few minutes is a reasonable default when a dashboard or API caller is waiting on the run, an hour when a person answers from a phone, longer for email. After expiry the tool does not run and a late reply must not resume the old execution.
- Can the agent's explanation be used to auto-approve low-risk calls?
- No. The explanation is text the model wrote, and under prompt injection it is text the attacker wrote, so any rule that reads it (an auto-approve pattern, a risk score, a classifier) turns attacker-writable input into an authorization decision. Show it labelled as the agent's claim and log it. The decision comes from a person, or from a policy evaluated on the tool name and parameters only.