Study Notes
Notecore

Human in the Loop for Agentic Write Operations

Where and how to pause an autonomous graph before consequences occur — blast-radius analysis, static versus dynamic breakpoints, interrupt-before semantics, checkpointed suspension, approve/edit/deny decisions, and reliable resumption.

35 min read·verified Content review 2026·reviewed 2026-09-01

After this you should be able to

  • Place a human gate at the correct write decision boundary
  • Implement and diagnose checkpointed approve, edit, and deny flows
HITLInterruptsApproval Gates
Agentic AI · Human in the Loop

How to put a human decision boundary between an AI agent’s output and the irreversible action it triggers — sized to consequence, not to distrust, and wired into LangGraph so it cannot be bypassed.

Updated September 2026 · Read ~24 min · LangGraph / tech-support agent

// the big idea

Every catastrophic AI incident shares one failure: the system was built as if the model were infallible. The damage is rarely the mistake itself — mistakes are expected. It is the missing human decision boundary between the agent’s output and the irreversible write it fires. A human-in-the-loop gate is not an admission the agent is weak; it is an engineering control sized to blast radius.

Section 1Notebook Summary#

Telegraphic revision notes — fully derivable from the detailed notes below. Copy by hand; box the marked lines.

1.1  Blast radius#

  • Blast radius = max scope of irreversible damage one autonomous action can cause if it runs wrong.
  • = irreversibility × scale (how hard to undo × how many entities hit).
  • Rule: autonomy ∝ 1 / blast radius. Higher blast → MORE human oversight, not less.
  • Not philosophy → a design constraint, like a memory budget. Can’t document blast radius → not production-ready.
  • Accountability: LLM has no legal personhood → the engineering team that deployed it is liable.
Doodle: 2×2 box. x = few→many entities, y = reversible→irreversible. Star the top-right cell (Q4).
ActionBlastOversight
KB search → wrong answerlowfull autonomy
Create GitHub issue (bad title)mediumapproval gate
Delete customer recordhighHITL + dual confirm
Bulk email 50k customersmaxmulti-level + senior auth

1.2  HITL = feature, not failure#

  • Airbag / fire-exit: a control sized to consequence, not proof the system is bad.
  • Division of labor: agent does the 15-min cognitive work; human spends 10s on the one authorization.
  • 99 / 1 principle → agent 99%, human 1% (the final decision). ~97% cut in human effort/ticket.
  • Bonus: each approve/deny = a labeled training signal + audit trail.
  • Regulated (MiFID, HIPAA, GDPR) often require documented human oversight.
💡 TIPSenior/junior lawyer: associate drafts 4h, partner reviews 20min & signs. Firm never sends unsigned contracts — the signature is accountable judgment, not distrust.

1.3  Decision boundary#

  • Where the gate sits. Set by blast radius. Place at last moment before the irreversible write.
  • Too EARLY (before agent computes) → security theater; human does all the work anyway.
  • Too LATE (after write) → retrospective HITL; audits, doesn’t control.
  • Correct = between draft complete and create_issue().
Doodle: pipeline Classify→Research→Draft │GATE│ WRITE. Mark early (X), here (✓), late (X).

1.4  Breakpoints: static vs dynamic#

StaticDynamic
set atcompile timeruntime (node logic)
howinterrupt_before/afterNodeInterrupt
firesalways at named nodeonly if condition (score>0.85)
bypass?nevermay skip
⭐ TRICKStatic = hardwired into the engine at compile. An LLM cannot reason its way around it. That is exactly the posture irreversible writes need.

1.5  interrupt_before vs _after#

  • before: run all nodes up to but not including named node, then stop.
  • after: run including named node, serialize, then stop.
  • Combine for multi-checkpoint pipelines (classify → draft → send).

1.6  compile() = control surface#

  • Not just topology lock. 3 behavioral dials:
  • persistence → checkpointer
  • interrupt placement → interrupt_before / after
  • observability → debug

1.7  Interrupt lifecycle (7)#

  • 1 normal exec → 2 boundary reached → 3 serialize state → 4 thread released
  • 5 human review window (3s or 3wk) → 6 inject decision + resume → 7 write executes
  • Thread released = no CPU, no blocked thread, no open sockets. State safe on disk.
