Menu

LangGraph Tool Calling: Build Agents That Take Action

Build LangGraph agents that call tools with @tool, bind_tools, and ToolNode. Step-by-step examples for web search, database queries, and API calls in Python.

Written by Selva Prabhakaran | 28 min read

Tool calling lets your LangGraph agent go beyond text — it can search the web, query databases, and hit APIs, all within a structured loop you control.

Your LangGraph agent can think through problems. It can write thoughtful replies. But it can’t actually do anything on its own. It can’t look things up online, check a database, or call an API. It talks a good game, but takes no action.

That changes with tool calling. You hand the LLM a set of functions it can reach for. When the model decides one of those functions would help, it sends back a structured request rather than plain text. LangGraph catches that request, runs the function, and feeds the output back to the model. The LLM then uses that output to craft its final answer.

That’s the full pattern. Let’s walk through it step by step.

What Does Tool Calling Mean in LangGraph?

At its core, tool calling is a way for the LLM to ask your code to run a specific function. The model does not run anything itself — it sends a structured message that says “please call this function with these inputs.” Your code then runs the function and passes the result back.

In LangGraph, three pieces work together to make this happen. First, you create tools with LangChain’s @tool decorator. Second, you link those tools to your chat model using .bind_tools(). Third, you use ToolNode from langgraph.prebuilt to run the tools when the LLM asks for them.

Here’s a small example that shows all three parts. We’ll make a simple math tool, link it to the model, and let LangGraph handle the rest.

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 is all it takes. The @tool decorator wraps a plain function so the LLM can find and call it. The .bind_tools() method tells the model what tools are on offer, what inputs they expect, and what they do. LangGraph handles everything from there.

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

How Do You Create Tools with @tool?

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

The name becomes the tool’s label. The docstring becomes the text the LLM reads when it picks which tool to use. In my experience, vague docstrings are the biggest reason models pick the wrong tool — so 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 close attention to the type hints on each parameter. They’re not just nice-to-have. The @tool decorator reads them to build the JSON schema that tells the LLM what each argument should look like. Leave them out, and the schema breaks.

Tip: Write docstrings for the LLM, not for fellow developers. Rather than “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 model reads this text to decide *when* a tool is the right fit.

How Does bind_tools Connect Tools to the Model?

Creating tools is just the first step. The LLM still doesn’t know they exist. That’s where .bind_tools() comes in — it attaches the tool schemas to every call so the model can see what’s on the table.

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 try to answer the math question with words — it chose the multiply tool and sent back a tool_calls list with the function name and its arguments.

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

But what if the question doesn’t call for a tool?

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 with no tool calls. It only reaches for a tool when it judges one would help. This is the right behavior — the model thinks about whether a tool fits before using one.

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

So far we’ve talked to the model one-on-one. But a tool call just returns a request — nobody has actually run the tool yet or fed the output back. That’s the job of the LangGraph graph.

ToolNode from langgraph.prebuilt takes care of running tools. It reads the tool calls from the latest AI message, runs the right functions, and sends results back as ToolMessage objects. Pair it with tools_condition for routing and you get a full LangGraph tool calling loop.

We need two nodes: one for the LLM (the “agent”) and one for running tools. tools_condition checks whether the LLM sent any tool calls. If yes, flow goes to the tool node. If no, the chat wraps up.

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 for a tool. If it asks for a tool, tools_condition routes to the tool node. The tool runs, its output goes back to the agent, and the LLM puts together its final reply.

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 in the chat. The user asks a question. The AI sends an empty message with a tool call. The tool sends back 345. Then the AI writes the final answer using that result. That’s one full trip through the tool-calling cycle.

Key Insight: `tools_condition` is a ready-made router. It peeks at the last AI message for tool calls. If it finds any, it returns `”tools”`. If not, it returns `END`. You don’t need to write this check yourself.

How Are Tool Results Tracked in State?

Each time a tool runs, it adds a ToolMessage to the chat state. This message holds the tool’s output along with a tool_call_id that ties it back to the original request. The LLM reads this on its next turn to build a final answer.

Why does the tool_call_id matter? When the model fires off several tool calls in one turn, each result needs to link to its matching request. Without that link, the model can’t tell which answer goes with 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 Does the LLM Pick the Right Tool from Many?

