🤖AI Agents Guide
TutorialsComparisonsReviewsExamplesIntegrationsUse CasesTemplatesGlossary
Get Started
🤖AI Agents Guide

Your comprehensive resource for understanding, building, and implementing AI Agents.

Learn

  • Tutorials
  • Glossary
  • Use Cases
  • Examples

Compare

  • Tool Comparisons
  • Reviews
  • Integrations
  • Templates

Company

  • About
  • Contact
  • Privacy Policy

© 2026 AI Agents Guide. All rights reserved.

Home/Tutorials/Build Human-in-the-Loop Agents (LangGraph)
advanced38 min read

Build Human-in-the-Loop Agents (LangGraph)

Learn how to build AI agents that pause and request human approval before taking consequential actions, using LangGraph interrupt nodes, Command patterns, and practical HITL workflow designs that work in production.

Person reviewing documents on a screen representing human oversight of an AI workflow
Photo by Possessed Photography on Unsplash
By AI Agents Guide Team•February 28, 2026

Table of Contents

  1. What You'll Learn
  2. Prerequisites
  3. Architecture Overview
  4. Step 1: Install Dependencies
  5. Step 2: Define the Agent State
  6. Step 3: Build the Planner Node
  7. Step 4: The Interrupt Node
  8. Step 5: Executor and Responder Nodes
  9. Step 6: Compile the Graph with a Checkpointer
  10. Step 7: The Approval API
  11. Production Considerations
  12. What's Next
Assortment of mechanical parts scattered on a white background.
Photo by Falconphoto Falconphoto on Unsplash

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 Command pattern 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:

  1. Planner — Understands the user request and proposes an action
  2. Interrupt (Approval Gate) — Pauses execution and surfaces the proposed action to a human reviewer
  3. Executor — Carries out the approved action (or handles rejection)
  4. 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

Diagram showing the LangGraph HITL workflow with interrupt node pausing between planner and executor

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

Related Tutorials

How to Create a Meeting Scheduling AI Agent

Build an autonomous AI agent to handle meeting scheduling, calendar checks, and bookings intelligently. This step-by-step tutorial covers Python implementation with LangChain, Google Calendar integration, and advanced features like conflict resolution for efficient automation.

How to Manage Multiple AI Agents

Master managing multiple AI agents with this in-depth tutorial. Learn orchestration, state sharing, parallel execution, and scaling using LangGraph and custom tools. From basics to production-ready swarms for complex tasks.

How to Train an AI Agent on Your Own Data

Master training AI agents on custom data with three methods: context stuffing, RAG using vector databases, and fine-tuning. This beginner-to-advanced guide includes step-by-step code examples, pitfalls, and best practices to build knowledgeable agents for your specific needs.

← Back to All Tutorials