How to add human approval for LangChain tool calls

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.

Steps to add human approval for LangChain tool calls:

  1. 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

  2. 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

  3. 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="")
    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.

  4. 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.

  5. 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.

  6. 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.

  7. Remove the local smoke-test script.
    $ rm approve_tool_call.py