Blogs / Human-in-the-Loop Agents with LangGraph

Human-in-the-Loop Agents with LangGraph

Published
October 3, 2026
Author
Faizan Nadeem
Tags
LangGraph AI Agents Postgres Python
A pair of hands typing on a laptop keyboard at a desk
Photo by Vitaly Gariev on Unsplash

The pitch for autonomous agents is always “it handles the whole task end to end.” The reality of shipping one is that almost nobody actually wants that for anything consequential β€” sending an email to a customer, deleting a database row, approving a refund. What they want is an agent that does the work and stops right before the irreversible part, waits for a person to look at it, and then finishes. That’s a completely different engineering problem than “call the model in a loop,” because now your agent has to survive being paused for an unknown length of time β€” seconds if someone’s watching a dashboard, days if the approver is on vacation.

Most agent frameworks don’t actually support that. They support “pause,” in the sense that you can stop calling .invoke(), but they don’t support “resume with everything intact,” because there was never anywhere durable to put the state in the first place. LangGraph is one of the few that treats this as a first-class scenario rather than something you bolt on with a database table and a lot of custom glue. This post covers the two pieces that make it work: the interrupt() function, which pauses a running graph mid-node, and a Postgres-backed checkpointer, which is what makes “resume days later” actually true instead of aspirational.

You’ll learn:

  • Why an in-memory pause isn’t the same thing as a durable one
  • How interrupt() stops a graph node and hands control back to your application
  • How to resume a paused graph with Command(resume=...)
  • Setting up PostgresSaver so paused state survives a process restart
  • What actually gets written to Postgres on every checkpoint, and why that matters for debugging
  • How to design the human review step itself so it doesn’t become the bottleneck
  • The failure modes specific to long-paused graphs, and how to guard against them

Table of Contents

  1. Why a Pause Button Needs to Be Durable
  2. The interrupt Function
  3. Resuming with Command
  4. Checkpointers: What Actually Gets Saved
  5. Postgres-Backed Checkpointing in Production
  6. Resuming a Graph Days Later
  7. Designing the Review Step Itself
  8. A Full Approval Flow, End to End
  9. Testing Pause and Resume Locally
  10. Failure Modes and How to Guard Against Them
  11. Wrapping Up

Why a Pause Button Needs to Be Durable

It’s worth being precise about what “human in the loop” actually requires, because it’s easy to build something that looks right in a demo and falls over in production. A demo-grade pause is: hold the Python process open, block on input(), and continue once someone types something. That works exactly as long as the process stays alive and one person is watching a terminal.

Production human-in-the-loop needs three things a blocking input() call can’t give you: the ability to pause across a process restart (deploys happen; servers get rescheduled), the ability to have the “resume” event come from a completely different process β€” a webhook from a Slack approval button, a click in an internal dashboard β€” and the ability to have many agents paused simultaneously without holding a thread or a connection open for each one. That’s the actual requirement, and it’s why this isn’t solvable with a loop and a flag β€” it needs the graph’s state written somewhere durable, indexed by an ID you can look up later. If you haven’t yet made the decision to use a graph-based framework at all, that decision point β€” chains versus graphs β€” is covered in a dedicated post; this one assumes you’ve already landed on LangGraph and picks up from there.

The interrupt Function

LangGraph’s interrupt() is a function you call from inside a node. When it’s hit, the graph stops execution at that exact point, and whatever you passed to interrupt() gets surfaced to whoever is running .invoke().

from langgraph.types import interrupt

def request_approval(state: AgentState):
    decision = interrupt({
        "action": "send_refund",
        "amount": state["refund_amount"],
        "customer": state["customer_id"],
        "reason": state["reason"],
    })
    return {"approved": decision["approved"]}

When request_approval runs and hits interrupt(...), the graph’s execution halts and the payload you passed is returned to the caller as part of the invocation result, tagged as an interrupt rather than a normal completion:

result = graph.invoke({"refund_amount": 42.50, "customer_id": "cust_9", "reason": "damaged item"}, config)

if "__interrupt__" in result:
    interrupt_data = result["__interrupt__"][0].value
    print(interrupt_data)
    # {'action': 'send_refund', 'amount': 42.5, 'customer': 'cust_9', 'reason': 'damaged item'}

