Human-in-the-Loop: Tool Approval
A coding agent that can edit files is one bad tool call away from clobbering your work. Before we let it run write_file or edit_file, we want a human to look at the change and say yes or no. That pause, stop, show me, wait for my answer, then continue, is human-in-the-loop (HITL).
The naive way to build this is a flag you check inside each tool. The problem: a tool function has no way to suspend the whole agent and come back later with the same in-memory state. That's exactly what LangGraph's interrupt() gives us, and it's the entire trick. This post builds tool approval on top of it, one piece at a time.
- interrupt(value)
- Suspends the graph mid-node, saves a checkpoint, and sends value out as the question.
- Command(resume=answer)
- Wakes the suspended graph; answer becomes the return value of interrupt().
- checkpointer
- Saves state at every step so a suspended run can continue later, even in another process.
- replay
- On resume the node runs again from the top; answered interrupts return their saved value instantly.
1. The shape#
We start from a three-node loop: chat calls the LLM; if it asked for tools, tools runs them and loops back; otherwise the turn ends. This chapter inserts exactly one thing into that loop: a human_approval node between the decision and the execution. The dashed pieces below are the entire diff. Click a node to see what it does, its contract with state, and the code it runs.
In code, the diff is three lines in build_graph:
builder.add_node("human_approval", human_approval_node) # NEW
builder.add_conditional_edges("chat", route_after_chat) # tool_calls -> "human_approval", else END
builder.add_edge("human_approval", "tools") # NEW
builder.compile(checkpointer=InMemorySaver()) # interrupt() needs a checkpointerNotice we did not add any new state field. Approvals and rejections will flow through the message list like everything else, and the agent still does exactly what it did. There is just a gate before it acts.
2. The one primitive: interrupt()#
interrupt(value) does two things in a single call:
- It suspends the graph and saves a checkpoint (which is why the compile line above needs a checkpointer).
- It hands
valueto the outside world, the value surfaces out ofgraph.stream(...)as the thing you're pausing to ask about.
Later, you resume by feeding a Command(resume=answer) back into the graph, and that answer becomes the return value of the interrupt() call, right where it paused. The mental model is a function call that crosses the process boundary. Click each side to see its half of the round trip:
Everything below is just deciding what to ask and who answers.
3. The approval node#
The LLM can ask for several tools in one turn, so we loop over the pending calls and interrupt for each:
def human_approval_node(state: State) -> dict:
last = state["messages"][-1] # the AIMessage with tool_calls
rejections = []
for tc in last.tool_calls:
decision = interrupt({"name": tc["name"], "args": tc["args"]})
if not decision["approved"]:
rejections.append(ToolMessage(
content=f"User rejected this call. Reason: {decision['reason']}",
tool_call_id=tc["id"],
))
return {"messages": rejections} # approved calls add nothing and fall through to toolsThe dict passed to interrupt is the question: the caller needs the tool name to decide how to render the prompt (a diff for an edit, a plain y/n for a read) and the args to actually show the change. A rejection becomes a ToolMessage carrying the reason. That satisfies the API contract (every tool_call needs exactly one response) and lets the model read why you said no.
One rule matters more than everything else in this node:
On resume, LangGraph re-runs the node from the top. Already-answered interrupts return their stored value instantly without re-prompting, and the loop walks to the next one. That only works because everything before each
interrupt()here is pure: we just read messages. Never do irreversible work before aninterrupt()in the same node, or it will run twice.
4. Two producers, one rule#
Both approve and reject continue the graph; rejecting does not stop anything. What differs is who produces the ToolMessage and whether the tool runs: on approval it comes from tool_node, which runs the call for real; on rejection it came from human_approval, and the call must never run. So tool_node has to skip any call that already has an answer:
def tool_node(state: State) -> dict:
ai = next(m for m in reversed(state["messages"]) if getattr(m, "tool_calls", None)) # NEW: last message may be a rejection now
answered = {m.tool_call_id for m in state["messages"] if isinstance(m, ToolMessage)} # NEW
messages = []
for tc in ai.tool_calls:
if tc["id"] in answered: # NEW: rejected in human_approval -> skip
continue
result = TOOLS_BY_NAME[tc["name"]].invoke(tc["args"])
messages.append(ToolMessage(content=str(result), tool_call_id=tc["id"]))
return {"messages": messages}The continue guards two failures at once: it stops us from running a tool the human just rejected, and from emitting a second ToolMessage for the same id, which would break the one-response-per-call rule.
Watch the agent run a mixed turn (you reject the edit, approve the read), then watch what the model does with the rejection. On the graph above the player, the interrupt leaves the frame to reach you and comes back as a Command(resume=...):
Nothing has run yet. The thread holds the system prompt and this request is still in the composer. Hit send (or press Enter) to run it.
Send the message to start the run, or click the bar and use ← → to step through.
Every call ends with exactly one response. No new state field, no special routing, it all falls out of the message list.
5. The human side: a resume loop#
The graph can now pause, but someone has to notice the pause, ask you, and resume. That's the CLI. A turn becomes a loop: drive the stream, and whenever an interrupt surfaces, prompt and resume with Command(resume=...). (The code below is simplified. The repo version may differ in detail, but the job is the same: collect a yes or no, hand it back, wake the graph.)
def run_turn(graph, stream_input, config) -> None:
while True:
interrupted = False
for event in graph.stream(stream_input, config=config, stream_mode="updates"):
if "__interrupt__" in event: # graph paused at human_approval
payload = event["__interrupt__"][0].value # the {"name", "args"} we passed in
stream_input = Command(resume=prompt_approval(payload))
interrupted = True
break # abandon this stream, resume below
for update in event.values():
render_update(update)
if not interrupted: # a full pass with no pause → done
returnThis handles multiple tool calls for free: each interrupt() in the node produces one __interrupt__ event, we prompt and resume, the node replays to its next interrupt(), and we go again: one prompt per call, in order.
The prompt itself is small. Its only real contract is the return value, which must be exactly the dict that comes back out of interrupt() inside human_approval_node:
def prompt_approval(call: dict) -> dict:
print(render_preview(call["name"], call["args"])) # tool name + args, diff-style for edits
if input("approve? [y/N]: ").strip().lower() in ("y", "yes"):
return {"approved": True, "reason": ""}
return {"approved": False, "reason": input("reason: ").strip() or "No reason given."}6. Resume with a bare Command#
When you resume, Command(resume=...) must be passed bare, not wrapped in your state dict. This is wrong:
graph.invoke({"messages": Command(resume={"approved": True})}, config)
# NotImplementedError: Unsupported message type: <class 'langgraph.types.Command'>The error is the tell. Inside {"messages": ...}, the add_messages reducer tries to coerce every item into a Message, and a Command is not a message, so it blows up. The fix is to hand it in at the top level:
graph.invoke(Command(resume={"approved": True}), config)The rule: a plain dict means "new state, run a fresh step." A bare Command(resume=...) means "don't start fresh, wake the suspended checkpoint and deliver this value to the waiting interrupt()." LangGraph tells them apart by the type of the input, so wrapping it hides the signal and you lose the resume path entirely.
7. Mechanism vs. policy#
What we built is the right mechanism: pause per tool call, show a diff, reject with feedback, resume. But notice the policy in this version asks about every tool, including read-only ones like read_file and grep. For a real coding agent that reads dozens of files per task, that's exhausting; you'd hammer "y" all day.
Real coding agents gate by risk: reads run automatically, writes and shell commands ask, and there's an "auto-accept" escape hatch for long runs. With this design that's a one-line change in the node, a set of tool names that require approval, and continue for the rest:
REQUIRES_APPROVAL = {"edit_file", "write_file"}
for tc in last.tool_calls:
if tc["name"] not in REQUIRES_APPROVAL:
continue # read-only tools fall straight through to `tools`
decision = interrupt({"name": tc["name"], "args": tc["args"]})
...The mechanism doesn't change; only the question of which calls reach interrupt(). That separation, a clean mechanism, a swappable policy, is the thing to take away.
8. Recap#
interrupt(value)suspends the graph and sendsvalueout;Command(resume=answer)wakes it and makesanswerthe return value ofinterrupt(). That round trip is the whole feature.- On resume the node re-runs from the top; already-answered interrupts return their stored value instantly instead of re-prompting. Keep everything before an
interrupt()pure: never do irreversible work ahead of one in the same node, or it runs twice. - A dedicated
human_approvalnode keeps the HITL step visible in the graph topology. The whole chapter is one node and two edges. - Approve → the call falls through and
tool_noderuns it. Reject →human_approvalwrites aToolMessagewith the reason, andtool_nodeskips any call that's already answered (so it neither runs nor double-responds). - State stays "just messages." No extra fields, rejections and results are both messages.
- Resume with a bare
Command, never wrapped in{"messages": ...}.
9. Up next#
The agent can now act safely, but on long tasks it drifts off the goal: thirty tool calls in, your request is buried at the top of the thread. Next chapter: a TODO list the agent writes and re-reads as it works: two no-op tools, one new state channel, and InjectedState, the mechanism that lets a tool read agent state the model never sees. Plus an /auto switch so approval stops being a tax on every read.