teachyou.ai academy
← All posts
LangGraphAI Agents

Human-in-the-Loop with LangGraph: Interrupts and Approvals Explained

Ira Menon · Jun 14, 2026 · 16 min read

Your agent just drafted an email to a customer, and it's about to send it. Or it just built a SQL query that deletes 40,000 rows, and it's about to execute it. Or it just decided to charge a customer's card for the wrong amount, and it's about to call the payments API. In a demo, this is thrilling — look, the agent did it all by itself. In production, this is how you end up writing an incident postmortem. Full autonomy is a great default for actions you can undo in a click, and a terrible default for actions you can't. The fix isn't to strip your agent of its ability to act — it's to insert a human at exactly the points where the cost of being wrong is high enough to justify a pause. This is what human-in-the-loop (HITL) means in the context of LangGraph, and it's not a bolt-on feature — it's a first-class part of how the graph executes, checkpoints, and resumes. This article walks through the mechanics: where to put the pause, what to show the human, how to handle every branch of their response, and what happens when nobody responds at all.

Why full autonomy is the wrong default for consequential actions

Every tool call an agent makes falls somewhere on a spectrum from reversible to irreversible. Reading a file, searching the web, summarizing a document — these are cheap to get wrong. If the agent misreads a file, you re-run it. No harm done. But sending an email, issuing a refund, deleting a database row, merging a pull request, or firing off a wire transfer — these are one-way doors. Once the action executes, the graph cannot undo it, and neither can you, most of the time.

The mistake teams make early on is treating "the agent is smart enough" as a substitute for "the action is safe enough." Those are two different axes. A GPT-4-class model can be extremely good at deciding *what* to do and still be the wrong entity to decide, alone, *whether it should be allowed to do it*. Model quality doesn't change the blast radius of a mistake. A highly capable agent that autonomously sends the wrong refund amount to the wrong customer is not a safer failure than a mediocre agent doing the same thing — it's actually worse, because a capable agent earns trust faster, and trust is precisely what erodes review discipline.

Human-in-the-loop isn't a tax on autonomy. It's a targeted circuit breaker: the graph pauses immediately before the risky node, hands the proposed action to a human in a form they can actually evaluate, and only proceeds once that human approves, edits, or rejects it. The rest of the graph — planning, retrieval, drafting, reasoning — still runs autonomously. You're not putting a human in the loop for everything. You're putting a human in the loop for the one or two steps where being wrong is expensive.

LangGraph is built for this because of how it models execution: as a graph of nodes with persisted state, not a single opaque function call. That persistence is what makes "pause here, wait indefinitely, resume later with new information" possible without hacky polling loops or long-lived open connections.

The core primitive: interrupting before a risky node

LangGraph exposes this pattern through interrupts — checkpoints where the graph stops executing, serializes its current state, and returns control to whatever is running it (your API server, your CLI, your Slack bot). The graph doesn't crash and it doesn't lose context. It's paused, the same way a debugger pauses at a breakpoint, with every variable still in scope.

There are two ways to trigger this. The coarse-grained way is to compile the graph with an explicit list of node names to interrupt before:

from langgraph.graph import StateGraph, END

builder = StateGraph(AgentState)
builder.add_node("plan", plan_node)
builder.add_node("draft_email", draft_email_node)
builder.add_node("send_email", send_email_node)  # the risky one
builder.add_node("log_result", log_result_node)

builder.set_entry_point("plan")
builder.add_edge("plan", "draft_email")
builder.add_edge("draft_email", "send_email")
builder.add_edge("send_email", "log_result")
builder.add_edge("log_result", END)

graph = builder.compile(
    checkpointer=checkpointer,
    interrupt_before=["send_email"],
)

With interrupt_before=["send_email"], the graph will run plan and draft_email normally, then stop cold before send_email executes. Nothing downstream of that node runs. The state — including the drafted email, the reasoning that produced it, and any tool calls it's about to make — sits in the checkpointer, waiting.

The finer-grained way, and the one you'll reach for in most real applications, is the interrupt() function called from inside a node. Instead of pausing before a whole node, you pause mid-node, exactly where you need human input, and you can pass a payload describing what you need approved:

from langgraph.types import interrupt

