#31 LangGraph Human-in-the-Loop Explained | Add Human Approval to Your AI Agents
Human-in-the-Loop (HITL) is an architectural pattern in agentic AI where an autonomous agent deliberately pauses its execution pipeline to request human intervention, validation, or feedback before committing to critical or irreversible actions. By incorporating checkpointing and interrupt primitives within LangGraph, developers can build resilient agent workflows that mitigate LLM hallucinations, enforce business governance, and safely execute high-stakes operations such as sending emails, deleting records, or running terminal commands.
1. The Need for Human-in-the-Loop in Agentic Systems
In standard autonomous pipelines, an agent receives a user prompt, reasons over required steps, invokes tools, and produces a final outcome in a single uninterrupted execution loop.
While modern Large Language Models (LLMs) are powerful reasoning engines, they are not infallible. Autonomous agents face key structural vulnerabilities in production:
- Hallucinations and Fact Drift: LLMs can misinterpret dates, invent facts, or create inappropriate phrasing (e.g., miscalculating days of the week or generating inaccurate contractual commitments).
- Irreversible Side Effects: Actions like sending an email to a real recipient, modifying database records, or deleting production files cannot be undone once executed.
- Lack of Contextual Judgment: An agent might execute a task logically according to its prompt while violating unspoken human nuances or corporate tone guidelines.
To eliminate the risks of the naive approach, Human-in-the-Loop introduces an explicit breakpoint between generation and execution. The system pauses its execution state, surfaces the draft to a human operator, and waits for explicit approval or modification feedback before moving forward.
2. Core Mental Model: Checkpointing, Interrupts, and Resumption
Building an interactive Human-in-the-Loop system requires three foundational components in LangGraph:
- Checkpointer (
In-Memory Saver/ Persistent DB): A graph must track its historical snapshots across invocations. When an agent pauses execution, the state graph must persist its internal variables (e.g., query, draft body, feedback history) under a unique identifier (thread_id). - Interrupt Primitive (
interrupt(...)): A special call inside a node that pauses the LangGraph execution engine. It returns control to the caller while packing a custom payload (such as the draft text and prompt instructions) into the thread's checkpoint. - Resumption Primitive (
Command(resume=...)): To re-awaken a paused graph, the client re-invokes the graph on the identicalthread_idusing aCommandobject. The payload passed toresumeis injected directly into the variable where theinterruptwas initially evaluated.
3. Naive Linear Graph vs. Cyclic HITL Architecture
The Naive Linear Architecture
In an initial linear graph, control flows strictly forward:
Start -> Draft Email -> Human Feedback -> Finalize Node -> End
If the human rejects the output or requests adjustments, a purely linear graph has no mechanism to rewind execution back to the drafting stage without restarting the entire program from scratch, discarding all prior context.
The Cyclic HITL Architecture
By replacing static edges with Conditional Edges (add_conditional_edges), the graph inspects the current state after the interrupt resolves. If the human input contains modifications, control loops back to the Draft Email node. The LLM re-evaluates the draft alongside the user's specific critique until the human enters an explicit approval token.
4. State Definition and Node Implementations
The graph's state represents the schema of data moving between nodes. Using Pydantic's BaseModel, we define MailState to track the user prompt, current draft, human critique, and the final output message.
Defining State
from pydantic import BaseModel
from typing import Optional
class MailState(BaseModel):
query: str = ""
draft: str = ""
human_feedback: str = ""
final_response: str = ""
Implementing Graph Nodes
The architecture consists of three nodes:
draft_email: Evaluates whether human feedback exists. If empty, it generates an initial draft. If human feedback is present, it constructs a revision prompt that explicitly includes the previous draft, the critique, and the original user intent.human_feedback_node: Executesinterrupt(...)with a UI-facing dictionary payload. When resumed viaCommand(resume=...), it evaluates whether the response is an approval (e.g.,"yes","ok","approved") or a critique.finalize_node: Executes side effects (such as sending the actual email) once the human approves.
from langchain_groq import ChatGroq
from langgraph.types import interrupt
# Initialize LLM using ChatGroq
llm = ChatGroq(model="openai/gpt-oss-20b")
def draft_email(state: MailState) -> MailState:
"""
Generates an initial email or revises an existing draft
based on human feedback.
"""
if state.human_feedback:
# Instruction priority: Put the rewrite objective and critique first,
# followed by the historical context to avoid LLM context-loss.
prompt = (
f"Rewrite this draft email for the following user request: {state.query}\n\n"
f"You previously generated this draft:\n{state.draft}\n\n"
f"Human feedback to apply:\n{state.human_feedback}\n\n"
f"Generate the revised draft:"
)
response = llm.invoke(prompt)
state.draft = response.content
else:
response = llm.invoke(state.query)
state.draft = response.content
return state
def human_feedback_node(state: MailState) -> MailState:
"""
Halts graph execution and yields an interrupt containing the draft.
Resumes when a human provides input.
"""
# System pauses execution at this line and serializes state to checkpointer
feedback = interrupt({
"draft_email": state.draft,
"question": "Do you want to approve or rewrite the email?"
})
cleaned_feedback = str(feedback).strip().lower()
# Check for approval tokens
if cleaned_feedback in ["approved", "ok", "yes"]:
state.human_feedback = "" # Clear feedback to indicate approval
else:
state.human_feedback = str(feedback) # Retain revision instructions
return state
def finalize_node(state: MailState) -> MailState:
"""
Simulates sending the verified email after approval.
"""
if state.human_feedback:
state.final_response = f"Sending aborted: {state.human_feedback}"
else:
print("Dispatching email to recipient...")
state.final_response = "Email sent successfully."
return state
5. Conditional Routing and Edge Wiring
To allow the graph to loop back for revisions, we introduce a conditional router function returning a Literal["draft", "final"].
from typing import Literal
from langgraph.graph import StateGraph, START, END
from langgraph.checkpoint.memory import InMemorySaver
def condition_routing(state: MailState) -> Literal["draft", "final"]:
"""
Directs graph flow based on whether human feedback was left unresolved.
"""
if state.human_feedback:
# Route back to rewrite if critique is present
return "draft"
# Route forward to finalize if approved
return "final"
# 1. Initialize StateGraph
graph_builder = StateGraph(MailState)
# 2. Add Nodes
graph_builder.add_node("draft", draft_email)
graph_builder.add_node("human_feedback", human_feedback_node)
graph_builder.add_node("final", finalize_node)
# 3. Add Edges
graph_builder.add_edge(START, "draft")
graph_builder.add_edge("draft", "human_feedback")
# 4. Add Conditional Edge for the Feedback Loop
graph_builder.add_conditional_edges(
"human_feedback",
condition_routing,
{
"draft": "draft",
"final": "final"
}
)
graph_builder.add_edge("final", END)
# 5. Compile with an In-Memory Checkpointer
checkpointer = InMemorySaver()
app = graph_builder.compile(checkpointer=checkpointer)
6. End-to-End Execution Flow Walkthrough
Complete Code Execution Script
from langgraph.types import Command
# Session configuration to map state snapshots across calls
config = {"configurable": {"thread_id": "thread-alpha-101"}}
# --- INITIAL INVOCATION ---
initial_input = {
"query": (
"I want to send an email for a medical leave request. "
"Dates will be 2nd June to 5th June. Create a simple mail."
)
}
print("=== 1. Starting Workflow ===")
first_run = app.invoke(initial_input, config=config)
print("\n[Current Output State]:")
print("Draft Content:\n", first_run["draft"])
print("Interrupt Payload:\n", first_run.get("__interrupt__", "No interrupt triggered"))
# --- FIRST RESUMPTION: REVISION REQUESTED ---
revision_instruction = (
"I want to change the leave dates. New dates are 4th June to 7th June. "
"Remove the manager name placeholders and just sign off saying 'Thank you'."
)
print("\n=== 2. Resuming with Human Revision Feedback ===")
second_run = app.invoke(Command(resume=revision_instruction), config=config)
print("\n[Current Output State after Revision]:")
print("Revised Draft Content:\n", second_run["draft"])
print("Interrupt Payload:\n", second_run.get("__interrupt__", "No interrupt triggered"))
# --- SECOND RESUMPTION: APPROVAL GRANTED ---
print("\n=== 3. Resuming with Human Approval ===")
final_run = app.invoke(Command(resume="yes"), config=config)
print("\n[Final Workflow State]:")
print("Final Response:", final_run["final_response"])
print("Draft:", final_run["draft"])
7. Production Edge Cases, Prompt Bugs, and Pitfalls
1. The "Prompt-Ordering" Recency Bug
- The Bug: When prompting the LLM to rewrite a draft, placing the general request (
state.query) at the very end of the prompt causes the model to prioritize the original prompt over the human's critique. As a result, the model regenerates the initial draft instead of applying the requested changes. - The Solution: Structure the rewrite prompt hierarchically: (1) Goal / Rewrite command, (2) User feedback / critique, (3) Prior draft, and (4) Original base context.
2. Thread ID Mismanagement
- Calling
app.invoke(Command(resume=...), config={"configurable": {"thread_id": "different_id"}})creates a runtime failure or spawns a blank state graph. - The
thread_idacts as the primary key in the checkpointer storage engine. It must remain strictly consistent across the entire life cycle of an interactive session.
3. Missing Checkpointers During Compilation
- Calling
graph_builder.compile()without passingcheckpointer=...causesinterrupt(...)calls to crash at runtime. - The
interruptmechanism requires a persistent or in-memory checkpointer to serialize the graph snapshot before exiting.
Summary and Key Takeaways
- Core Premise: Human-in-the-Loop (HITL) introduces controlled human verification into autonomous agent execution paths, preventing unmonitored mistakes during sensitive actions.
- The Three LangGraph Primitives:
InMemorySaver: Persists historical graph states using a uniquethread_id.interrupt(...): Suspends graph execution at a specific node and yields a payload to the user.Command(resume=...): Re-invokes a paused graph, passing user input directly back to the suspended node.
- Safe Action Boundaries: Destructive or outbound external tool calls (e.g., email sending, payments, deletions) must reside in separate execution nodes downstream from human approval interrupts.
- Cyclic Refinement: By combining
interrupt(...)with conditional edges (add_conditional_edges), agents can loop back to revision nodes indefinitely until an approval token is received. - Prompt Priority in Re-Drafting: When revising text via an LLM, prioritize the human's feedback and previous draft over the original prompt to prevent the model from ignoring edit instructions.
- Real-World Agent Alignment: HITL mirrors production permission patterns used in developer agents (e.g., VS Code agent tools, AntiGravity), where actions like executing bash scripts or deleting files require explicit human consent.