Human in the Loop for LangChain Agents: A Practical Guide
How to pause a LangChain or LangGraph agent for human input with interrupt(), resume with Command, and wire the human side to a real reviewer over an API.
· 10 min read · by the Yoonet team

LangChain's answer to human in the loop lives in LangGraph: an
interrupt() call that pauses a graph mid run, persists its state
through a checkpointer, and resumes — minutes or days later — when something
calls back with a Command(resume=...). It is the best designed HITL
primitive in any agent framework right now, and the docs demonstrate it almost
entirely with a developer typing approvals into a terminal.
This guide walks the real path: interrupt a tool, park the graph durably, and wire the resume to an actual reviewer — first your own team, then a managed human endpoint when nobody on your team wants to be the approval queue.
Step 1 — Interrupt inside the tool
Put the pause where the risk is: inside the tool that performs the irreversible action. Everything before it — reasoning, retrieval, drafting — runs at machine speed; only the moment of commitment waits for a person.
from langchain.tools import tool
from langgraph.types import interrupt
@tool
def issue_refund(order_id: str, amount: float) -> str:
"""Issue a refund to a customer."""
# Pause the graph here. The payload surfaces to whatever is
# driving the graph; execution stops until someone resumes it.
decision = interrupt({
"action": "issue_refund",
"order_id": order_id,
"amount": amount,
"question": "Approve this refund?",
})
if decision.get("approved"):
do_refund(order_id, amount) # your implementation
return f"Refunded {amount} on {order_id}"
return f"Refund declined: {decision.get('reason', 'no reason given')}"
Whatever you pass to interrupt() becomes the payload your outer
system sees, and whatever the resumer passes back becomes its return value.
Design that payload as a brief for a stranger: action, arguments, and a plain
question. You are writing for whoever answers it, and eventually that will not
be you.
Step 2 — Compile with a checkpointer and park the run
interrupt() only works on a graph compiled with a checkpointer —
that is what makes the pause survivable across processes and days. In production
use Postgres or SQLite, keyed by a thread_id you can find again.
import sqlite3
from langgraph.checkpoint.sqlite import SqliteSaver
from langgraph.types import Command
checkpointer = SqliteSaver(sqlite3.connect("agent.db", check_same_thread=False))
graph = builder.compile(checkpointer=checkpointer)
config = {"configurable": {"thread_id": "case-1042"}}
run = graph.invoke({"messages": [("user", "Refund order 1042, it arrived broken")]}, config)
# The graph is now parked at the interrupt. Inspect what it wants:
state = graph.get_state(config)
print(state.tasks[0].interrupts) # -> ({'action': 'issue_refund', 'amount': ...},)
# ...time passes; a human answers...
graph.invoke(Command(resume={"approved": True}), config)
Nothing is blocked while the graph is parked: no thread waiting on
input(), no worker held open. The run is a row in the checkpoint
store until the resume arrives. That property is what makes real human latency —
an hour, a working day — affordable.
Step 3 — Give the interrupt to an actual human
Here the framework's job ends and yours begins. Someone has to see the pending interrupt, understand it, decide, and trigger the resume. The minimum viable version is a Slack message with two buttons wired to your backend — fine for one agent and a tolerant team. It stops being fine when volume grows, when approvals arrive at 2am, or when the decision needs judgement rather than a glance: the failure modes covered in the four HITL patterns.
The alternative is to treat the reviewer as infrastructure. Instead of posting to Slack, post the interrupt payload to hitl.ph as a task; a trained, named specialist on Yoonet's floor in Balanga owns it and the decision comes back on a webhook.
import requests
HITL = "https://api.hitl.ph/v1"
HEADERS = {"Authorization": f"Bearer {HITL_API_KEY}"}
def send_to_reviewer(interrupt_payload: dict, thread_id: str) -> str:
"""Turn a LangGraph interrupt into a task a real person will own."""
r = requests.post(f"{HITL}/tasks", headers=HEADERS, json={
"type": "decision.own",
"brief": (
f"An automation wants to run {interrupt_payload['action']} "
f"with {interrupt_payload}. Approve or decline, with one line of reasoning."
),
"context": {"thread_id": thread_id},
"return": {"mode": "webhook", "url": "https://your-app.com/hooks/hitl"},
})
return r.json()["id"] # e.g. "task_8sJ2k0Lm"
The decision.own task type means an accountable person makes and
signs the call — the right fit for approvals. Ambiguous data cases fit
data.validate, and document heavy reviews fit
document.review; the task type list maps
each to its wire string.
Step 4 — Resume on the webhook
When the reviewer answers, the result arrives at your webhook as structured data.
Look up the thread, build the Command, and the graph picks up
exactly where it stopped — inside the tool, at the interrupt() call.
# Your webhook handler: the human answered, wake the graph up.
@app.post("/hooks/hitl")
def hitl_result(task: dict):
thread_id = task["context"]["thread_id"]
config = {"configurable": {"thread_id": thread_id}}
graph.invoke(
Command(resume={
"approved": task["result"]["decision"] == "approve",
"reason": task["result"]["reasoning"],
}),
config,
)
return {"ok": True}End to end: the agent runs free until the refund tool, parks itself, a person decides in minutes, and the graph finishes the job with the human's reasoning in the transcript. No polling loops, no held workers, no engineer play acting as an approval queue. If your service layer is FastAPI, the FastAPI worked example turns this sketch into a complete deployable service.
Production notes
- One interrupt per risky tool, not one per run. Gate the irreversible 5 per cent; let the rest flow.
- Timebox the wait. Park a deadline alongside the thread and decide what expiry means — auto decline is usually the safe default.
- Log the resume payload. The human's reasoning is audit gold; keep it with the run, not just in the tool's return string.
- Cost sanity: a managed reviewer at USD $2.50 a task is cheap against an engineer context switching, and unlike the Slack channel it does not go home at 5pm.
Read next
- Human in the Loop Patterns for AI Agents
- Human in the Loop with the OpenAI Agents SDK
- LangGraph Human in the Loop with FastAPI
Or start with the product: the hitl.ph API docs, the five task types and pricing.