NovuSpark
All articles
AIApril 24, 2026 · NovuSpark Team

Human-in-the-Loop Workflows with LangGraph

This is the fourth post in our LangGraph fundamentals series, building on conditional edges and the agent-loop concepts from earlier in this series.

We flagged in our LangChain series that a tool taking a consequential action — a refund, an account change — needs safeguards beyond just trusting an agent's judgment. Pausing for actual human approval before a consequential step proceeds is one of the most direct safeguards available, and it needs real infrastructure: a graph has to genuinely stop, potentially for hours or days, and later resume in exactly the state it left off in. That's what LangGraph's checkpointing provides.

Interrupts: pausing a graph deliberately

from langgraph.graph import StateGraph, END
from langgraph.checkpoint.memory import MemorySaver
 
class RefundState(TypedDict):
    request_amount: float
    customer_id: str
    approved: bool
    result: str
 
def evaluate_refund(state: RefundState) -> RefundState:
    if state["request_amount"] > 500:
        state["approved"] = False  # requires human review — set explicitly, not assumed
    else:
        state["approved"] = True
    return state
 
def process_refund(state: RefundState) -> RefundState:
    state["result"] = f"Refund of ${state['request_amount']} processed for {state['customer_id']}"
    return state
 
graph = StateGraph(RefundState)
graph.add_node("evaluate_refund", evaluate_refund)
graph.add_node("process_refund", process_refund)
graph.set_entry_point("evaluate_refund")
graph.add_edge("evaluate_refund", "process_refund")
graph.add_edge("process_refund", END)
 
checkpointer = MemorySaver()
app = graph.compile(checkpointer=checkpointer, interrupt_before=["process_refund"])

interrupt_before=["process_refund"] tells the graph to stop before that specific node runs, no matter what any conditional logic decided — the graph reaches that point and genuinely pauses, returning control to your application rather than continuing automatically.

invoke()evaluate_refundPAUSED — checkpoint persistedhours or days, any processhuman approvesprocess_refund
Fig. 1 — the graph genuinely halts at the interrupt point; a checkpoint lets it resume exactly where it stopped, in a different process, after any real amount of time

Resuming after a real decision

config = {"configurable": {"thread_id": "refund-request-4471"}}
 
result = app.invoke(
    {"request_amount": 750, "customer_id": "cust-123", "approved": False, "result": ""},
    config=config,
)
print("Paused. Current state:", app.get_state(config).values)
Paused. Current state: {'request_amount': 750, 'customer_id': 'cust-123', 'approved': False, 'result': ''}

Execution has genuinely stopped here — not just slowed down, actually halted, with its exact state persisted via the checkpointer and addressable later through thread_id. A human reviewer — hours or days later, in an entirely separate process, possibly on an entirely different machine — can approve and resume:

# Later, potentially in a completely different process, after a human reviews the request
app.update_state(config, {"approved": True})
final_result = app.invoke(None, config=config)
print(final_result["result"])
Refund of $750 processed for cust-123

app.invoke(None, config=config) — passing None as input — resumes a graph from exactly where it left off, using the thread_id to look up its persisted state, rather than starting over from the entry point. This is the mechanism that makes "pause for a genuinely long time, then resume exactly where you stopped" actually work, rather than requiring the whole workflow to somehow stay running and blocked the entire time a human takes to respond.

Rejecting, not just approving

A real approval workflow needs a genuine rejection path too, not just the happy path shown above:

def route_after_review(state: RefundState) -> str:
    if state["approved"]:
        return "process"
    return "reject"
 
def reject_refund(state: RefundState) -> RefundState:
    state["result"] = f"Refund request for {state['customer_id']} was declined."
    return state
 
graph.add_node("reject_refund", reject_refund)
graph.add_conditional_edges(
    "evaluate_refund",
    route_after_review,
    {"process": "process_refund", "reject": "reject_refund"},
)

Combining a conditional edge (from the previous post in this series) with the interrupt means a reviewer's decision — not just whether to proceed, but which of several distinct outcomes to route to — genuinely determines the graph's subsequent path, rather than the interrupt being a simple binary gate on one fixed next step.

Persisting checkpoints beyond a single process

MemorySaver stores checkpoints in memory — genuinely fine for local development and testing, and gone the instant the process restarts, which is a real problem for anything actually waiting on a human who might take hours. Production deployments use a persistent checkpointer instead:

from langgraph.checkpoint.postgres import PostgresSaver
 