Doodle: laptop hibernate. RAM→disk, close lid (gap), open lid, exact state back.
⚠ PITFALLthread_id must be byte-for-byte identical on resume. Mismatch raises NO error — it silently starts a fresh run.

1.8  Checkpointer#

  • SQLite saver: put(thread_id, metadata, serialized_state) after every node.
  • Preserves: state fields, message list, internal_notes, github_draft, thread_id/config, graph position.
  • NOT preserved: in-memory Python objects, the graph object itself → must rebuild from same script.
  • memory saver = no HITL; SQLite = good; Postgres = production scale.

1.9  Resume + denial#

  • Carry decision with Command(resume=…) from langgraph.types.
  • Payload = dict, not bool (carries reviewer note; extensible; self-documenting).
  • 3 UI actions: approve / deny / edit&approve.
  • Denial path (5): read approved=False → write escalation_denied → return w/o write → route synthesizer → synth reads findings.
⚠ PITFALLA gate with no denial branch can only approve — deny then crashes, returns empty, or loops. Design the deny path first.

Section 2Detailed Study Notes#

A support agent reads a ticket, diagnoses the fault, and files a GitHub issue for engineering. One night it misreads a customer’s ID, calls the delete-account tool with the wrong one, and a real customer’s order history, payment methods, and ticket record are gone. The model did what models sometimes do — it hallucinated a parameter. The question this lesson answers is not “how do we stop the model from ever being wrong” (we can’t), but “why was there nothing standing between that wrong output and the irreversible action it triggered?”

2.1The mandate: blast radius#

Blast radius is the largest amount of irreversible damage a single autonomous action can do if it runs on bad input. The term is borrowed from nuclear engineering, where it meant the physical radius a detonation could destroy. For an agent, it is a number you calculate for every tool before you ever decide how much freedom to give it.

Two things set it. The first is irreversibility — how hard or impossible it is to undo the action once done. The second is scale — how many entities (users, records, emails, transactions) one execution touches. High irreversibility combined with high scale is maximum blast radius; low-and-low is minimum.

Analogy · the dial

Think of a volume dial wired backwards to a wrecking ball. The bigger the ball (blast radius), the lower you’re forced to set the autonomy dial. Autonomy is inversely proportional to blast radius: the more damage an action can do, the more human oversight it demands — not less.

irreversibility → scale (entities affected) → Q1 · low blast Full autonomy KB search → wrong answer Q2 · medium Rate limit + monitoring many reversible writes Q3 · high HITL + dual confirm delete one customer Q4 · maximum ★ Multi-level HITL + senior authorization bulk email 50k
Autonomy response by quadrant. The top-right corner — irreversible and wide — is where the two-person rule lives.

The engineering implication is blunt: the blast radius of an action sets the minimum level of human oversight required before it runs. Before an agent gets write access to production, the blast radius of every write must be documented. If it can’t be, the agent is not ready.

💡 TIP · accountability

When an agent takes a harmful action, the LLM cannot be blamed — it has no legal personhood, no contracts, no capacity for consequences. Liability lands on the engineering team that deployed it without appropriate oversight. HITL is partly how that team discharges its duty of care.

Analogy · the two-person rule

No single person, whatever their rank, can launch a nuclear weapon. Two independently authorized people must confirm simultaneously with separate keys. That isn’t distrust of either individual — it’s oversight matched to irreversibility. Irreversible agent writes deserve the same discipline.

2.2HITL is a feature, not a failure#

Human-in-the-loop is a deliberate split of work between agent and reviewer, not a patch for a weak model. The common misread is that a human gate means the AI “isn’t good enough.” An airbag doesn’t mean the car is badly built; a fire exit doesn’t mean the building is prone to fire. All three are controls calibrated to how bad the worst case is, installed regardless of baseline quality.

The agent does the expensive cognitive work — reading the ticket, querying data sources, cross-referencing the knowledge base, diagnosing root cause, and drafting a well-structured escalation with the right severity, title, and body. That would take a human operator ~15 minutes; the agent does it in seconds. The human’s job shrinks to ~10 seconds: read the pre-computed draft and click approve or deny.