Real agents carry 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 how it selects 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())

Link all three to a model and set up a fresh graph. The LLM reads each tool’s docstring and decides which one fits the question. You don’t write routing code for this — the model handles tool choice 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. The math one used calculate. The geography one used lookup_capital. The word count one used word_count. The LLM chose right every time, guided only by the docstrings.

Warning: Never use `eval()` in real production code. The `calculate` tool above uses it for a quick demo. In a live app, use a safe math parser like `numexpr` or `sympy` to block code injection.

What Happens When a Tool Fails?

Tools break. APIs time out. Databases go down. Bad inputs slip through. Your agent must handle these bumps without crashing the whole graph.

ToolNode has built-in error handling through its handle_tool_errors flag. Set it to True, and it catches exceptions. Instead of blowing up, it sends the error message back as a ToolMessage. The LLM reads that 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 whole run. With the flag on, the error turns into a message the LLM can read and explain. The model even tells the user why the math failed — far better than a raw stack trace.

Tip: Use clear error messages in your tools. Rather than a vague `raise ValueError(“error”)`, write `raise ValueError(“Cannot divide by zero — please provide a non-zero divisor”)`. The LLM reads this text, so make it useful.

How Do You Build a Search-Capable Agent?

Let’s mix several tools into a more life-like agent. This one uses both search and math, and it can handle multi-step questions where the model calls tools one after another.

We’ll fake a Tavily search tool here. In a real project, you’d use TavilySearchResults from langchain_community — it needs 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}"

Wire up the graph with both tools and test it with a factual query.

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 saw this was a fact-based question, picked tavily_search, got back the info, and summed it up. Had the question been “What’s 15% of 200?”, it would have reached for calculator instead.

Real-World Example: A Customer Lookup Agent

Here’s something closer to what you’d ship in practice. This agent answers customer questions by looking up orders and checking stock. It shows how tools bridge the gap between your LLM and real 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']}"

Put together the support graph with a system prompt and try some real-sounding questions.

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 shape most real agents follow. Swap the dicts for live database queries or API calls and you’ve got a working support system.

typescript
{
  type: 'exercise',
  id: 'tool-calling-ex1',
  title: 'Exercise 1: Create and Bind a Custom Tool',
  difficulty: 'intermediate',
  exerciseType: 'write',
  instructions: 'Create a tool called `string_reverse` that takes a string and returns it reversed. Then bind it to a ChatOpenAI model and invoke the model with a message asking it to reverse the word "LangGraph". Print the tool_calls from the response.',
  starterCode: 'from langchain_core.tools import tool\nfrom langchain_openai import ChatOpenAI\nfrom langchain_core.messages import HumanMessage\n\n# Define the tool\n@tool\ndef string_reverse(text: str) -> str:\n    \"\"\"Reverse a string of text.\"\"\"  \n    # YOUR CODE: return the reversed text\n    pass\n\n# Bind and invoke\n# YOUR CODE: bind the tool to a model and invoke it\n\nprint("DONE")',
  testCases: [
    { id: 'tc1', input: 'print(string_reverse.invoke("hello"))', expectedOutput: 'olleh', description: 'string_reverse should reverse a string' },
    { id: 'tc2', input: 'print(string_reverse.name)', expectedOutput: 'string_reverse', description: 'Tool name should be string_reverse' },
  ],
  hints: [
    'Use Python slicing to reverse a string: text[::-1]',
    'Full solution: return text[::-1], then llm = ChatOpenAI(model="gpt-4o-mini").bind_tools([string_reverse])',
  ],
  solution: '@tool\ndef string_reverse(text: str) -> str:\n    \"\"\"Reverse a string of text.\"\"\"\n    return text[::-1]\n\ntools = [string_reverse]\nllm = ChatOpenAI(model=\"gpt-4o-mini\").bind_tools(tools)\nresponse = llm.invoke([HumanMessage(content=\"Reverse the word LangGraph\")])\nprint(response.tool_calls)\nprint(\"DONE\")',
  solutionExplanation: 'The @tool decorator converts string_reverse into a LangChain tool. bind_tools() attaches the tool schema to the model. When invoked with a reversal request, the model returns a tool_call instead of answering directly.',
  xpReward: 15,
}