Nothing after the interrupt() call has run yet β€” the node function itself hasn’t even returned. This is the detail that trips people up: interrupt() doesn’t return a value on this call, it raises control back out of the graph entirely. The line decision = interrupt(...) only actually assigns a value on the next invocation, after you resume β€” LangGraph replays the node function from the start and this time interrupt() returns immediately with what you passed in on resume.

Resuming with Command

To resume, you don’t call .invoke() with a new input β€” you call it with a Command object wrapping the value the paused interrupt() call should receive.

from langgraph.types import Command

result = graph.invoke(
    Command(resume={"approved": True}),
    config,  # same config, same thread_id, as the original call
)

The config β€” specifically its thread_id β€” is what ties this resume back to the exact paused execution. LangGraph looks up the checkpointed state for that thread, replays the interrupted node with interrupt() now returning {"approved": True}, and continues execution from there through the rest of the graph as normal.

config = {"configurable": {"thread_id": f"refund-{refund_id}"}}

# First call β€” pauses at request_approval
result = graph.invoke({"refund_amount": 42.50, "customer_id": "cust_9"}, config)

# ... hours or days pass, a human clicks "approve" in a dashboard ...

# Second call β€” resumes and completes
final = graph.invoke(Command(resume={"approved": True}), config)

A node can also call interrupt() more than once across a graph’s lifetime β€” a review, then a second confirmation step later β€” and each one independently pauses and resumes using the same Command(resume=...) pattern, keyed by the same thread_id.

Checkpointers: What Actually Gets Saved

None of this works without a checkpointer, because interrupt() on its own is just a control-flow signal β€” it’s the checkpointer that actually persists the state so a different process, possibly minutes or days later, can pick it back up. Every time a node finishes executing, the checkpointer writes a snapshot of the full graph state, plus metadata about which node is next, keyed by thread_id.

from langgraph.checkpoint.memory import MemorySaver

checkpointer = MemorySaver()
graph = builder.compile(checkpointer=checkpointer)

MemorySaver is fine for local development, but it’s still just a Python dict under the hood β€” kill the process and every paused thread is gone. For anything that needs to survive past the lifetime of one process, you need a checkpointer backed by real storage.

Postgres-Backed Checkpointing in Production

PostgresSaver (from the langgraph-checkpoint-postgres package) persists the same state to an actual Postgres table, which is what turns “pause” into “pause indefinitely, across restarts, resumable from any process that has the connection string.”

from langgraph.checkpoint.postgres import PostgresSaver

DB_URI = "postgresql://user:password@localhost:5432/agent_db"

with PostgresSaver.from_conn_string(DB_URI) as checkpointer:
    checkpointer.setup()  # creates the checkpoint tables on first run
    graph = builder.compile(checkpointer=checkpointer)
    result = graph.invoke(initial_state, config)

For a long-lived server process β€” a FastAPI app handling many concurrent agents β€” you generally want to open the connection once at startup rather than per-request, and use the async variant so pausing one agent doesn’t block others:

from langgraph.checkpoint.postgres.aio import AsyncPostgresSaver

async def build_graph():
    checkpointer = AsyncPostgresSaver.from_conn_string(DB_URI)
    await checkpointer.setup()
    return builder.compile(checkpointer=checkpointer)

# in a request handler
result = await graph.ainvoke(initial_state, config)

checkpointer.setup() is idempotent and only needs to run once per database β€” in a real deployment you’d typically run it as part of a migration step rather than on every app boot, the same way you’d treat any other schema migration.

Resuming a Graph Days Later

The part worth spelling out explicitly, because it’s the whole point of using Postgres over MemorySaver: the process that resumes an agent doesn’t have to be the process that started it. A refund-approval agent paused on Friday can be resumed by an entirely different server instance on Monday, as long as it has the thread_id and a connection to the same Postgres database.

# On a completely different server, days later, triggered by a Slack button click:

async def handle_approval_webhook(refund_id: str, approved: bool):
    checkpointer = AsyncPostgresSaver.from_conn_string(DB_URI)
    graph = builder.compile(checkpointer=checkpointer)

    config = {"configurable": {"thread_id": f"refund-{refund_id}"}}
    result = await graph.ainvoke(Command(resume={"approved": approved}), config)
    return result