⭐ TRICK · the 99/1 principle

Without HITL a human does 100% of an escalation. With it, the agent does the 99% and the human does the 1% that matters — the authorization of the irreversible step. Human effort per ticket drops from ~15 min to ~30 s, roughly a 97% reduction. The gate is not overhead; it is what makes that reduction safe enough to ship.

Two more reasons it earns its place. Every approve/deny is a labeled training signal with ground-truth annotation you get for free. And in regulated industries — MiFID (financial), HIPAA (health), GDPR (data) — documented human oversight of automated decisions is often a legal requirement, not a nicety.

Analogy · senior and junior lawyer

An associate spends four hours researching case law and drafting a contract. The partner spends twenty minutes reviewing, corrects two clauses, and signs. The firm never sends an unsigned contract — not because the associate is incompetent, but because the signature is an accountable human judgment that the document is correct and appropriate to send.

2.3Where the gate goes: the decision boundary#

The decision boundary is the exact point in the workflow where human review is required. It is not an arbitrary design choice — blast radius sets it, and it belongs at the last possible moment before the irreversible action runs. Put it at either extreme and the review becomes worthless.

Ingress / PII mask→ Classifier→ Dispatcher→ Tech · Billing · Fraud→ GitHub gate→ Synthesizer→ Egress
Classify Research Draft create_issue() IRREVERSIBLE too early security theater ✓ correct draft done, write pending too late audits, can’t prevent
One gate, three positions. Only the middle one both saves the human’s time and prevents the bad write.

Too early → security theater#

Ask the human to approve the ticket classification before any research. The reviewer must now do all the downstream work themselves to judge it. The agent saved them nothing. A gate that exists but adds no value, because it intercepts before the agent has computed anything.

Too late → retrospective HITL#

Ask for approval after the write already ran. The human can only review what happened, not prevent it. Useful for audit trails and training signal — but it audits rather than controls, so the bad write still shipped.

Correct placement is precise: after the tech-analysis agent has searched the knowledge base, diagnosed root cause, and prepared the complete draft (title, body, idempotency key), and before the GitHub tool node calls PyGithub’s create_issue. The reviewer sees a finished artifact with full context, and their approve/deny still changes the outcome.

2.4compile() is a control surface#

The compile() call does more than lock in nodes and edges — it configures how the graph behaves at runtime. Read it as three dials, not a finalization step.

Persistence#

checkpointer= — serialize state after every node so it survives a restart.

Interrupt placement#

interrupt_before / after — where the graph halts for a human.

Observability#

debug=True — print the full state dict at each transition during development.

graph.py · the control surface
from langgraph.checkpoint.sqlite import SqliteSaver

checkpointer = SqliteSaver.from_conn_string("support.db")

app = workflow.compile(
    checkpointer=checkpointer,               # 1. persistence: survive restarts
    interrupt_before=["github_tool_node"],   # 2. static gate: halt before the write
    debug=True,                              # 3. observability while developing
)

Without a checkpointer, state lives only in the process’s RAM and vanishes when the terminal closes — which is why the checkpointer is what makes HITL possible at all. It is inseparable from suspend-and-resume (section 2.7).

2.5interrupt_before vs interrupt_after#

Both take a list of node names and both halt the graph — they differ only on which side of the named node the cut falls.

interrupt_before

Stop before the node runs#

  • Runs every node up to but not including the named one.
  • The named node’s work has not happened yet.
  • Right choice for an irreversible write: halt before create_issue.
interrupt_after

Stop after the node runs#

  • Runs the named node, serializes, then stops.
  • The node’s output is already in state for review.
  • Right choice to review a produced draft or classification before moving on.

Complex pipelines combine them — e.g. review the classification (after), then the draft (after), then gate the send (before).

2.6Static vs dynamic breakpoints#

A static breakpoint is fixed at compile time; a dynamic one is decided at runtime inside a node. Static gates fire every single time execution reaches the named node, unconditionally, regardless of state values. That unconditional quality is the point.

⭐ TRICK · non-bypassable by design

