Menu

LangGraph Tool Calling — How to Build Agents That Take Real Actions

Written by Selva Prabhakaran | 25 min read

Your LangGraph agent can think through problems. It can write solid responses. But right now, it can’t do anything. No web searches. No database lookups. No API calls. It’s a brain with no arms.

Tool calling fixes that. You hand the LLM a menu of functions. When the model spots one that would help, it sends back a structured request instead of plain text. LangGraph grabs that request, runs the function, and passes the result back. The LLM then uses that output to craft its final answer.

That’s the whole idea. Let’s build it step by step.

What Is Tool Calling in LangGraph?

When an LLM “calls a tool,” it doesn’t actually run any code. It outputs a structured message that says, “Please run this function with these arguments.” Your code does the real work — it runs the function and feeds the result back to the model.

Three pieces make this work in LangGraph. First, you create tools with LangChain’s @tool decorator. Second, you attach them to your chat model using .bind_tools(). Third, you use ToolNode from langgraph.prebuilt to handle the actual execution when the LLM asks for it.

Here’s a small example with all three parts. We define a math tool, bind it to the model, and let LangGraph take care of running it.

python
import os
from dotenv import load_dotenv
from langchain_openai import ChatOpenAI
from langchain_core.tools import tool
from langchain_core.messages import HumanMessage
from langgraph.graph import StateGraph, MessagesState, START, END
from langgraph.prebuilt import ToolNode, tools_condition

load_dotenv()

# Step 1: Define a tool
@tool
def multiply(a: int, b: int) -> int:
    """Multiply two numbers together."""
    return a * b

# Step 2: Bind the tool to the model
tools = [multiply]
llm = ChatOpenAI(model="gpt-4o-mini").bind_tools(tools)

print("Setup complete — model with tool binding ready")
python
Setup complete — model with tool binding ready

Three lines of setup and you’re done. The @tool decorator turns a normal function into something the LLM can find and call. The .bind_tools() method lets the model see what tools exist, what they accept, and what they do. LangGraph handles the rest.

Key Insight: The LLM never runs your tools itself. It sends back a “tool call” message. Your code (through ToolNode) does the actual work. This split is what keeps tool calling safe and under your control.

How Do You Define Tools with the @tool Decorator?

Every tool starts as a plain Python function. The @tool decorator from langchain_core.tools turns it into something the LLM can work with. Two things matter most: the function name and the docstring.

The name becomes the tool’s ID. The docstring is what the LLM reads when it decides which tool to pick. In my experience, vague docstrings are the biggest reason models choose the wrong tool. Spend time writing clear ones.

python
@tool
def search_web(query: str) -> str:
    """Search the web for current information about a topic.
    Use this when the user asks about recent events,
    facts you're unsure about, or anything requiring
    up-to-date information."""
    # In production, this would call a real search API
    return f"Search results for '{query}': Python 3.13 was released in October 2024 with improved error messages."

@tool
def get_weather(city: str, unit: str = "celsius") -> str:
    """Get the current weather for a city.
    Args:
        city: The city name (e.g., 'London', 'New York')
        unit: Temperature unit - 'celsius' or 'fahrenheit'
    """
    return f"Weather in {city}: 22°{unit[0].upper()}, partly cloudy"

print(f"Tool name: {search_web.name}")
print(f"Tool description: {search_web.description}")
python
Tool name: search_web
Tool description: Search the web for current information about a topic.
    Use this when the user asks about recent events,
    facts you're unsure about, or anything requiring
    up-to-date information.

Pay attention to the type hints. They aren’t just for show. The @tool decorator uses them to build the JSON schema that tells the LLM what each argument looks like. Drop the type hints, and the schema breaks.

Tip: Write docstrings for the model, not for developers. Instead of “Fetches weather data from API,” write “Get the current weather for a city. Use this when the user asks about weather, temperature, or forecasts.” The LLM reads this text to decide when to reach for the tool.

How Does .bind_tools() Work?

Creating tools is only half the job. The LLM also needs to know they exist. That’s where .bind_tools() comes in — it attaches tool schemas to every request so the model can see what’s on the menu.

python
tools = [multiply, search_web, get_weather]
llm_with_tools = ChatOpenAI(model="gpt-4o-mini").bind_tools(tools)