def send_email_node(state: AgentState) -> AgentState:
    proposed_action = {
        "type": "send_email",
        "to": state["recipient"],
        "subject": state["subject"],
        "body": state["draft_body"],
    }

    decision = interrupt(proposed_action)

    if decision["approved"]:
        result = email_client.send(
            to=decision.get("edited_to", proposed_action["to"]),
            subject=decision.get("edited_subject", proposed_action["subject"]),
            body=decision.get("edited_body", proposed_action["body"]),
        )
        return {**state, "email_sent": True, "email_result": result}

    return {**state, "email_sent": False, "rejection_reason": decision.get("reason")}

This is a meaningfully different shape than interrupt_before. The node itself decides, based on the state it's holding, whether this particular instance of the action warrants a pause. A $12 refund and a $12,000 refund can hit the same node and get different treatment — one sails through, the other stops for a human. That conditional judgment is where most of the design work in human-in-the-loop systems actually lives, and it's worth its own section.

Deciding which steps actually need approval

The most common mistake teams make once they've discovered interrupt() is sprinkling it everywhere. If every tool call pauses for approval, you haven't built a human-in-the-loop agent — you've built a human-does-the-loop agent with extra steps. Reviewers stop reading carefully within a day because there's too much to review, and the entire safety mechanism degrades into rubber-stamping. Approval fatigue is a real failure mode and it defeats the purpose just as thoroughly as no review at all.

Instead, gate approval on risk, and be explicit about what "risk" means for your domain. A useful rubric:

  • Irreversibility. Can this action be undone by a subsequent action, or is it permanent? Deleting a row is usually permanent. Archiving it is not.
  • Blast radius. Does this affect one record, or does the query touch every row in a table? A WHERE id = 42 update is a different risk class than an unscoped UPDATE.
  • Financial exposure. Is money moving? Above what threshold does a refund or charge need a second set of eyes?
  • External visibility. Is a customer, partner, or the public going to see this action directly (an email, a public post, a support ticket reply)? Internal side effects are more forgiving than customer-facing ones.
  • Confidence of the agent. If your agent exposes a confidence score or if the plan required unusual branching logic to arrive at this action, that's itself a signal to route to a human, independent of the action's inherent risk.

Encode this as an explicit policy function rather than leaving it implicit in prose that developers forget:

def requires_approval(action: dict) -> bool:
    if action["type"] == "send_email" and action.get("external_recipient"):
        return True
    if action["type"] == "refund" and action["amount_cents"] > 5000:
        return True
    if action["type"] == "delete_row" and not action.get("scoped_by_id"):
        return True
    return False

Call this at the top of every node that performs a side effect, and only invoke interrupt() when it returns True. Low-risk paths — a $3 refund, an internal Slack notification, a read-only lookup — execute straight through. This keeps the review queue small enough that a human can actually give each item real attention, which is the entire point.

Presenting the pending action so a human can actually judge it

This is the part that's easy to underinvest in and expensive to get wrong. If your "approval" step is a JSON blob of the raw tool call — {"tool": "send_email", "args": {"to": "j.chen@acmecorp.com", "subject": "Re: Invoice #88213", "body": "Hi Jamie,\\n\\nPer your..."}} — you have technically implemented human-in-the-loop, but you've made the human's job harder than it needs to be, which pushes them toward skimming and rubber-stamping. A reviewer staring at raw arguments has to reconstruct the intent themselves before they can evaluate it. That reconstruction tax is what burns reviewers out.

Translate the proposed action into the same language a human colleague would use to describe it out loud. Concretely, that means:

  • A one-line summary of intent — "Send a refund confirmation email to jamie@acmecorp.com for $340.00" — before any raw payload.
  • The diff against the customer's current state, when relevant — "Current subscription: Pro plan, renews July 18. Proposed change: downgrade to Free plan, effective immediately."
  • The reasoning trace, or at least the key steps, so the human can see *why* the agent landed here, not just *what* it wants to do.
  • The raw payload, collapsed by default, for the reviewer who wants to verify exact wording or exact field values before approving.

In practice this means your interrupt() payload should carry a rendered, human-readable form alongside the machine-actionable one:

def build_approval_payload(state: AgentState) -> dict:
    return {
        "summary": f"Send email to {state['recipient']} — subject: '{state['subject']}'",
        "reasoning": state.get("agent_reasoning", ""),
        "preview": {
            "to": state["recipient"],
            "subject": state["subject"],
            "body": state["draft_body"],
        },
        "risk_flags": state.get("risk_flags", []),
    }

