LangChain Human-in-the-Loop: How HITL Actually Works
The article explains Human-in-the-Loop in LangChain and LangGraph, contrasting low-level interrupt and Command primitives with higher-level HumanInTheLoopMiddleware for tool-call gating. It details persistence with checkpointers, the four review decisions, batched versus per-call modes, and common production failures like interrupt storms and state loss. It concludes with practical guidance on when to use each pattern inside larger automation systems.

What Human-in-the-Loop Means in LangChain
Human-in-the-loop (HITL) in LangChain and LangGraph is the set of mechanisms that pause an agent's execution at a defined checkpoint, typically before a tool call, so a person can approve, edit, reject, or respond before the agent continues. It is implemented through LangGraph's low-level interrupt() that halts the graph, and at a higher level through HumanInTheLoopMiddleware that wraps that pause into a configurable policy. Either way, the goal is the same: an agent that wants to write a file or run a SQL query stops and waits for a person to decide what happens next.
In practice, you declare which tools need oversight. When the model proposes a flagged tool, like sending an email, executing a write query, or modifying persistent data, the middleware checks the policy and issues an interrupt instead of calling the tool immediately. The agent stops, surfaces the tool name and arguments to your review UI, and waits. That pause keeps people in control without rewriting the agent logic for every sensitive action. You can apply it narrowly, for example auto-approving read-only searches while gating every write, so reviewers only see actions that matter.
That review offers four built-in outcomes that cover most operational needs:
- approve: run the call exactly as proposed
- edit: change the arguments, for example fixing a file path or recipient, then run
- reject: skip the call and return feedback that tells the model why
- respond: treat the human message as the tool result itself, useful for ask-user tools
Teams add HITL because autonomous actions carry real risk. Even a well-prompted agent can choose the wrong table to write, overwrite production data, or send a draft that was never meant to leave. Adding a checkpoint at the tool boundary gives you a verification step, a domain expert sign-off, and a clear audit point for sensitive actions like database writes, while still giving AI a well-defined job to do right up to that boundary.
The concept is simple, but LangChain gives you two different ways to build it — and picking the wrong one causes real pain later.
LangGraph interrupt() vs. HumanInTheLoopMiddleware: Two Ways to Pause an Agent
LangGraph interrupt()/Command and HumanInTheLoopMiddleware are LangChain's two distinct ways to pause an agent for human input, differing in where the pause logic lives and how much you have to build yourself. Before choosing between them, it helps to know what happens to the agent's state while it waits: both paths rely on LangGraph's persistence layer to save state and block indefinitely until resumed.
The low-level primitive: interrupt() and Command
The primitive is interrupt() called inside any node. The Interrupts documentation describes it as a dynamic pause that can be placed anywhere and made conditional on your logic. The payload you pass must be JSON-serializable and is surfaced to the caller via stream.interrupts or __interrupt__.
Execution resumes only when you re-invoke the graph with the same thread_id and a Command(resume=...). That resume value becomes the return value of the interrupt() call. The node restarts from its beginning on resume, so any side effects before the interrupt must be idempotent, and you should not wrap the call in try/except or reorder calls within the node.
Command(resume=...) is the only Command pattern intended as outside input to invoke() or stream_events(). The other parameters (update, goto, graph) are for returning from node functions, not for driving a paused run.
This gives you arbitrary control: pause inside a tool, before an API call, in the middle of a multi-step approval, or based on a Pydantic response_schema. You own the UI, the routing, and the validation.
The prebuilt wrapper: HumanInTheLoopMiddleware
HumanInTheLoopMiddleware is a higher-level wrapper for the common case of reviewing tool calls. The human-in-the-loop middleware docs state it checks each tool call against a configurable policy and issues an interrupt that halts execution when intervention is needed.
You add it to create_agent(middleware=[HumanInTheLoopMiddleware(...)]) and configure:
interrupt_on: a mapping of tool name toTrue(interrupt with defaults),False(auto-approve), or anInterruptOnConfigwithallowed_decisionsand optionaldescription.description_prefix: defaults to "Tool execution requires approval" and prefixes the interrupt message.interrupt_mode:"batched"(default, one interrupt per model turn with an ordered list of decisions) or"per_call"(one interrupt per gated call, answered by interrupt ID, requireslangchain>=1.4.6).when: an optional predicate receiving aToolCallRequestthat returns True to interrupt, letting you gate on arguments like file paths or SQL type. Conditional predicates requirelangchain>=1.3.3.
Interrupts from the middleware carry type: "tool_approval" with tool_call_id, name, and args, plus a response_schema describing allowed decisions. You don't write the pause sites yourself.
| Aspect | LangGraph interrupt()/Command | HumanInTheLoopMiddleware |
|---|---|---|
| Configuration | Call interrupt() in any node; resume via Command(resume=...) | Add to create_agent with interrupt_on mapping |
| Pause granularity | Anywhere in graph, arbitrary logic | Only tool calls matching interrupt_on policy |
| Control | Full custom: any JSON payload, response_schema, Command(goto=...) | Policy-driven: allowed_decisions, batched/per_call modes, auto-approve False |
| Interrupt surface | Value on stream.interrupts; resume value returns to call site | tool_approval value with tool_call_id, name, args |
| Best for | Custom multi-step approvals, non-tool gates | Simple gating of file writes, SQL, email |
When teams reach for one over the other
Use interrupt()/Command when the pause is not tied to a single tool call: cross-node business rules, a human needs to supply missing data that changes control flow, or you need a fully custom approval graph with goto routing.
Use HumanInTheLoopMiddleware when your policy is "pause these tool calls, let those run": writing files, executing non-SELECT SQL, sending email, or any side-effecting action where tool-call gating is sufficient. It removes boilerplate around batching, ID mapping, and decision validation, and LangChain plans to make per_call the default going forward.
Persisting Interrupted State: Checkpointers in Production
LangGraph checkpointers persist the full graph state while an agent waits for human input, and InMemorySaver is unsafe past local development because it keeps that state only in process RAM. Durable state solves "can the agent survive the wait" — the next question is what happens on the human side of that wait.
When LangGraph pauses for a review, it does not hold the thread in memory. It writes a checkpoint (the latest state, the next node to run, and any pending interrupts) to the configured checkpointer, keyed by thread_id. Resume works because the graph can be recompiled on any process and load that thread back from storage.
InMemorySaver and MemorySaver store those checkpoints in RAM. The official persistence troubleshooting guide states that when the process restarts, all checkpoints are lost. For a human-in-the-loop flow that may wait minutes, hours, or days, that means a deploy, autoscaler event, or crash wipes every paused conversation, and the human's approval returns to nothing.
That is why backend choice is not a configuration detail. The memory production guidance is explicit: in production, use a checkpointer backed by a database. LangGraph ships PostgresSaver / AsyncPostgresSaver, RedisSaver, MongoDBSaver, and others via separate packages like langgraph-checkpoint-postgres. They require a one-time setup() call to create tables or indexes, then behave like InMemorySaver from your application code but write to durable storage.
With a durable checkpointer, a paused graph survives restarts. Any replica can resume the same thread_id because state lives outside the worker. That is the property you need for HITL: interrupt, persist, exit the process, come back later, load checkpoint, continue with the human's Command. Without it, you keep people in control on paper but lose the proof on restart.
InMemorySaver loses all paused agent state on restart — never use it past local development; pair any production HITL flow with a durable checkpointer.
Treat the checkpointer as infrastructure, not an SDK option. Choose Postgres when you already run it and want transactional history and retention policies, Redis when you need low-latency ephemeral threads with TTLs, or a custom backend when compliance demands it. Getting the pause-and-resume mechanics right is only useful if the review step itself is designed deliberately.
Designing the Review Step: Approve, Edit, Reject, Respond
In the human-in-the-loop docs, LangChain defines the review step around four human decisions: approve, which executes the tool with original arguments; edit, which modifies arguments before execution; reject, which skips execution with feedback; and respond, which returns a human message as a synthetic tool result. The review UI is where that design holds or breaks once real traffic and real failure conditions hit it.
The four decisions in practice
Approve means run it exactly as the agent proposed it. You use it when the arguments already meet your policy: a write_file that targets /workspace/archive/orders.csv with the correct content, a database insert you have checked, or an email draft you are willing to send as-is. The tool runs and its output goes back to the model.
Edit means keep the intent but fix the arguments. That is the workhorse for keeping people in control without restarting the whole turn. Example: the agent proposes write_file with path: "orders_archive.csv"; you change it to archive/orders_2024.csv. Or it proposes DELETE FROM orders WHERE created_at < '2025-01-01'; you add a tenant filter before execution. Edit conservatively — LangChain notes that large changes can cause the model to re-evaluate and re-issue unexpected calls. When you edit, the middleware can prepend a notice so the model knows a human replaced the call.
Reject means do not run this tool call and tell the model why. Use it for denials, not for answering questions. Example: rejecting an email_send to an external list with message "external broadcast not allowed in demo; use internal review address instead." The tool is skipped and the model receives an error-status message with your reason, which it can act on.
Respond is different. Skip execution entirely and return your message as if the tool itself replied. It exists for "ask user" style tools where the human is the tool. Example: an ask_user step asking "which date range should I archive?" — you respond "Last quarter only, 2024-10-01 to 2024-12-31" and that string becomes the tool result. Do not use respond to deny side-effecting tools; its message is treated as success.
Who approves what
Not everyone should approve everything. Map tool risk to an owner:
- File or database writes to production: data owner or on-call engineer
- Outbound email, SMS, or customer-facing updates: compliance or account owner
- Financial or privileged actions: team lead with audit requirement
- Low-risk clarifications via
ask_user: the end user in the thread
Give AI a well-defined job by scoping interrupt_on per tool and per role. If only finance can approve execute_sql DELETEs, don't show them file writes they don't own.
Batched review vs per-call
By default LangChain batches: one interrupt per model turn lists every gated call, answered with an ordered list of decisions. Per-call mode raises one interrupt per gated call as it starts, each answered by its interrupt ID. Batched is faster to scan but fragile when order is lost; per-call is clearer for UIs where reviewers handle work item by item and where latency matters because safe tools keep running while one risky call waits. Choose per-call for new agents where you expect multiple simultaneous approvals.
Preventing review bottlenecks
Interrupt storms happen when every tool call pauses. Three ways to cut noise:
- Add a
whenpredicate so only risky variance interrupts — for example, onlywrite_fileoutside/workspace/or only SQL that is not a read-only SELECT. - Set
allowed_decisionsper tool to reduce cognitive load — allow only approve/reject for a destructiveexecute_sql, but allow edit forwrite_file. - Batch intentionally: group related writes into one review pass per turn, and surface full args, diffs, and prior thread history in the review UI so the decision is quick. Real workflows tend to separate read paths that auto-approve from write paths that always ask.
When the review step shows exactly what will run, who must approve it, and how to answer without rework, human oversight stops being a gate and becomes part of the workflow.
Common Failure Modes When Running HITL Agents in Production
You ship an agent that pauses before every tool call for safety, and on the first busy morning the review queue fills with pending items, many for read-only lookups that never needed a human in the first place. That queue is the start of a predictable failure chain: interrupt storms that overwhelm reviewers, stale approvals that execute against outdated context, silent state loss from ephemeral checkpointers, and missing audit trails for who approved what.
Interrupt storms
When every tool invocation triggers a pause, reviewers drown and agents stall. The agent is working as coded, but the design treats low-risk and high-risk actions the same. Mitigation is to add conditional interrupt predicates: auto-approve read-only tools, let low-risk writes pass with a rule, and only pause on irreversible actions like sending email, charging, or deleting data. Use tiers, rate limits per thread, and batch similar approvals so a human sees one decision, not twenty.
Stale approvals
A reviewer approves a tool call, but by the time the graph resumes the underlying data has changed — a price updated, a ticket was closed elsewhere, a file was overwritten. Practitioners in the wild report this as graphs resuming from the wrong point with inconsistent state updates, especially under async execution. Mitigate by time-boxing every pending interrupt with a TTL, showing the snapshot that triggered the pause alongside the current live value, and requiring re-validation if more than a few minutes have passed. If the context drifts, fail closed and ask the agent to re-plan rather than execute the old args.
Silent state loss on redeploy
This is the most expensive surprise. As the official persistence docs note, MemorySaver and InMemorySaver store checkpoints in RAM and all checkpoints are lost when the process restarts. As an independent guide documents, two replicas behind a load balancer break immediately because state on one replica is invisible to the other. Any open human-in-the-loop pause waiting on that state disappears on redeploy or pod restart. The fix is to wire a durable checkpointer backed by Postgres, Redis, or SQLite-file storage from day one, even in staging, and treat InMemorySaver as test-only. Add health checks that verify the checkpointer is reachable before the graph accepts new threads.
Missing audit trails
If you log only that a tool was approved, you cannot answer who approved what, when, and on which version of the arguments. That blocks incident response and compliance. Log the decision with thread_id, checkpoint_id, tool name and args hash, reviewer ID, timestamp, expiry, and the final payload actually executed including edits. Store it outside the checkpoint tables so it survives pruning. Handled well, these failure modes turn into design decisions rather than surprises — and they point to where HITL fits inside a larger automation system.
Where HITL Checkpoints Fit Into a Larger Automation System
LangChain human-in-the-loop checkpoints work best when they guard the few tool calls where a wrong automatic action is expensive to undo, because each pause adds durable state and human process to maintain. Every decision covered so far (which primitive, which checkpointer, how reviews are structured) ultimately answers one question: how much of the workflow should a human actually touch.
AI-powered content systems and workflow automation built around your team’s tools, processes, and goals—designed, implemented, and maintained by a Cambridge-trained automation engineer.
If your review is simple, start with the managed path. When you only need to gate a known action, like sending an email, publishing a draft, or executing a payment, HumanInTheLoopMiddleware gives you approve, edit, reject and respond handling without custom state code. Give AI a well-defined job and keep people in control at that boundary.
Reach for raw interrupt() and Command when the approval itself has logic. That is multi-step review, branching on reviewer role, parallel sign-offs, or edits that must rewrite the next part of the graph. In those cases the flow control belongs in your own node, not in a tool wrapper.
Neither approach works without treating persistence and review design as core plumbing. If the paused state cannot survive a restart and your reviewer cannot see context and audit trail, you do not have a production HITL system — you have a demo that pauses.
These primitives matter beyond LangGraph. Defined agent tasks, explicit approval checkpoints, and durable execution are the building blocks underneath custom agent systems built for publishers and agencies automating research and content operations. The pattern is the point, not the library.
Practical rule to ship: list every tool your agent can call, keep only the ones where human judgment prevents real harm or waste, wire those with the middleware, and only replace it with custom interrupt() logic when the workflow around the human gets more complex than the tool call itself.
Frequently Asked Questions
Do I need to build a custom approval UI for HumanInTheLoopMiddleware?
You need some UI to display the interrupt, but the middleware already structures it. It emits a payload with type: "tool_approval", tool_call_id, name, and args plus a response_schema. Your UI just renders those fields and collects approve, edit, reject, or respond. See the human-in-the-loop docs.
How do I correctly resume a graph that is waiting on human input?
Re-invoke the same graph with the same thread_id using config={"configurable": {"thread_id":...}} and pass Command(resume=...) as input. The resume value becomes the return value of the interrupt() call. The node restarts from its beginning on resume, so keep pre-interrupt effects idempotent. Command(resume=...) is the only Command pattern intended as input to invoke() or stream().
When should I drop HumanInTheLoopMiddleware and use interrupt() directly?
Use raw interrupt() when the pause logic is not a single tool call. That includes cross-node business rules, collecting missing data that changes control flow, parallel sign-offs, or routing with Command(goto=...). If your policy is simply pause these tools and auto-approve those, the middleware is less boilerplate.
Can I auto-approve read-only tools and only interrupt on risky writes?
Yes. Set interrupt_on to False for safe tools and True or an InterruptOnConfig for risky ones, and add a when predicate that receives a ToolCallRequest and returns True only for risky variance. For example, only interrupt write_file outside /workspace/ or SQL that is not SELECT. The predicate option requires langchain>=1.3.3.
What is the difference between batched and per_call interrupt modes?
batched is the default. One interrupt per model turn lists every gated call and you answer with an ordered list of decisions. per_call raises one interrupt per gated call as it starts, each answered by its interrupt ID, so safe tools can keep running while one risky call waits. per_call requires langchain>=1.4.6 and LangChain plans to make it the default.
Why does my paused agent disappear after a deploy or fail on multiple replicas?
You are likely using InMemorySaver, which stores checkpoints in RAM and loses all checkpoints when the process restarts. State on one replica is also invisible to another, so a request that switches replica forgets the user. In production use a checkpointer backed by a database like PostgresSaver or RedisSaver and run its setup() once.
If I edit a tool call, will the agent get confused?
edit modifies arguments before execution and is the workhorse for fixing paths or adding filters without restarting the turn. Large edits can cause the model to re-evaluate and re-issue unexpected calls, so keep changes conservative and scoped to the same intent. The middleware can prepend a notice so the model knows a human replaced the call.
How is respond different from reject?
reject skips execution and returns rejection feedback as an error, so the model can try another approach. respond skips execution and returns your message as a synthetic tool result treated as success, which is for ask-user style tools. Do not use respond to deny side-effecting tools like email or delete.