# Ask something that should trigger tool use
response = llm_with_tools.invoke(
    [HumanMessage(content="What is 15 times 23?")]
)

print(f"Content: '{response.content}'")
print(f"Tool calls: {response.tool_calls}")
python
Content: ''
Tool calls: [{'name': 'multiply', 'args': {'a': 15, 'b': 23}, 'id': 'call_abc123', 'type': 'tool_call'}]

Look at what happened. The content field is empty. The model didn’t answer the math question itself — it decided multiply was the better move and sent back a structured tool_calls list with the function name and arguments.

Without .bind_tools(), the model would just say “15 times 23 is 345” in plain text. With tool binding, it hands the math off to your function. For simple math, that’s a small win. For web searches, database queries, or API calls, it changes everything.

But what if the question doesn’t need a tool at all?

python
response = llm_with_tools.invoke(
    [HumanMessage(content="What is the capital of France?")]
)

print(f"Content: '{response.content}'")
print(f"Tool calls: {response.tool_calls}")
python
Content: 'The capital of France is Paris.'
Tool calls: []

The model answered on its own — no tool calls. It only reaches for tools when it thinks one would help. That’s the behavior you want. The model reasons about whether a tool is needed before it asks for one.

How Do You Build the Tool-Calling Graph with ToolNode?

So far, we’ve called the model on its own. But a tool call is just a request — nobody has actually run the function yet. That’s where the graph comes in.

ToolNode from langgraph.prebuilt does the heavy lifting. It reads tool calls from the last AI message, runs the right functions, and sends back the results as ToolMessage objects. Pair it with tools_condition for routing, and you get a full tool-calling loop.

You need two nodes: one for the LLM (the “agent”) and one for running tools. tools_condition checks if the LLM asked for any tools — if yes, the graph goes to the tool node. If no, it ends.

python
def agent_node(state: MessagesState):
    """Call the LLM with the current message history."""
    response = llm_with_tools.invoke(state["messages"])
    return {"messages": [response]}

# Build the graph
graph_builder = StateGraph(MessagesState)

# Add nodes
graph_builder.add_node("agent", agent_node)
graph_builder.add_node("tools", ToolNode(tools))

# Add edges
graph_builder.add_edge(START, "agent")
graph_builder.add_conditional_edges("agent", tools_condition)
graph_builder.add_edge("tools", "agent")

# Compile
graph = graph_builder.compile()

print("Graph compiled with agent and tool nodes")
python
Graph compiled with agent and tool nodes

Here’s how the flow works. A user message comes in and hits the agent node. The LLM either replies directly or asks to call a tool. If it asks for a tool, tools_condition sends the graph to the tool node. The tool runs, the result goes back to the agent, and the LLM puts together a final answer.

python
result = graph.invoke(
    {"messages": [HumanMessage(content="What is 15 times 23?")]}
)

for msg in result["messages"]:
    print(f"{msg.type}: {msg.content}")
python
human: What is 15 times 23?
ai:
tool: 345
ai: 15 times 23 is 345.

Four messages tell the whole story. The human asks. The AI sends an empty message with a tool call. The tool returns 345. Then the AI writes the answer using that result. That’s one full tool-calling cycle.

Key Insight: tools_condition is a ready-made routing function. It checks the last AI message for tool calls. If there are any, it returns "tools". If not, it returns END. You don’t have to write this logic yourself.

How Are Tool Results Stored in State?

Each tool run adds a ToolMessage to the conversation history. This message holds the tool’s output plus a tool_call_id that ties it back to the original request. The LLM reads this on its next turn to form its answer.

Why does tool_call_id matter? When the model fires off several tool calls at once, each result needs to match up with its request. Without this link, the model can’t tell which output belongs to which call.

python
result = graph.invoke(
    {"messages": [HumanMessage(content="Search the web for LangGraph latest version")]}
)

for msg in result["messages"]:
    prefix = msg.type.upper()
    if hasattr(msg, "tool_calls") and msg.tool_calls:
        print(f"{prefix}: [tool_call: {msg.tool_calls[0]['name']}({msg.tool_calls[0]['args']})]")
    elif hasattr(msg, "tool_call_id") and msg.tool_call_id:
        print(f"{prefix} (id={msg.tool_call_id[:12]}...): {msg.content[:80]}")
    else:
        print(f"{prefix}: {msg.content[:100]}")
