12: Building Tools for Your Agent
In Part IV, we're going to build an agent — an AI that figures out what to do with a user's request and then does it, rather than just responding to it like a chatbot.
Here's the difference in action. Say a user asks, "Please cancel order #12345." A chatbot would say something like: "Go to My Page > Order History and click the 'Cancel' button for that order." From there, it's on the user to follow those steps themselves. An agent does the work instead — it looks up the order, checks whether it's eligible for cancellation, and cancels it. It takes action.
What makes that possible is tools: a tool that looks up an order, a tool that cancels one, a tool that sends an email. With tools, an LLM stops being limited to generating text and starts actually getting things done.
We'll build this piece by piece across Part IV: first the tools the agent will use (this chapter), then wiring those tools up to an LLM (Chapter 13), and finally the agent loop that cycles through decide → act → observe (Chapter 14).
This chapter covers the first piece — defining tools, connecting them to real data, and handling errors safely.
12.1) Defining Tools with the @tool Decorator
12.1.1) How Does a Tool Work?
We just saw an agent look up and cancel an order using tools. Before going further, here's one thing worth clearing up: when people say "the LLM uses a tool," it sounds like the LLM is the one calling it directly. It isn't. The LLM never executes anything itself — all it does is ask for a tool to be called with certain arguments. The actual execution happens in our code.
For that to work, the LLM has to know which tools exist and when each one applies. So every tool comes with three pieces of metadata:
name— a short identifier likeget_orderthat the LLM uses to specify which tool it wants.description— a sentence describing what the tool does and when to use it. This is what the LLM reads to pick the right tool for the job.- Input schema — what the tool's parameters are: their names, types, and meaning. The LLM needs this to fill in arguments correctly.
None of this requires extra work on your part. name comes straight from the function's name, description comes from its docstring, and the input schema comes from the parameters' type hints. All you have to do is attach LangChain's @tool decorator.
12.1.2) Building Your First Tool
Let's put this into practice. Write a function with type hints and a docstring, then decorate it with @tool.
from langchain.tools import tool
@tool
def get_weather(city: str) -> str:
"""Get the current weather for a given city."""
return f"It's always sunny in {city}!"Let's check what @tool generated for us.
print(get_weather.name)
# Output: get_weather
print(get_weather.description)
# Output: Get the current weather for a given city.
print(get_weather.args)
# Output: {'city': {'title': 'City', 'type': 'string'}}The function name get_weather became its name, the docstring became its description, and the type hint city: str became its input schema. All three pieces of metadata from the previous section were generated automatically. This is what the LLM uses to pick a tool and fill in its arguments.
Once @tool is attached, the function becomes a LangChain tool object, which means you can no longer call it like a normal function — get_weather("Paris") won't work. You call it with .invoke() instead, the same standard execution method we used for chains back in Chapter 6. Arguments go in as a dictionary:
result = get_weather.invoke({"city": "Paris"})
print(result)
# Output: It's always sunny in Paris!12.1.3) Customizing name and description
By default, name comes from the function name and description from the docstring. You can override both.
Pass a name as the first argument to @tool:
@tool("web_search")
def search(query: str) -> str:
"""Search the web for information."""
return f"Results for: {query}"
print(search.name)
# Output: web_searchYou can override the description too, using the description parameter. This is useful when you want to keep the docstring as a note for other developers while giving the LLM something more tailored:
@tool("calculator", description="Performs arithmetic. Use this for any math problem.")
def calc(expression: str) -> str:
"""Evaluate a mathematical expression string."""
return str(eval(expression)) # WARNING: eval() is unsafe. Never use it in production.Stick to snake_case for tool names — some LLM providers reject names with spaces or special characters.
12.1.4) Defining an Input Schema with Pydantic
When a tool takes several parameters, or you want to describe each one individually, define the input schema with a Pydantic model instead. This is the same BaseModel and Field we used for structured output back in Chapter 7.
from pydantic import BaseModel, Field
from langchain.tools import tool
class WeatherInput(BaseModel):
"""Input for weather queries."""
location: str = Field(description="City name (e.g., Seoul, Tokyo)")
units: str = Field(default="celsius", description="Temperature unit (celsius or fahrenheit)")
@tool(args_schema=WeatherInput)
def get_weather_detailed(location: str, units: str = "celsius") -> str:
"""Get current weather with a chosen temperature unit."""
temp = 22 if units == "celsius" else 72
return f"Current weather in {location}: {temp} degrees {units[0].upper()}"Whatever you write in Field(description=...) becomes part of the input schema the LLM reads, so it knows exactly what each parameter means. Most of the time, type hints and a clear docstring are all you need — reach for args_schema only when you need that extra level of detail per parameter.
12.2) Handling Tool Errors
In the real world, tools can fail — a database connection drops, or an input shows up that you didn't plan for. In this section, we'll handle these errors inside the tool itself, so the agent can respond sensibly instead of stopping cold. First, let's set up the functions our tools will rely on.
12.2.1) Setting Up: Product Lookup Functions
# product_service.py
PRODUCTS = {
1: {"name": "Wireless Mouse", "price": 29.99, "stock": 120},
2: {"name": "Mechanical Keyboard", "price": 89.99, "stock": 0},
3: {"name": "USB-C Hub", "price": 45.50, "stock": 35},
4: {"name": "Laptop Stand", "price": 39.00, "stock": 8},
}
def fetch_product(product_id: int) -> dict:
"""Look up product information by ID."""
product = PRODUCTS.get(product_id)
if product is None:
raise ValueError(f"Product with ID {product_id} not found.")
return product
def fetch_stock(product_id: int) -> int:
"""Return the stock quantity for a product."""
product = PRODUCTS.get(product_id)
if product is None:
raise ValueError(f"Product with ID {product_id} not found.")
return product["stock"]Both functions raise a ValueError when given a product ID that doesn't exist.
12.2.2) Handling Errors in a Tool
Let's wrap fetch_product in a get_product tool.
from langchain.tools import tool
from product_service import fetch_product
@tool
def get_product(product_id: int) -> str:
"""Look up a product by its ID. Returns its name, price, and stock level."""
product = fetch_product(product_id)
return f"Product {product_id}: {product['name']} — ${product['price']:.2f}, {product['stock']} in stock."With a valid ID, it works as expected.
print(get_product.invoke({"product_id": 1}))
# Output: Product 1: Wireless Mouse — $29.99, 120 in stock.But pass an ID that doesn't exist, and fetch_product raises a ValueError that nothing catches — the agent's execution stops right there.
print(get_product.invoke({"product_id": 99}))
# ValueError: Product with ID 99 not found.The fix is straightforward: catch the exception inside the tool and return a string the LLM can understand, instead of letting it propagate. Success or failure, the tool always returns a string, and the LLM uses that string to decide what to do next.
from langchain.tools import tool
from product_service import fetch_product
@tool
def get_product(product_id: int) -> str:
"""Look up a product by its ID. Returns its name, price, and stock level."""
try:
product = fetch_product(product_id)
return f"Product {product_id}: {product['name']} — ${product['price']:.2f}, {product['stock']} in stock."
except ValueError as e:
return f"Error: {e}"
except Exception as e:
return f"Unexpected error looking up product {product_id}: {e}"print(get_product.invoke({"product_id": 1}))
# Output: Product 1: Wireless Mouse — $29.99, 120 in stock.
print(get_product.invoke({"product_id": 99}))
# Output: Error: Product with ID 99 not found.A nonexistent ID no longer raises an exception — it returns an error message the LLM can understand instead.
Let's apply the same pattern to check_stock:
from product_service import fetch_stock
@tool
def check_stock(product_id: int) -> str:
"""Check whether a product is currently in stock."""
try:
stock = fetch_stock(product_id)
if stock > 0:
return f"{stock} units available."
return "Out of stock."
except ValueError as e:
return f"Error: {e}"
except Exception as e:
return f"Unexpected error checking stock for product {product_id}: {e}"Tools built this way can be tested directly with .invoke(). Make sure a tool works correctly on its own before wiring it up to an LLM. Otherwise, when something goes wrong inside the agent later, you won't be able to tell whether the tool is broken or the model just made a bad call.