A LangChain agent can call Python tools that change real systems, so sensitive actions need a pause point before the tool executes. Approval gates fit refunds, emails, database writes, file deletion, account changes, and other operations where an automatic tool call could have business or security consequences.
HumanInTheLoopMiddleware checks proposed tool calls against an interrupt_on policy. When a matching tool is requested, LangGraph saves the agent state with a checkpointer and returns an interrupt instead of running the tool.
The local smoke test uses a fake tool-calling model so no provider key is required. The approval policy, InMemorySaver checkpointer, shared thread_id, and Command(resume=…) decision payload are the same integration points used with a provider or self-hosted chat model.
Related: How to install LangChain with pip
Related: How to create a LangChain agent
Related: How to define a tool in LangChain
Use a project virtual environment so LangChain and LangGraph dependencies stay separate from the system Python installation.
Related: How to create a virtual environment for LangChain
$ python3 -m pip install --upgrade langchain
The langchain package includes the agent factory, built-in middleware, and LangGraph runtime dependencies used by the approval gate.
Related: How to install LangChain with pip
$ cat > approve_tool_call.py <<'PY'
from langchain.agents import create_agent
from langchain.agents.middleware import HumanInTheLoopMiddleware
from langchain_core.language_models.chat_models import BaseChatModel
from langchain_core.messages import AIMessage
from langchain_core.outputs import ChatGeneration, ChatResult
from langgraph.checkpoint.memory import InMemorySaver
from langgraph.types import Command
from pydantic import PrivateAttr
class ToolCallingFakeModel(BaseChatModel):
_responses: list[AIMessage] = PrivateAttr()
_index: int = PrivateAttr(default=0)
def __init__(self, responses: list[AIMessage], **kwargs):
super().__init__(**kwargs)
self._responses = responses
@property
def _llm_type(self) -> str:
return "tool-calling-fake"
def bind_tools(self, tools, **kwargs):
return self
def _generate(self, messages, stop=None, run_manager=None, **kwargs):
response = self._responses[self._index]
self._index = min(self._index + 1, len(self._responses) - 1)
return ChatResult(generations=[ChatGeneration(message=response)])
tool_runs = []
def send_refund(customer_id: str, amount: float) -> str:
"""Send a refund to a customer account."""
tool_runs.append({"customer_id": customer_id, "amount": amount})
return f"refund_sent customer_id={customer_id} amount={amount:.2f}"
model = ToolCallingFakeModel(
responses=[
AIMessage(
content="",
tool_calls=[
{
"name": "send_refund",
"args": {"customer_id": "CUST-1042", "amount": 49.5},
"id": "call_refund_1",
}
],
),
AIMessage(content="Refund approval recorded."),
]
)
agent = create_agent(
model=model,
tools=[send_refund],
middleware=[
HumanInTheLoopMiddleware(
interrupt_on={
"send_refund": {
"allowed_decisions": ["approve", "reject"],
}
}
)
],
checkpointer=InMemorySaver(),
)
config = {"configurable": {"thread_id": "refund-approval-demo"}}
paused = agent.invoke(
{"messages": [{"role": "user", "content": "Refund CUST-1042 for 49.50"}]},
config=config,
)
print("paused_before_tool=", "__interrupt__" in paused, sep="")
print("tool_runs_before_approval=", len(tool_runs), sep="")
resumed = agent.invoke(
Command(resume={"decisions": [{"type": "approve"}]}),
config=config,
)
print("tool_runs_after_approval=", len(tool_runs), sep="")
print("tool_output=", resumed["messages"][-2].content, sep="")
print("final_message=", resumed["messages"][-1].content, sep="")
PY
The fake model only forces one protected tool call for local validation. Replace it with the chat model already used by the application when adding approval to an application agent.
$ python3 approve_tool_call.py paused_before_tool=True tool_runs_before_approval=0 tool_runs_after_approval=1 tool_output=refund_sent customer_id=CUST-1042 amount=49.50 final_message=Refund approval recorded.
tool_runs_before_approval=0 proves the protected tool did not run before the human decision. tool_runs_after_approval=1 proves the same paused run resumed and executed the tool after approval.
from langchain.agents import create_agent from langchain.agents.middleware import HumanInTheLoopMiddleware from langgraph.checkpoint.memory import InMemorySaver agent = create_agent( model=chat_model, tools=[lookup_customer, send_refund], checkpointer=InMemorySaver(), middleware=[ HumanInTheLoopMiddleware( interrupt_on={ "send_refund": { "allowed_decisions": ["approve", "reject"], }, "lookup_customer": False, } ) ], )
InMemorySaver is for local tests and prototypes. Use a durable checkpointer before deployment so a pending approval can survive a process restart.
from langgraph.types import Command config = {"configurable": {"thread_id": "refund-approval-demo"}} result = agent.invoke( Command( resume={ "decisions": [ { "type": "reject", "message": "Refund requires manager approval.", } ] } ), config=config, )
Use the same thread_id that produced the interrupt, and send decisions in the same order as the interrupted actions. Use reject to deny a side-effecting tool. respond is for ask-user style tools because its message is treated as a successful tool result.
$ rm approve_tool_call.py