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.
Deedglass
A tool call meets your rules before it runs
Asks to run
DELETE FROM visits;
Deletes every row in visits
Deedglass checks your rules
- Reading dataAllowed
- Deleting a whole table (DROP, TRUNCATE)Never
- Changing or deleting rows without saying which (WHERE)This one
- Other changesWait for a person
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
interruptmust be idempotent, because the node restarts from the beginning on resume. - Don’t wrap
interruptin try/except, or you catch the signal and the pause never reaches the graph. - Don’t reorder
interruptcalls 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:
- Put side effects after
interrupt(), so they run once, on the resumed pass. - Split into two nodes: one that asks, one that acts.
- 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.
InMemorySaverloses 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
HumanInTheLoopMiddlewarewith an explicit entry per tool. - Read-only tools
False; writes withapprove,edit,reject; destructive toolsrejectonly, 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.