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
Steps to add human approval for LangChain tool calls:
- Open an activated Python project environment.
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 - Install or update 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 - Create a no-key approval smoke-test script.
$ 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="") PYThe 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.
- Run the approval smoke test.
$ 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.
- Move the middleware block into the application agent.
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.
- Resume paused calls with one review decision per requested action.
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.
- Remove the local smoke-test script.
$ rm approve_tool_call.py
Mohd Shakir Zakaria is a cloud architect with deep roots in software development and open-source advocacy. Certified in AWS, Red Hat, VMware, ITIL, and Linux, he specializes in designing and managing robust cloud and on-premises infrastructures.