Key takeaways
An approval prompt is not authorization. Unless the service that moves money can tell an approved call from an unapproved one, your gate is a convention inside your own process and anything that hijacks the model walks straight past it.
Gate rate is the dial that matters. With 31 percent of turns gated, my reviewers decided 96 percent of requests in under 10 seconds and rejected 0.4 percent. At 4 percent gated they rejected 12 percent. Same people, same tools, different amount of noise.
Pausing is the easy half. Both LangGraph and the OpenAI Agents SDK will suspend a run and serialise it for you. Neither will notify a human, expire a stale request or escalate it, and that missing layer is where the engineering actually is.
Who is on the hook when it refunds the wrong customer. Our head of support asked exactly that, nine minutes into a demo, and it ended the meeting. I had a confident answer for how the assistant would decide to issue a refund and no answer at all for who had signed off on the decision. Approval gates are what I built that fortnight, and almost everything I learned was that suspending a run is trivial and every single thing wrapped around the suspension is not.
Where the assistant stands, and what a gate adds
Last part weighed multi agent patterns and concluded that a second agent buys coordination cost well before it buys capability, so our documentation and support assistant stayed one loop with five tools and the durable state Part 17 gave it. This part deals with an awkward fact I have been stepping around since Part 14: two of those five tools change something outside our process. One escalates a ticket and pages an on call engineer. One issues a refund.
Reading tools are forgiving. A bad retrieval produces a wrong answer that a support engineer notices and corrects. Writing tools are not forgiving, because a wrong refund is money that has left, and a spurious page at 03:00 costs you goodwill you cannot buy back. Volume made the decision easy for me. Across 2,400 assistant runs in a month, 6.2 percent touched a write tool at all, which means gating every write costs a pause on roughly one run in sixteen. That ratio is why the argument about whether gates slow the product down mostly evaporates once you measure it rather than imagine it.
Risk tiers and which actions need a gate
OWASP files this problem under excessive agency, and splits it usefully into three separate root causes: excessive functionality, where an agent can reach tools beyond its task; excessive permissions, where those tools run with broader rights than the task needs; and excessive autonomy, where a high impact action executes with no human involved. Worth noticing that only the third one is fixed by an approval gate. If your refund tool holds credentials that can refund any account in the estate, a gate on that tool is a plaster over a permissions problem, and I have watched a team spend a sprint on approval UX when twenty minutes of scoping a service account would have done more.
Below is the lookup I keep in the design doc. Every tool gets a tier before it gets an implementation, and the tier fixes the gate, the reviewer and the expiry without further debate.
| Tier | Example in the assistant | Gate | Reviewer and expiry |
|---|---|---|---|
| 0 read only | search_docs, get_ticket_status | None | Trace only |
| 1 reversible, internal | add_internal_note | None, logged | Daily digest to the team channel |
| 2 externally visible, reversible | escalate_ticket, email_customer | Async, auto approve inside policy | Any duty engineer, 30 min expiry |
| 3 irreversible or monetary | issue_refund, close_account | Always, signed single use token | Named approver, 15 min expiry |
| 4 bulk, over 5 records | bulk_refund, bulk_close | Always, two approvers | Two named approvers, 15 min expiry |
Tier 4 exists because of an incident that was not ours. Bulk operations deserve their own row for a boring reason: an agent asked to fix a billing problem for a customer will occasionally decide the fix applies to a cohort, and a single approval click covering 400 refunds is a very different act from one covering a single refund, even though your UI probably renders them identically.
Approval gates in the OpenAI Agents SDK
Mechanically this is a small amount of code. Mark a tool as needing approval, run the agent, and rather than executing that tool the runner suspends and hands you a list of pending items. Note that needs_approval accepts a callable as well as a boolean, which is how policy such as a value threshold gets expressed without a second system.
Now the half that people skip. A paused run has to survive the wait, because a reviewer is a human being who is at lunch. Converting the result to a RunState and serialising it gives you something you can park in Postgres and rehydrate in a completely different process, which is the difference between an approval feature and a demo.
Thirty four kilobytes per parked run is worth internalising before you design the table. At our volumes that is nothing, but a team gating a chatty agent at a few hundred thousand runs a day is storing real data, and I would set a retention policy on it from day one rather than after the disk alert. RunState carries your application context too, so treat the blob as persisted data and keep secrets out of the context object.
Here is a failure that cost me most of an afternoon, and it is embarrassing precisely because it is not subtle. RunState.from_json is a coroutine. Forget to await it and nothing complains at the point of the mistake; you get a coroutine object that happily accepts an attribute lookup and then does not.
Reason it stung is that our approval worker caught the AttributeError, logged it as a transient error and retried, and the retry failed identically. Nineteen approvals piled up before anyone read the log. Any code path that handles a human decision deserves a loud, unswallowed failure, because a silently broken approval worker looks exactly like a quiet afternoon.
Same gate in LangGraph, with durable state
LangGraph reaches the same place from a different direction, through middleware over a checkpointed graph. What you gain is that persistence is the framework’s problem rather than yours, provided you supply a checkpointer; without one, an interrupt has nowhere to store the paused graph and the pattern simply does not work. What you also gain is two decision types the Agents SDK does not offer, edit and respond.
Notice that issue_refund is restricted to approve and reject while escalate_ticket permits edit. That asymmetry is deliberate and I would defend it hard. Editing a tool call rewrites what the model asked for, and the LangChain documentation itself warns that substantial edits can make the model re-evaluate its approach and run tools again or take unexpected paths. For a ticket escalation, a reviewer correcting the target team is useful and cheap to undo. For money, an edited amount means the thing that executes is no longer the thing anyone reasoned about, and the audit trail now has to explain a number that neither the model nor the policy produced. Approve or reject, and if the amount is wrong, reject with the reason and let the model propose again.
What the docs undersell
Both SDKs document the pause and the resume beautifully and say almost nothing about the interval between them. There is no built in notification, no timeout, no escalation and no expiry anywhere in either surface. A paused run waits indefinitely by default, which in practice means until someone files a support complaint. Budget a week for the queue, the notifier, the expiry sweeper and the audit table, because none of it comes in the box.
Approval fatigue and auto approval rules
Our first policy gated everything that was not a plain read, which put a gate in front of 31 percent of turns. It felt responsible. Measurement said otherwise. I instrumented every decision with the time between the reviewer seeing the request and clicking, and then walked the gate rate down over six weeks by moving tiers and adding auto approval rules.
Two numbers from that chart changed how I design gates. At 31 percent gated, 96 percent of decisions landed in under 10 seconds and 0.4 percent were rejections, which is a reviewer clearing a queue rather than exercising judgement. At 4.7 percent gated, median time to decide was 40 seconds and 12 percent were rejections. Nothing about the reviewers changed. What changed was whether the request arriving in their notification carried any information, and a request that arrives every few minutes carries none.
Three auto approval rules did most of that reduction. Refunds under 50 USD execute without a pause and land in a daily digest, which is the callable in the first code sample. Escalations to the team that already owns the ticket auto approve inside business hours and gate outside them. Anything touching more than five records always gates regardless of value. Cost of the first rule is bounded and visible, roughly 380 USD a month of ungated goodwill refunds, against a reviewer cost we were paying in attention rather than currency and could not see at all.
Failure modes of an approval gate
My worst version of this was not insecure, it was silent. Version one wrote pending approvals into Postgres and rendered them on an internal page, which is a perfectly reasonable design if anyone has bookmarked the page. Nobody had. Forty one approvals accumulated over six days, median time to decision was 4.1 hours and p95 was 31 hours. One refund request sat for five days, by which point the customer had already opened a chargeback, which cost us the disputed 240 USD plus a 15 USD fee plus a support thread. Fixing it took an afternoon: post the request into the team channel with approve and reject buttons and stamp a 15 minute expiry on it. Median time to decision fell to 90 seconds.
Beyond the silent queue and the rubber stamping already covered, two failures matter more than they get credit for. Stale approval is where a reviewer approves a refund of 240 USD and something between the click and the execution changes the arguments, whether through a retry, a rehydrated state from an older code path, or an agent that re-proposes the call after new context arrives. Bypass is worse: an approval decision that lives only in your agent process is advisory, so a model steered by a bad instruction it read in a document can reach the same endpoint through a different, ungated tool and nothing downstream notices.
Both are closed by the same move, which is to make approval a credential rather than a flag. Mint a short lived single use token that commits to the exact arguments, and have the refund service refuse anything without one. Below is roughly what runs on our side, trimmed to the parts that matter.
Swap 240 for 2400 while reusing a token minted for 240 and you get PermissionError: approval does not match these arguments, which is the stale approval case closed. Note also that _spent is an in process set purely to keep the sample short; in production that is a row with a unique constraint on jti, because a set does not survive a restart and two replicas do not share one.
Gate the write at the service, not in the agent
If you take one thing from this part, put the enforcement point outside the agent process. Use the SDK to pause, to serialise and to collect the decision, because both frameworks do that well and you should not rebuild it. Then have the reviewer’s decision mint a credential that the downstream service independently verifies, so that a call arriving without a valid, unspent, argument bound token is refused no matter which tool, which agent or which prompt produced it. Pattern I would steer you away from is the tempting one, where needs_approval is set to True on the sensitive tools and the team calls the problem solved. That configuration is genuinely useful and it is also entirely advisory, and the distinction never shows up in testing because your tests do not contain an adversary. Part 24 takes up that adversary properly.
One thing to do on Monday: list every tool your agent can call, mark the ones that change state outside your process, and for each one ask whether the receiving service could tell an approved call from an unapproved one if it were handed both. If the answer is no anywhere, you do not have an approval gate there, you have a prompt that asks nicely. Part 20 turns next to building the eval set, which is the thing that tells you whether any of this machinery actually improved outcomes, and doing it before you widen tool permissions is the cheaper order. Part 14 on tool calling and Part 15 on agent loops remain the mechanics underneath all of it.
References
- Human in the loop, OpenAI Agents SDK documentation, for needs_approval, RunResult.interruptions, RunState serialisation and the approve, reject and rejection_message surface used above.
- Human in the loop, LangChain documentation, for HumanInTheLoopMiddleware, interrupt_on, the four decision types and the warning about editing tool arguments.
- LLM06 Excessive Agency, OWASP Top 10 for LLM Applications, for the split into excessive functionality, permissions and autonomy, and for human approval of high impact actions as a mitigation.


DrJha