python
HUMAN: Search the web for LangGraph latest version
AI: [tool_call: search_web({'query': 'LangGraph latest version'})]
TOOL (id=call_abc123...): Search results for 'LangGraph latest version': Python 3.13 was released in O
AI: Based on the search results, here's what I found about LangGraph's latest versio

How Do You Use Multiple Tools Together?

Real agents need more than one tool. The LLM picks the best fit based on the user’s question and the docstrings you wrote. Here’s an agent with three tools — watch it choose the right one each time.

python
@tool
def calculate(expression: str) -> str:
    """Evaluate a mathematical expression.
    Use this for any math calculation the user asks for.
    Examples: '2 + 2', '100 * 0.15', '(50 + 30) / 4'
    """
    try:
        result = eval(expression)
        return str(result)
    except Exception as e:
        return f"Error: {e}"

@tool
def lookup_capital(country: str) -> str:
    """Look up the capital city of a country."""
    capitals = {
        "france": "Paris", "germany": "Berlin",
        "japan": "Tokyo", "brazil": "Brasília",
        "india": "New Delhi", "australia": "Canberra"
    }
    return capitals.get(country.lower(), f"Capital not found for {country}")

@tool
def word_count(text: str) -> int:
    """Count the number of words in a piece of text."""
    return len(text.split())

Bind all three and build a new graph. The LLM reads each docstring and picks the tool that fits. You don’t write any routing logic for tool selection — the model does all of that on its own.

python
multi_tools = [calculate, lookup_capital, word_count]
multi_llm = ChatOpenAI(model="gpt-4o-mini").bind_tools(multi_tools)

def multi_agent(state: MessagesState):
    return {"messages": [multi_llm.invoke(state["messages"])]}

multi_graph = StateGraph(MessagesState)
multi_graph.add_node("agent", multi_agent)
multi_graph.add_node("tools", ToolNode(multi_tools))
multi_graph.add_edge(START, "agent")
multi_graph.add_conditional_edges("agent", tools_condition)
multi_graph.add_edge("tools", "agent")
multi_graph = multi_graph.compile()

questions = [
    "What is 25% of 840?",
    "What's the capital of Japan?",
    "How many words are in: 'The quick brown fox jumps over the lazy dog'?"
]

for q in questions:
    result = multi_graph.invoke({"messages": [HumanMessage(content=q)]})
    final = result["messages"][-1].content
    print(f"Q: {q}")
    print(f"A: {final}\n")
python
Q: What is 25% of 840?
A: 25% of 840 is 210.0.

Q: What's the capital of Japan?
A: The capital of Japan is Tokyo.

Q: How many words are in: 'The quick brown fox jumps over the lazy dog'?
A: The sentence "The quick brown fox jumps over the lazy dog" contains 9 words.

Each question hit a different tool. Math went to calculate. Geography went to lookup_capital. Word counting went to word_count. The LLM got it right every time, guided by the docstrings alone.

Warning: Don’t use eval() in production. The calculate tool above uses it for simplicity. In a real app, use a math parser like numexpr or sympy to avoid code injection risks.

How Do You Handle Tool Errors Without Crashing?

Tools break. APIs time out. Databases go offline. Bad inputs sneak through. Your agent needs to deal with these failures without taking down the whole graph.

ToolNode has built-in error handling through handle_tool_errors. Set it to True, and it catches exceptions. Instead of crashing, it sends the error message back as a ToolMessage. The LLM sees the error and responds in a helpful way.

python
@tool
def divide(a: float, b: float) -> float:
    """Divide the first number by the second number."""
    if b == 0:
        raise ValueError("Cannot divide by zero!")
    return a / b

error_tools = [divide]
error_llm = ChatOpenAI(model="gpt-4o-mini").bind_tools(error_tools)

def error_agent(state: MessagesState):
    return {"messages": [error_llm.invoke(state["messages"])]}

error_tool_node = ToolNode(error_tools, handle_tool_errors=True)

