Human-in-the-Loop for AI Agents: Why Most Approval Is Just input() in a Trenchcoat

Here is a bug I have now watched land in three different production agent systems, always the same way. The agent proposes a consequential action — refund this customer, delete this resource, send this email to a client. A human-approval step fires. A reviewer gets a Slack ping. And then, before they click, the worker restarts: a routine deploy, an autoscaler scaling in, an OOM kill. When the reviewer finally clicks Approve, nothing happens. The pending request lived in the memory of a process that no longer exists.

Nobody notices at first, because the failure is silent — no error, just an approval that goes nowhere and an action that never happens, or worse, an agent that retries from the top and does the first half of the work twice. This is not a rare edge case. It is the default behaviour of most agent frameworks, and it comes from one design mistake repeated everywhere: treating human approval as a UI prompt instead of a durable control primitive.

🛡️ The one-sentence version: if your "human in the loop" is a process blocked on input() — or any in-memory pause — you don't have human-in-the-loop, you have a race condition with a person in it.

Why input() is the wrong shape

The naive implementation is seductive because it reads correctly: the agent reaches a decision point, calls something that blocks until a human responds, and continues. In a notebook it works perfectly. The trouble is that "blocks until a human responds" can mean minutes, hours, or overnight — and no production system holds a process open, pinned to one machine, for that long without something restarting it.

A human approval is not a function call that returns quickly. It is a gap in time that must be crossed by state, not by a held stack frame. The agent needs to stop, persist everything required to resume, release the worker, and let a completely different process pick the work back up when the answer arrives — possibly on a different machine, possibly days later. That is the same problem durable workflow engines solve, which is why the good answers here all look like durable execution rather than like a blocking call.

