LangGraph interrupt() or HumanInTheLoopMiddleware: which to use for tool approval

LangChain gives you two ways to pause an agent for a person: a ready-made middleware that reviews tool calls, and the lower-level interrupt() it's built on. They differ in effort and in one trap: nodes re-run on resume.

The short answer

For approving tool calls in a LangChain agent, use HumanInTheLoopMiddleware. It checks each tool call against a per-tool policy and interrupts when review is needed, and the reviewer can approve, edit, reject or respond.1 Use LangGraph’s interrupt() directly when the pause isn’t about a tool call: a custom approval step, a question to the user mid-workflow, a review of a draft.2

Both need a checkpointer and a thread ID, because the paused state has to be saved and found again.12 And both inherit one LangGraph rule worth knowing before you ship: when you resume, the paused node restarts from the beginning. We tested what that does to a side effect.

Every MCP tool call goes through your rules first. Here a delete that doesn’t say which rows is stopped before it reaches the database. These are the rules deedglass init starts with; the call is made up.

The middleware: approval per tool

You configure interrupt_on with an entry per tool. Each value is True (interrupt with the default config), False (auto-approve) or a config object listing the allowed decisions.1

The four decisions:1

Decision What happens
approve The tool runs with the arguments the agent proposed
edit The reviewer changes the arguments, then it runs
reject The call is skipped and rejection feedback goes back to the agent
respond The reviewer’s message becomes a synthetic tool result; the tool doesn’t run

To continue after an interrupt, you invoke again with a Command carrying the decisions, for example resume={"decisions": [{"type": "approve"}]}, on the same thread ID.1

A sketch of the configuration, following the docs’ names:

Code Program code, for developers. The text around it says what it does.

from langchain.agents import create_agent
from langchain.agents.middleware import HumanInTheLoopMiddleware
from langgraph.checkpoint.memory import InMemorySaver

agent = create_agent(
    model=model,
    tools=[search_orders, refund_order, delete_customer],
    middleware=[HumanInTheLoopMiddleware(interrupt_on={
        "search_orders": False,
        "refund_order": {"allowed_decisions": ["approve", "edit", "reject"]},
        "delete_customer": {"allowed_decisions": ["reject"]},
    })],
    checkpointer=InMemorySaver(),
)

Read-only tools are auto-approved, refunds can be approved or corrected, and customer deletion can only be refused from this agent. Treat this as a sketch to check against the docs for your installed version; we didn’t run it against a live model.

The edit decision deserves a policy of its own. A reviewer who changes a refund from 250 to 25 has made a decision the agent didn’t, and your logs should show both the proposed and the final arguments.

interrupt(): the primitive

interrupt() pauses graph execution, saves the state and returns a value to the caller; you resume by invoking the graph again with Command(resume=...), and the resume value becomes the return value of interrupt().2 The middleware is built on the same persistence layer.1

The docs list rules for using it:2

  • Side effects before interrupt must be idempotent, because the node restarts from the beginning on resume.
  • Don’t wrap interrupt in try/except, or you catch the signal and the pause never reaches the graph.
  • Don’t reorder interrupt calls within a node; resume values are matched by position.
  • Use a durable checkpointer in production, and a thread ID so the runtime knows which state to resume.

The re-run trap, tested

We installed LangGraph 1.2.17 in a scratch environment and wrote a one-node graph that records a “refund requested” email, then asks for approval:

Code Program code, for developers. The text around it says what it does.

emails_sent = []

def refund_node(state: S):
    emails_sent.append("refund requested")          # side effect BEFORE interrupt
    decision = interrupt({"question": f"Approve refund of {state['amount']}?"})
    if decision != "approve":
        return {"result": "rejected"}
    return {"result": f"refunded {state['amount']}"}

Compiled with InMemorySaver, run once, then resumed with Command(resume="approve") on the same thread:

Code What the screen or file shows, copied as is.

after first invoke: {'question': 'Approve refund of 250?'} | emails: 1
after resume: refunded 250 | emails: 2

Two emails for one refund. The node ran up to the interrupt, paused, then ran again from the top when resumed. Every resume adds another. In real code, that line might send a Slack message, write an audit row, call a webhook or, worst, start the refund.

The fixes, in order of preference:

  1. Put side effects after interrupt(), so they run once, on the resumed pass.
  2. Split into two nodes: one that asks, one that acts.
  3. Make the side effect idempotent, keyed by something stable such as the thread ID and step, if it must come first.

The middleware avoids this for the tool itself: the tool runs after the decision. Your own nodes around it are still subject to the rule.

Side by side

HumanInTheLoopMiddleware interrupt()
Purpose Review tool calls Pause anywhere in a graph
Setup One middleware, per-tool config Your own node logic
Decisions approve, edit, reject, respond1 Whatever value you resume with
Needs checkpointer and thread ID Yes1 Yes2
Re-run on resume The tool runs once, after the decision Your node restarts from the top
Good for “Refunds need a person” “Ask the user which account,” “review this draft”

Production notes

  • Durable checkpointer. InMemorySaver loses paused runs on restart. A paused approval that waits overnight needs a database-backed checkpointer, which the docs advise for production.2
  • Thread IDs are identity. Whoever can resume a thread can approve its tool calls. Don’t let one user resume another’s thread.
  • Rules belong outside the prompt. “Ask before refunds” in the system prompt isn’t the middleware. Config in code is. For rules that also hold for other clients, a gateway in front of the tools does the same job; our post on policy languages for tool calls compares the options.
  • Record decisions. Who approved, what they changed in edit, and why they rejected. That’s your audit trail for each human-in-the-loop approval.

A checklist

  • Tool approvals use HumanInTheLoopMiddleware with an explicit entry per tool.
  • Read-only tools False; writes with approve, edit, reject; destructive tools reject only, or removed.
  • A durable checkpointer, and thread IDs scoped to one user.
  • No side effects before interrupt() in custom nodes, or they’re idempotent.
  • No interrupt() inside try/except.
  • Decisions logged with the reviewer and any edited arguments.

For the same pattern in the Vercel AI SDK, see toolApproval, tested. To see what the agents configured on your machine can change without a prompt, paste the config into the check page or run npx deedglass scan (how the grade works); to try approval rules in your browser, open the playground.

Written with AI assistance and checked against our claims register.

The takeaway

In our LangGraph run a side effect placed before interrupt() ran twice, once per invoke. Use the middleware for tool calls; put side effects after interrupt.

Read nextVercel AI SDK tool approval: moving from needsApproval to toolApproval Try it: install in 7 s

Related on this site

More posts

Sources

  1. 1LangChain docs: Human-in-the-loop · docs.langchain.com · checked 26 October 2026
  2. 2LangGraph docs: Interrupts · docs.langchain.com · checked 26 October 2026

Published 26 October 2026, sources last checked 26 October 2026. If something here is out of date, write to hello@deedglass.com.

Try it on your own setup

npx deedglass scan

Grades only your own setup. Nothing is uploaded.

Pricing