Build Human-in-the-Loop AI Agents with LangGraph
Fully autonomous AI agents are powerful — and occasionally terrifying. An agent that can send emails, execute database writes, or submit API requests without any human check is one mistaken inference away from a significant incident. Human-in-the-loop (HITL) design is how you get the efficiency of automation without sacrificing control.
LangGraph, the state-machine framework built on LangChain, has first-class support for HITL through its interrupt() function and Command pattern. These primitives let you pause a graph execution at any node, serialize the state, wait for human input, and resume — even hours or days later. This tutorial builds a real approval-gated agent that you can adapt for email sending, code deployment, financial transactions, or any other consequential action.
Understand the theory first by reading about human-in-the-loop patterns before continuing.
What You'll Learn#
- The difference between synchronous interrupts and async approval queues
- How to use LangGraph
interrupt()to pause graph execution - How to use the
Commandpattern to resume with human feedback - How to persist state between interrupts using LangGraph checkpointers
- How to build a web API that exposes the approval workflow
Prerequisites#
- Python 3.10+
- LangGraph 0.2+ and LangChain 0.3+
- Familiarity with LangGraph multi-agent patterns
- Understanding of what AI agents are
Architecture Overview#
The agent has four nodes:
- Planner — Understands the user request and proposes an action
- Interrupt (Approval Gate) — Pauses execution and surfaces the proposed action to a human reviewer
- Executor — Carries out the approved action (or handles rejection)
- Responder — Formats and returns the final output
The key innovation is that the graph state is checkpointed between the Interrupt and Executor nodes. The human reviewer can approve, reject, or modify the proposed action asynchronously. The graph resumes only when the human provides input.
Step 1: Install Dependencies#
pip install langgraph==0.2.0 langchain==0.3.0 langchain-openai==0.2.0 \
fastapi==0.115.0 uvicorn==0.30.6 python-dotenv==1.0.1 \
langgraph-checkpoint-sqlite==0.1.0
The langgraph-checkpoint-sqlite package gives you persistent state storage so interrupts survive process restarts.
Step 2: Define the Agent State#
# state.py
from typing import TypedDict, Optional, Literal
class ProposedAction(TypedDict):
action_type: str # e.g., "send_email", "database_write"
description: str # human-readable summary
parameters: dict # actual parameters for the action
risk_level: Literal["low", "medium", "high"]
class AgentState(TypedDict):
user_request: str
proposed_action: Optional[ProposedAction]
approval_status: Optional[Literal["pending", "approved", "rejected", "modified"]]
human_feedback: Optional[str]
modified_parameters: Optional[dict]
final_result: Optional[str]
error: Optional[str]
Step 3: Build the Planner Node#
# nodes.py
from langchain_openai import ChatOpenAI
from langchain_core.prompts import ChatPromptTemplate
from pydantic import BaseModel, Field
from state import AgentState, ProposedAction
import json
class ActionProposal(BaseModel):
action_type: str = Field(description="Type of action: send_email, write_file, api_call, database_write")
description: str = Field(description="Human-readable description of what will happen")
parameters: dict = Field(description="Parameters needed to execute the action")
risk_level: str = Field(description="Risk level: low, medium, or high")
planner_llm = ChatOpenAI(model="gpt-4o", temperature=0)
planner_chain = ChatPromptTemplate.from_messages([
("system", """You are a planning agent. Given a user request, propose a specific action.
Be precise about what you will do and what parameters are needed.
Risk levels: low=read-only, medium=creates data, high=modifies/deletes/sends externally."""),
("human", "User request: {request}"),
]) | planner_llm.with_structured_output(ActionProposal)
def planner_node(state: AgentState) -> AgentState:
proposal = planner_chain.invoke({"request": state["user_request"]})
return {
**state,
"proposed_action": {
"action_type": proposal.action_type,
"description": proposal.description,
"parameters": proposal.parameters,
"risk_level": proposal.risk_level,
},
"approval_status": "pending",
}
Step 4: The Interrupt Node#
This is the heart of HITL. interrupt() pauses the graph and surfaces data to the caller. The graph cannot proceed until Command(resume=...) is called.
# nodes.py (continued)
from langgraph.types import interrupt, Command
def approval_gate_node(state: AgentState) -> AgentState:
"""
Pause execution and wait for human approval.
The interrupt payload is what gets surfaced to the reviewer.
"""
action = state["proposed_action"]
# Only interrupt for medium or high risk actions
if action["risk_level"] == "low":
return {**state, "approval_status": "approved"}
# This pauses the graph and returns the payload to whoever called invoke()
human_decision = interrupt({
"type": "approval_request",
"proposed_action": action,
"message": f"Agent wants to: {action['description']}. Approve?",
})
# human_decision is provided when the graph is resumed via Command(resume=...)
return {
**state,
"approval_status": human_decision.get("decision", "rejected"),
"human_feedback": human_decision.get("feedback", ""),
"modified_parameters": human_decision.get("modified_parameters"),
}
Step 5: Executor and Responder Nodes#
# nodes.py (continued)
import smtplib
from email.message import EmailMessage
def executor_node(state: AgentState) -> AgentState:
"""Execute the action only if approved."""
if state["approval_status"] != "approved":
return {
**state,
"final_result": f"Action cancelled. Reason: {state.get('human_feedback', 'Rejected by reviewer')}",
}
action = state["proposed_action"]
params = state.get("modified_parameters") or action["parameters"]
try:
if action["action_type"] == "send_email":
result = _send_email(params)
elif action["action_type"] == "write_file":
result = _write_file(params)
else:
result = f"Action '{action['action_type']}' executed with params: {params}"
return {**state, "final_result": result}
except Exception as e:
return {**state, "error": str(e), "final_result": f"Execution failed: {e}"}
def _send_email(params: dict) -> str:
"""Demo email sender — swap with real SMTP or Gmail API in production."""
to = params.get("to", "")
subject = params.get("subject", "")
body = params.get("body", "")
# In production: connect to SMTP server with proper auth
return f"Email sent to {to} with subject '{subject}'"
def _write_file(params: dict) -> str:
path = params.get("path", "/tmp/agent_output.txt")
content = params.get("content", "")
with open(path, "w") as f:
f.write(content)
return f"File written to {path} ({len(content)} chars)"
def responder_node(state: AgentState) -> AgentState:
if state.get("error"):
return {**state, "final_result": f"Error: {state['error']}"}
return state
Step 6: Compile the Graph with a Checkpointer#
The checkpointer is what makes interrupts durable. Without it, the graph state is lost if the process restarts while waiting for approval.
# graph.py
from langgraph.graph import StateGraph, END
from langgraph.checkpoint.sqlite import SqliteSaver
from nodes import planner_node, approval_gate_node, executor_node, responder_node
from state import AgentState
def build_graph():
graph = StateGraph(AgentState)
graph.add_node("planner", planner_node)
graph.add_node("approval_gate", approval_gate_node)
graph.add_node("executor", executor_node)
graph.add_node("responder", responder_node)
graph.set_entry_point("planner")
graph.add_edge("planner", "approval_gate")
graph.add_edge("approval_gate", "executor")
graph.add_edge("executor", "responder")
graph.add_edge("responder", END)
# Persistent checkpointer — state survives process restarts
checkpointer = SqliteSaver.from_conn_string("./hitl_checkpoints.db")
return graph.compile(checkpointer=checkpointer, interrupt_before=["approval_gate"])
app_graph = build_graph()
Step 7: The Approval API#
Expose the HITL workflow over HTTP so humans can approve via a web interface, Slack bot, or any other frontend.
# api.py
import uuid
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
from langgraph.types import Command
from graph import app_graph
api = FastAPI(title="HITL Agent API")
class StartRequest(BaseModel):
user_request: str
user_id: str = "anonymous"
class ApprovalRequest(BaseModel):
thread_id: str
decision: str # "approved" | "rejected"
feedback: str = ""
modified_parameters: dict = {}
@api.post("/start")
async def start_workflow(req: StartRequest):
thread_id = str(uuid.uuid4())
config = {"configurable": {"thread_id": thread_id}}
# Run until the interrupt
result = app_graph.invoke(
{"user_request": req.user_request, "approval_status": None},
config=config,
)
# result will contain the interrupt payload
pending = app_graph.get_state(config)
interrupt_data = None
for task in pending.tasks:
if task.interrupts:
interrupt_data = task.interrupts[0].value
return {
"thread_id": thread_id,
"status": "pending_approval",
"proposed_action": interrupt_data,
}
@api.post("/approve")
async def approve_action(req: ApprovalRequest):
config = {"configurable": {"thread_id": req.thread_id}}
# Resume the graph with the human's decision
result = app_graph.invoke(
Command(resume={
"decision": req.decision,
"feedback": req.feedback,
"modified_parameters": req.modified_parameters or None,
}),
config=config,
)
return {
"status": "completed",
"result": result.get("final_result"),
"approval_status": result.get("approval_status"),
}
@api.get("/status/{thread_id}")
async def get_status(thread_id: str):
config = {"configurable": {"thread_id": thread_id}}
state = app_graph.get_state(config)
return {"thread_id": thread_id, "state": state.values}
Start the API: uvicorn api:api --reload
Test the full flow:
# Start a workflow
curl -X POST http://localhost:8000/start \
-H "Content-Type: application/json" \
-d '{"user_request": "Send an email to team@example.com about the Q1 report"}'
# Approve it (use the thread_id from the previous response)
curl -X POST http://localhost:8000/approve \
-H "Content-Type: application/json" \
-d '{"thread_id": "THREAD_ID", "decision": "approved", "feedback": "Looks good"}'
Production Considerations#
- Add observability with Langfuse tracing to log every approval decision
- Set approval timeouts — auto-reject actions pending for more than N hours to avoid zombie workflows
- Deploy the agent container with Docker best practices
- Test your approval workflows before production with the AI agent testing guide
- Implement role-based approval — high-risk actions require senior approval, low-risk can be auto-approved
What's Next#
- Explore the LangGraph multi-agent tutorial to build HITL into multi-agent systems
- Read the human-in-the-loop glossary entry for a framework comparison
- Apply HITL to a coding agent that asks for review before running generated code
- Review AI agent security best practices for hardening approval workflows
- Use HITL patterns in a legal document review agent for compliance workflows