Because a static breakpoint is hardwired into the execution engine at compile time, there is no routing path, state condition, edge config, or LLM decision that can reach the node without first triggering the interrupt. A hallucinating model cannot reason its way around it. Safety must not depend on runtime logic being correct — so irreversible writes get static gates.

A dynamic breakpoint is raised from inside a node’s logic with a NodeInterrupt, so it can fire conditionally on a runtime value. The fraud agent computes a risk score and only pauses for a human when the score crosses a threshold; low-risk tickets proceed automatically.

nodes.py · dynamic breakpoint
from langgraph.errors import NodeInterrupt

def fraud_analysis_agent(state: AgentState) -> dict:
    score = assess_fraud_risk(state)
    if score > 0.85:                       # conditional on runtime state
        raise NodeInterrupt(
            f"Fraud score {score:.2f} exceeds threshold; human review required."
        )
    return {"internal_notes": [f"fraud: clear ({score:.2f})"]}
DimensionStaticDynamic
Configured atcompile time (interrupt_before/after)runtime, inside node logic (NodeInterrupt)
Firesalways at the named nodeonly when a condition holds
Depends on state?noyes
Can be bypassed?neveryes — low-risk cases skip it
Use forevery irreversible writerisk-gated review (fraud score, amount)

2.7The interrupt lifecycle#

When the graph hits a breakpoint it doesn’t “wait” — it stops completely, saves everything to disk, and releases the thread. The execution model is deterministic, so the sequence is worth knowing exactly.

1 normal execution 2 boundary reached 3 serialize to disk 4 thread released 5 human review window 3s … 3 weeks 6 inject + resume 7 write runs no CPU · no blocked thread · no open sockets — state safe on disk
Phases 1–4 unwind the run; 5 is unbounded; 6–7 reconstruct and finish.

At the boundary, LangGraph calls the checkpointer’s put one final time — the saved checkpoint holds every state field, the full message list, all findings so far, the GitHub draft, and metadata marking the GitHub tool node as next. Then app.invoke returns to the caller and the Python thread is freed. The graph is not blocked in any technical sense; it has stopped, and its state sits in SQLite.

Analogy · laptop hibernation

On sleep, the OS writes all of RAM — open apps, in-progress documents, socket state — to a hibernation file, then draws almost no power. Days later you open the lid, it reads the file back, and every app resumes exactly where it was. The checkpointer is that hibernation file for the graph.

⚠ PITFALL · the thread_id trap

Resumption is keyed by thread_id. If the one you resume with isn’t byte-for-byte identical to the one you invoked with, LangGraph raises no error — it silently starts a brand-new graph run and leaves the real paused ticket suspended forever. This is the most common production HITL failure.

2.8The checkpointer: what is preserved#

After every node the checkpointer’s put is called with three things: the thread_id (which conversation), a checkpoint metadata object (node name, step number, timestamp), and the serialized state as a JSON-compatible structure. SQLite writes these as a row in a checkpoints table indexed by thread_id and step, so the checkpoint history is the full ordered sequence of snapshots.

Preserved across the boundary#

  • every field in the state dict
  • the complete message list (human / AI / tool)
  • internal_notes with all findings so far
  • the github_draft dict (title, body, key)
  • thread_id and all config values
  • graph position — the next node to run

NOT preserved — must rebuild#

  • in-memory Python objects (custom class instances, open files, network connections)
  • the compiled graph object itself

On restart, the same script must reconstruct an identical graph — same nodes, edges, and compile params — and connect to the same DB file. Then invoke(None) finds the checkpoint and continues from the GitHub tool node.

This is why graph-construction code must be stable, deterministic, and version-controlled: if the process dies mid-review, the state is safe, but resuming means running the same script to rebuild the same topology before reconnecting.

CheckpointerSupports HITL?Use
MemorySaverno — state dies with the processquick demos only
SqliteSaveryessmall / single-node production
PostgresSaveryesproduction scale, multi-instance
💡 TIP · why interrupts beat the alternatives

