Human in the loop

Updated 5 interview angles 4 min read source
On this page8
  1. The mechanism
  2. Why this is not a callback
  3. The approval pattern
  4. Review and edit
  5. The rules that keep resume correct
  6. Where to put the gate
  7. Related
  8. Interview angle

Human in the loop

Pausing a graph mid-run, showing a person what is about to happen, and resuming with their answer. In a regulated domain this is the feature that makes an agent shippable at all.

The mechanism

python
from langgraph.types import interrupt, Command

def approve(state: State) -> dict:
    decision = interrupt("Approve this refund?")
    return {"approved": decision}

interrupt() stops the graph at that point. The value you pass is surfaced to the caller; the graph’s state is checkpointed; the process can exit entirely.

Resuming supplies the value that interrupt() returns:

python
config = {"configurable": {"thread_id": "refund-9"}}

graph.invoke({"amount": 500}, config)   # pauses here

# ...minutes or days later, possibly another process
graph.invoke(Command(resume=True), config)

Command(resume=True) makes interrupt(...) return True and execution continues from that point — not from the start of the graph.

A checkpointer is mandatory. Without one there is nothing to resume from, and this is the single most common setup error. See Persistence and threads.

Why this is not a callback

The pause is durable. Your API can return a 202, the worker can shut down, and someone can approve the action tomorrow from a different machine — the state is a row in Postgres, not a suspended coroutine.

That is what makes it viable for real approval workflows, where the human is a compliance officer with a queue, not a user staring at a spinner.

The approval pattern

python
from typing import Literal
from langgraph.types import interrupt, Command

Outcome = Literal["execute", "cancel"]

def approve(state: State) -> Command[Outcome]:
    ok = interrupt({
        "action": "refund",
        "amount": state["amount"],
        "customer": state["customer_id"],
    })
    return Command(goto="execute" if ok else "cancel")

Two things worth copying here. The interrupt payload is a dict describing what is about to happen — the reviewer needs the details, not a yes/no prompt with no context. And the node returns a Command, so the decision routes directly rather than being stashed in state for a separate router.

Review and edit

The resume value is not limited to a boolean. Pass back edited content and the node continues with it:

python
def review(state: State) -> dict:
    edited = interrupt({
        "instruction": "Review before sending",
        "draft": state["draft"],
    })
    return {"draft": edited}

This is the shape for anything where a human corrects the model rather than just gating it — and the corrections are worth capturing as eval cases, since they are labelled examples of the model being wrong. See Building eval sets.

The rules that keep resume correct

Resuming re-executes the node from its start. Everything before the interrupt() call runs a second time. That single fact generates all the rules:

Do Do not
Put side effects after charge a card before
Keep call order fixed skip an interrupt conditionally
Split complex nodes loop with varying logic
Let it raise wrap in bare except

Side effects before the interrupt run twice. If the node emails the customer and then asks for approval, the customer gets two emails. Move the effect after the pause, or make it idempotent — the same discipline as Idempotency Keys (API-Level Deep Dive).

Gotcha: interrupt() works by raising a control exception. A bare try/except around it swallows the pause and the graph runs straight through the gate. This is a silent failure of an approval control, which in FinTech is the worst category of bug — catch specific exceptions, never bare.

Conditionally skipping an interrupt breaks resume indexing. The runtime matches resume values to interrupts by their order within the node, so a node that sometimes calls one interrupt and sometimes two will resume into the wrong one. Keep the call sequence deterministic; branch by routing to a different node instead.

Where to put the gate

The design question, not a mechanical one: gate on the action, not on the turn. Interrupting before every model call trains reviewers to click approve without reading, which is the rubber-stamping failure.

Gate on what is hard to reverse — money moving, a message sending, a record deleting. Everything else runs unattended. That is also the answer to “how do you make oversight meaningful”: a low volume of high-consequence decisions, with the disagreement rate monitored.

Interview angle 5

  • “How do you add human approval to an agent?” - interrupt() inside a node pauses the graph and checkpoints state; Command(resume=value) continues from that point with the value as the interrupt’s return. A checkpointer is mandatory — that is the usual setup error.
  • “Is the pause a blocked process?” - no, and that is the point. State is a row in the database, so the worker can exit and someone can approve days later from another machine. That is what makes it usable for a compliance queue rather than a spinner.
  • “What breaks on resume?” - the node re-executes from its start, so anything before the interrupt runs twice. Side effects belong after the pause or must be idempotent, or the customer gets two emails.
  • “What’s the subtle failure?” - wrapping interrupt() in a bare try/except. It works by raising a control exception, so a broad catch swallows the pause and the graph runs straight through the approval gate silently.
  • “Where do you put the gate?” - on hard-to-reverse actions, not on every turn. Interrupting constantly produces rubber-stamping, which is worse than no gate because it looks like oversight in an audit.