Skip to content
datarekha

Human-in-the-loop with LangGraph

Pause the graph, let a human review or edit, resume from where it stopped. The pattern is "LLM proposes, human disposes" — and it's what makes agents safe to ship.

Written and reviewed by Last reviewed

6 min read Advanced Agentic AI Lesson 19 of 78

What you'll learn

  • How interrupt_before and interrupt_after pause the graph at chosen nodes
  • The "LLM proposes, human disposes" pattern for safe write actions
  • How to resume — with or without edits — via app.invoke(None, config)

Before you start

The agents that survive contact with production almost never run end-to-end without supervision.

Whenever the LLM is about to do something with consequences, you want a human in the loop (a person who can review, approve, or correct the agent’s planned action before it happens).

Consequential actions include:

  • send an email
  • file a ticket
  • run a SQL DELETE
  • post to social

LangGraph’s interrupt mechanism is built for this. You declare which nodes should pause before or after they run.

The graph stops mid-flight, persists state via the checkpointer, and waits. A human inspects, edits if needed, and resumes. The shape is consistent — LLM proposes, human disposes.

This works because of the checkpointer you added in the previous lesson: when the graph pauses, it writes the full current state to durable storage.

The process can sleep, the pod can restart — and invoke(None, config) later reads that saved state and picks up exactly where it stopped.

How interrupts work

A graph pausing at an interrupt node, persisting its state, and resuming on human approval or rejection.

The pause is durable — state is checkpointed, not held in memory.

Pause and resume

Two knobs on .compile():

app = graph.compile(
    checkpointer=checkpointer,
    interrupt_before=["send_email"],   # pause before running this node
    interrupt_after=["draft"],         # pause after this node finishes
)

When .invoke() reaches an interrupted node, it stops and returns. The state is in the checkpointer. The graph is paused, not finished.

Resuming is one line:

app.invoke(None, config=config)

That None is the signal: “no new input — just resume from where you stopped.”

LangGraph picks up at the interrupt point, runs the next node, and continues.

If a human edited state in between, you call app.update_state(...) first, then .invoke(None, ...).

The canonical example — an email-drafting agent

Three nodes:

  1. draft — LLM writes an email based on the user’s intent.
  2. review — human looks at the draft, optionally edits.
  3. send — actually sends the email via API.

We interrupt_before=["send"] so the graph pauses with the draft ready, before anything irreversible happens.

# Mocked LangGraph with human-in-the-loop interrupt. The interrupt_before
# pattern, update_state, and resume-with-None mirror real LangGraph.

from typing import TypedDict

START, END = "__start__", "__end__"

class MemorySaver:
    def __init__(self): self.store = {}
    def get(self, t): return self.store.get(t)
    def put(self, t, s): self.store[t] = s

class StateGraph:
    def __init__(self, schema, reducers=None):
        self.schema, self.reducers = schema, reducers or {}
        self.nodes, self.edges = {}, {}
    def add_node(self, n, fn): self.nodes[n] = fn
    def add_edge(self, s, d): self.edges[s] = d
    def compile(self, checkpointer=None, interrupt_before=None):
        return CompiledGraph(self, checkpointer, interrupt_before or [])

class CompiledGraph:
    def __init__(self, g, cp, ib):
        self.g, self.cp, self.interrupt_before = g, cp, ib
    def _merge(self, state, update):
        merged = dict(state)
        for k, v in update.items():
            merged[k] = self.g.reducers[k](state.get(k), v) if k in self.g.reducers else v
        return merged
    def invoke(self, new_input, config):
        thread = config["configurable"]["thread_id"]
        if new_input is None:
            # resume — run the node we paused before, skipping its interrupt once
            saved = self.cp.get(thread)
            state, node, resuming = saved["state"], saved["next_node"], True
        else:
            prior = self.cp.get(thread)
            state = self._merge(prior["state"] if prior else {}, new_input)
            node, resuming = self.g.edges[START], False
        while True:
            if node == END:
                self.cp.put(thread, {"state": state, "next_node": END, "interrupted": False})
                return state
            if node in self.interrupt_before and not resuming:
                # PAUSE — persist and return early
                self.cp.put(thread, {"state": state, "next_node": node, "interrupted": True})
                return state
            resuming = False
            state = self._merge(state, self.g.nodes[node](state))
            node = self.g.edges[node]
    def get_state(self, config):
        return self.cp.get(config["configurable"]["thread_id"])
    def update_state(self, config, update):
        thread = config["configurable"]["thread_id"]
        saved = self.cp.get(thread)
        saved["state"] = self._merge(saved["state"], update)
        self.cp.put(thread, saved)