checkpointer = PostgresSaver.from_conn_string("postgresql://localhost/langgraph_checkpoints")
app = graph.compile(checkpointer=checkpointer, interrupt_before=["process_refund"])

The same swap-the-backend pattern covered repeatedly throughout this blog — Terraform's remote state backend, LangChain's swappable vector stores and chat history backends — applied here to checkpoint persistence: a graph paused on a request now survives a server restart, a redeploy, or the application running across multiple instances, because its actual state lives in Postgres, not in one specific process's memory.

Notifying a human that a decision is actually waiting

A paused graph is only useful if the person meant to review it actually finds out there's something to review — LangGraph itself doesn't send that notification; that's application logic wired around the interrupt:

def evaluate_refund(state: RefundState) -> RefundState:
    if state["request_amount"] > 500:
        state["approved"] = False
        notify_reviewer(
            f"Refund of ${state['request_amount']} for {state['customer_id']} needs approval",
            thread_id=state.get("thread_id"),
        )
    else:
        state["approved"] = True
    return state

A real system typically pairs the interrupt itself with a notification (a Slack message, an email, an internal dashboard entry) linking directly to the specific thread_id a reviewer needs to act on — the interrupt mechanism handles the "genuinely pause and preserve state" half of the problem; surfacing that pending decision to an actual person is a separate, equally necessary piece of the overall system.

Designing what actually requires human approval

Not every action needs a human in the loop, and treating every single step as needing approval defeats most of the point of automating a workflow at all. The pattern worth adopting deliberately: identify the specific, genuinely consequential decisions — above a dollar threshold, an irreversible action, anything with real legal or safety weight — and interrupt specifically before those, while everything else in the same graph runs fully automated. The evaluate_refund node above already embodies this: small refunds proceed automatically; only the larger, more consequential ones reach the interrupt point at all.

Time-boxing an approval: what happens if nobody responds

A request genuinely stuck waiting on human approval indefinitely — because the assigned reviewer is on leave, or the notification was missed — is its own kind of failure mode, distinct from the graph itself malfunctioning. A production system typically pairs the interrupt with an external timeout check, not something LangGraph enforces on its own:

from datetime import datetime, timedelta
 
def check_stale_approvals():
    for thread_id in get_pending_approval_threads():
        state = app.get_state({"configurable": {"thread_id": thread_id}})
        paused_at = state.created_at
        if datetime.now() - paused_at > timedelta(hours=24):
            escalate_to_secondary_reviewer(thread_id)

A scheduled job (the same cron-triggered pattern covered for Docker image rescanning and SageMaker pipeline retraining elsewhere in this blog) checking for approvals that have been pending longer than an acceptable window, and escalating them to a secondary reviewer or an alert, closes the gap between "the graph correctly paused" and "someone will actually notice and act on it in time."

Auditing every approval decision

Because a paused-then-approved graph represents a genuinely consequential decision, it's worth recording who approved it and when, not just that it was approved:

def approve_refund(thread_id: str, approver_email: str):
    config = {"configurable": {"thread_id": thread_id}}
    app.update_state(config, {
        "approved": True,
        "approved_by": approver_email,
        "approved_at": datetime.now().isoformat(),
    })
    return app.invoke(None, config=config)

Capturing approved_by and approved_at directly in the graph's own state gives a genuine audit trail for exactly this kind of consequential, human-gated action — the same audit discipline covered for Terraform state, GitHub Actions deployment history, and SageMaker's Model Registry approval status throughout this blog, applied here to a human-in-the-loop decision specifically.

What to actually remember from this post

  • interrupt_before genuinely pauses a graph's execution at a specific node, not just an optional slowdown — real infrastructure is required to make this actually work.
  • A checkpointer persists a graph's exact state, addressable later by thread_id, which is what allows resuming a graph that paused for hours or days, from a different process entirely.
  • invoke(None, config=...) resumes from a checkpoint rather than restarting a graph's execution from its entry point.
  • A conditional edge after the interrupt supports genuine reject paths, not just a binary approve-and-continue gate.
  • Use a persistent checkpointer (Postgres or similar) in productionMemorySaver doesn't survive a process restart, a real problem for anything genuinely waiting on human timescales.
  • Notifying the actual human reviewer is separate application logic, not something the interrupt mechanism handles on its own.

Next in the series: Multi-Agent Systems with LangGraph, the final post — covering how multiple distinct agents, each with their own responsibilities, coordinate within a single graph.

Ready when you are

Want training built around your team's real work?

Tell us about your team and what you're trying to solve — we'll recommend a program that fits.