When Should You Skip Tool Calling?

Tool calling isn’t the best fit for every case. Here are times to consider other paths:

  • Simple, single-turn tasks. If your agent just answers from its training data and doesn’t need outside info, adding tools is needless overhead. A plain ChatOpenAI call is faster and simpler.
  • Speed-critical apps. Each tool call adds a round trip — the LLM asks for a tool, your code runs it, then the LLM reads the result. If you need answers in under a second, think about loading the data upfront and dropping it straight into the prompt.
  • Models that can’t do function calling. Some older or smaller open-source models can’t produce clean tool calls. If .bind_tools() gives you garbled output, switch to a model with native function calling support (GPT-4o, Claude 3.5, Llama 3.1+, Mistral).
Note: For production work, look at `create_react_agent` from `langgraph.prebuilt`. It builds the whole tool-calling graph in a single call: `graph = create_react_agent(model, tools)`. The step-by-step build we walked through here shows you the inner workings. But for shipped code, the prebuilt version saves time and cuts down on errors.

What Happens in One Full Tool-Calling Cycle?

Here’s the exact flow during a single LangGraph tool calling round. Knowing these steps makes debugging much less painful.

StepWhat happensMessage type
1User sends a questionHumanMessage
2Agent node calls the LLM with tool schemas
3LLM sends back a tool call requestAIMessage (with tool_calls)
4tools_condition routes to the tool node
5ToolNode runs the matching function
6Tool result is added to stateToolMessage
7Flow goes back to the agent node
8LLM reads tool result and writes its answerAIMessage (with content)
9tools_condition routes to END

Steps 3 through 8 can repeat. If the LLM needs a second tool call after seeing the first result, the loop goes again. This is how agents tackle multi-step problems — the model keeps reaching for tools until it has enough info to reply.

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)

What goes wrong: The LLM has nothing to read about the tool. Even worse, @tool 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: Skipping bind_tools before building the graph

Wrong:

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

What goes wrong: The model has no idea tools exist. It answers everything in plain text, even when a tool would help.

Right:

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

Mistake 3: No edge from tools back to agent

Wrong:

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

What goes wrong: The tool runs, but there’s no way back to the agent. The graph stops after the tool. 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: Leaving tool errors unhandled

Wrong:

python
ToolNode(tools)  # Default: handle_tool_errors=False

What goes wrong: Any error in a tool crashes the entire run. The user sees a stack trace, not a reply.

Right:

