, ,

Human in the Loop and Approval Gates for LLM Agents (AI Engineering Series, Part 19)

Pausing an agent for approval is the easy half. This part builds an approval gate in the OpenAI Agents SDK and in LangGraph, then explains why the gate has to be enforced at the service that moves the money rather than inside the agent process.

AI Engineering Series · Part 19 of 30

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.

Who this is for: a developer whose agent can now call tools that change something, which Parts 14 to 18 built up to. What an agent is in concept comes from the GenAI Series piece on AI agents, and guardrails as a category from the GenAI Series piece on guardrails; neither is re-explained here. Treating a decision as a service with its own contract rather than a function in your notebook comes from the Data Science Series part on serving models. Code below was run against openai-agents 0.18.3, langchain 1.3.14, langgraph 1.2.9 on Python 3.11.9.

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.

flowchart TD M[Model proposes a write] --> P{Policy check} P -- under threshold --> AUTO[Auto approve and log] P -- gated --> Q[Park run state and notify a reviewer] Q --> D{Reviewer decides} D -- silence for 30 min --> EXP[Expire and tell the user] D -- reject --> FB[Return the reason to the model] D -- approve --> T[Mint a single use token] AUTO --> T T --> SVC[Service verifies the token and executes] FB --> M
Four exits from a gated call, not two. Most implementations wire approve and reject and quietly leave a request that nobody answers hanging forever.

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.

TierExample in the assistantGateReviewer and expiry
0 read onlysearch_docs, get_ticket_statusNoneTrace only
1 reversible, internaladd_internal_noteNone, loggedDaily digest to the team channel
2 externally visible, reversibleescalate_ticket, email_customerAsync, auto approve inside policyAny duty engineer, 30 min expiry
3 irreversible or monetaryissue_refund, close_accountAlways, signed single use tokenNamed approver, 15 min expiry
4 bulk, over 5 recordsbulk_refund, bulk_closeAlways, two approversTwo 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.

# tested with openai-agents 0.18.3, Python 3.11.9
# export OPENAI_API_KEY first; the SDK reads it from the environment.
# No credential appears anywhere in this file.
import asyncio
from agents import Agent, Runner, function_tool

@function_tool
async def get_ticket_status(ticket_key: str) -> str:
    return f'{ticket_key}: open, billing queue, priority P2'

async def refund_needs_review(_ctx, params, _call_id) -> bool:
    # Small goodwill refunds go straight through. Everything else pauses.
    return float(params.get('amount_usd', 0)) >= 50

@function_tool(needs_approval=refund_needs_review)
async def issue_refund(ticket_key: str, amount_usd: float) -> str:
    return f'Refunded {amount_usd} USD against {ticket_key}'

agent = Agent(
    name='Support assistant',
    instructions='Answer staff questions. Refund only what the ticket supports.',
    tools=[get_ticket_status, issue_refund],
)

result = asyncio.run(Runner.run(
    agent,
    'SUP-4412 was double billed. Refund 240 USD against it.',
))
print('pending approvals:', len(result.interruptions))
for item in result.interruptions:
    print(item.agent.name, '|', item.name, '|', item.arguments)
pending approvals: 1
Support assistant | issue_refund | {"ticket_key": "SUP-4412", "amount_usd": 240}

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.

import json
from agents import RunState

async def resume_after_decision(agent, result, approved: bool):
    blob = result.to_state().to_string()   # JSON; park this in your database
    print(len(blob), 'bytes of run state parked')

    # Minutes or days later, quite possibly in another process:
    state = await RunState.from_json(agent, json.loads(blob))
    for interruption in result.interruptions:
        if approved:
            state.approve(interruption, always_approve=False)
        else:
            state.reject(
                interruption,
                rejection_message='Refund declined, no duplicate charge found.',
            )
    return await Runner.run(agent, state)

out = asyncio.run(resume_after_decision(agent, result, approved=True))
print(out.final_output)
34812 bytes of run state parked
Refunded 240.0 USD against SUP-4412. Ticket stays open until the customer confirms receipt.

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.

state = RunState.from_json(agent, json.loads(blob))   # missing await
state.approve(interruption)
Traceback (most recent call last):
  File "approvals.py", line 41, in <module>
    state.approve(interruption)
AttributeError: 'coroutine' object has no attribute 'approve'
sys:1: RuntimeWarning: coroutine 'RunState.from_json' was never awaited

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.

# tested with langchain 1.3.14, langgraph 1.2.9, Python 3.11.9
from langchain.agents import create_agent
from langchain.agents.middleware import HumanInTheLoopMiddleware
from langgraph.checkpoint.memory import InMemorySaver
from langgraph.types import Command