graph LR
  A["Agent reaches
consequential action"] --> B["Persist state
to durable store"] B --> C["Emit typed
approval request"] C --> D["Worker released
(no process held)"] C --> E["Reviewer notified
Slack · email · dashboard"] E --> F["Human decides
approve · edit · reject + why"] F --> G["Idempotent resume
load state, continue once"] G --> H["Agent reasons over
the response"]

The load-bearing arrow is the third one: the worker is released while the human thinks. Nothing holds a process open across the gap — the state is in durable storage, and any worker can resume from it. Everything that makes production HITL hard follows from taking that arrow seriously.

The six properties a production HITL primitive needs

A recent audit of twelve agent frameworks scored them on a six-axis rubric and found none scored above the halfway mark. The rubric is good enough that I now use it as a checklist, so here it is with what each axis actually protects against.

PropertyWhat it meansWhat breaks without it
DurabilityPaused state persists to a real store (Postgres, a workflow engine), not process memoryA restart during a pending approval loses the request entirely
IdempotencyResuming re-runs no side effects; the work before the pause happens exactly onceDouble refunds, duplicate emails, resources created twice on resume
Typed I/OThe request and the response are typed schemas (Pydantic / Zod), not raw stringsA malformed or ambiguous human response corrupts the resumed run
Channel abstractionThe same request can route to Slack, email, a web queue — pluggablyApproval is welded to one channel; you rebuild it for the next one
Verifier hookThe human's response can pass through a check (auth, policy, an AI sanity check) before it resumes the agentA wrong or spoofed approval flows straight back into a consequential action
Admin UIA dashboard of in-flight approvals — who is waiting, how long, on whatOperational blindness: you can't see the queue of stuck agents

The two that matter most, and that almost everything gets wrong, are the first two. Durability decides whether the system works at all under real operations. Idempotency decides whether it works correctly — and it is the subtler trap, because the standard durable-resume model re-executes the code up to the pause point. If you did anything with a side effect before the approval step, it happens again on every resume. I've written before about this class of problem in evaluating agent systems: the failures that survive to production are the silent, replay-shaped ones, not the loud ones.

How the actual tools measure up

Three layers exist here, and conflating them is the most common mistake I see when someone says "we'll just use X for approvals." They do different jobs.

Layer 1 — the framework primitive: LangGraph interrupt()

LangGraph is the reference implementation of the durable-pause idea, and it has become the de facto primitive that other tools build on. interrupt() pauses a graph node; you resume with graph.invoke(Command(resume=value), config=...). Paired with a durable checkpointer — PostgresSaver — the paused state lives in Postgres, so it survives a worker restart. That is the durability axis handled properly, out of the box, which is rare.

# LangGraph: durable interrupt + resume
from langgraph.types import interrupt, Command

def approve_refund(state):
    # Anything with a side effect ABOVE this line runs again on resume.
    # Keep it pure, or make it idempotent, or move it below.
    decision = interrupt({
        "action": "refund",
        "customer_id": state["customer_id"],
        "amount": state["amount"],
    })
    if decision["approved"]:
        return {"status": "refunded", "note": decision.get("reason", "")}
    return {"status": "declined", "note": decision.get("reason", "")}

# Resume, hours later, from any worker — state was in Postgres:
graph.invoke(Command(resume={"approved": True}), config={"configurable": {"thread_id": tid}})

⚠️ The idempotency footgun, stated plainly: code before interrupt() re-runs on resume. If you charged the card, wrote a row, or sent a message above that line, it happens again when the human approves. This is documented behaviour, not a bug — but it is the single thing teams miss, and it turns a refund approval into a double refund. Put side effects after the interrupt, or guard them with an idempotency key.

Layer 2 — the review UI: agent-inbox

LangGraph gives you the pause; it does not give you somewhere for a human to see and answer it. That is what agent-inbox (MIT, from the LangChain org) is: an inbox-style web UI that renders LangGraph interrupts and lets a reviewer accept, edit, ignore, or respond. It expects your interrupts to follow a HumanInterrupt schema, and then it handles the "where does the human actually click" problem the primitive leaves open. It is the admin-UI axis, filled in — for LangGraph specifically.

Worth knowing what it is not: it is a UI over the LangGraph deployment, not a general approval bus. If you are on LangGraph it is close to free value; if you are not, it does not apply.

Layer 3 — the commercial approval layer: HumanLayer, and the wider category

HumanLayer sits above the primitive and adds the parts an enterprise wants: an approver clicks approve or deny in Slack, and — the part that matters architecturally — on a rejection it passes the human's feedback back to the agent, which can adjust its approach. That is the line between real human-in-the-loop and a glorified confirmation dialog. A gate can only block; a feedback loop can correct. A reviewer who can say why they rejected something is worth far more than one who can only say no, because the agent gets to reason over the reason.

There is a growing category of commercial "approval-as-a-service" products around this idea — HITL-as-an-API offerings that give you a webhook, a hosted queue, and a Slack or web approver so you don't build the plumbing yourself. I'm deliberately not scoring specific vendors here beyond HumanLayer: the category is young and thinly documented, and per my own sourcing rule I won't assert feature claims about products I couldn't verify against primary docs. Evaluate any of them against the six-axis rubric above — especially durability (is my pending approval in their store or mine?) and idempotency on resume — before you route a consequential action through one.

The interesting outlier: awaithumans

One small open-source project worth a look precisely because it is organised around the rubric rather than around a framework: awaithumans (Apache-2.0). Its pitch is exactly the sentence this whole problem needs — "pause your AI agent, ask a human, resume with their answer" — and it ships typed responses (Pydantic and Zod), durable Temporal and LangGraph adapters, channel routing across Slack/email/web, a verifier hook, and an audit trail. That is more of the six axes than most of the field.

It is also tiny — a handful of stars, a few open issues. So I'd read it as a well-shaped reference for what the primitive should look like rather than something to bet production on today. Small-but-correct is a better teacher than large-but-wrong, and this is the clearest small-but-correct example I've found.

A decision tree

graph TD
  A["Do you need human approval
on a consequential action?"] --> B{"Already on
LangGraph?"} B -->|"Yes"| C["interrupt() + PostgresSaver
+ agent-inbox for the UI"] B -->|"No"| D{"Is the action
high-risk / regulated?"} D -->|"Yes"| E["Durable workflow engine
(Temporal / Restate) under the agent,
+ a HITL layer over it"] D -->|"No, low stakes"| F{"Will it ever run
unattended in production?"} F -->|"No — dev / internal only"| G["A blocking prompt is fine.
Be honest that it is not production."] F -->|"Yes"| E C --> H["Handle idempotency
before the interrupt point"] E --> H

The only branch where input()-style blocking is acceptable is the explicitly non-production one. Everything that runs unattended converges on the same requirement: durable state and idempotent resume. The framework is a detail; that requirement is not.

The part nobody puts in the demo: this is a compliance surface

For anything the regulators call high-risk, human oversight is not a nice-to-have. Article 14 of the EU AI Act requires high-risk AI systems to be designed so a person can effectively oversee them — interpret the output, and intervene, stop, or override the system. A human-approval step that silently loses requests on a restart is not just a bug in that context; it is a gap in a control the law requires you to have. The same discipline I argued for in designing multi-agent systems over sensitive data applies here: the oversight mechanism has to be as durable and auditable as the thing it oversees, or it isn't oversight.

Which means the admin-UI and verifier-hook axes stop being polish and become evidence. When someone asks "who approved this action, when, and on what information," a dashboard of in-flight and completed approvals with an audit trail is the answer. A Slack message that scrolled away is not.

What to carry away

Human approval in an agent is a durable control primitive, not a UI prompt, and the difference is the whole game. The test is brutally simple: restart your worker while an approval is pending, and see whether the approval still works. If it doesn't, you have a demo, not a system — no matter how good the Slack message looks.

Score any tool on the six axes before you trust it: durability and idempotency decide whether it works at all and works correctly; typed I/O, channels, a verifier hook and an admin UI decide whether it survives contact with a real organisation. LangGraph's interrupt() plus a Postgres checkpointer plus agent-inbox is the most complete off-the-shelf path today; HumanLayer adds the feedback-loop and enterprise layer; awaithumans is the clearest small reference for the shape of the primitive.

And treat the human's response as a signal, not a switch. The reviewer who can edit and explain is doing real work in the loop. The reviewer who can only click yes is a speed bump you'll eventually route around — which is exactly how consequential actions end up unsupervised.

Frequently asked questions

Why does human-in-the-loop approval break in production?

Because most frameworks implement it as a blocking call — the agent waits on input() or an in-memory pause. If the worker restarts while a request is pending (deploy, autoscale, crash), the paused state is gone and the request is lost. Production approval must persist to durable storage so a pending decision survives the process that created it, and resume idempotently so the work before the pause isn't re-executed.

What is the standard human-in-the-loop primitive for LangGraph?

interrupt() pauses a graph node; resuming is graph.invoke(Command(resume=value), config=...). With a durable checkpointer like PostgresSaver, the paused state lives in Postgres and survives a restart. The caveat to handle: code before interrupt() re-runs on resume, so any side effect there must be made idempotent.

What is the difference between a permission gate and true human-in-the-loop?

A permission gate answers yes or no to a proposed action. True HITL treats the human's response — including edits and free-text feedback — as a signal the agent reasons over and adjusts its plan around, in the same run. A gate can only block; a feedback loop can correct.

Does the EU AI Act require human oversight of AI agents?

For high-risk systems, yes. Article 14 requires them to be designed so a person can effectively oversee them, including interpreting outputs and being able to intervene, stop, or override. A durable, auditable approval mechanism is a compliance requirement there, not just good engineering.

References