Building an end-to-end Coding Agent with LangGraph

Human-in-the-Loop: Tool Approval

Jun 17, 2026 · 11 min read

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.

terms in this chapter
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.

graphDashed = new this chapter. Click a node for role, contract, code.

In code, the diff is three lines in build_graph:

python
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 checkpointer

Notice 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:

  1. It suspends the graph and saves a checkpoint (which is why the compile line above needs a checkpointer).
  2. It hands value to the outside world, the value surfaces out of graph.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:

the interrupt() round tripPick a side to read what crosses the boundary.

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:

python
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 tools

The 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 an interrupt() 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:

python
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=...):

a mixed turn: reject the edit, approve the readstep 1 of 9
START
chat
END
tools
human_approval
you

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.

context windowwhat chat sends to the model1 msg · 0 chars
you>set the default temperature to 0.7 and show me the README
statemessages 1

Send the message to start the run, or click the bar and use ← → to step through.

Step 1, you: message

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.)

python
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
            return

This 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:

python
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:

python
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:

python
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:

python
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 sends value out; Command(resume=answer) wakes it and makes answer the return value of interrupt(). 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_approval node 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_node runs it. Reject → human_approval writes a ToolMessage with the reason, and tool_node skips 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.