# --- the graph ---
class State(TypedDict):
    intent: str
    draft: str
    sent: bool

def draft_email(state):
    return {"draft": (
        f"Subject: Following up on {state['intent']}\n\n"
        f"Hi — wanted to circle back on {state['intent']}. "
        f"Let me know if next week works.\n\nThanks!"
    )}

def send_email(state):
    print(f"  [SENDING] {state['draft'][:60]!r}...")
    return {"sent": True}

graph = StateGraph(State)
graph.add_node("draft", draft_email)
graph.add_node("send", send_email)
graph.add_edge(START, "draft")
graph.add_edge("draft", "send")
graph.add_edge("send", END)

checkpointer = MemorySaver()
app = graph.compile(checkpointer=checkpointer, interrupt_before=["send"])  # pause before sending!

config = {"configurable": {"thread_id": "email-1"}}

# --- run until interrupt ---
print("Step 1: invoke — should pause before 'send'")
state = app.invoke({"intent": "the Q3 contract", "draft": "", "sent": False}, config=config)
print(f"  draft:\n{state['draft']}\n  sent? {state['sent']}")

snapshot = app.get_state(config)
print(f"  paused at node: {snapshot['next_node']}, interrupted: {snapshot['interrupted']}")

# --- human reviews and edits the draft ---
print("\nStep 2: human edits the draft")
app.update_state(config, {"draft": state["draft"].replace("circle back", "follow up")})
edited = app.get_state(config)["state"]
print(f"  edited draft:\n{edited['draft']}")

# --- resume — invoke with None ---
print("\nStep 3: resume with invoke(None, config)")
final = app.invoke(None, config=config)
print(f"  sent? {final['sent']}")
Step 1: invoke — should pause before 'send'
  draft:
Subject: Following up on the Q3 contract

Hi — wanted to circle back on the Q3 contract. Let me know if next week works.

Thanks!
  sent? False
  paused at node: send, interrupted: True

Step 2: human edits the draft
  edited draft:
Subject: Following up on the Q3 contract

Hi — wanted to follow up on the Q3 contract. Let me know if next week works.

Thanks!

Step 3: resume with invoke(None, config)
  [SENDING] 'Subject: Following up on the Q3 contract\n\nHi — wanted to fol'...
  sent? True

The flow, step by step

  1. Call .invoke(input, config). The graph runs draft, then stops because send is in interrupt_before. The draft is in state.
  2. The application UI displays the draft, lets the human edit.
  3. The human’s edits flow back via app.update_state(config, {...}).
  4. The application calls app.invoke(None, config). The graph picks up at send, runs it, finishes.

The graph code didn’t have to know about the human. The interrupt is a runtime concern, not a node concern.

Detecting “paused” state

After a paused .invoke(), you check the state snapshot to see where it stopped.

snapshot = app.get_state(config)
# snapshot.next        → ("send",)   # the next node it would run
# snapshot.values      → the current state dict
# snapshot.tasks       → tasks pending at the pause point

In a real app, snapshot.next being non-empty means “we paused; show the UI.” Empty means the graph finished.

Patterns beyond “approve/edit”