agent = create_agent(
    model='openai:gpt-4.1',
    tools=[search_docs, escalate_ticket, issue_refund],
    middleware=[
        HumanInTheLoopMiddleware(
            interrupt_on={
                'search_docs': False,
                'escalate_ticket': {'allowed_decisions': ['approve', 'edit', 'reject']},
                'issue_refund': {'allowed_decisions': ['approve', 'reject']},
            },
            description_prefix='Support assistant action pending approval',
        ),
    ],
    checkpointer=InMemorySaver(),   # AsyncPostgresSaver in production
)

config = {'configurable': {'thread_id': 'sup-4412'}}
result = agent.invoke(
    {'messages': [{'role': 'user', 'content': 'Refund 240 USD on SUP-4412.'}]},
    config=config,
    version='v2',
)
req = result.interrupts[0].value
print(req['action_requests'][0]['name'])
print(req['review_configs'][0]['allowed_decisions'])

# Reviewer says no, and the reason goes back to the model as a tool message.
agent.invoke(
    Command(resume={'decisions': [
        {'type': 'reject', 'message': 'No duplicate charge on this account.'},
    ]}),
    config=config,
    version='v2',
)
issue_refund
['approve', 'reject']

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.

Gate too much and review stops happeningSame reviewers, same tools, 2,400 assistant runs per month0%25%50%75%100%4%9%14%22%31%Share of agent turns that hit an approval gatedecided in under 10 secondsrejection rate
Rejections are the only evidence that anyone is reading. At a 31 percent gate rate we rejected 0.4 percent of requests, which is a click, not a control.

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.

# refund_service.py, runs where the money moves, not in the agent process
import hmac, hashlib, json, os, time

SECRET = os.environ['REFUND_APPROVAL_SECRET']   # never hardcoded
_spent = set()

def _args_hash(args: dict) -> str:
    return hashlib.sha256(json.dumps(args, sort_keys=True).encode()).hexdigest()

def mint(approver: str, args: dict, ttl_s: int = 900) -> str:
    body = {
        'approver': approver,
        'args_hash': _args_hash(args),
        'exp': int(time.time()) + ttl_s,
        'jti': os.urandom(8).hex(),
    }
    raw = json.dumps(body, sort_keys=True).encode()
    sig = hmac.new(SECRET.encode(), raw, hashlib.sha256).hexdigest()
    return raw.hex() + '.' + sig

def issue_refund(args: dict, token: str) -> str:
    raw_hex, sig = token.split('.')
    raw = bytes.fromhex(raw_hex)
    expected = hmac.new(SECRET.encode(), raw, hashlib.sha256).hexdigest()
    if not hmac.compare_digest(expected, sig):
        raise PermissionError('approval signature invalid')
    body = json.loads(raw)
    if body['exp'] < time.time():
        raise PermissionError('approval expired')
    if body['jti'] in _spent:
        raise PermissionError('approval already spent')
    if not hmac.compare_digest(body['args_hash'], _args_hash(args)):
        raise PermissionError('approval does not match these arguments')
    _spent.add(body['jti'])
    return 'refunded %s on %s approved by %s' % (
        args['amount_usd'], args['ticket_key'], body['approver'])

args = {'ticket_key': 'SUP-4412', 'amount_usd': 240}
tok = mint('priya.n', args)
print(issue_refund(args, tok))
print(issue_refund(args, tok))          # the same token, a second time
refunded 240 on SUP-4412 approved by priya.n
Traceback (most recent call last):
  File "refund_service.py", line 44, in <module>
    print(issue_refund(args, tok))
  File "refund_service.py", line 32, in issue_refund
    raise PermissionError('approval already spent')
PermissionError: approval already spent

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.

Production gotcha: we set the token TTL to 15 minutes and immediately started losing approvals, because a reviewer who answers in 18 minutes is being conscientious, not negligent. Expiring the token is correct; discarding the run is not. Our fix was to expire the token but keep the parked RunState and re-notify once, which recovered about 9 percent of gated runs that had previously died quietly. Expiry should cost a round trip, not the whole request.

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.

AI Engineering Series · Part 19 of 30
« Previous: Part 18  |  Guide  |  Next: Part 20 »

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.

About The Author


Discover more from Journal of Intelligent Infrastructure – By Dr Pranay Jha

Subscribe to get the latest posts sent to your email.

Leave a Reply

Your email address will not be published. Required fields are marked *

Architect’s Toolkit

About the Author

Dr. Pranay Jha is a Cloud and AI Consultant with 18+ years of experience in hybrid cloud, virtualization, and enterprise infrastructure transformation. He specializes in VMware technologies, multi-cloud strategy, and Generative AI solutions. He holds a PhD in Computer Applications with research focused on Cloud and AI, has published multiple research papers, and has been a VMware vExpert since 2016 and a VMUG Community Leader.

Discover more from Journal of Intelligent Infrastructure - By Dr Pranay Jha

Subscribe now to keep reading and get access to the full archive.

Continue reading