This is the practical difference between a toy pause and a real one: the thread_id is effectively a durable pointer into Postgres, and any process with database access can pick up exactly where the graph left off β€” full message history, all intermediate state, no re-running of nodes that already completed. You can inspect what’s paused at any time with graph.get_state(config), which returns the current checkpointed state and, critically, .next β€” the tuple of node names that will run when you resume, so a dashboard can show “waiting on: request_approval” without guessing.

Designing the Review Step Itself

The mechanics above are necessary but not sufficient β€” a human-in-the-loop step that’s mechanically correct but poorly designed just becomes a slow, resented bottleneck. A few things that make the review step itself actually work:

  • Pass the reviewer everything they need in the interrupt payload, not a reference to go look something up. If interrupt() hands back {"action": "send_refund", "amount": 42.50, "reason": "damaged item"}, a reviewer can decide from that alone. If it hands back {"refund_id": "r_123"}, you’ve just made every approval a two-step process.
  • Make the resume payload’s shape match what the node actually branches on. Don’t resume with a raw boolean if you’ll eventually need a rejection reason too β€” design {"approved": bool, "note": str} from the start, even if you don’t use note yet.
  • Time-box what “paused” means for your use case. A refund approval that’s still pending after five days is probably a process failure, not a patient reviewer β€” add expiry logic (a scheduled job that checks updated_at on stale threads and escalates or auto-rejects) rather than letting threads pause forever.
  • Log the interrupt payload independently of the checkpoint. Postgres checkpoint tables aren’t meant to be a human-readable audit log β€” write a separate row to an approvals table when interrupt() fires, so you have a query-able history that doesn’t depend on deserializing LangGraph’s internal state format.

A Full Approval Flow, End to End

It helps to see the pieces above assembled into something closer to a real service β€” an agent that drafts a refund, pauses for approval, and either issues it or notifies the customer of a rejection, exposed through a small FastAPI app.

from fastapi import FastAPI
from langgraph.graph import StateGraph, START, END
from langgraph.types import interrupt, Command
from langgraph.checkpoint.postgres.aio import AsyncPostgresSaver

class RefundState(TypedDict):
    customer_id: str
    amount: float
    reason: str
    approved: bool | None

def draft_refund(state: RefundState):
    # Any model calls or business logic that decide the refund amount happen here.
    return {"amount": round(state["amount"], 2)}

def request_approval(state: RefundState):
    decision = interrupt({
        "action": "approve_refund",
        "customer_id": state["customer_id"],
        "amount": state["amount"],
        "reason": state["reason"],
    })
    return {"approved": decision["approved"]}

def issue_refund(state: RefundState):
    process_payment_refund(state["customer_id"], state["amount"])
    return {}

def notify_rejection(state: RefundState):
    send_customer_email(state["customer_id"], "Your refund request was declined.")
    return {}

def route_on_approval(state: RefundState) -> str:
    return "issue_refund" if state["approved"] else "notify_rejection"

builder = StateGraph(RefundState)
builder.add_node("draft_refund", draft_refund)
builder.add_node("request_approval", request_approval)
builder.add_node("issue_refund", issue_refund)
builder.add_node("notify_rejection", notify_rejection)
builder.add_edge(START, "draft_refund")
builder.add_edge("draft_refund", "request_approval")
builder.add_conditional_edges("request_approval", route_on_approval)
builder.add_edge("issue_refund", END)
builder.add_edge("notify_rejection", END)

app = FastAPI()

@app.post("/refunds/{refund_id}/start")
async def start_refund(refund_id: str, customer_id: str, amount: float, reason: str):
    checkpointer = AsyncPostgresSaver.from_conn_string(DB_URI)
    graph = builder.compile(checkpointer=checkpointer)
    config = {"configurable": {"thread_id": f"refund-{refund_id}"}}
    result = await graph.ainvoke(
        {"customer_id": customer_id, "amount": amount, "reason": reason, "approved": None},
        config,
    )
    return {"status": "pending_review", "interrupt": result.get("__interrupt__")}

@app.post("/refunds/{refund_id}/decide")
async def decide_refund(refund_id: str, approved: bool):
    checkpointer = AsyncPostgresSaver.from_conn_string(DB_URI)
    graph = builder.compile(checkpointer=checkpointer)
    config = {"configurable": {"thread_id": f"refund-{refund_id}"}}
    result = await graph.ainvoke(Command(resume={"approved": approved}), config)
    return {"status": "completed"}

