Human-in-the-Loop: interrupt(), Approval, and Editing the Plan (LangGraph in Production, Module 07)
Human-in-the-Loop: interrupt(), Approval, and Editing the Plan (LangGraph in Production, Module 07)
This is Module 07 of LangGraph in Production, a free 16-module course that takes you from your first StateGraph to a deployed, durable autonomous coding agent on LangGraph v1.x. Start at Module 1 or browse the full syllabus.
The Cowboy Agent Problem
Imagine this: you hand Forge the issue “Bug: tally report --month 2026-05 crashes with KeyError on empty category.” The agent springs into action. It reads the issue, classifies it as a bug, calls the Planner. The Planner probes the Tally repo and produces a tidy Plan: target files are tally/report.py and tally/models.py. Forge fans out two Editor tasks in parallel, rewrites both files, runs pytest — 12 passed. Total wall time: nine seconds.
Then, ten seconds later, a PR lands on main. Tests are green. CI merges it automatically. And buried in the diff is a refactor to models.py that nobody asked for — the Planner thought it was relevant, the Editor executed it, nobody checked.
This is the cowboy agent pattern: fully autonomous, very fast, and dangerous in proportion to how well it works. The problem is not autonomy per se — the problem is autonomy without a pause. The solution is not to wrap Forge in a polling while True loop in your own code. The solution is interrupt(): a native framework pause that suspends the graph at a specific node, surfaces a payload to the caller, waits for a decision, and resumes with the decision’s value injected back into the node’s return value. It integrates with the checkpointer you already built in Module 6, so a pause can last ten seconds or three hours with no lost work.
After this module, Forge pauses twice: once before any file is touched (you approve the Plan), and once before the PR is opened (you approve the diff). If you reject or edit the Plan, Forge re-plans without burning a full restart. The cowboy retires.
In This Module
You’ll learn:
- How
interrupt(payload)suspends the graph and surfacespayloadto the caller, and howgraph.invoke(Command(resume=value), config)resumes it withvalueas the return ofinterrupt()(CA7.1) - Why the interrupted node re-executes from its very beginning on resume, and how to keep code before
interrupt()idempotent so you don’t pay for double LLM calls (CA7.2) - The difference between static breakpoints (
compile(interrupt_before=["n"])) — a debugging tool — andinterrupt()— a production HITL mechanism — and when to use each (CA7.3) - The four HITL patterns: approve/reject, edit-state, review-tool-calls, validate-input — and which one applies where in Forge (CA7.4)
- The
PullRequestschema (frozen here), theopen_prtool, and the two newForgeStatefields (plan_approved,pull_request) introduced in this module (CA7.5) - Why
NodeInterruptis gone in v1.x and what replaces it
You’ll build: Forge gains two human-in-the-loop checkpoints: interrupt() pauses before editing (you approve or reject the Plan) and again before opening the PR (you approve the diff). Rejection routes back to the Planner without a full restart. The open_pr tool and the PullRequest schema are introduced here and frozen for the rest of the course.
Concepts covered: CA7 — Human-in-the-loop (core). This module BUILDs CA7.1–CA7.5 of the LangGraph theory inventory.
Prerequisites: Modules 1–6 complete. SqliteSaver operational from Module 6 — the checkpointer is mandatory for interrupt() to work. Plan schema frozen (M4). editor/tester/fixer nodes in place (M3–M5). .env with ANTHROPIC_API_KEY or Ollama fallback configured.
Where We Are
M1 ✅ M2 ✅ M3 ✅ M4 ✅ M5 ✅ M6 ✅ M7 👉 M8 ⬜ M9 ⬜ M10 ⬜ M11 ⬜ M12 ⬜ M13 ⬜ M14 ⬜ M15 ⬜ M16 ⬜
Forge before M7: fully automatic from intake to PR. The graph never pauses. Hand it an issue, it plans, edits N files in parallel via the Send API, runs tests in a fix-it loop, and opens a PR — all without asking permission.
Forge after M7: two controlled pauses at the two highest-risk decision points. The first pause surfaces the Plan to you before a single file is written in the sandbox. The second pause surfaces the PR metadata before open_pr is called. Both pauses integrate with the SqliteSaver checkpointer from M6: the state survives the pause regardless of duration.
What we are not doing here: time-travel (walking state history, replaying from a past checkpoint, forking a bad run) — that is M8. Durability modes (durability="exit") and retry policies land in M9. Streaming the interrupt payload to a live UI comes in M11.
interrupt() and Command(resume=): The Anatomy of a Pause
Before interrupt() existed, getting a human decision into a running graph required gymnastics: polling the state externally, checking a flag, re-invoking with a new input. The framework had no native way to suspend and inject a value. The only built-in pause was static breakpoints — compile(interrupt_before=["node_name"]) — but those stop execution without surfacing a payload or accepting a return value. They are a debug tool, not a HITL protocol.
interrupt() is different. It is a call you put inside a node’s body. When the node executes that call, the graph suspends immediately. The value you passed to interrupt() is surfaced to the caller in the result’s __interrupt__ key. The caller inspects it, makes a decision, and resumes the graph by invoking it again with Command(resume=decision_value). The return value of the interrupt() call inside the node becomes decision_value. Execution continues from there.
Here is the plan_approval node from the actual lab code in forge/nodes.py:
from langgraph.types import Send, interrupt
def plan_approval(state: ForgeState) -> dict:
"""Pause for a human to approve / edit / reject the plan."""
decision = interrupt({
"action": "approve_plan",
"summary": state["plan"].summary,
"target_files": state["plan"].target_files,
})
edited = decision.get("edited_plan") if isinstance(decision, dict) else None
if edited:
plan = Plan(**edited)
return {"plan": plan, "targets": _targets_from_plan(plan), "plan_approved": True}
approved = bool(decision.get("approved")) if isinstance(decision, dict) else bool(decision)
return {"plan_approved": approved}
Notice what the node does before interrupt(): it reads state, builds a dict, and calls interrupt(). No LLM call, no file write, no side effect. That is intentional and non-negotiable — you will see why in the next section.
The caller pattern looks like this (from tests/test_pipeline.py):
from langgraph.types import Command
from forge.graph import build_graph
from forge import checkpoint
graph = build_graph(checkpointer=checkpoint.in_memory())
cfg = {"configurable": {"thread_id": "approve"}, "recursion_limit": 50}
# First invoke: runs until the interrupt, returns the payload
r1 = graph.invoke({"issue": "report crashes on empty category", "repo_path": repo}, cfg)
assert r1["__interrupt__"][0].value["action"] == "approve_plan"
# Operator inspects plan, approves it
r2 = graph.invoke(Command(resume={"approved": True}), cfg)
# Graph ran until the SECOND interrupt (pr_approval)
assert r2["__interrupt__"][0].value["action"] == "approve_pr"
# Operator approves the PR
out = graph.invoke(Command(resume={"approved": True}), cfg)
assert out["pull_request"].status == "open"
This is real test code from the lab, passing against LangGraph 1.2.5. Three invokes, two pauses, one thread id. The checkpointer from M6 holds the state between each call: if the process dies between the first and second invoke, you can re-run graph.invoke(Command(resume={"approved": True}), cfg) on the same thread_id and it picks up exactly where it stopped.
Two things are required for interrupt() to work:
- A checkpointer must be passed to
build_graph(). Without it,interrupt()raises a configuration error at runtime. The thread must survive between the suspend and the resume — that is literally the checkpointer’s job. - The same
config(samethread_id) must be passed to every subsequentinvoke. The graph needs to identify which saved state to restore.
sequenceDiagram
participant Caller
participant Graph
participant Node as plan_approval node
Caller->>Graph: invoke({"issue": "..."}, cfg)
Graph->>Node: execute — 1st time
Node->>Graph: interrupt({"action": "approve_plan", ...})
Graph-->>Caller: result["__interrupt__"][0].value = payload
Note over Caller: Inspect plan, decide
Caller->>Graph: invoke(Command(resume={"approved": True}), cfg)
Graph->>Node: RE-EXECUTE from start (idempotent before interrupt!)
Node->>Node: interrupt() returns {"approved": True}
Node->>Graph: return {"plan_approved": True}
Graph->>Graph: continue to dispatch → editor × N → ...
The Re-Execution Trap: Why Idempotence Before interrupt() Is Non-Negotiable
⚠️ Common misconception
“When the graph resumes after
interrupt(), execution continues from the line after theinterrupt()call.”This is false. When
graph.invoke(Command(resume=...), cfg)is called, the node re-executes from its very first line. Every line of code in the node body runs again — including every line that appears before theinterrupt()call.
This is the single most common bug when developers first use interrupt(). The mental model of “resume from the pause point” is intuitive and wrong. LangGraph is a checkpoint-based system: resuming a node means re-running it from scratch, with the interrupt mechanism replaying the saved Command(resume=...) value as the return of interrupt(). It is the same node executing again, not a continuation.
What does this mean in practice? Any code before interrupt() that has a side effect will run twice: once on the original invocation, and once on the resume.
Here is the broken version:
# BROKEN: the LLM call runs on BOTH the first invoke and the resume
def plan_approval_broken(state: ForgeState) -> dict:
# This call happens twice: once before the pause, once after resume
enriched = config.get_model("smart").invoke([HumanMessage("Review this plan...")])
decision = interrupt({"plan": enriched.content, "message": "Approve?"})
return {"plan_approved": decision.get("approved", False)}
The LLM call is made on the first run (before the pause) and again on the resume. You pay twice. If the model call is slow or expensive, this doubles your latency and cost on every HITL interaction.
The fix is to separate concerns into separate nodes:
# CORRECT: side effects live in their own node, before plan_approval
def enrich_plan(state: ForgeState) -> dict:
"""Separate node: do the costly LLM call here."""
enriched = config.get_model("smart").invoke(...)
return {"plan": enriched_plan}
def plan_approval(state: ForgeState) -> dict:
"""HITL node: pure state read + interrupt(). No side effects."""
decision = interrupt({
"action": "approve_plan",
"summary": state["plan"].summary,
"target_files": state["plan"].target_files,
})
approved = bool(decision.get("approved")) if isinstance(decision, dict) else bool(decision)
return {"plan_approved": approved}
The golden rule: a node that contains interrupt() must do nothing before the call except read state. No LLM calls, no file writes, no API requests, no database writes. Pure reads only. This makes the re-execution harmless — reading state twice produces the same result.
This is also why the Forge lab’s plan_approval node looks the way it does. The Planner is a separate node that runs before plan_approval. By the time plan_approval executes, the Plan is already in state. The node reads it, packages it, and calls interrupt(). On resume, it reads the same state again (unchanged), calls interrupt() (which immediately returns the saved Command(resume=...) value), and returns. One LLM call total, not two.
Multiple interrupt() calls in one node. LangGraph supports more than one interrupt() in a single node — they are matched to resumes in order. If a node calls interrupt() twice:
def two_step_gate(state: ForgeState) -> dict:
first = interrupt("Approve the plan summary?") # resume 1 provides this
second = interrupt("Confirm the target files?") # resume 2 provides this
return {"plan_approved": first and second}
The first invoke(Command(resume=val1), cfg) supplies val1 to first, then hits the second interrupt() and pauses again. The second invoke(Command(resume=val2), cfg) supplies val2 to second. Useful for multi-step confirmation flows, but prefer separate nodes for readability unless the two questions are genuinely part of a single logical gate.
Static Breakpoints vs. interrupt(): Debug vs. Production
LangGraph has two distinct pause mechanisms. Conflating them is a freshness trap — v0.x tutorials often used breakpoints as the only pause option. In v1.x, the roles are clearly separated.
interrupt_before / interrupt_after (static) | interrupt() (dynamic) | |
|---|---|---|
| Purpose | Debugging — inspect state before or after a node | Production HITL — surface a payload, receive a decision |
| Where configured | compile(interrupt_before=["node_name"]) or per-call in invoke config | Inside the node body: value = interrupt(payload) |
| Payload surfaced | Current graph state (read via get_state()) | The argument passed to interrupt() |
| Resume call | invoke(None, cfg) — no value injected | invoke(Command(resume=value), cfg) — value becomes interrupt()’s return |
| Trigger condition | Every time the named node is reached | Only when the node actually calls interrupt() (conditional, dynamic) |
| Suitable for production? | No — it stops on every run; no way to inject a decision value | Yes — designed for this use case |
Use interrupt_before when you want to freeze a run in LangGraph Studio and inspect the state, or when you are debugging a test and want to see what a node receives. Use interrupt() when you want a human to make a decision that affects what the graph does next.
The lab includes a smoke test that demonstrates the difference directly: the static breakpoint test resumes with invoke(None, cfg) and the dynamic HITL test resumes with invoke(Command(resume={"approved": True}), cfg).
The Four HITL Patterns
CA7.4 maps four distinct patterns for human-in-the-loop interaction. Forge uses the first two directly in M7.
Pattern 1 — Approve / reject. The interrupt() payload is a question. The operator’s decision routes the graph conditionally. In Forge, plan_approval implements this: if approved=True, the gate function fans out to Editor nodes via Send; if approved=False, it routes to abandon.
def gate(state: ForgeState):
"""After approval: fan out to editors (Send), or route to abandon."""
if not state.get("plan_approved"):
return "abandon"
return dispatch(state) # returns list[Send]
The graph wiring in build_graph():
builder.add_edge("planner", "plan_approval")
builder.add_conditional_edges("plan_approval", nodes.gate, ["editor", "abandon"])
builder.add_edge("abandon", END)
Pattern 2 — Edit-state. The operator does not just approve or reject — they pass back a modified value that directly updates state. In plan_approval, if the Command(resume=...) contains an edited_plan key, Forge uses it:
edited = decision.get("edited_plan") if isinstance(decision, dict) else None
if edited:
plan = Plan(**edited)
return {"plan": plan, "targets": _targets_from_plan(plan), "plan_approved": True}
The operator passes Command(resume={"edited_plan": {"summary": "...", "target_files": ["report.py"], ...}}), and Forge proceeds with the corrected plan without calling the Planner again. This saves one (potentially expensive) smart model call and gives the operator surgical control over exactly what changes.
Pattern 3 — Review tool calls. interrupt() can live inside a @tool function, not just a node. If you want the operator to inspect the diff before open_pr submits it to GitHub, you put the interrupt inside the tool. Forge’s M7 implementation puts the pause in the pr_approval node instead (simpler for the lab), but the tool-level version is the pattern to reach for when you need approval at the tool-call boundary in a create_agent loop.
Pattern 4 — Validate input. Loop on interrupt() until the operator provides a valid value:
def validated_input_gate(state: ForgeState) -> dict:
while True:
value = interrupt({"message": "Provide a valid plan (JSON):", "schema": "Plan"})
try:
plan = Plan(**value)
return {"plan": plan, "plan_approved": True}
except Exception as e:
# interrupt() is called again on the next loop iteration
# Each call is a separate pause + resume cycle
continue
Not built in Forge M7, but the mechanism works: each iteration of the loop is a new interrupt() call and therefore a new pause-resume cycle.
The Full Forge HITL Flow
flowchart TD
A([issue]) --> B[intake]
B --> C[triage]
C --> D[planner]
D --> E{plan_approval\ninterrupt\nApprove plan?}
E -->|approved=True\nor edited_plan| F[gate → Send ×N]
E -->|approved=False| G[abandon]
G --> Z([END])
F --> H[editor × N]
H --> I[apply — defer=True]
I --> J[tester]
J -->|passed=False & attempts < 3| K[fixer]
K --> J
J -->|give_up| Z
J -->|passed=True| L{pr_approval\ninterrupt\nOpen PR?}
L -->|approved=True| M[pr opened\nPR.md written]
L -->|approved=False| N([changes on branch\nno PR])
M --> Z
N --> Z
style E fill:#f9a,stroke:#c66,color:#000
style L fill:#f9a,stroke:#c66,color:#000
The two pink nodes are the HITL gates. Everything else is the Forge flow you built in M1–M6. The gates cost zero in terms of new LangGraph concepts — they are just nodes with interrupt() calls and a SqliteSaver beneath them.
PullRequest, open_pr, and the v1.x Interrupt Shape
The PullRequest Schema (Frozen Here)
Module 7 introduces PullRequest in forge/models.py. This schema is frozen for the rest of the course — do not add fields in later modules:
class PullRequest(BaseModel):
"""The PR Forge opens after human approval — frozen at Module 7."""
title: str
body: str
branch: str
files_changed: list[str]
status: Literal["draft", "open", "merged"] = "draft"
Five fields exactly. No pr_number, no url, no author, no created_at. In production you would add those, but for the course, keeping the schema minimal and stable is more important than simulating a full GitHub response.
The ForgeState Extensions
Two new fields in forge/state.py — both absent before M7, both added by the addition-only rule:
class ForgeState(TypedDict, total=False):
# ... all M2–M5 fields unchanged ...
# --- Module 7 ---
plan_approved: bool # HITL plan-approval gate
pull_request: PullRequest | None # the PR opened after approval
# --- Module 12 adds: review ---
Both are last-write-wins (no reducer annotation). review: Review | None does not appear until M12 — do not add it here.
The open_pr Tool
The lab’s open_pr tool lives in forge/tools.py as a factory function (like the other tools from M4–M5, which close over a repo_path):
def make_pr_tool(repo_path: str | Path):
root = Path(repo_path)
@tool
def open_pr(title: str, body: str, files_changed: list[str]) -> str:
"""Open a pull request: write PR.md describing the change."""
(root / "PR.md").write_text(
f"# {title}\n\n{body}\n\nFiles changed: {', '.join(files_changed)}\n"
)
return f"opened PR: {title}"
return open_pr
In the lab, “opening a PR” means writing PR.md to the sandbox. In production, you replace the body with a GitHub API call. The interface stays the same; only the side effect changes. The pr_approval node calls this directly — it does not go through create_agent because there is no LLM decision to make; the operator has already approved.
Here is pr_approval, the second HITL gate:
def pr_approval(state: ForgeState) -> dict:
"""Pause for a human to approve opening the PR; open it on approval."""
files = [e.path for e in state.get("edits", [])]
decision = interrupt({"action": "approve_pr", "files": files})
approved = bool(decision.get("approved")) if isinstance(decision, dict) else bool(decision)
if not approved:
return {"messages": [AIMessage(content="PR not approved — changes left on the branch.")]}
pr = PullRequest(
title=f"Fix: {state['issue'][:60]}",
body=state["plan"].summary if state.get("plan") else state["issue"],
branch="forge/auto-fix",
files_changed=files,
status="open",
)
sandbox.write_file(state["repo_path"], "PR.md",
f"# {pr.title}\n\n{pr.body}\n\nFiles changed: {', '.join(files)}\n")
return {"pull_request": pr, "messages": [AIMessage(content=f"Opened PR: {pr.title}")]}
Notice that pr_approval creates the PullRequest object and writes the file in one node. This works because there is no LLM call before interrupt() — the only state reads are state.get("edits", []) and state["issue"], which are idempotent. On resume, the node reads the same values, builds the same draft object, calls interrupt() (which immediately returns the saved approval), and proceeds to write the file. The file write happens after the interrupt call, on the resume path — not before it.
NodeInterrupt Is Gone: What Replaced It
In LangGraph v0.x, some tutorials used from langgraph.errors import NodeInterrupt with raise NodeInterrupt(value="..."). This API is removed in v1.x. If you find it in code you are reading, that code is outdated.
The correct v1.x import is:
from langgraph.types import interrupt # the only valid path in v1.x
# NEVER: from langgraph.errors import NodeInterrupt (removed)
The Interrupt shape surfaced to the caller in v1.x has two fields: value (the payload you passed to interrupt()) and id (an internal identifier). The old fields interrupt_id, when, resumable, and ns are gone. The test confirms the shape:
r1 = graph.invoke({"issue": "...", "repo_path": repo}, cfg)
assert r1["__interrupt__"][0].value["action"] == "approve_plan"
# ^^^^^
# .value is the payload you passed to interrupt()
Hands-On Lab: Wiring the Two HITL Gates
Objective: Add plan_approval and pr_approval to Forge, introduce PullRequest and the open_pr tool, and verify both gates via offline tests that use Command(resume=...) without any real API calls.
Lab path: module-07/ in langgraph-prod-labs
This lab cost approximately $0.15 on June 2026 prices (a few
smart-tier Planner calls). The sandbox Tester runspyteston Tally with no API key. The fallback Ollama configuration costs $0.00 — swap one line inforge/config.py.
Step 1: Extend forge/models.py
Add PullRequest at the bottom of models.py, after the existing M5 classes. No existing class changes:
class PullRequest(BaseModel):
"""The PR Forge opens after human approval — frozen at Module 7."""
title: str
body: str
branch: str
files_changed: list[str]
status: Literal["draft", "open", "merged"] = "draft"
Run the contract test immediately:
def test_pullrequest_contract():
pr = PullRequest(title="t", body="b", branch="x", files_changed=["report.py"])
assert pr.status == "draft" # opens as draft, set to "open" on approval
Step 2: Extend forge/state.py
Add the two M7 fields after the M5 block:
# --- Module 7 ---
plan_approved: bool # HITL plan-approval gate
pull_request: PullRequest | None # the PR opened after approval
# --- Module 12 adds: review ---
Import PullRequest in the state module header alongside the other model imports.
Step 3: Add open_pr to forge/tools.py
Add make_pr_tool as a factory function alongside make_read_tools and make_write_tool. The unit test for it:
def test_open_pr_tool_writes_pr_md():
repo = sandbox.make_sandbox(Path(__file__).resolve().parents[1] / "tally")
open_pr = tools.make_pr_tool(repo)
msg = open_pr.invoke({"title": "Fix bug", "body": "desc", "files_changed": ["report.py"]})
assert "opened PR" in msg and (Path(repo) / "PR.md").exists()
No LLM required. Pure filesystem side effect, fully testable offline.
Step 4: Add the Two HITL Nodes to forge/nodes.py
Add plan_approval, gate, abandon, and pr_approval. The gate function is what the conditional edge calls — it either fans out (dispatch(state) returns list[Send]) or routes to abandon:
def gate(state: ForgeState):
"""After approval: fan out to editors (Send), or route to abandon."""
if not state.get("plan_approved"):
return "abandon"
return dispatch(state)
def abandon(state: ForgeState) -> dict:
return {"messages": [AIMessage(content="Plan not approved — abandoning the run.")]}
Test the gate in isolation — no graph required:
def test_gate_routes_on_approval():
targets = [FileTarget(path="report.py", instruction="fix")]
base = {"repo_path": "/r", "issue": "i", "targets": targets}
assert nodes.gate({**base, "plan_approved": False}) == "abandon"
sends = nodes.gate({**base, "plan_approved": True})
assert len(sends) == 1 and all(isinstance(s, Send) for s in sends)
Step 5: Wire the Graph in forge/graph.py
The full build_graph() function after M7 changes:
def build_graph(checkpointer=None):
builder = StateGraph(ForgeState)
builder.add_node("intake", nodes.intake)
builder.add_node("triage", nodes.triage)
builder.add_node("planner", nodes.planner)
builder.add_node("plan_approval", nodes.plan_approval)
builder.add_node("abandon", nodes.abandon)
builder.add_node("editor", nodes.editor)
builder.add_node("apply", nodes.apply_edits, defer=True)
builder.add_node("tester", nodes.tester)
builder.add_node("fixer", nodes.fixer)
builder.add_node("pr_approval", nodes.pr_approval)
builder.add_edge(START, "intake")
builder.add_edge("intake", "triage")
builder.add_conditional_edges(
"triage", nodes.route_by_type,
{"bug": "planner", "feature": "planner", "chore": "planner"},
)
builder.add_edge("planner", "plan_approval")
builder.add_conditional_edges("plan_approval", nodes.gate, ["editor", "abandon"])
builder.add_edge("abandon", END)
builder.add_edge("editor", "apply")
builder.add_edge("apply", "tester")
builder.add_conditional_edges(
"tester", nodes.route_after_tests,
{"done": "pr_approval", "give_up": END, "fixer": "fixer"},
)
builder.add_edge("fixer", "tester")
builder.add_edge("pr_approval", END)
return builder.compile(checkpointer=checkpointer)
The checkpointer parameter is passed through. Tests use checkpoint.in_memory(); production uses checkpoint.sqlite("forge_checkpoints.db").
Step 6: Run the Integration Tests Offline
The pipeline test in tests/test_pipeline.py drives the full happy path with a RoutedFakeModel (no API key needed):
def test_hitl_approve_plan_and_pr(monkeypatch, make_routed_model):
repo = _setup(monkeypatch, make_routed_model)
graph = build_graph(checkpointer=checkpoint.in_memory())
cfg = {"configurable": {"thread_id": "approve"}, "recursion_limit": 50}
r1 = graph.invoke({"issue": "report crashes on empty category", "repo_path": repo}, cfg)
assert r1["__interrupt__"][0].value["action"] == "approve_plan"
r2 = graph.invoke(Command(resume={"approved": True}), cfg)
assert r2["__interrupt__"][0].value["action"] == "approve_pr"
out = graph.invoke(Command(resume={"approved": True}), cfg)
assert out["test_result"].passed is True
assert out["pull_request"].status == "open"
assert (Path(repo) / "PR.md").exists()
And the rejection path:
def test_hitl_reject_plan_abandons(monkeypatch, make_routed_model):
# ... setup ...
r1 = graph.invoke({"issue": "..."}, cfg)
out = graph.invoke(Command(resume={"approved": False}), cfg)
assert out.get("plan_approved") is False
assert out.get("pull_request") is None # abandoned — no PR
# report.py is unchanged — editors never ran
assert "totals.get(cat" not in (Path(repo) / "report.py").read_text()
Run all tests across M1–M7:
uv run pytest tests/ -q
Expected: all offline tests green, no API key required. The Tester node runs real pytest on the Tally sandbox during the integration test — that is your deterministic signal that the fix actually works.
Try It Yourself
-
Edit-state pattern: Resume with
Command(resume={"edited_plan": {"summary": "fix only report.py", "target_files": ["report.py"], "steps": [...], "risk": "low"}})instead of{"approved": True}. Verify thatstate["plan"].target_filescontains onlyreport.pyand that the Planner node was not re-called. -
Crash between pauses: After the first
invoketriggers the plan-approval interrupt, kill the process. Create a new process, restore theSqliteSaveron the same database file, and invokegraph.invoke(Command(resume={"approved": True}), cfg)with the samethread_id. Verify the run completes from where it stopped — no re-execution ofintake,triage, orplanner. -
Static breakpoint comparison: In a separate script, pass
interrupt_before=["plan_approval"]tocompile()instead of usinginterrupt()in the node body. Resume withinvoke(None, cfg). Observe that no payload reaches the caller and no decision value is injected — this is why breakpoints are for debugging only.
In Production
Two HITL checkpoints is the right number for a coding agent. That is an opinion, and I am comfortable defending it: plan approval before any write hits the sandbox catches the Planner’s worst mistakes (wrong files, scope creep, risky refactors). PR approval before pushing catches anything the test suite does not cover (style, intent, hidden coupling). More than two checkpoints and you have rebuilt a manual process with extra steps. Zero and you are a cowboy deployer.
The checkpointer is what makes these pauses production-viable. A plan approval pause can last thirty seconds (operator is watching the terminal) or three hours (operator is in a meeting). LangGraph does not care — the state is persisted in SQLite or Postgres, and graph.invoke(Command(resume=...), cfg) on the same thread ID works identically either way. This is why Module 6 is a hard prerequisite: without a checkpointer, interrupt() raises an error at runtime.
The idempotence constraint before interrupt() is not accidental overhead — it is a production invariant. Every node that contains interrupt() should be testable with a fake resume (no LLM call, no API key). If you cannot test it offline, you have not separated concerns correctly. Forge’s plan_approval and pr_approval nodes pass this bar: both are pure state reads before the interrupt() call.
Pattern 2 (edit-state) is underused in practice. Most developers implement approve/reject and stop there. But letting the operator pass a revised_plan directly in Command(resume=...) eliminates a full Planner re-run — potentially one smart-tier model call saved per rejected plan. At scale (hundreds of issues per day), that adds up.
Observability: in M11 you will stream the interrupt() payload to a live UI using get_stream_writer, so the operator sees the plan appear in real time rather than waiting for the full invoke to return. The mechanism is the same; the delivery channel changes.
Audit trail: get_state_history() records every checkpoint, including every Interrupt checkpoint with the saved payload. You can reconstruct exactly what the operator was shown and what they approved — useful for compliance. This becomes the foundation for time-travel in M8.
Mastery Corner
What to Really Understand Here
CA7.1 — interrupt(payload) is a call inside a node that suspends the graph and surfaces payload in the result’s __interrupt__ key. Resuming requires graph.invoke(Command(resume=value), same_config), where value becomes the return value of the interrupt() call. A checkpointer is mandatory.
CA7.2 — On resume, the node re-executes from line 1. Every line before interrupt() runs again. Make those lines pure state reads. Put anything with side effects in a separate upstream node.
CA7.3 — Static breakpoints (compile(interrupt_before=["n"])) stop execution for debugging; they surface the state, not a custom payload, and resume with invoke(None, cfg). They inject no value. They are not a production HITL mechanism.
CA7.4 — Four HITL patterns: approve/reject (conditional routing on the resume value), edit-state (resume value directly updates state), review-tool-calls (interrupt() inside a @tool), validate-input (loop on interrupt() until valid). Forge M7 implements the first two.
CA7.5 — The v1.x interrupt shape is Interrupt(value=..., id=...). NodeInterrupt is removed. The only valid import is from langgraph.types import interrupt.
Five Mastery Questions
Q1. You add this node to Forge:
def plan_approval(state: ForgeState) -> dict:
enriched = config.get_model("smart").invoke([HumanMessage("Summarize this plan...")])
decision = interrupt({"plan": enriched.content, "message": "Approve?"})
return {"plan_approved": decision.get("approved", False)}
After deploying, your team notices that every plan approval is billed for two LLM calls instead of one. What is the root cause?
A) interrupt() forces a full graph restart, re-running all previous nodes including the Planner.
B) The node re-executes from line 1 on resume, so get_model().invoke() runs twice — once before the pause, once after.
C) The checkpointer serializes and deserializes the model call, causing a second invocation.
D) Command(resume=...) triggers a second call to interrupt() which re-invokes the model.
Q2. A teammate configures compile(interrupt_before=["plan_approval"]) instead of calling interrupt() inside the plan_approval node. The operator resumes with graph.invoke(None, cfg). Which statement is true?
A) The behavior is identical: both surface the plan payload and accept a decision value.
B) The static breakpoint surfaces the current graph state, not a custom payload; the resume injects no value; the node cannot receive an operator decision.
C) The static breakpoint is more efficient because it avoids re-executing the node on resume.
D) invoke(None, cfg) and invoke(Command(resume=value), cfg) are interchangeable; both inject the value correctly.
Q3. The operator wants to correct the Planner’s list of target_files (say, remove models.py) without triggering a full re-plan. Which HITL pattern applies, and what does the correct Command(resume=...) look like?
A) Approve/reject — Command(resume={"approved": False}), then re-invoke from scratch.
B) Edit-state — Command(resume={"edited_plan": {"summary": "...", "target_files": ["report.py"], "steps": [...], "risk": "low"}}), handled by the edited = decision.get("edited_plan") branch of plan_approval.
C) Validate-input — loop until the operator provides a valid plan via the CLI.
D) Review-tool-calls — put interrupt() inside make_read_tools to intercept the Planner’s file reads.
Q4. You clone a repo that contains:
from langgraph.errors import NodeInterrupt
raise NodeInterrupt(value="Approve plan?")
You run it against LangGraph 1.2.5. What happens, and what is the correct migration?
A) It works identically to interrupt() — NodeInterrupt is an alias in v1.x.
B) NodeInterrupt is removed in v1.x; the import fails with ImportError. Migrate to from langgraph.types import interrupt and replace raise NodeInterrupt(...) with value = interrupt(...).
C) NodeInterrupt is deprecated but still functional; it logs a deprecation warning but does not raise an error.
D) The graph runs without pausing because NodeInterrupt is caught internally and swallowed.
Q5. A node contains two sequential interrupt() calls. An operator resumes the first pause with Command(resume="yes"). What happens next?
A) Both interrupt() calls receive "yes" simultaneously; the node returns after the second one.
B) The graph resumes, the node re-executes from line 1, the first interrupt() returns "yes" (the saved resume value), and execution hits the second interrupt() — causing a second pause. A second Command(resume=...) is needed.
C) The second interrupt() is skipped; Command(resume=...) provides a value for the first call only, and the node continues past the second call with None.
D) The runtime raises an error because multiple interrupt() calls in one node are not supported in v1.x.
Answers
A1 — B. The node re-executes from its very beginning on resume (CA7.2). get_model().invoke() appears before interrupt(), so it runs on both the original invocation and the resume. Fix: move the LLM call to a separate upstream node; keep plan_approval as a pure state reader.
A2 — B. Static breakpoints (interrupt_before) surface the graph’s current state via get_state(). They do not pass a custom payload, and resuming with invoke(None, cfg) injects no value into the node. The node cannot receive an operator decision through this mechanism. Use interrupt() for production HITL (CA7.3).
A3 — B. This is the edit-state pattern (CA7.4). The operator constructs a corrected Plan dict and passes it in Command(resume={"edited_plan": {...}}). The plan_approval node checks decision.get("edited_plan"), builds a new Plan object, and returns it — skipping the Planner entirely.
A4 — B. NodeInterrupt was removed in LangGraph v1.x (CA7.5). The import raises ImportError. The migration is from langgraph.types import interrupt, and instead of raise NodeInterrupt(value=...), you write result = interrupt(...) and use result as the return value.
A5 — B. LangGraph matches Command(resume=...) values to interrupt() calls in order (CA7.2). The first resume provides the value for the first interrupt(). Execution continues and hits the second interrupt(), pausing again. A second Command(resume=...) is required to satisfy it.
Traps to Avoid
Trap 1 — Assuming resume continues from the interrupt() call site. The node re-executes from line 1 on resume. Anything before interrupt() runs twice. This is the source of double billing, double writes, and duplicated side effects. Keep everything before interrupt() pure.
Trap 2 — Using NodeInterrupt from a v0.x tutorial. from langgraph.errors import NodeInterrupt is removed in v1.x and will fail at import time. The correct API is from langgraph.types import interrupt. If you are reading a tutorial that uses raise NodeInterrupt(...), that tutorial is outdated.
Trap 3 — Using interrupt_before as a production HITL mechanism. Static breakpoints cannot surface a custom payload and cannot receive a decision value. They are a debugging convenience. Resuming with invoke(None, cfg) does not inject anything into the graph — it just continues. For production HITL, use interrupt().
Key Takeaways
-
interrupt(payload)suspends the graph and surfacespayloadto the caller inresult["__interrupt__"][0].value.graph.invoke(Command(resume=value), config)resumes the graph and injectsvalueas the return ofinterrupt(). A checkpointer is mandatory for both to work. -
The node re-executes from its start on resume. Every line before
interrupt()runs again. Keep those lines to pure state reads. Put LLM calls, file writes, and API requests in separate upstream nodes. -
interrupt_before/interrupt_afterare static breakpoints for debugging — they surface state, accept no return value, and resume withinvoke(None, cfg).interrupt()is the production HITL mechanism. They are not interchangeable. -
The four HITL patterns — approve/reject, edit-state, review-tool-calls, validate-input — cover every HITL scenario you will encounter. Forge M7 implements the first two.
-
PullRequest(title,body,branch,files_changed,status) is the frozen M7 schema.open_pris the canonical tool name.plan_approvedandpull_requestare the twoForgeStatefields introduced here. -
NodeInterruptis removed in LangGraph v1.x. The only valid import isfrom langgraph.types import interrupt. The v1.xInterruptshape has two fields:valueandid.
What’s Next + Newsletter
This is Module 07 of LangGraph in Production, a free 16-module course that takes you from your first StateGraph to a deployed, durable autonomous coding agent on LangGraph v1.x. Start at Module 1 or browse the full syllabus.
Want the full LangGraph mastery question bank (interview-style) and the next module in your inbox? Subscribe here — it’s free, like everything in this series.
Module 08 — Time-Travel: Forge can now pause and wait for approval — but what if the plan was wrong and you want to go back? Module 8 introduces time-travel: walking state history with get_state_history(), replaying from a past checkpoint, and forking a bad run to try a different Plan without starting over.
Links: Lab code — module-07 · Previous: Module 06 — Persistence · Next: Module 08 — Time-Travel · Course index
References
-
LangGraph Docs — How-to: Human-in-the-loop /
interrupt(): https://langchain-ai.github.io/langgraph/how-tos/human_in_the_loop/ (verified June 2026) -
LangGraph Docs —
langgraph.types:interrupt,Command,Send: https://langchain-ai.github.io/langgraph/reference/types/ (verified June 2026) -
LangGraph Docs — Breakpoints (static
interrupt_before/interrupt_after): https://langchain-ai.github.io/langgraph/how-tos/breakpoints/ (verified June 2026) -
LangGraph Changelog — v1.0 GA release notes (removal of
NodeInterrupt, newinterrupt()API): https://github.com/langchain-ai/langgraph/releases (verified June 2026) -
Lab code —
langgraph-prod-labs/module-07/: https://github.com/dupuis1212/langgraph-prod-labs/tree/main/module-07