python
ToolNode(tools, handle_tool_errors=True)
typescript
{
  type: 'exercise',
  id: 'tool-calling-ex2',
  title: 'Exercise 2: Build a Complete Tool-Calling Graph',
  difficulty: 'intermediate',
  exerciseType: 'write',
  instructions: 'Build a complete LangGraph tool-calling graph with a `celsius_to_fahrenheit` tool. The tool should convert a Celsius temperature to Fahrenheit using the formula: F = C * 9/5 + 32. Create the agent node, tool node, edges, and compile the graph. Then invoke it with "Convert 100 degrees Celsius to Fahrenheit" and print the final message content.',
  starterCode: 'from langchain_core.tools import tool\nfrom langchain_openai import ChatOpenAI\nfrom langchain_core.messages import HumanMessage\nfrom langgraph.graph import StateGraph, MessagesState, START, END\nfrom langgraph.prebuilt import ToolNode, tools_condition\n\n@tool\ndef celsius_to_fahrenheit(celsius: float) -> float:\n    \"\"\"Convert a temperature from Celsius to Fahrenheit.\"\"\"\n    # YOUR CODE: implement the conversion\n    pass\n\n# YOUR CODE: bind tools, build graph, invoke\n\nprint(\"DONE\")',
  testCases: [
    { id: 'tc1', input: 'print(celsius_to_fahrenheit.invoke({"celsius": 0}))', expectedOutput: '32.0', description: '0°C should be 32°F' },
    { id: 'tc2', input: 'print(celsius_to_fahrenheit.invoke({"celsius": 100}))', expectedOutput: '212.0', description: '100°C should be 212°F' },
  ],
  hints: [
    'The formula is: fahrenheit = celsius * 9/5 + 32',
    'After the tool, follow the pattern: bind_tools → agent function → StateGraph → add_node/add_edge → compile → invoke',
  ],
  solution: '@tool\ndef celsius_to_fahrenheit(celsius: float) -> float:\n    \"\"\"Convert a temperature from Celsius to Fahrenheit.\"\"\"\n    return celsius * 9/5 + 32\n\ntools = [celsius_to_fahrenheit]\nllm = ChatOpenAI(model=\"gpt-4o-mini\").bind_tools(tools)\n\ndef agent(state: MessagesState):\n    return {\"messages\": [llm.invoke(state[\"messages\"])]}\n\ngraph = StateGraph(MessagesState)\ngraph.add_node(\"agent\", agent)\ngraph.add_node(\"tools\", ToolNode(tools))\ngraph.add_edge(START, \"agent\")\ngraph.add_conditional_edges(\"agent\", tools_condition)\ngraph.add_edge(\"tools\", \"agent\")\ngraph = graph.compile()\n\nresult = graph.invoke({\"messages\": [HumanMessage(content=\"Convert 100 degrees Celsius to Fahrenheit\")]})\nprint(result[\"messages\"][-1].content)\nprint(\"DONE\")',
  solutionExplanation: 'The tool implements the Celsius-to-Fahrenheit formula. The graph follows the standard pattern: agent node → conditional routing → tool node → back to agent. When invoked, the LLM calls celsius_to_fahrenheit(100), gets 212.0, and formulates a human-readable response.',
  xpReward: 20,
}

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 is what turns an LLM that thinks into an agent that acts. Here’s what you learned:

  • @tool decorator wraps any Python function so the LLM can find and call it
  • .bind_tools() tells the chat model which tools are available and what they expect
  • ToolNode from langgraph.prebuilt runs tools when the LLM asks for them
  • tools_condition steers the graph based on whether the LLM made a tool call
  • Error handling via handle_tool_errors=True stops tool failures from crashing your graph
  • Multiple tools work side by side — the LLM picks the right one by reading docstrings

Practice exercise: Build an agent with three tools: get_stock_price(ticker: str) that sends back a fake price, convert_currency(amount: float, from_currency: str, to_currency: str) for money conversion, and format_number(number: float, decimals: int) for number display. Test 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 first calls `get_stock_price(“AAPL”)` 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 with no extra code from you.

Next up: we’ll use tool calling to build a full ReAct agent — one that reasons through each step before it acts.

Frequently Asked Questions

Can the LLM fire off many tools in one turn?

Yes. Models like GPT-4o and Claude 3.5 can do parallel tool calls. They put several entries in the tool_calls list at once, and ToolNode runs them all. This is faster than one-by-one calls when the tools 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 sets @tool apart from StructuredTool?

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

How do I use create_react_agent instead of building by hand?

create_react_agent from langgraph.prebuilt wraps the whole tool-calling graph into one call: graph = create_react_agent(model, tools). It sets up the agent node, tool node, and routing for you. It builds the same graph we made step by step, just in a single line.

Does LangGraph tool calling work with open-source models?

It does, but results vary by model. Models tuned for function calling (Llama 3.1+, Mistral, Command R+) work well with .bind_tools(). Smaller or older ones may not send back clean tool calls. Check your model’s docs for function calling support before you start.

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

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

References

  1. LangGraph Documentation — Quickstart Guide. Link
  2. LangChain Documentation — Tool Calling. Link
  3. LangGraph Prebuilt — ToolNode API Reference. Link
  4. LangChain Blog — Tool Calling with LangChain. Link
  5. OpenAI Documentation — Function Calling. Link
  6. LangGraph GitHub — Prebuilt Tool Node Source Code. Link
  7. 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
Free Callback - Limited Slots
Not Sure Which Course to Start With?
Talk to our AI Counsellors and Practitioners. We'll help you clear all your questions for your background and goals, bridging the gap between your current skills and a career in AI.
10-digit mobile number
📞
Thank You!
We'll Call You Soon!
Our learning advisor will reach out within 24 hours.
(Check your inbox too — we've sent a confirmation)
⚡ 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