Skip to content

#330: Selective Approval With LangGraph

Last week we created a basic LangGraph example for the human-in-the-loop pattern. We ended up with a solution that run our tools but only after we approved the run. While this works, it gets cumbersome in no time. Especially when we have many tools and most of them are safe to use.

In this post we use a policy-based approach that allows us to create a list of safe tools for that we do not need a manual intervention. Let us see how we can build that.

Some dummy tools

To make the distinction between tools that need a manual approval and those who do not, we need a few tools. For this post we use 3 dummy tools with hard-coded return values to focus on the tool call and not their implementation. We need a few imports, our LLM and then we can define our tools:

import json
from typing import Annotated

from langchain_openai import ChatOpenAI
from langchain_core.tools import BaseTool
from langchain_core.messages import HumanMessage, AIMessage
from langgraph.graph import StateGraph, START, END
from langgraph.graph.message import add_messages
from langgraph.prebuilt import ToolNode
from pydantic import BaseModel


# 1. define LLM
llm = ChatOpenAI(
    base_url="http://localhost:1234/v1",
    api_key="lm-studio",
    model="openai/gpt-oss-20b",
    temperature=0.1,
)


# 2. create tools
class WeatherTool(BaseTool):
    name: str = "get_weather"
    description: str = "Return current weather for a city."

    def _run(self, city: str) -> str:
        return f"The weather in {city} is sunny and 75°F."


class TranslateTool(BaseTool):
    name: str = "translate_text"
    description: str = "Translate English text to Spanish."

    def _run(self, text: str) -> str:
        return f"Spanish translation of '{text}': 'Hola mundo!'"


class SummarizeTool(BaseTool):
    name: str = "summarize_article"
    description: str = "Summarize a long article."

    def _run(self, article: str) -> str:
        return f"Summary: {article[:60]}..."


TOOLS = [
    WeatherTool(),
    TranslateTool(),
    SummarizeTool(),
]


# 3. bind tools to model
tool_enabled_llm = llm.bind_tools(TOOLS)


# 4. create safe list
SAFE_TOOLS = {
    "get_weather",
}

The last part is important. In our SAFE_TOOLS list go the tools we do not want to ask the user for permission. If we build it that way, newly added unsafe tools will not fall through our safety net. This is our policy that tells our application what is safe and what is not. It does not look like much, but it is a massive improvement.

Define the nodes

We create a dedicated LLM node that will check the user input and find the matching tool. In our router_node we make the decision if the tool is safe to use or if we need to hand it over to the hitl_node to check for permissions. We end the node definition with a tool_node that will do the tool call:

# 5. define state
class State(BaseModel):
    messages: Annotated[list, add_messages]

    # router writes here
    next_step: str | None = None


# 6. define nodes
def llm_node(state: State):
    print("\n=== User ===")
    print(state.messages[-1].content)

    response = tool_enabled_llm.invoke(state.messages)

    if not response.tool_calls:
        raise RuntimeError("LLM did not select a tool.")

    tool_call = response.tool_calls[0]

    print("\n=== LLM selected ===")
    print(tool_call["name"])
    print(
        json.dumps(
            tool_call["args"],
            indent=2,
        )
    )

    return {
        "messages": [response],
    }


def router_node(state: State):
    last_ai: AIMessage = state.messages[-1]

    tool_name = last_ai.tool_calls[0]["name"]

    next_step = "auto_execute" if tool_name in SAFE_TOOLS else "hitl"

    print("\n=== Router ===")
    print(f"{tool_name} -> {next_step}")

    return {
        "next_step": next_step,
    }


def hitl_node(state: State):
    last_ai: AIMessage = state.messages[-1]

    tool_call = last_ai.tool_calls[0]

    print("\n=== HITL ===")
    print(f"Tool: {tool_call['name']}")
    print(f"Args: {tool_call['args']}")

    confirm = input("Proceed? (y/n): ").strip().lower()

    if confirm != "y":
        print("\nExecution aborted.")

        raise RuntimeError("User denied tool execution.")

    print("\nApproved.")

    return {}


# 7. use Tool node to run tools
tool_node = ToolNode(TOOLS)

We get all the manual intervention nicely encapsulated inside hitl_node. That gives us a more reusable design than what we created last week.

Define the graph

We can now build our graph and use the above defined nodes:

# 8. build graph
graph = StateGraph(state_schema=State)

graph.add_node("llm", llm_node)
graph.add_node("router", router_node)
graph.add_node("hitl", hitl_node)
graph.add_node("auto_execute", tool_node)
graph.add_node("manual_execute", tool_node)

