How to Implement Human-in-the-Loop in LangGraph Using the interrupt() Pattern
Photo by Google DeepMind on Unsplash
The Problem: Unchecked AI Agent Actions
When I first deployed an AI agent that could execute SQL queries autonomously, I learned a painful lesson. The agent ran a DELETE statement on my development database within minutes of deployment. No approval step. No confirmation. Just execution.
This is the core problem with autonomous agents: they can cause real damage before you realize what happened:
- SQL queries that modify production databases
- API calls that trigger financial transactions
- Email sends that cannot be undone
- File operations that overwrite critical data
Most frameworks force you to duct-tape approval logic into your agent after realizing these risks. This retrofitting is painful because:
- No native pause/resume mechanisms exist
- Custom solutions require managing state across asynchronous boundaries
- Error handling becomes complex when human delays are involved
- The duct-taped solutions break under edge cases
A Reddit commenter captured this frustration: “LangGraph’s interrupt() is the part that doesn’t get enough credit. Once you need a human to approve a tool call before it fires, most other frameworks make you duct-tape it together. The interrupt/resume pattern actually models the state transition cleanly.”
The Direct Answer
Use LangGraph’s interrupt() function inside your node or tool to pause execution mid-workflow, then resume with Command(resume=...) to pass human decisions back.
This pattern cleanly models state transitions for human approval without duct-taping custom solutions. Other frameworks require complex workarounds for this capability; LangGraph handles it natively.
How interrupt/resume Works
The interrupt() function pauses graph execution at a specific node. The caller receives interrupt data via ["__interrupt__"] key. When ready, the caller invokes the graph again with Command(resume=...) to continue execution.
Pattern 1: Simple Approval Node
from langgraph.types import interrupt, Command
def approval_node(state: State) -> Command[Literal["proceed", "cancel"]]: # Pause execution; payload shows up under result["__interrupt__"] is_approved = interrupt({ "question": "Do you want to proceed with this action?", "details": state["action_details"] })
# Route based on the response if is_approved: return Command(goto="proceed") else: return Command(goto="cancel")Pattern 2: Tool Call Review Before Execution
This is the exact use case from the Reddit comment—approving tool calls before they fire:
from typing import Unionfrom langgraph.types import interrupt
def review_tool_call(tool_call: ToolCall) -> Union[ToolCall, ToolMessage]: """Review a tool call before it executes.""" human_review = interrupt({ "question": "Is this correct?", "tool_call": tool_call, })
review_action = human_review["action"] review_data = human_review.get("data")
if review_action == "continue": return tool_call # Execute as-is elif review_action == "update": # Human modified arguments return {**tool_call, **{"args": review_data}} elif review_action == "feedback": # Human rejected, provide feedback to LLM return ToolMessage( content=review_data, name=tool_call["name"], tool_call_id=tool_call["id"] )Pattern 3: Wrapped Tool with Approval
from langchain.tools import toolfrom langgraph.types import interrupt
@tooldef run_query_tool_with_interrupt(**tool_input): """SQL query tool requiring human approval before execution.""" request = { "action": "run_query", "args": tool_input, "description": "Please review the SQL query" }
response = interrupt([request])
if response["type"] == "accept": # Approved - execute the tool tool_response = run_query_tool.invoke(tool_input) elif response["type"] == "edit": # Human modified the query tool_input = response["args"]["args"] tool_response = run_query_tool.invoke(tool_input) elif response["type"] == "response": # Rejected - return human feedback to LLM tool_response = response["args"]
return tool_responseWhy This Pattern Matters
The Reddit commenter’s insight about “clean state transition modeling” is accurate because:
- No custom state management: LangGraph’s checkpointer handles persistence automatically
- No duct-taping: The pattern is native to the framework, not retrofitted
- No async complexity: Resume value returns directly to interrupt call
- No polling loops: Graph execution genuinely pauses
Comparison with other approaches:
| Approach | Human Approval Implementation |
|---|---|
| Raw Python | Custom pause logic, manual state persistence |
| LangChain (old) | No native support, requires wrapper code |
| LangGraph | interrupt() + Command(resume=...) built-in |
Full Human-in-the-Loop Workflow
from typing import Literal, Optional, TypedDictfrom langgraph.checkpoint.memory import MemorySaverfrom langgraph.graph import StateGraph, START, ENDfrom langgraph.types import Command, interrupt
class ApprovalState(TypedDict): action_details: str status: Optional[Literal["pending", "approved", "rejected"]]
def approval_node(state: ApprovalState) -> Command[Literal["proceed", "cancel"]]: # Expose details for UI rendering decision = interrupt({ "question": "Approve this action?", "details": state["action_details"], })
# Route based on human decision return Command(goto="proceed" if decision else "cancel")
def proceed_node(state: ApprovalState): return {"status": "approved"}
def cancel_node(state: ApprovalState): return {"status": "rejected"}
# Build the graphbuilder = StateGraph(ApprovalState)builder.add_node("approval", approval_node)builder.add_node("proceed", proceed_node)builder.add_node("cancel", cancel_node)builder.add_edge(START, "approval")builder.add_edge("proceed", END)builder.add_edge("cancel", END)
# Checkpointer required for resumable workflowscheckpointer = MemorySaver()graph = builder.compile(checkpointer=checkpointer)
# Usage: Two-phase invocationconfig = {"configurable": {"thread_id": "approval-123"}}
# Phase 1: Invoke, pauses at interruptinitial = graph.invoke( {"action_details": "Transfer $500", "status": "pending"}, config=config,)print(initial["__interrupt__"])# Shows the question to render in UI
# Phase 2: Resume with human decisionresumed = graph.invoke(Command(resume=True), config=config)print(resumed["status"]) # -> "approved"Tool Approval Pattern for High-Risk Operations
from langchain_core.runnables import RunnableConfigfrom langchain.tools import toolfrom langgraph.types import interrupt
@tooldef sensitive_api_call(config: RunnableConfig, **tool_input): """API call requiring human approval before execution.""" # Show tool call details for review request = { "action": "api_call", "endpoint": "/api/payment", "args": tool_input, "risk": "HIGH" }
response = interrupt([request])
# Handle three approval types if response["type"] == "accept": return actual_api_call(tool_input) elif response["type"] == "edit": modified_input = response["args"] return actual_api_call(modified_input) elif response["type"] == "response": return response["args"] # User feedback to LLM else: raise ValueError(f"Unknown type: {response['type']}")Common Mistakes to Avoid
Mistake 1: Forgetting the Checkpointer
Without a checkpointer, interrupted graphs cannot resume:
# WRONG: No persistencegraph = builder.compile()
# CORRECT: With checkpointer for resumable workflowsfrom langgraph.checkpoint.memory import MemorySavercheckpointer = MemorySaver()graph = builder.compile(checkpointer=checkpointer)Mistake 2: Not Using thread_id
Each interrupted conversation needs a unique thread_id:
config = {"configurable": {"thread_id": "approval-123"}}initial = graph.invoke({"input": "data"}, config=config)# Resume requires same thread_idresumed = graph.invoke(Command(resume=True), config=config)Mistake 3: Ignoring the interrupt Key
The interrupt payload is available via ["__interrupt__"]:
initial = graph.invoke({"input": "data"}, config=config)print(initial["__interrupt__"])# -> [Interrupt(value={'question': ..., 'details': ...})]Mistake 4: Not Handling All Resume Types
Tool review needs to handle three types: accept, edit, feedback:
# Only accept case is common mistakeresponse = interrupt([request])if response["type"] == "accept": return tool.invoke(tool_input)# Missing edit and feedback handling!Mistake 5: Interrupting After the Action
Place interrupt BEFORE the risky operation, not after:
# WRONG: Interrupt after actiondef execute_node(state): result = dangerous_action(state) interrupt("Was this OK?") # Too late! return result
# CORRECT: Interrupt before actiondef approval_node(state): approved = interrupt("Approve action?") if approved: return dangerous_action(state) return {"cancelled": True}Decision Framework
Do you need human approval before tool execution?├─ Yes -> Use interrupt() inside tool wrapper│ └─ Pattern: interrupt() -> review -> accept/edit/feedback└─ No -> Do you need human approval for workflow decisions? ├─ Yes -> Use interrupt() in approval node │ └─ Pattern: interrupt() -> Command(goto=...) └─ No -> Standard LangGraph workflow without interruptSimplified rules:
- Approving tool calls before execution ->
interrupt()in tool wrapper - Workflow branching decisions ->
interrupt()in approval node - No human intervention needed -> Standard graph without interrupt
Summary
LangGraph’s interrupt() pattern provides native human-in-the-loop capability that other frameworks require duct-taping to achieve. Use it inside nodes for workflow decisions, or wrap tools for approving risky operations before execution. The pattern cleanly models state transitions with built-in checkpointing, eliminating custom persistence code.
Next step: Add a checkpointer to your LangGraph graph and place interrupt() before any tool call that modifies production data.
Final Words + More Resources
My intention with this article was to help others share my knowledge and experience. If you want to contact me, you can contact by email: Email me
Here are also the most important links from this article along with some further resources that will help you in this scope:
- 👨💻 LangGraph interrupt() Documentation
- 👨💻 Tool Call Review Pattern
- 👨💻 Reddit Discussion: LangGraph vs LangChain for Custom Agents
- 👨💻 LangGraph Checkpoint Persistence
Oh, and if you found these resources useful, don’t forget to support me by starring the repo on GitHub!
Comments