error_graph = StateGraph(MessagesState)
error_graph.add_node("agent", error_agent)
error_graph.add_node("tools", error_tool_node)
error_graph.add_edge(START, "agent")
error_graph.add_conditional_edges("agent", tools_condition)
error_graph.add_edge("tools", "agent")
error_graph = error_graph.compile()

result = error_graph.invoke(
    {"messages": [HumanMessage(content="What is 10 divided by 0?")]}
)

for msg in result["messages"]:
    print(f"{msg.type}: {msg.content}")
python
human: What is 10 divided by 0?
ai:
tool: Error: ValueError('Cannot divide by zero!')
ai: Division by zero is undefined — you cannot divide 10 by 0.

Without handle_tool_errors=True, that ValueError would crash the graph. With it, the error turns into a message the LLM can read and explain. The model even tells the user why the math failed — much better than a raw stack trace.

Tip: Write clear error messages in your tools. Instead of a vague raise ValueError("error"), try raise ValueError("Cannot divide by zero — use a non-zero number"). The LLM reads this text, so make it useful.

How Do You Build a Search-Capable Agent?

Let’s put several tools together into something more real. This agent can both search and calculate, handling questions where the model might need to call tools one after another.

We’ll fake a Tavily search tool here. In production, you’d use TavilySearchResults from langchain_community with a TAVILY_API_KEY in your environment.

python
@tool
def tavily_search(query: str) -> str:
    """Search the web for real-time information.
    Use for current events, recent data, or facts
    that might have changed recently.
    """
    search_db = {
        "langgraph": "LangGraph is a framework by LangChain for building stateful, multi-actor applications with LLMs. Latest version: 0.2.x (2025).",
        "python": "Python 3.13 released October 2024. Key features: improved error messages, JIT compiler.",
    }
    for key, value in search_db.items():
        if key in query.lower():
            return value
    return f"No relevant results found for: {query}"

@tool
def calculator(expression: str) -> str:
    """Calculate a mathematical expression.
    Supports: +, -, *, /, **, parentheses.
    Example inputs: '100 * 1.15', '(50 + 30) / 2'
    """
    try:
        return str(eval(expression))
    except Exception as e:
        return f"Calculation error: {e}"

Build the graph with both tools and try a factual question.

python
search_tools = [tavily_search, calculator]
search_llm = ChatOpenAI(model="gpt-4o-mini").bind_tools(search_tools)

def search_agent(state: MessagesState):
    return {"messages": [search_llm.invoke(state["messages"])]}

search_graph = StateGraph(MessagesState)
search_graph.add_node("agent", search_agent)
search_graph.add_node("tools", ToolNode(search_tools))
search_graph.add_edge(START, "agent")
search_graph.add_conditional_edges("agent", tools_condition)
search_graph.add_edge("tools", "agent")
search_graph = search_graph.compile()

result = search_graph.invoke(
    {"messages": [HumanMessage(content="What is LangGraph and what version is it on?")]}
)
print(result["messages"][-1].content)
python
LangGraph is a framework by LangChain for building stateful, multi-actor applications with LLMs. The latest version is 0.2.x (2025).

The model knew this was a facts question. It chose tavily_search, got the answer, and wrapped it up. A math question like “What’s 15% of 200?” would have gone to calculator instead.

Real-World Example: How Would You Build a Customer Data Lookup Agent?

Here’s something closer to a real deployment. This agent answers customer service questions by pulling order info and checking stock levels. It shows how tools connect your LLM to actual data.

python
ORDERS_DB = {
    "ORD-001": {"customer": "Alice", "item": "Laptop", "status": "shipped", "total": 1299.99},
    "ORD-002": {"customer": "Bob", "item": "Headphones", "status": "delivered", "total": 199.99},
    "ORD-003": {"customer": "Alice", "item": "Mouse", "status": "processing", "total": 49.99},
}

INVENTORY_DB = {
    "Laptop": {"stock": 15, "price": 1299.99},
    "Headphones": {"stock": 42, "price": 199.99},
    "Mouse": {"stock": 0, "price": 49.99},
    "Keyboard": {"stock": 28, "price": 79.99},
}