If you're surfacing this in a Slack approval bot, a support dashboard, or an internal admin panel, render summary and reasoning as the primary text, and tuck preview behind a "view details" toggle. The goal is that a tired reviewer at 4:45pm on a Friday can still make the right call in three seconds for the easy cases, and knows exactly where to look when something's off.

Resuming the graph with the human's decision

Once the graph is paused on an interrupt(), it isn't doing anything — no polling, no timers, no open connection required. The state is checkpointed (to Postgres, SQLite, or whatever checkpointer you've configured), and the graph resumes only when you explicitly invoke it again with a Command carrying the human's response:

from langgraph.types import Command

# Human approved the email as-is
result = graph.invoke(
    Command(resume={"approved": True}),
    config={"configurable": {"thread_id": thread_id}},
)

# Human approved but edited the body first
result = graph.invoke(
    Command(resume={
        "approved": True,
        "edited_body": "Hi Jamie, apologies for the delay — your refund of $340 has been processed.",
    }),
    config={"configurable": {"thread_id": thread_id}},
)

# Human rejected outright
result = graph.invoke(
    Command(resume={"approved": False, "reason": "Wrong customer — this refund belongs to a different ticket"}),
    config={"configurable": {"thread_id": thread_id}},
)

The thread_id is what ties this resume call back to the exact paused execution — it's how the checkpointer knows which frozen state to reload. This is also why the interrupt() call inside send_email_node above can simply do decision = interrupt(proposed_action) and treat the return value as if it arrived synchronously: when the graph resumes, execution picks back up inside that same node, at that same line, with decision now bound to whatever was passed into Command(resume=...). From the node's point of view, it's as if interrupt() just returned a value — the fact that hours may have passed and a human on the other side of a Slack message clicked a button is invisible to the node's logic.

This is the detail that makes LangGraph's approach different from hand-rolling your own "pause and wait" logic with a database flag and a cron job: the graph's control flow reads like ordinary sequential code, even though execution is genuinely suspended and resumed across process boundaries, potentially on a different machine, potentially days later.

Handling rejection and edits gracefully

Approval isn't binary in practice — humans reject things, and humans edit things, and your graph needs a real answer for both, not just a happy path that assumes yes.

For edits, the pattern shown above already covers the common case: the human's Command(resume=...) payload carries the edited fields, and the node substitutes them in before executing. The important discipline here is to always re-validate edited input the same way you'd validate agent-generated input — a human editing a refund amount to 50000000 instead of 500 is a fat-fingered zero away from a much worse incident than anything the agent would have proposed. Don't let "a human touched it" become an excuse to skip validation.

For rejection, you have three realistic paths, and which one you pick should depend on why it was rejected, not just that it was rejected:

  1. Abort the branch entirely. The action was fundamentally wrong — wrong customer, wrong data, shouldn't happen at all. Route to an end state that logs the rejection and reason, and stops.
  2. Loop back for re-planning. The intent was right but the execution was wrong — the agent drafted a bad email, but a refund of some kind is still warranted. Route back to the planning or drafting node, carrying the rejection reason as additional context, so the agent's next attempt is actually informed by what went wrong rather than blindly retrying the same thing.
  3. Escalate to a different human. The first reviewer didn't feel qualified to decide — this needs a manager, a legal reviewer, or a domain specialist. Route to a different queue rather than back to the agent at all.

A conditional edge handles the branching cleanly:

def route_after_approval(state: AgentState) -> str:
    if state.get("email_sent"):
        return "log_result"
    if state.get("rejection_reason", "").startswith("needs_replan"):
        return "draft_email"  # loop back with feedback
    if state.get("rejection_reason", "").startswith("escalate"):
        return "escalate_to_manager"
    return "abort"

builder.add_conditional_edges(
    "send_email",
    route_after_approval,
    {
        "log_result": "log_result",
        "draft_email": "draft_email",
        "escalate_to_manager": "escalate_to_manager",
        "abort": END,
    },
)

The failure mode to watch for is an unbounded retry loop — an agent that gets rejected, re-plans, gets rejected again, re-plans again, forever. Carry a retry counter in state and cap it. After two or three rejected attempts, route to a human unconditionally rather than letting the agent keep guessing; at that point the agent isn't converging on the right answer, and burning more model calls on it isn't going to change that.