Naive approval loops waste resources. A polling loop keeps a process alive burning CPU while it checks a queue; synchronous blocking (input()) ties up an execution thread, which is catastrophic on an async server where a small thread pool multiplexes many requests. The interrupt + checkpointer approach frees the thread entirely and keeps state on disk — zero resource cost during a review window of any length.

2.9Resuming, and the denial path#

The human’s decision comes back into the graph as a Command. Imported from langgraph.types, Command(resume=value) is the formal carrier for the reviewer’s answer, passed to app.invoke in place of a fresh initial state.

⭐ TRICK · resume with a dict, never a bare bool

A boolean carries only the yes/no. A dict carries the decision plus the reviewer note (needed for the audit trail and for the synthesizer’s final reply), it’s extensible without breaking parsers (add review_id, timestamp, edited_draft later), and it makes the payload self-documenting in the log. Define the schema up front and treat it as an API contract.

resume.py · inject the decision
from langgraph.types import Command

config = {"configurable": {"thread_id": "ticket-8842"}}   # byte-for-byte identical

approval = Command(resume={
    "approved": True,
    "reviewer_note": "Confirmed outage; matches the PagerDuty alert.",
})
final_state = app.invoke(approval, config=config)          # resumes from the gate

Two requirements are strict: the thread_id must match the original invocation exactly, and the graph used to resume must be the same compiled graph (same nodes, edges, and interrupt config). Get either wrong and resumption silently misbehaves.

The denial path deserves as much design attention as approval — a gate that only handles “yes” works half the time. On denial it must route the agent to a graceful alternative rather than crash, return empty, or loop.

github_tool_node reads decision approved? create_github_issue() escalation_created no write escalation_denied synthesizer yes no
Both branches write a finding and converge on the synthesizer, so the user always gets a coherent reply.

The five-step denial sequence: (1) the node reads approved == False; (2) it writes an escalation_denied finding (with the reviewer note) to internal_notes; (3) it returns without calling create_github_issue, so no write runs; (4) the graph routes to the synthesizer; (5) the synthesizer reads all findings and produces a final response — including any manual escalation instructions — so the user gets a complete answer regardless of outcome.

nodes.py · denial-aware gate
def github_tool_node(state: AgentState) -> dict:
    draft = state["github_draft"]
    decision = state.get("human_decision", {})

    if decision.get("approved"):
        result = create_github_issue(
            title=draft["title"],
            body=draft["body"],
            idempotency_key=draft["idempotency_key"],
        )
        finding = {"type": "escalation_created",
                   "issue_url": result["url"],
                   "reviewer_note": decision.get("reviewer_note", "")}
    else:
        finding = {"type": "escalation_denied",         # no write happens
                   "reviewer_note": decision.get("reviewer_note", "")}

    return {"internal_notes": [finding]}                # synthesizer reads this
💡 TIP · two interrupt styles [+]

LangGraph supports two interchangeable ways to pause: the compile-time interrupt_before shown here (a static gate, resumed with invoke(None) after update_state), and an inline interrupt(payload) call inside a node that returns the resumed value directly (resumed with Command(resume=...)). The session demonstrated both; the mental model — halt, persist, release, resume — is identical. This clarification is added for orientation, not from the source.

2.10Interview & practice Q&A#

What is blast radius, and how do you compute it for a tool?

It’s the maximum irreversible damage one autonomous action can cause on bad input. You estimate it from two variables: irreversibility (how hard to undo) and scale (how many entities one execution affects). High on both is maximum blast radius; the value sets the minimum human oversight the tool requires before it ships.

Why is HITL a feature rather than an admission the model is weak?

Because it’s a control sized to consequence, like an airbag or a fire exit, installed regardless of baseline quality. It divides labor: the agent does the expensive 99% and the human authorizes the 1% that’s irreversible. It also produces labeled training signal and can satisfy regulatory requirements for documented oversight.

Where exactly should the decision boundary sit, and what goes wrong at the extremes?

At the last moment before the irreversible write — after the draft is complete, before create_issue. Too early is security theater: the reviewer must redo the agent’s work, so the gate adds no value. Too late is retrospective HITL: the write already ran, so review can audit but not prevent.

Static vs dynamic breakpoint — when do you reach for each?