@tool
def check_order_status(order_id: str) -> str:
    """Check the status of a customer order by order ID.
    Order IDs look like 'ORD-001', 'ORD-002', etc.
    """
    order = ORDERS_DB.get(order_id.upper())
    if not order:
        return f"No order found with ID: {order_id}"
    return (f"Order {order_id}: {order['item']} for "
            f"{order['customer']} — Status: {order['status']}, "
            f"Total: ${order['total']}")

@tool
def check_inventory(product_name: str) -> str:
    """Check if a product is in stock and its price."""
    product = INVENTORY_DB.get(product_name)
    if not product:
        return f"Product '{product_name}' not found in catalog"
    status = "In stock" if product["stock"] > 0 else "Out of stock"
    return f"{product_name}: {status} ({product['stock']} units), Price: ${product['price']}"

Build the graph with a system message that sets the tone, then test with real customer queries.

python
cs_tools = [check_order_status, check_inventory]
cs_llm = ChatOpenAI(model="gpt-4o-mini").bind_tools(cs_tools)

def cs_agent(state: MessagesState):
    system_msg = {
        "role": "system",
        "content": "You are a helpful customer service agent. "
                   "Use tools to look up order and inventory info. "
                   "Be concise and friendly."
    }
    messages = [system_msg] + state["messages"]
    return {"messages": [cs_llm.invoke(messages)]}

cs_graph = StateGraph(MessagesState)
cs_graph.add_node("agent", cs_agent)
cs_graph.add_node("tools", ToolNode(cs_tools))
cs_graph.add_edge(START, "agent")
cs_graph.add_conditional_edges("agent", tools_condition)
cs_graph.add_edge("tools", "agent")
cs_graph = cs_graph.compile()

result = cs_graph.invoke(
    {"messages": [HumanMessage(content="Check order ORD-001 please")]}
)
print("Q: Check order ORD-001 please")
print(f"A: {result['messages'][-1].content}\n")

result = cs_graph.invoke(
    {"messages": [HumanMessage(content="Is the Mouse in stock?")]}
)
print("Q: Is the Mouse in stock?")
print(f"A: {result['messages'][-1].content}")
python
Q: Check order ORD-001 please
A: Your order ORD-001 for a Laptop has been shipped! The total was $1,299.99.

Q: Is the Mouse in stock?
A: Unfortunately, the Mouse is currently out of stock. The price is $49.99. Would you like me to check another product?

This is the blueprint for production agents. Swap the dictionaries for real database queries or API calls, and you’ve got a working customer service system.

When Should You NOT Use Tool Calling?

Tool calling isn’t right for every situation. Here are some cases where you should pick a different approach:

  • Simple Q&A from training data. If your agent just answers questions it already knows, tool calling adds overhead for no gain. A plain ChatOpenAI call is faster and simpler.

  • Apps that need sub-second responses. Each tool call adds a round trip — the LLM asks for a tool, your code runs it, then the LLM reads the result. For very tight latency needs, try loading the data upfront and putting it in the prompt.

  • Models that don’t support function calling. Older or smaller open-source models may not produce clean tool calls. If .bind_tools() gives you garbled output, you need a model with native function calling support (GPT-4o, Claude, Llama 3.1+, Mistral).

Note: For production, use create_react_agent from langgraph.prebuilt. It builds the entire tool-calling graph in one line: graph = create_react_agent(model, tools). The manual setup we did here teaches you what’s going on under the hood. For real deployments, the prebuilt version saves time and avoids mistakes.

What Does the Full Tool-Calling Cycle Look Like?

Here’s a step-by-step breakdown of one complete cycle. Knowing this flow makes debugging much easier.

Step What Happens Message Type
1 User sends a question HumanMessage
2 Agent node sends it to the LLM with tool schemas
3 LLM sends back a tool call request AIMessage (with tool_calls)
4 tools_condition routes to the tool node
5 ToolNode runs the function
6 Result goes into state ToolMessage
7 Graph loops back to the agent node
8 LLM reads the tool result and writes an answer AIMessage (with content)
9 tools_condition routes to END

Steps 3–8 can repeat. If the LLM decides it needs another tool after reading the first result, the cycle loops. That’s how agents handle multi-step tasks — the model keeps calling tools until it has enough info to answer.

