Approval gates for agent tool calls: where to put the human in the loop
How OpenAI's Agents SDK, the Claude Agent SDK, LangGraph and MCP let an agent pause for a person before risky tool calls, and how to choose which calls need a gate, what the reviewer sees, and how to resume safely.
Some links are affiliate links; we may earn a commission at no extra cost to you. As an Amazon Associate I earn from qualifying purchases. Disclosure
Most agent failures that actually hurt are not wrong answers. They are wrong actions: an email that went to the wrong person, a refund issued twice, a file deleted because a web page told the model to. The fix that every major agent framework now ships is the same basic idea, a gate that pauses the loop before a risky tool call runs and waits for a person to approve, reject, or edit it. This article walks through how that gate works in four places builders actually use (OpenAI's Agents SDK, the Claude Agent SDK, LangGraph, and the Model Context Protocol), then covers the design questions the docs leave to you: which calls deserve a gate, what the reviewer should see, and how to resume a paused run without creating new problems.
If you have read our piece on handling tool errors in agent loops, you will notice an overlap. A rejected approval is, from the model's point of view, just another tool result it has to cope with, and the same rules about clear, actionable messages apply.
Why a gate, and why not everywhere
The clearest statement of the problem comes from OWASP. Its entry LLM06:2025 Excessive Agency describes the vulnerability as "damaging actions" performed "in response to unexpected, ambiguous or manipulated outputs from an LLM," and traces the root cause to three things: excessive functionality, excessive permissions, and excessive autonomy. Its example of excessive autonomy is an extension that deletes a user's documents "without any confirmation from the user." Among the mitigations it lists is to "require a human to approve high-impact actions before they are taken," with the specific example of a social media app whose "post" operation includes a user approval routine.
Notice that approval is only one item on OWASP's list. The same page tells you to give the agent fewer tools, narrower tools, and narrower credentials, and to enforce authorization in the downstream system "rather than relying on an LLM to decide if an action is allowed." Its worked attack is an email summarizer whose plugin can also send mail; an injected message tricks it into forwarding sensitive data. OWASP's fixes come in order: use a read-only mail extension, authenticate with a read-only OAuth scope, and only then, as an alternative, require the user to "manually review and hit 'send' on every mail drafted." In other words, the best gate is often a tool the agent never had. An approval prompt is what you add for the actions you genuinely need the agent to take but cannot afford to have it take wrongly.
The protocol layer says the same thing. The current MCP tools specification states that "there SHOULD always be a human in the loop with the ability to deny tool invocations," and that applications should present confirmation prompts for operations. "SHOULD" leaves room for judgment, and that judgment is the real design work.
How the OpenAI Agents SDK does it
OpenAI's Python Agents SDK has the most explicit version of the pattern. Its human-in-the-loop guide lets you mark a tool with needs_approval=True, or pass an async function that decides per call based on the parsed arguments. The guide's own example gates a send_email tool only when the subject mentions a refund, which is a good model for real systems: the gate is about the specific action, not the tool name.
When a call needs approval, the run pauses and the result carries interruptions, a list of ToolApprovalItem entries with the agent name, tool name and arguments. You convert the result to a RunState, call state.approve(...) or state.reject(...), and resume with Runner.run(agent, state). Two details are worth knowing before you build on it. First, the callable rules "fail closed": if the model's arguments are missing, malformed JSON, or not an object, the SDK skips your function and requires manual approval. That is the correct default, and if you write your own gate you should copy it. Second, approvals can be "sticky." Passing always_approve=True persists the decision for later calls to the same tool, and the docs warn that for the local shell and apply-patch tools, sticky decisions match by tool type and name rather than by owning agent, so an always-approve can carry over to another agent's shell tool after a handoff. If different agents have different permissions, the guide recommends per-call decisions instead.
Rejections matter too. By default the model gets the SDK's standard rejection text, but you can set a run-wide tool_error_formatter or pass a rejection_message to a single state.reject(...) call. Use that. "The reviewer denied this refund because the order is outside the 30-day window" lets the model explain itself to the user or pick a different path; a generic refusal tends to produce a retry of the same call.
How the Claude Agent SDK does it
Anthropic's Claude Agent SDK permissions guide frames the same problem as an evaluation order. When Claude requests a tool, the SDK runs hooks first, then deny rules, then ask rules, then the active permission mode, then allow rules, and only if none of those settled the call does it invoke your canUseTool callback. The companion page on handling approvals and user input shows the callback returning either an allow, optionally with modified input through updatedInput, or a deny with a message, and notes that on a deny "Claude sees the message."
The trap the docs call out loudly is that "auto-approved tools never reach canUseTool." If you list a tool bare in allowedTools or run in bypassPermissions mode, any check you put in the callback is silently skipped for that tool. The guide also warns that allowed_tools "does not constrain bypassPermissions": listing only Read alongside that mode still approves Bash, Write and Edit. For a check that must run on every call, the docs point you to a PreToolUse hook, which runs before everything else and whose deny applies even in bypass mode. Deny rules are similarly robust. A scoped rule like Bash(rm *) is enforced "in every permission mode, including bypassPermissions."
The practical lesson generalizes beyond Claude: know exactly where in your stack the approval decision is made, and make sure nothing upstream of it can approve a call first. A gate that can be bypassed by a convenience setting is not a gate.
How LangGraph does it
LangGraph treats approval as one use of a general pause primitive. Its interrupts documentation has you call interrupt() inside a node or a tool with any JSON-serializable payload. The graph saves its state through a checkpointer, keyed by a thread_id, and "waits indefinitely" until you resume with Command(resume=...), whose value becomes the return value of the interrupt() call. The docs show a send_email tool that interrupts with the recipient, subject and body, then lets the resume payload override any of those fields before sending. That is the approve-and-edit pattern, and for drafted content it is often more useful than a plain yes or no, because a reviewer can fix a small mistake without throwing the whole action away.
The important caveat is how resume works. LangGraph restarts the whole node from the beginning, so "any code that ran before the interrupt will execute again." The docs therefore say side effects placed before an interrupt must be idempotent, and give the example of an audit-log insert that would create duplicate records on every resume. They also warn against wrapping interrupt() in a bare try/except, because the pause works by raising an exception, and against reordering or conditionally skipping interrupt calls within a node, since resume values are matched by index. If your gate sits in front of a payment or a write, put the interrupt first and the side effect after it, or in a separate node.
How MCP handles asking the user
MCP's mechanism is slightly different, because the server is not the one running the loop. The current elicitation specification lets a server ask the user for input through the client. In form mode it sends a flat JSON schema, and the user can accept, decline, or cancel. The spec says servers "MUST NOT" use form mode for sensitive information such as passwords, API keys, access tokens or payment credentials, and must use URL mode for those, which sends the user to an external page so the secret never passes through the client. Clients in turn must show which server is asking, let users review and modify form responses, and display the target domain and get consent before opening a URL.
Elicitation is a good way for a server to confirm something it knows is consequential, such as "Delete 412 rows from orders?", but it does not replace a client-side gate. The MCP schema's tool annotations include readOnlyHint, destructiveHint, idempotentHint and openWorldHint, and they are tempting inputs for an approval policy. The schema is blunt that these are hints, "not guaranteed to provide a faithful description of tool behavior," and that clients "should never make tool use decisions based on ToolAnnotations received from untrusted servers." A reasonable policy uses them to tighten gates (prompt when a trusted server marks a tool destructive) but never to loosen them for a server you do not control. Note also the defaults: destructiveHint defaults to true and readOnlyHint to false, so a tool with no annotations should be treated as one that can change things.
Deciding which calls get a gate
Across all four systems, the mechanics are easy; the policy is the hard part. Gate too little and you have the OWASP failure. Gate too much and reviewers start clicking approve without reading, which can be worse than no gate because it looks like oversight.
A workable approach is to sort actions by what happens if they go wrong rather than by tool. Reads of the user's own data rarely need approval. Writes that are easy to undo, like creating a draft or adding a label, usually do not either. Actions that leave your system and cannot be recalled, such as sending a message, publishing, paying, or deleting, are the ones that earn a prompt, and so are actions whose arguments came from untrusted content like a fetched web page or an incoming email. The OpenAI example of gating send_email only for refund subjects is this idea in miniature: same tool, different risk, different gate. When the gate is a function, make it fail closed the way the Agents SDK does.
Then design the prompt for the reviewer. Show the exact arguments that will run, not the model's summary of them, and escape them before rendering, since tool names and arguments are model output. OpenAI's guide says to "treat tool names and arguments as untrusted display content and escape them when rendering HTML." Offer edit as well as approve and reject where the action is a draft. And when the answer is no, send a specific reason back to the model.
Resuming safely
Long-running approvals mean persisted state, and persisted state is an attack surface. OpenAI's guide is the most detailed on this: RunState.from_json() "does not authenticate the snapshot or the person submitting it," so keep the full snapshot on the server, send the reviewer only what they are allowed to see plus opaque identifiers, authenticate the reviewer from your own session rather than the request body, check that the decision matches a pending item you stored, and make consumption of each pending request atomic so a replayed or concurrent submission cannot resume the same run twice. It also suggests storing a version marker with paused state, because an approval may sit for days while your prompts or tools change underneath it.
LangGraph's version of the same rule is to use a durable checkpointer in production and to keep side effects idempotent, since resume re-runs code. Put together, the checklist is short: a paused run is a record you own, a decision is a request you verify, and the action behind the gate should be safe to attempt exactly once.
The short version
An approval gate is the last line of defense, not the first. Start by removing tools and permissions the agent does not need, as OWASP recommends. For the actions that remain and cannot be undone, pause before they run, show the reviewer the real arguments, let them edit, return a clear reason on rejection, and treat the paused state as something an attacker might try to replay. Every framework covered here supports that pattern today; the differences are mostly in where the decision is made and what can bypass it, and those are the parts worth reading twice in your framework's docs.
Human-in-the-loop gates are a production control, not a model feature. Designing Machine Learning Systems by Chip Huyen frames the same idea for ML systems generally: put irreversible actions behind checks outside the model, and design the review surface so a person can decide quickly.
If you run models on your own machine, we keep a short list of local LLM gear — GPUs, Macs, RAM and storage — with plain-text Amazon links.