Human-in-the-Loop with interrupt()
- Human-in-the-loop (HITL) puts a human checkpoint at critical points of an agent workflow so consequential actions (buying stock, sending mail, publishing a post) are approved, corrected or rejected by a person rather than decided autonomously.
- In LangGraph it is one function:
interrupt(payload)pauses the run, the payload surfaces to the caller under__interrupt__, andinvoke(Command(resume=value), config)continues from the pause withvalueas the return ofinterrupt. - A checkpointer is mandatory, because the paused state has to be saved and reloaded. Put the interrupt inside the sensitive tool, not in the chat node, so only that action asks for approval.
Part 9 adds the last core component to the chatbot. The bot can already search, fetch prices and
answer over uploaded documents, and it will happily execute a purchase_stock tool the moment you
ask. That is the problem. This lesson explains why some actions need a human gate, shows how
LangGraph's interrupt and Command implement it, and wires approval into the stock-purchase
tool, first from a CLI loop and then from the Streamlit UI.
Why a human gate existsโ
The lesson's definition: HITL is a design approach where a human actively participates at critical points of the workflow to supervise, approve, correct or guide the model's output. It is not applied everywhere. Fetching the weather needs no approval. Spending money does, because a model can hallucinate: asked for 10 shares, it may buy 20, or the wrong company.
Reasons listed in the lesson: it helps the agent complete tasks it should not finish alone, adds accountability (someone reviewed the action), improves accuracy and safety, keeps ethical alignment (a human rejects a post with abusive content before it is published), and gives a better user experience because people trust a bot that asks before acting.
Four common patterns:
| Pattern | Shape |
|---|---|
| Action approval | approve or reject before execution (the stock purchase) |
| Output review and edit | agent drafts, human edits and finalises |
| Ambiguity clarification | "which Friday, this one or next?" |
| Escalation | bot cannot handle it, hands off to a person |
How interrupt pauses and Command resumesโ
Inside any node or tool, call decision = interrupt(payload). Execution stops there. The invoke
call returns as usual, but the result carries an __interrupt__ entry holding your payload (a
string or a dict with whatever the human needs to see: type, reason, question, instruction). No
model output appears yet.
To continue, call invoke again on the same thread with Command(resume=value) instead of a
message. LangGraph reloads the checkpoint for that thread, finds the paused point, and the
interrupt call now returns value. Your code after it runs normally: check the decision, execute
the action or return a declined message.
This is why the checkpointer is required. The pause is only possible because the state at that
moment is saved somewhere (in memory or a database) and can be loaded when the resume arrives. The
same thread_id config must be used on both calls, or the resume has nothing to resume.
without HITL:
"buy 10 AAPL" -> chat_node -> tools(purchase_stock) -> chat_node -> "order placed"
with HITL:
"buy 10 AAPL" -> chat_node -> tools(purchase_stock)
interrupt("Approve buying 10 shares of AAPL?")
... run paused, state checkpointed ...
Command(resume="yes") -> purchase_stock continues -> chat_node -> "order placed"
Command(resume="no") -> purchase_stock returns "declined" -> chat_node -> "declined"
Where the interrupt goesโ
The lesson first demonstrates the mechanism in the simplest possible place: the chat node itself,
asking "do you really want to ask this question?" before every answer. That is only to show the
API. The rule for a real agent with many tools is different: do not gate the chat node. Put
the interrupt inside the specific tool that performs the sensitive action. Every other tool keeps
running without prompts, and the approval text can include the exact arguments the model generated
(quantity and symbol), which is what the human actually needs to check.
Driving it from a CLI and a UIโ
The CLI loop changes in one place. After each invoke, check result.get("__interrupt__"). If
present, print the payload, read a yes/no from the terminal, and invoke again with
Command(resume=decision). If absent, print the last message as before. "Hello" never triggers it;
"purchase 20 stock of Apple" does, and answering no yields a declined message instead of an order.
The Streamlit version is the same logic with an approve/reject button pair instead of input().
The backend file does not change between CLI and UI. LangSmith traces show the interrupt as a step
inside the tool run, so approvals are auditable after the fact.
Code that mattersโ
from langchain_core.messages import HumanMessage
from langchain_core.tools import tool
from langgraph.types import interrupt, Command
@tool
def purchase_stock(symbol: str, quantity: int) -> str:
"""Place a buy order for a stock. Sensitive: requires human approval before executing."""
decision = interrupt(
f"Approve buying {quantity} shares of {symbol}? (yes/no)"
)
if isinstance(decision, str) and decision.lower() == "yes":
# sketch: a real tool would call a brokerage API here
return f"Purchase order placed for {quantity} shares of {symbol}."
return f"Purchase of {quantity} shares of {symbol} was declined by the human."
# purchase_stock is appended to the tools list and bound like every other tool;
# the graph is compiled with a checkpointer (the lesson uses a SQLite one).
config = {"configurable": {"thread_id": "demo"}}
while True: # sketch of the CLI loop
user_input = input("You: ")
if user_input.lower() in ("exit", "quit"):
break
result = chatbot.invoke(
{"messages": [HumanMessage(content=user_input)]}, config=config
)
interruption = result.get("__interrupt__")
if interruption:
print("HITL:", interruption[0].value)
decision = input("Your decision (yes/no): ").strip().lower()
result = chatbot.invoke(Command(resume=decision), config=config)
print("Bot:", result["messages"][-1].content)
The notebook demo uses a dict payload and a dict resume instead:
interrupt({"type": "approval", "question": q, "instruction": "Approve? yes/no"}) paired with
Command(resume={"approved": user_input}), then if decision.get("approved") == "no". Either
shape works; the resume value is whatever you pass, and your code decides how to read it.
Failure modes and gotchasโ
- No checkpointer.
interruptcannot pause without somewhere to save the state. Compile with a checkpointer before adding any HITL. - Different
thread_idon resume. The resume must target the thread that paused. A new thread ID means a new run with no pause to continue. - Gating the chat node. Every message then asks for approval, including "hello". Gate the action, not the conversation.
- Side effects before the interrupt. On resume, LangGraph re-enters the paused node or tool and replays it up to the interrupt, so code placed before
interruptinside that function can run twice. Keep the irreversible call after the decision check. - Ambiguous resume shape. If the tool expects a string and the UI sends a dict (or the reverse),
decision.lower()ordecision.get(...)fails. Pick one shape per interrupt and document it in the payload. - Missing keys in
.env. The notebook demo errored on first run because environment variables were not loaded;interruptonly surfaces after the model call succeeds.
Takeawaysโ
- Worth remembering: HITL in LangGraph is two calls,
interrupt(payload)to pause andCommand(resume=value)to continue, plus a checkpointer to make the pause durable. - The approval payload should carry the concrete arguments the model produced. Approving "buy 20 AAPL" is meaningful; approving "do the purchase" is not.
- Approval belongs in the sensitive tool. Search, weather and RAG keep running unattended; only money, messages and publishing wait for a person.
- The four patterns (approval, review-and-edit, clarification, escalation) are all the same mechanism with different payloads and different code after the resume.
Test yourselfโ
Why does a graph with interrupt() need a checkpointer?
The pause works by saving the run's state at the interrupt point and reloading it when Command(resume=...) arrives on the same thread. Without a checkpointer there is nothing to reload, so the run cannot continue from where it stopped.
How does the calling code know that an interrupt happened?
The result of invoke contains an interrupt entry holding the payload passed to interrupt. If that key is absent, the run finished normally and the last message is the answer.
In an agent with many tools, where should the approval interrupt live and why?
Inside the tool that performs the sensitive action (for example purchase_stock), not in the chat node. That way only that action prompts for approval, the prompt can show the exact generated arguments, and all other tools run without interruption.
Related: Loop Engineering ยท Agent Persistence ยท Glossary