What Are the Most Common Mistakes?

Mistake 1: No docstring on the tool function

Wrong:

python
@tool
def get_price(item: str) -> float:
    return prices.get(item, 0.0)

Why it breaks: The LLM has nothing to read. The @tool decorator throws a ValueError if the docstring is missing.

Right:

python
@tool
def get_price(item: str) -> float:
    """Look up the current price of a product by name."""
    return prices.get(item, 0.0)

Mistake 2: Forgetting .bind_tools() before building the graph

Wrong:

python
llm = ChatOpenAI(model="gpt-4o-mini")  # No .bind_tools()!

Why it breaks: The model has no idea tools exist. It answers everything in plain text, even when a tool would give a better result.

Right:

python
llm = ChatOpenAI(model="gpt-4o-mini").bind_tools(tools)

Mistake 3: No edge from tools back to the agent

Wrong:

python
graph.add_edge(START, "agent")
graph.add_conditional_edges("agent", tools_condition)
# Forgot: graph.add_edge("tools", "agent")

Why it breaks: The tool runs, but the result has nowhere to go. The graph stops right after the tool node. The user gets a raw ToolMessage instead of a proper answer.

Right:

python
graph.add_edge(START, "agent")
graph.add_conditional_edges("agent", tools_condition)
graph.add_edge("tools", "agent")  # Results go back to the agent

Mistake 4: No error handling on ToolNode

Wrong:

python
ToolNode(tools)  # Default: handle_tool_errors=False

Why it breaks: Any exception in a tool crashes the whole graph. The user sees a stack trace instead of a response.

Right:

python
ToolNode(tools, handle_tool_errors=True)

Complete Code

Click to expand the full script (copy-paste and run)

python
# Complete code from: LangGraph Tool Calling -- Build Agents That Take Action
# Requires: pip install langchain-openai langchain-core langgraph python-dotenv
# Python 3.9+
# Set OPENAI_API_KEY in your .env file

import os
from dotenv import load_dotenv
from langchain_openai import ChatOpenAI
from langchain_core.tools import tool
from langchain_core.messages import HumanMessage
from langgraph.graph import StateGraph, MessagesState, START, END
from langgraph.prebuilt import ToolNode, tools_condition

load_dotenv()

# --- Section 1: Define Tools ---

@tool
def multiply(a: int, b: int) -> int:
    """Multiply two numbers together."""
    return a * b

@tool
def search_web(query: str) -> str:
    """Search the web for current information about a topic."""
    return f"Search results for '{query}': Python 3.13 was released in October 2024."

@tool
def get_weather(city: str, unit: str = "celsius") -> str:
    """Get the current weather for a city."""
    return f"Weather in {city}: 22°{unit[0].upper()}, partly cloudy"

@tool
def calculate(expression: str) -> str:
    """Evaluate a mathematical expression."""
    try:
        return str(eval(expression))
    except Exception as e:
        return f"Error: {e}"

@tool
def lookup_capital(country: str) -> str:
    """Look up the capital city of a country."""
    capitals = {
        "france": "Paris", "germany": "Berlin",
        "japan": "Tokyo", "brazil": "Brasília",
        "india": "New Delhi", "australia": "Canberra"
    }
    return capitals.get(country.lower(), f"Capital not found for {country}")

@tool
def divide(a: float, b: float) -> float:
    """Divide the first number by the second number."""
    if b == 0:
        raise ValueError("Cannot divide by zero!")
    return a / b

# --- Section 2: Build a Tool-Calling Graph ---

tools = [multiply, search_web, get_weather, calculate, lookup_capital]
llm = ChatOpenAI(model="gpt-4o-mini").bind_tools(tools)

def agent_node(state: MessagesState):
    return {"messages": [llm.invoke(state["messages"])]}

graph_builder = StateGraph(MessagesState)
graph_builder.add_node("agent", agent_node)
graph_builder.add_node("tools", ToolNode(tools))
graph_builder.add_edge(START, "agent")
graph_builder.add_conditional_edges("agent", tools_condition)
graph_builder.add_edge("tools", "agent")
graph = graph_builder.compile()

# --- Section 3: Test the Agent ---

