Who this is for and what you will be able to do
This guide is for developers who want an agent to stop before side-effecting actions, such as writing a file or running SQL, and wait for a person to decide. After reading it, you can attach HumanInTheLoopMiddleware to tool calls, explain the four decision types, and resume the same thread after an interrupt.
What human-in-the-loop means here
The Human-in-the-Loop (HITL) middleware adds human oversight to agent tool calls. When the model proposes an action that may need review, such as writing to a file or executing SQL, the middleware can pause execution and wait for a decision.[1]
It checks each tool call against a configurable policy. If intervention is needed, it issues an interrupt that halts execution. The graph state is saved through LangGraph's persistence layer, so the pause is safe and the run can resume later.[1]
A human decision chooses what happens next. approve runs the action as-is. edit changes it before it runs. reject denies it and gives feedback. respond supplies a reply in place of the tool, which suits "ask user" style tools.[1]
Prerequisites
- Python and JavaScript versions of the examples are both provided. The per-call interrupt mode requires
langchain1.4.6 or later.[1] - Interrupts need a checkpointer to persist graph state. Use
InMemorySaverfor testing and prototyping. In production, use a persistent checkpointer such asAsyncPostgresSaverorMongoDBSaver.[1] - Every invocation needs a thread ID in its config, which ties the execution to one conversation thread.[1]
Step by step: set an approval policy
- Import
HumanInTheLoopMiddlewarefromlangchain.agents.middlewareand add it to themiddlewarelist ofcreate_agent.[1] - Configure each tool with
interrupt_on. In the example,write_fileisTrue, so all four decisions are allowed.execute_sqlallows onlyapproveandreject, so it cannot be edited.read_dataisFalse, a safe operation that needs no approval.[1] - Create an
InMemorySaverand pass it as thecheckpointer. The full example:
from langchain.agents import create_agent
from langchain.agents.middleware import HumanInTheLoopMiddleware # [!code highlight]
from langgraph.checkpoint.memory import InMemorySaver # [!code highlight]
agent = create_agent(
model="gpt-6.1-sol",
tools=[write_file, execute_sql, read_data],
middleware=[
HumanInTheLoopMiddleware( # [!code highlight]
interrupt_on={
"write_file": True, # All decisions (approve, edit, reject, respond) allowed
"execute_sql": {"allowed_decisions": ["approve", "reject"]}, # No editing allowed
"read_data": False, # Safe operation, no approval needed
},
# Prefix for interrupt messages - combined with tool name and args to form the full message
# e.g., "Tool execution pending approval: execute_sql with query='DELETE FROM...'"
# Individual tools can override this by specifying a "description" in their interrupt config
description_prefix="Tool execution pending approval",
),
],
# Human-in-the-loop requires checkpointing to handle interrupts.
# In production, use a persistent checkpointer like AsyncPostgresSaver or MongoDBSaver.
checkpointer=InMemorySaver(), # [!code highlight]
)- Invoke the agent with a config that carries a thread ID, for example
config = {"configurable": {"thread_id": "some_id"}}. The run stops at the interrupt, and withversion="v2"the result is aGraphOutputwhoseinterruptsattribute lists the actions awaiting review.[1] - After the reviewer decides, resume with a
Commandon the same thread ID:
agent.invoke(
Command( # [!code highlight]
resume={"decisions": [{"type": "approve"}]} # or "reject" [!code highlight]
), # [!code highlight]
config=config, # Same thread ID to resume the paused conversation
version="v2",
)Several actions waiting: answer in order
One model turn can produce several tool calls that need approval. Each action needs its own decision, and the decisions must follow the same order as the actions in the interrupt request.[1] The example below combines an approve, an edit and a reject:
{
"decisions": [
{"type": "approve"},
{
"type": "edit",
"edited_action": {
"name": "tool_name",
"args": {"param": "new_value"}
}
},
{
"type": "reject",
"message": "This action is not allowed"
}
]
}The default batched mode raises one interrupt per model turn, listing every call that needs review, and takes an ordered list of answers. The per_call mode raises one interrupt per gated call and takes answers keyed by interrupt ID. The page recommends per-call mode for new agents and says LangChain plans to make it the default in the next major release.[1]
Pause only some calls
By default every call to a tool listed in interrupt_on pauses. To pause selectively, add a when predicate to that tool's configuration. The predicate receives a ToolCallRequest and returns True to interrupt or False to auto-approve. Conditional interrupts require langchain 1.3.3 or later.[1] The example below decides from the arguments: writes outside the workspace directory and SQL that is not a read-only SELECT both pause.
def writes_outside_workspace(request: ToolCallRequest) -> bool:
"""Pause writes to paths outside the workspace directory."""
path = request.tool_call["args"].get("path", "")
return not path.startswith("/workspace/")
def is_write_query(request: ToolCallRequest) -> bool:
"""Pause SQL that isn't a read-only SELECT."""
query = request.tool_call["args"].get("query", "")
return not query.lstrip().upper().startswith("SELECT")How an approval flows
The model generates a response. The middleware inspects it for tool calls. If any need human input, it builds a HITLRequest and calls interrupt, and the agent waits. Once decisions arrive, approved or edited calls run, rejected calls get ToolMessages, and execution resumes.[1] In per-call mode, the middleware pauses each gated call as it runs and executes the tool only after that call's decision arrives.[1]
For streaming, call stream_events() with version="v3". stream.messages yields LLM tokens, and stream.interrupted reports whether the run paused for human input.[1]
Limits and common questions
- The predicate runs again on resume and must return the same result for the same call. If it returns
Falseon resume, the tool runs and the reviewer's answer is ignored, even areject.[1] - In batched mode, if the predicate result changes for only some calls, the middleware raises a
ValueError.[1] - Keep edits conservative. Large changes to the arguments the model first produced can make the model re-evaluate, which may run the tool several times or produce unexpected actions.[1]
- Use
rejectto deny an action. Therespondmessage is returned to the model as a successful tool result, so it is the wrong tool for denial.[1] - After an edit, the model receives a note that a reviewer replaced the call. The
edit_noticeoption changes that note, andNoneremoves it.[1] - A rejection without a
messagegets a default note telling the model the tool did not run and should not be retried unless the user asks.[1] - An invalid answer raises a
ValidationErroragainst the interrupt'sresponse_schema. Nothing is saved, so a corrected answer can go to the same interrupt ID.[1] - Place
HumanInTheLoopMiddlewarebefore retry or error-handling middleware so it wraps them; otherwise a retried call asks the reviewer again.[1] - Use a persistent checkpointer in production.
InMemorySaveris for tests and prototypes.[1]
Next steps
For the details of interrupts, read the LangGraph interrupts documentation. For the streaming modes, see the LangChain streaming guide.[1]
Adapted from the LangChain documentation page *Human-in-the-loop*[1], released under the MIT License.
Sources
- 1Human-in-the-loopLangChain docs (MIT)
Cite this page: Human-in-the-loop approval for risky tool calls in LangChain. TellWorld, https://www.tellworld.net/practice/practice-langchain-human-in-the-loop. Updated 2026-11-14