Static (interrupt_before/after, compile time) fires unconditionally at the named node and cannot be bypassed by any routing or LLM decision — use it for every irreversible write. Dynamic (NodeInterrupt, raised in node logic) fires only when a runtime condition holds — use it for risk-gated review like a fraud score above a threshold.

Why is a checkpointer mandatory for HITL, not optional?

Without persistence the graph state lives only in process RAM and disappears when the process ends, so there’s nothing to resume. The checkpointer serializes state to disk at the interrupt boundary, freeing the thread while the human reviews for seconds or weeks, then lets the graph reconstruct and continue.

What does the checkpointer NOT preserve, and why does that matter?

It preserves JSON-serializable state (fields, messages, findings, draft, config, graph position) but not in-memory Python objects or the compiled graph itself. So resuming after a restart requires re-running the same, deterministic, version-controlled script to rebuild an identical graph before reconnecting to the same DB file.

Why resume with a structured dict instead of a boolean?

A dict carries the decision plus the reviewer note (for audit and the final reply), stays extensible without breaking parsers, and is self-documenting in logs. Treat the payload schema as an API contract between the reviewer’s interface and the consuming node so in-flight resumptions don’t break across versions.

A resume call silently starts a new run instead of continuing. What’s the likely cause?

A thread_id mismatch. It must be byte-for-byte identical to the invoking call; any difference means LangGraph can’t find the paused checkpoint and starts fresh with no error raised. Check for stray underscores, whitespace, or casing.

Why design the denial path before writing the approval path?

A gate with no denial handling can only approve — a “no” then crashes, returns empty, or loops. The denial path must write an escalation_denied finding, skip the write, and route to the synthesizer so the user still gets a complete, useful response.

2.11Key takeaways#

  • Damage comes from a missing decision boundary, not from the model being wrong — mistakes are expected.
  • Blast radius = irreversibility × scale; autonomy is inversely proportional to it.
  • HITL is a control sized to consequence, not proof of weakness — the 99/1 split makes automation safe.
  • Place the gate at the last moment before the irreversible write; not before the work, not after it.
  • Static breakpoints are non-bypassable at compile time — the right posture for writes; dynamic ones gate on runtime risk.
  • The checkpointer makes HITL possible: persist, release the thread, resume by thread_id.
  • Resume with a dict via Command, and always build a graceful denial path.
blast radiusautonomy ∝ 1/blast99/1 principle decision boundaryinterrupt_beforestatic = non-bypassable NodeInterruptcheckpointerthread_id exact Command(resume)deny → synthesizer

Section 3Microexercises#

Reading proves you recognize the pattern; these prove you can build it. Attempt each before opening the walkthrough.

3.1Build the static HITL gate#

// your task

Wire a non-bypassable approval gate in front of an irreversible GitHub write, then handle both outcomes.

  • Compile the graph so it always halts before github_tool_node and survives a process restart.
  • Write github_tool_node so that on approval it calls create_github_issue(...), and on denial it performs no write.
  • In both cases append a finding to internal_notes so the synthesizer can reply.

Expected result: approval → an escalation_created finding with the issue URL; denial → an escalation_denied finding and no call to create_github_issue.

Show the full walkthrough

Step 1 — The gate lives in compile(), not in a node. Two dials matter: interrupt_before=["github_tool_node"] makes the halt static and unconditional, and a checkpointer makes the pause survivable. Without the checkpointer there is nothing to resume.

Step 2 — Inside the node, branch on decision["approved"]. The approval branch calls the write with the pre-drafted fields (the LLM already produced them — the node calls PyGithub directly, no inference). The denial branch skips the write entirely.

Step 3 — Return a finding in both branches. That single guarantee is what lets the synthesizer produce a coherent reply no matter the outcome.

solution
from langgraph.checkpoint.sqlite import SqliteSaver

app = workflow.compile(
    checkpointer=SqliteSaver.from_conn_string("support.db"),
    interrupt_before=["github_tool_node"],   # static, non-bypassable
)