test_questions = [
    "What is 15 times 23?",
    "What's the capital of Japan?",
    "Search the web for LangGraph latest version",
]

for q in test_questions:
    result = graph.invoke({"messages": [HumanMessage(content=q)]})
    print(f"Q: {q}")
    print(f"A: {result['messages'][-1].content}\n")

# --- Section 4: Error Handling ---

error_tools = [divide]
error_llm = ChatOpenAI(model="gpt-4o-mini").bind_tools(error_tools)

def error_agent(state: MessagesState):
    return {"messages": [error_llm.invoke(state["messages"])]}

error_graph = StateGraph(MessagesState)
error_graph.add_node("agent", error_agent)
error_graph.add_node("tools", ToolNode(error_tools, handle_tool_errors=True))
error_graph.add_edge(START, "agent")
error_graph.add_conditional_edges("agent", tools_condition)
error_graph.add_edge("tools", "agent")
error_graph = error_graph.compile()

result = error_graph.invoke(
    {"messages": [HumanMessage(content="Divide 10 by 0")]}
)
print(f"Error handling test: {result['messages'][-1].content}")

print("\nScript completed successfully.")

Summary

Tool calling bridges the gap between an LLM that thinks and an agent that acts. Here’s what we covered:

  • @tool decorator turns any Python function into a tool the LLM can find and use

  • .bind_tools() shows the model what tools are on the table

  • ToolNode from langgraph.prebuilt runs the tools when the LLM asks for them

  • tools_condition routes the graph based on whether the LLM made a tool call or not

  • Error handling with handle_tool_errors=True keeps tool failures from crashing your graph

  • Multiple tools work side by side — the LLM picks the right one based on your docstrings

Practice exercise: Build an agent with three tools: get_stock_price(ticker: str) that returns a fake price, convert_currency(amount: float, from_currency: str, to_currency: str) for currency math, and format_number(number: float, decimals: int) for formatting. Test it with “What’s the stock price of AAPL in euros?”

Solution

python
from langchain_core.tools import tool
from langchain_openai import ChatOpenAI
from langchain_core.messages import HumanMessage
from langgraph.graph import StateGraph, MessagesState, START
from langgraph.prebuilt import ToolNode, tools_condition

@tool
def get_stock_price(ticker: str) -> str:
    """Get the current stock price for a ticker symbol."""
    prices = {"AAPL": 189.50, "GOOGL": 142.30, "MSFT": 415.80}
    price = prices.get(ticker.upper(), 0.0)
    if price == 0.0:
        return f"Ticker {ticker} not found"
    return f"{ticker.upper()}: ${price}"

@tool
def convert_currency(amount: float, from_currency: str, to_currency: str) -> str:
    """Convert an amount from one currency to another."""
    rates = {"USD_EUR": 0.92, "USD_GBP": 0.79, "EUR_USD": 1.09}
    key = f"{from_currency.upper()}_{to_currency.upper()}"
    rate = rates.get(key, None)
    if rate is None:
        return f"Rate not available for {from_currency} to {to_currency}"
    return f"{amount} {from_currency} = {amount * rate:.2f} {to_currency}"

@tool
def format_number(number: float, decimals: int) -> str:
    """Format a number to a specific number of decimal places."""
    return f"{number:.{decimals}f}"

tools = [get_stock_price, convert_currency, format_number]
llm = ChatOpenAI(model="gpt-4o-mini").bind_tools(tools)

def agent(state: MessagesState):
    return {"messages": [llm.invoke(state["messages"])]}

graph = StateGraph(MessagesState)
graph.add_node("agent", agent)
graph.add_node("tools", ToolNode(tools))
graph.add_edge(START, "agent")
graph.add_conditional_edges("agent", tools_condition)
graph.add_edge("tools", "agent")
graph = graph.compile()

result = graph.invoke(
    {"messages": [HumanMessage(content="What's the stock price of AAPL in euros?")]}
)
print(result["messages"][-1].content)

The agent calls get_stock_price("AAPL") first to get $189.50, then calls convert_currency(189.50, "USD", "EUR") to do the conversion. Two tool calls in a row, handled by the graph loop.

Next up: we’ll use tool calling to build a full ReAct agent — one that reasons step by step before acting.