The interrupt mechanism is general. Variations:

  • Approve / reject / edit / regenerate. UI presents the draft; user clicks one. Approve resumes. Reject updates state with guidance and re-invokes the draft node. Regenerate clears the current draft and re-invokes draft.
  • Tool authorization. The LLM proposes a tool call; the graph pauses before executing high-risk tools (anything that writes, deletes, or spends money). The human authorizes specific calls.
  • Clarifying questions. The agent asks the user a question; the graph pauses; the user answers via update_state; resume.
  • Multi-stage approval. Different reviewers at different nodes — a draft reviewer pauses at one node, a legal reviewer at another.

All of these use the same primitives: interrupt_before or interrupt_after, update_state, and .invoke(None, config).

interrupt_after — auditing what just happened

interrupt_before is for safety: stop before doing something. The mirror, interrupt_after (pause after a node finishes, before the next one starts), is for audit: you can inspect what it produced before moving on.

Useful for:

  • Logging full state to an audit system before continuing.
  • Letting a human spot-check an intermediate result.
  • Running expensive evals on the output of a particular node.
app = graph.compile(
    checkpointer=cp,
    interrupt_after=["plan"],     # pause AFTER planning, before execution
)

A common bug — forgetting the checkpointer

Interrupts only work with a checkpointer.

If you forget to pass checkpointer=... to .compile(), the graph will raise an error when it hits an interrupted node. There is no persisted state to pause into or resume from. Always pair them.

In one breath

  • The pattern is “LLM proposes, human disposes” — gate any consequential action (send, write, delete, spend) on a person.
  • interrupt_before pauses just before a node runs (safety); interrupt_after pauses after (audit) — both persist state via the checkpointer and return early.
  • Resume with app.invoke(None, config) — the None means “no new input, continue from saved state”; apply human edits first with update_state.
  • Interrupts don’t work without a checkpointer — there’s nothing to pause into or resume from.
  • The same three primitives cover approve/reject/edit, tool authorization, clarifying questions, and multi-stage review — and edits from a client are untrusted input, so constrain what update_state may change.

Quick check

Quick check

0/3
Q1What does interrupt_before=['send_email'] do?
Q2How do you resume a paused graph?
Q3What does interrupts not work without?

Wrapping up

You now have the full LangGraph toolkit:

  • state graphs
  • reducers
  • persistence
  • human-in-the-loop

With those four pieces, you can express almost any agent shape, including:

  • multi-step tool use
  • retries
  • multi-turn conversations
  • approval gates
  • audit pauses

The next steps in the track look at evaluation, observability, and production patterns — what it takes to actually run these agents in front of users.

Sign in to track your progress

Completed lessons, your XP, level, and streak save to your account — it's free and takes a few seconds.

Practice this in an interview

All questions
How does RLHF work and what problem does it solve?

RLHF (Reinforcement Learning from Human Feedback) aligns a language model's outputs to human preferences by training a reward model on ranked human comparisons, then using that reward signal to fine-tune the policy with reinforcement learning. It solves the gap between a model that is good at next-token prediction and a model that is genuinely helpful, harmless, and honest.

How do function/tool calling and LLM agents work at a high level?

Tool calling extends the LLM's output space to include structured function invocations. The model emits a JSON object naming a tool and its arguments; the runtime executes the tool and feeds the result back as a new message. An agent is a loop that repeats this cycle — observe, think, act — until the task is complete or a stopping condition is met.

What is tool use or function calling in LLMs, and how do you design good tools for an agent?

Tool use lets an LLM emit a structured request for an external function, which the application validates, authorizes, executes, and returns to the model. Reliable tools have clear descriptions, narrow scope, strict typed inputs, least-privilege access, idempotency, and useful structured errors.

What is an AI agent, and how does it differ from a single LLM call?

An AI agent is an application that lets an LLM choose and execute validated tools in a bounded loop, carrying observations and state forward until it reaches a goal or needs approval. A single LLM call produces one response or tool-call proposal and stops; it does not itself provide the loop, live-system access, memory, or side effects.

Related lessons

Explore further