def github_tool_node(state: AgentState) -> dict:
    draft = state["github_draft"]
    decision = state.get("human_decision", {})

    if decision.get("approved"):
        result = create_github_issue(
            title=draft["title"],
            body=draft["body"],
            idempotency_key=draft["idempotency_key"],
        )
        finding = {"type": "escalation_created", "issue_url": result["url"]}
    else:
        finding = {"type": "escalation_denied",
                   "reviewer_note": decision.get("reviewer_note", "")}

    return {"internal_notes": [finding]}

Why: the gate’s safety comes from the compile-time interrupt (no routing or LLM can skip it), and its usefulness comes from both branches feeding the synthesizer.

3.2Diagnose: the resume that vanishes#

// your task

This code pauses correctly at the gate, but after the reviewer approves, the issue is never created and the ticket seems stuck. No exception is thrown. Predict the exact failure and fix it.

buggy
config_a = {"configurable": {"thread_id": "ticket-8842"}}
app.invoke(initial_state, config=config_a)     # pauses at github_tool_node

# ...later, in the approval handler:
config_b = {"configurable": {"thread_id": "ticket_8842"}}   # note the change
app.update_state(config_b, {"human_decision": {"approved": True}})
app.invoke(None, config=config_b)

State the expected behavior: what does invoke(None, config=config_b) actually do here?

Show the full walkthrough

Root cause — the thread_id changed between invocation and resume: "ticket-8842" (hyphen) vs "ticket_8842" (underscore). The checkpointer keys everything by thread_id, and it must match byte-for-byte.

Predicted failure — no exception. config_b points at a thread with no saved checkpoint, so update_state and invoke(None) operate on an empty thread: LangGraph starts a fresh run with no input and returns an empty/partial result. Meanwhile the real paused run under "ticket-8842" stays suspended forever, and the issue is never created. This silence is exactly why the bug is dangerous.

Fix — use one identical thread_id for the whole lifecycle (ideally reuse the same config object).

fixed
config = {"configurable": {"thread_id": "ticket-8842"}}
app.invoke(initial_state, config=config)       # pauses

# ...later, same config object, byte-for-byte identical id:
app.update_state(config, {"human_decision": {"approved": True}})
app.invoke(None, config=config)                # resumes the real run

Why: resumption is a database lookup by thread_id. A mismatched key doesn’t error — it just finds nothing and starts over.

3.3Diagnose: the gate that only approves#

// your task

The team ships this github_tool_node. Approvals work. Then a reviewer denies a ticket and the user gets a broken, empty-feeling response. Name the root cause, predict what the synthesizer sees, and fix it.

buggy
def github_tool_node(state: AgentState) -> dict:
    draft = state["github_draft"]
    decision = state.get("human_decision", {})

    if decision.get("approved"):
        result = create_github_issue(title=draft["title"], body=draft["body"])
        return {"internal_notes": [
            {"type": "escalation_created", "issue_url": result["url"]}
        ]}
    # (no else branch)

State the expected result on the denial path.

Show the full walkthrough

Root cause — the denial path is missing. A gate with no else branch can only approve.

Predicted failure — on denial the function falls off the end and implicitly returns None. LangGraph expects a state-update dict; returning None means no finding is written to internal_notes. The synthesizer then reads findings and sees nothing about the escalation, so it produces an incomplete, confusing reply — the “returns empty” failure mode. (Depending on the reducer, returning None can also raise when LangGraph tries to merge it.)

Fix — add the explicit denial branch: write an escalation_denied finding, skip the write, and let the graph route to the synthesizer.

fixed
def github_tool_node(state: AgentState) -> dict:
    draft = state["github_draft"]
    decision = state.get("human_decision", {})

    if decision.get("approved"):
        result = create_github_issue(title=draft["title"], body=draft["body"])
        finding = {"type": "escalation_created", "issue_url": result["url"]}
    else:
        finding = {"type": "escalation_denied",
                   "reviewer_note": decision.get("reviewer_note", "")}

    return {"internal_notes": [finding]}   # always return a finding

Why: denial is a first-class outcome. The gate must degrade gracefully so the user gets a complete answer whether or not the write happened.

interrupt_before + checkpointerbranch on approved always return a findingreuse the thread_id no else → empty replydeny writes escalation_denied

End of note · Human in the Loop for Agentic Write Operations