Timeout handling when no human responds

Every human-in-the-loop system eventually hits the case where the human doesn't respond — they're in a meeting, it's 2am in their timezone, they missed the Slack ping, or the review queue silently backed up behind a holiday. Since a LangGraph interrupt is a durable pause backed by a checkpointer, the graph will happily wait indefinitely with no resource cost — there's no thread blocked, no connection held open. But "the graph will wait forever" is a technical fact, not a product decision, and forever is rarely the right answer for a customer-facing action.

Handle this at the layer that manages your threads, not inside the graph itself. When you create the interrupt, record a deadline alongside the thread_id — in the same database or queue you're already using to track pending approvals:

pending_approvals.insert({
    "thread_id": thread_id,
    "created_at": now(),
    "expires_at": now() + timedelta(hours=4),
    "action_summary": payload["summary"],
})

Then run a periodic sweep — a cron job, a scheduled task, whatever your infrastructure already uses — that checks for expired approvals and resumes those threads with an explicit timeout decision rather than leaving them stuck:

for pending in pending_approvals.find_expired():
    graph.invoke(
        Command(resume={"approved": False, "reason": "timeout_no_response"}),
        config={"configurable": {"thread_id": pending["thread_id"]}},
    )
    pending_approvals.mark_resolved(pending["thread_id"])

What "timeout" should mean is domain-specific, and it's worth deciding deliberately rather than defaulting to whatever's easiest to code:

  • Default to reject for anything financial or destructive. Silence should never be interpreted as consent for a refund or a deletion.
  • Default to a safe fallback action where one exists — for a customer support email, "send a generic acknowledgment and flag for manual follow-up" is often better than either sending the original risky draft or leaving the customer with total silence.
  • Escalate rather than resolve, for genuinely time-sensitive actions — page a backup approver instead of auto-rejecting, if the cost of a false rejection is also high.

Whatever you choose, make the timeout outcome an explicit, logged, testable code path — not an accident of what happens to be sitting in a queue when someone eventually gets around to it.

Testing and observing your approval gates

A human-in-the-loop system that's never been tested with a rejection, an edit, and a timeout is a system you've only tested along its happiest path — and the whole reason you built the gate was to handle the unhappy paths correctly. Treat each branch as its own test case: write a test that resumes with {"approved": True}, one with an edited payload, one with {"approved": False}, and one that simulates a timeout sweep — and assert on the resulting state, not just that the graph didn't crash.

Log every interrupt and every resume with enough detail to reconstruct what happened later: what was proposed, what a human (or the timeout sweep) decided, what changed between proposal and execution if anything was edited, and how long the pause lasted. This log is your audit trail, and it's also your best source of data for tightening the requires_approval policy over time — if a category of action gets approved unchanged 99% of the time, that's a signal it might not need a human gate at all; if a category gets rejected or edited often, that's a signal your agent needs better prompting or more context before it even reaches that node.

Bringing it together

The pattern, end to end, is simpler than it looks once you've built one: identify the handful of nodes in your graph whose actions are expensive to undo, gate those specific nodes behind a risk check, call interrupt() with a human-readable summary when the check trips, and resume with Command(resume=...) carrying an explicit approve/edit/reject decision that your conditional edges route accordingly. Everything else in the graph — planning, retrieval, drafting — keeps running autonomously, because autonomy was never the problem. Unreviewed irreversibility was.

This is also where a lot of "agent frameworks" quietly fall apart in production. Interrupt-and-resume sounds trivial in a slide deck and gets genuinely hard the moment you need it to survive a server restart, a multi-day approval delay, an edited payload, and a timeout sweep, all without corrupting state. LangGraph's checkpointing model handles the hard part — durable, resumable state — so your job is just the design work: deciding what's risky, writing the risk check, and rendering the action so a human can actually evaluate it in the time they realistically have. Get those three right and the rest is plumbing.

If you want to go deeper than a single blog post can take you — checkpoint store selection, multi-approver workflows, combining HITL gates with retries and circuit breakers, and the dozen other things that separate a demo agent from one you'd trust with real customer data — that's exactly the kind of production discipline we drill into inside 30 Days of Hermes Agent.