graph.add_edge(START, "llm")
graph.add_edge("llm", "router")
graph.add_conditional_edges(
    "router",
    lambda state: state.next_step or "hitl",
    {
        "auto_execute": "auto_execute",
        "hitl": "hitl",
    },
)
graph.add_edge("hitl", "manual_execute")
graph.add_edge("auto_execute", END)
graph.add_edge("manual_execute", END)

app = graph.compile()
png_bytes = app.get_graph().draw_mermaid_png()
with open("hitl_advanced.png", "wb") as f:
    f.write(png_bytes)

This gives us a graph that looks like this:

We go from the start to the LLM node, then to our router who splits the workflow up into an automated tool call and the one with the HITL branch.

Glue code

We finish our implementation with this glue code to wire everything together:

# 9. Glue everything together
def run_query(user_input: str):
    print("\n" + "=" * 60)

    result = app.invoke({"messages": [HumanMessage(content=user_input)]})

    print("\n=== Final Messages ===")

    for msg in result["messages"]:
        print(
            "\n---",
            type(msg).__name__,
            "---",
        )

        if hasattr(msg, "content"):
            print(msg.content)


def main():

    demo_inputs = [
        # auto
        ("What's the weather in San Francisco?"),
        # HITL
        ("Translate 'Hello world' to Spanish."),
        # HITL
        (
            "Summarize this article: "
            "Artificial intelligence is "
            "rapidly transforming industries..."
        ),
    ]

    for query in demo_inputs:
        run_query(query)


if __name__ == "__main__":
    main()

We have hardcoded examples in this demo, but you can accept the input directly from the user.

Run the improved graph

When we now run our script, we should end up with the call to the weather tool without any manual checks while the other two tools require permissions:

=== User ===
What's the weather in San Francisco?

=== LLM selected ===
get_weather
{
  "city": "San Francisco"
}

=== Router ===
get_weather -> auto_execute

=== Final Messages ===

--- HumanMessage ---
What's the weather in San Francisco?

--- AIMessage ---


--- ToolMessage ---
The weather in San Francisco is sunny and 75°F.

============================================================

=== User ===
Translate 'Hello world' to Spanish.

=== LLM selected ===
translate_text
{
  "text": "Hello world"
}

=== Router ===
translate_text -> hitl

=== HITL ===
Tool: translate_text
Args: {'text': 'Hello world'}
Proceed? (y/n): y

Approved.

=== Final Messages ===

--- HumanMessage ---
Translate 'Hello world' to Spanish.

--- AIMessage ---


--- ToolMessage ---
Spanish translation of 'Hello world': 'Hola mundo!'

============================================================

=== User ===
Summarize this article: Artificial intelligence is rapidly transforming 
industries...

=== LLM selected ===
summarize_article
{
  "article": "Artificial intelligence is rapidly transforming industries, 
  reshaping how businesses operate and innovate. From healthcare to 
  finance, AI-driven analytics and automation are enhancing decision-making, 
  improving efficiency, and unlocking new revenue streams. Companies that 
  adopt AI technologies can gain a competitive edge by streamlining 
  processes, personalizing customer experiences, and predicting market 
  trends. However, challenges such as data privacy concerns, workforce 
  displacement, and ethical considerations must be addressed to ensure 
  responsible implementation. The future of AI promises continued growth, 
  but success hinges on strategic integration, robust governance frameworks, 
  and ongoing investment in talent development."
}

=== Router ===
summarize_article -> hitl

=== HITL ===
Tool: summarize_article
Args: {'article': 'Artificial intelligence is rapidly transforming 
industries, reshaping how businesses operate and innovate. From 
healthcare to finance, AI-driven analytics and automation are enhancing 
decision-making, improving efficiency, and unlocking new revenue streams. 
Companies that adopt AI technologies can gain a competitive edge by 
streamlining processes, personalizing customer experiences, and 
predicting market trends. However, challenges such as data privacy 
concerns, workforce displacement, and ethical considerations must be 
addressed to ensure responsible implementation. The future of AI promises 
continued growth, but success hinges on strategic integration, robust 
governance frameworks, and ongoing investment in talent development.'}
Proceed? (y/n): y

Approved.

=== Final Messages ===

--- HumanMessage ---
Summarize this article: Artificial intelligence is rapidly transforming 
industries...

--- AIMessage ---


--- ToolMessage ---
Summary: Artificial intelligence is rapidly transforming industries, ...

A tiny detail that may surprise you is the long text we now have that we shorten. This comes from the llm_node that generates more text to our example without us needing to ask for it. The LLM is back at generating text, something it does all the time if we do not tell it otherwise.

Next

With this little addition we only need to give permission to the tools we do not think are safe. By keeping a list of safe tools, new (dangerous) tools cannot just slip through and do something we do not want them to. Next week we look at memory and how we can integrate it into LangGraph.