n4nAI

How to add human-in-the-loop checkpoints in LangGraph

Step-by-step guide to adding LangGraph human-in-the-loop checkpoints: pause agent workflows for approval, persist state, and resume safely with code.

n4n Team3 min read560 words

Audio narration

Coming soon — every post will get a voice note here.

LangGraph human-in-the-loop patterns let you freeze an agent mid-execution, wait for a person to review or edit state, then resume without losing context. This guide walks through wiring checkpoints into a stateful graph using LangGraph’s interrupt primitive and a durable checkpointer, so you can drop approval gates into any agent workflow.

Step 1: Install and import the right packages

Use LangGraph 0.2.x or newer. The interrupt and Command types live in langgraph.types, and checkpointers ship under langgraph.checkpoint.

pip install langgraph langchain-core

For a durable backend we’ll use SQLite in this example; swap to Postgres later by changing the import.

from typing import TypedDict
from langgraph.graph import StateGraph, START, END
from langgraph.types import interrupt, Command
from langgraph.checkpoint.sqlite import SqliteSaver

Step 2: Define state and nodes with an interrupt

State is a typed dict. The node that needs human sign-off calls interrupt() with a payload describing what the human should see. The return value of interrupt() is whatever you pass to Command(resume=...) later.

class State(TypedDict):
    draft: str
    approved: bool
    final: str

def draft_node(state: State):
    # In real code, call an LLM here. If you route through n4n.ai, its
    # OpenAI-compatible endpoint with automatic fallback keeps this node
    # resilient when a provider is degraded.
    return {"draft": "Invoice #42 is ready for your review."}

def approval_node(state: State):
    # Pause for human. The dict is surfaced to your UI or CLI.
    review = interrupt({"draft": state["draft"], "action": "approve?"})
    # review is the human's response
    return {"approved": bool(review.get("approved", False))}

def send_node(state: State):
    if state["approved"]:
        return {"final": state["draft"] + " (sent)"}
    return {"final": "discarded"}

The interrupt call raises a control flow exception that LangGraph catches, snapshots state, and returns control to the caller.

Step 3: Compile the graph with a durable checkpointer

A checkpointer is what makes LangGraph human-in-the-loop survivable across process restarts. MemorySaver is fine for tests; SqliteSaver or PostgresSaver is mandatory for production.

builder = StateGraph(State)
builder.add_node("draft", draft_node)
builder.add_node("approval", approval_node)
builder.add_node("send", send_node)
builder.add_edge(START, "draft")
builder.add_edge("draft", "approval")
builder.add_edge("approval", "send")
builder.add_edge("send", END)

with SqliteSaver.from_conn_string("checkpoints.db") as saver:
    graph = builder.compile(checkpointer=saver)

The thread_id in the config scopes state. Every invocation with the same thread_id reads/writes the same checkpoint sequence.

Step 4: Run the graph and detect the interruption

Invoke with a config that includes thread_id. On first run the graph executes draft_node, hits approval_node, and stops at the interrupt.

config = {"configurable": {"thread_id": "job-1"}}
result = graph.invoke({"draft": ""}, config)
print(result)

LangGraph returns the state up to the interrupt, with an __interrupt__ key containing your payload. Your application should detect this and surface the question to a human instead of proceeding.

if "__interrupt__" in result:
    prompt = result["__interrupt__"][0].value
    print("Human needed:", prompt)

Step 5: Resume with human input via Command

Once the human responds, resume the exact same thread with Command(resume=...). The value you pass becomes the return value of interrupt() inside approval_node.

human_response = {"approved": True}
result = graph.invoke(Command(resume=human_response), config)
print(result)

The graph replays from the saved checkpoint, executes the remainder of approval_node, then runs send_node. Final state shows approved: True and final containing the sent string.

If the human rejects, pass {"approved": False} and the send_node branches accordingly.

Step 6: Verify the checkpoint and resume worked

Verification is two-fold: confirm the interrupt fired, and confirm the resumed run completed the remaining nodes.

Inspect state directly:

state = graph.get_state(config)
assert state.values["approved"] is True
assert "sent" in state.values["final"]

You can also list the checkpoint history to prove persistence:

for checkpoint in graph.get_state_history(config):
    print(checkpoint.step, checkpoint.values)

A successful run shows at least two steps: one ending at approval, one ending at END.

Step 7: Handle restarts and production persistence

Kill the Python process after Step 4, then restart and run Step 5 with the same thread_id and same checkpoints.db file. The graph resumes because the checkpoint is on disk, not in memory.

For multi-worker deployments, use PostgresSaver:

from langgraph.checkpoint.postgres import PostgresSaver
saver = PostgresSaver.from_conn_string("postgresql://user:pass@host/db")
saver.setup()
graph = builder.compile(checkpointer=saver)

Two caveats from production:

  • Idempotency: nodes after an interrupt may re-execute if you change graph topology. Keep side effects (sending email, writing DB) behind a flag in state or use LangGraph’s built-in retry controls.
  • Serialization: state must be JSON-serializable or registered with the checkpointer’s serializer. Don’t put raw model objects in state.

Alternative: interrupt_before

If you don’t want to modify the node, compile with interrupt_before=["approval"]. The graph pauses before entering the node; resume with Command(resume=None) and read prior state via get_state. The in-node interrupt() approach is better when you need to pass a dynamic payload to the human.

What you shipped

You now have a LangGraph human-in-the-loop gate that persists across restarts, surfaces a structured prompt to a person, and resumes exactly where it left off. The same pattern works for editing tool calls, confirming deletions, or any step where an agent shouldn’t act unilaterally.

Tagslanggraphhuman-in-the-loopai-agents

Written by

n4n Team

The team building n4n — a single OpenAI-compatible API in front of 240+ models, with automatic fallback, load balancing and pay-per-token metering.

More from n4n Team →

All langgraph for agent workflows posts →