An accounts-payable agent reads an invoice email and calls
wire_transfer(to_account="ACME-9921", amount=18500).
The tool is authorized — finance agents exist to move money — so a privilege broker rightly lets it through. The question is not may it. It is should it, this time: is that invoice real?
The broker cannot know. A human can, in about four seconds.
TL;DR
The naive fix — “make the agent ask in the chat” — fails twice. The person in the chat is often not the person with authority, and a run that blocks a thread waiting for a CFO is a run that times out and retries.
So: pause the run, don’t block it. The gate returns PAUSE, the harness parks
the run, and the request lands in a queue with a fingerprint of
(principal, tool, arguments). A human decides out of band. When the run resumes
and the model proposes the same action, the fingerprint matches the verdict.
The property that makes this trustworthy: the human approved an action, not a session. Approve $18,500 and the model comes back asking for $48,500 — the fingerprint misses and the gate pauses again.
OWASP: ASI02, ASI05, and ASI09 (Human-Agent Trust Exploitation) — the gate is where over-trust gets a checkpoint, and also where approval fatigue becomes the attack surface.
Runnable code:
patterns/governance/hitl_approval_gate/
The cycle, as program output
uv run python -m patterns.governance.hitl_approval_gate.demo
=== WITHOUT the pattern: $18,500 moves on the model's say-so ===
final: Done — I transferred $18,500 to ACME-9921 to settle the invoice.
=== WITH the pattern: the run pauses on a fingerprinted request ===
status: paused
parked on: awaiting human approval: amount 18500 USD exceeds the auto-approve
limit of 10000 USD (ref 0fd23a106bab74fa)
--- a human approves (out-of-band: Slack, portal, ticket) ---
status: completed
--- alternate history: the human rejects instead ---
tool result -> DENIED by policy: a human reviewer rejected this action
final: A reviewer rejected the transfer — I've flagged the invoice for manual processing.
Note the last line. The rejection comes back as a tool result, so the agent re-plans rather than failing opaquely.
The pattern
store = InMemoryApprovalStore() # a durable table in production
gate = HITLApprovalGate(
[GateRule("wire_transfer", above("amount", 10_000, unit=" USD"))],
store,
)
harness = Harness(model, tools, hooks=[broker, gate])
Design decisions worth arguing about:
1. Approve actions, not sessions. The fingerprint binds the verdict to exact
arguments. There is a test named
test_approval_does_not_transfer_to_different_arguments because this is the
property most home-grown approval flows get wrong: they approve “the next tool
call,” and the next tool call is not necessarily the one the human looked at.
2. Pause, don’t block. The run result is paused with a pending action.
Nothing waits in memory. Approval can arrive from Slack, a portal, or a ticket,
hours later, from someone who was not in the conversation.
3. Resume by replay. Re-running the same input through a deterministic harness
reaches the same proposed action, which now matches a verdict — no new machinery.
With a live model replay is not bit-identical, so pair the gate with
checkpointing to resume from the paused turn
instead. On LangGraph you get this for free via interrupt() and a checkpointer,
which is a genuine argument for that framework if approvals are central to your
product.
4. Triggers are risk tiers, not authorization. above("amount", 10_000)
encodes “routine below, judgment above.” What is ever legal stays in the broker.
The two hooks compose in the same harness, and the ordering matters: never ask a
human to approve something categorically forbidden.
When to use it
- Irreversible or expensive actions: payments, deletions, customer emails, production deploys, contract signatures.
- Actions where accountability must land on a named human. “The agent did it” is not an answer auditors accept.
- Early in an agent’s life, as a training-wheels tier you loosen as eval evidence accumulates.
When NOT to use it
- On everything. A gate on every tool call turns reviewers into a rubber stamp within a week. This is not a productivity complaint, it is a security one: alarm fatigue is how real threats get approved. Gate the top of the risk curve only.
- Where the reviewer cannot actually judge. Showing a human 400 lines of generated SQL and asking “approve?” is theatre. If the reviewer needs the agent’s help to evaluate the agent’s action, redesign the action.
- As a patch for a missing broker. If an action should never happen, deny it deterministically. Do not make a human say no every time — that is exactly how you train them to say yes.
- Where latency breaks the product. A gate makes the action take as long as your slowest approver. For a real-time customer-facing flow that may be unacceptable, and the honest answer is to narrow what the agent may do rather than to add a gate nobody will service.
Trade-offs and failure modes
- Latency becomes a product feature. Budget UX for it: queues, notifications, SLAs. An approval queue with no owner is an outage waiting for a quiet Friday.
- Approval fatigue is the attack surface. ASI09 in practice: flood the queue
with routine requests, slip one bad one through. Keep gated volume low and make
each request self-explanatory —
reasonis mandatory in this implementation for that reason. - The approval store is now security-critical. Whoever can write verdicts can approve wire transfers. Protect it like the payment system it has become, and audit every verdict via the decision trace.
- Fingerprints are brittle if arguments are noisy. A model that includes a timestamp or a re-worded description in the arguments produces a new fingerprint every attempt, so approvals never match. Canonicalise the fields that identify the action.
Frequently asked questions
How do I decide what needs approval?
Two questions: is it reversible, and is it visible to someone outside the company? Irreversible-and-external is always gated. Reversible-and-internal almost never is. Set the threshold from the distribution of real actions, not from a round number — a $10,000 limit on a system where the median transfer is $12,000 gates everything.
Should the agent wait, or return to the user?
Return. Tell the user their request is pending review and give them a reference. An agent that appears to hang is a support ticket; an agent that says “flagged for review, reference 0fd23a1” is a product.
What if nobody responds?
Then you have discovered your real approval SLA. Give pending requests an expiry, expire them into a denial rather than an approval, and alert on the expiry rate — a rising rate means the gate is mis-tuned or the queue is unstaffed.
Can an LLM be the approver?
For the judgment this pattern exists to capture, no. The point is putting accountability on a human who can be asked why. An LLM reviewer is a second opinion with the same failure modes as the first, which is the argument made at length in verification loops.
References
- OWASP GenAI Security Project, Top 10 for Agentic Applications 2026 — ASI02, ASI05, ASI09
- OpenAI, A Practical Guide to Building Agents — human-in-the-loop as a layered guardrail stage
- LangGraph
interruptand Microsoft Agent Framework’sToolApprovalMiddleware— the same pause-and-resume seam in production frameworks - Runnable code and tests:
patterns/governance/hitl_approval_gate/
Part of the agent harness and governance series. Next: the redaction boundary — why one scrubber is always wrong, and how an agent can act on data it never saw.