Frequently Asked Questions

Can an LLM call multiple tools in one turn?

Yes. Models like GPT-4o and Claude support parallel tool calls. They send back several entries in the tool_calls list at once, and ToolNode runs all of them. This is faster than calling tools one at a time when they don’t depend on each other.

python
# The model might return:
# tool_calls: [
#   {"name": "get_weather", "args": {"city": "London"}},
#   {"name": "get_weather", "args": {"city": "Tokyo"}}
# ]
# ToolNode runs both and returns both results

What’s the difference between @tool and StructuredTool?

@tool is a quick decorator for turning functions into tools. StructuredTool is a class that gives you more control over the schema, naming, and validation. Use @tool most of the time. Reach for StructuredTool when you need to build tools on the fly or tweak input schemas beyond what type hints allow.

How do I use create_react_agent instead of building manually?

Call create_react_agent from langgraph.prebuilt: graph = create_react_agent(model, tools). It wires up the agent node, tool node, and conditional routing for you. It builds the same graph we made by hand, just in one line.

Does tool calling work with open-source models?

Yes, but results vary. Models trained for function calling (Llama 3.1+, Mistral, Command R+) work well with .bind_tools(). Smaller or older models may not produce clean structured calls. Check your model’s docs for function calling support before building a tool-calling agent.

How do I cap the number of tool-calling loops?

Set a recursion limit in the config: graph.invoke(inputs, config={"recursion_limit": 10}). The default is 25. If the agent hits this limit, LangGraph raises a GraphRecursionError. Keep the limit sensible — if an agent needs 20+ tool calls to answer one question, the design likely needs rethinking.

References

  • LangGraph Documentation — Quickstart Guide. Link

  • LangChain Documentation — Tool Calling. Link

  • LangGraph Prebuilt — ToolNode API Reference. Link

  • LangChain Blog — Tool Calling with LangChain. Link

  • OpenAI Documentation — Function Calling. Link

  • LangGraph GitHub — Prebuilt Tool Node Source Code. Link

  • LangChain Documentation — Creating Custom Tools with @tool. Link

Free Course
Master Core Python — Your First Step into AI/ML

Build a strong Python foundation with hands-on exercises designed for aspiring Data Scientists and AI/ML Engineers.

Start Free Course
Trusted by 50,000+ learners
Related Course
Master Gen AI — Hands-On
Join 5,000+ students at edu.machinelearningplus.com
Explore Course
Get the full course,
completely free.
Join 57,000+ students learning Python, SQL & ML. One year of access, all resources included.
📚 10 Courses
🐍 Python & ML
🗄️ SQL
📦 Downloads
📅 1 Year Access
No thanks
🎓
Free AI/ML Starter Kit
Python · SQL · ML · 10 Courses · 57,000+ students
🎉   You're in! Check your inbox (or Promotions/Spam) for the access link.
⚡ Before you go

Python.
SQL. NumPy.
All free.

Get the exact 10-course programming foundation that Data Science professionals use.

🐍
Core Python — from first line to expert level
📈
NumPy & Pandas — the #1 libraries every DS job needs
🗃️
SQL Levels I–III — basics to Window Functions
📄
Real industry data — Jupyter notebooks included
R A M S K
57,000+ students
★★★★★ Rated 4.9/5
⚡ Before you go
Python. SQL.
All Free.
R A M S K
57,000+ students  ★★★★★ 4.9/5
Get Free Access Now
10 courses. Real projects. Zero cost. No credit card.
New learners enrolling right now
🔒 100% free ☕ No spam, ever ✓ Instant access
🚀
You're in!
Check your inbox for your access link.
(Check Promotions or Spam if you don't see it)
Or start your first course right now:
Start Free Course →
Scroll to Top
Scroll to Top
Course Preview

Machine Learning A-Z™: Hands-On Python & R In Data Science

Free Sample Videos:

Machine Learning A-Z™: Hands-On Python & R In Data Science

Machine Learning A-Z™: Hands-On Python & R In Data Science

Machine Learning A-Z™: Hands-On Python & R In Data Science

Machine Learning A-Z™: Hands-On Python & R In Data Science

Machine Learning A-Z™: Hands-On Python & R In Data Science