Notice that draft_refund β€” anything that might call a model or an external pricing service β€” runs before the interrupt, and issue_refund β€” the actual side effect β€” only ever runs after a resume, gated by route_on_approval. That ordering is deliberate: it’s the same “no side effects before an interrupt you might replay” rule from the failure-modes section below, just applied to a concrete flow instead of stated abstractly.

Testing Pause and Resume Locally

Because the whole point of this pattern is surviving a process boundary, it’s worth testing that boundary explicitly rather than trusting that MemorySaver behavior in development will carry over to Postgres in production. A useful local test: spin up a real Postgres instance (Docker is fine), start a graph, kill the Python process entirely, start a fresh process, and resume using only the thread_id and the connection string β€” no shared memory, no imports carried over from the first process.

import subprocess, time

# Process 1: start the graph, let it pause at the interrupt, then exit.
subprocess.run(["python", "start_refund.py", "--refund-id", "test-1"])

# Simulate a deploy, a crash, whatever β€” nothing from process 1 is still running.
time.sleep(1)

# Process 2: a completely fresh interpreter, resuming purely from Postgres state.
subprocess.run(["python", "resume_refund.py", "--refund-id", "test-1", "--approved", "true"])

If this round-trip works with two genuinely separate processes, you’ve validated the property that actually matters β€” not “the code compiles,” but “the state survives a boundary the code has zero visibility across.” Testing pause/resume within a single process (calling .invoke() twice in the same script) will pass even with a checkpointer that’s secretly leaking state through a module-level variable, and won’t catch that until it’s a production incident.

Failure Modes and How to Guard Against Them

Resuming with the wrong thread_id. LangGraph won’t error loudly if you pass a thread_id for a thread that isn’t actually paused at an interrupt β€” worst case, it just runs from whatever state exists (or nothing, for a fresh ID). Generate thread_ids deterministically from a real entity ID (f"refund-{refund_id}", not a random UUID you have to store somewhere else) so there’s no separate lookup table to get out of sync.

Node code that isn’t safe to replay. Because LangGraph replays the interrupted node function from the top on resume, any code before the interrupt() call inside that node runs again on resume. If that code sends an email or writes to an external system, you’ll double-send. Keep side effects after the interrupt() call, not before it, or make them idempotent.

Schema drift between pause and resume. If you change your AgentState TypedDict β€” add a required field β€” between when a thread was paused and when it resumes, deserializing the old checkpoint can break. Treat state schema changes the same way you’d treat a database migration: additive by default, and handle missing fields defensively in node code rather than assuming every checkpoint matches today’s schema.

Connection pool exhaustion under many paused threads. Paused threads don’t hold a database connection open β€” they’re just rows in Postgres β€” but a spike of simultaneous resumes can still exhaust your pool if AsyncPostgresSaver isn’t configured with a sensible pool size for your expected concurrency. Size the pool for your resume rate, not your pause rate.

Treating get_state() as a substitute for your own audit trail. graph.get_state(config) is genuinely useful for debugging a single paused thread interactively, but it’s not built to answer “show me every refund pending approval right now” β€” that’s a query LangGraph’s checkpoint schema wasn’t designed to serve efficiently at scale. If a dashboard needs to list pending approvals, maintain that as a normal application-level table (approvals with a status column) written alongside the checkpoint, rather than trying to scan checkpoint state for it.

Wrapping Up

The gap between “our agent can pause” and “our agent can pause for a week and resume on a different machine without losing anything” is exactly the gap between holding state in a Python variable and writing it to Postgres on every step. interrupt() gives you the control-flow primitive β€” a clean way to stop a node mid-execution and hand control back to whoever’s driving the graph β€” and PostgresSaver gives you the durability that makes the “resume days later” part actually true rather than a demo trick. Once both pieces are in place, human review stops being a special case your architecture has to work around and becomes just another node in the graph, one that happens to wait on a person instead of a model.

Faizan Nadeem
Author
Faizan Nadeem

AI/GenAI engineer specialising in agentic RAG, LangGraph, and production LLM systems with Python and FastAPI. 2 years building AI agents, RAG pipelines, and the backends behind them.

More articles