Integration

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

Or start with the product: the hitl.ph API docs, the five task types and pricing.