Module 2: Tools and Tool Calling

Creating Tools with @tool

Capsule overview

LangChain's @tool decorator turns any Python function into a tool a language model can invoke. It's the most direct and recommended way to create tools — you define a normal function, add @tool to it, and LangChain automatically generates the JSON schema the model needs to know when to call it and with what arguments.

In this capsule you'll learn to create basic tools, define argument schemas with Pydantic, write async tools, return structured data, and get to know the most useful built-in tools in the ecosystem. By the end, you'll be able to build any custom tool your applications need.

Everything you learn here is the foundation of the module's mini-project: an assistant with weather, search, and calculator tools.


Your first tool

The @tool decorator

Creating a tool is as simple as adding @tool to a Python function:

from langchain_core.tools import tool

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

That's it. With those 4 lines you have a working tool. But what does LangChain generate under the hood? Let's inspect it:

from langchain_core.tools import tool

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

print(multiply.name)
# Expected output: multiply

print(multiply.description)
# Expected output: Multiply two numbers.

print(multiply.args_schema.schema())
# Expected output:
# {
#     'description': 'Multiply two numbers.',
#     'properties': {
#         'a': {'title': 'A', 'type': 'integer'},
#         'b': {'title': 'B', 'type': 'integer'}
#     },
#     'required': ['a', 'b'],
#     'title': 'multiplySchema',
#     'type': 'object'
# }

LangChain pulls 3 things automatically out of your function:

  1. name — The function's name (multiply)
  2. description — The function's docstring ("Multiply two numbers.")
  3. args_schema — The parameter types, converted to JSON Schema

The model uses those 3 things to decide when to call the tool and what arguments to pass it.


Why name and description matter

The model reads each tool's name and description to decide which one to use. If your description is vague or wrong, the model won't know when to call it.

from langchain_core.tools import tool

# ❌ Bad description — the model can't tell when to use it
@tool
def process(x: str) -> str:
    """Process data."""
    return x.upper()

# ✅ Good description — the model knows exactly when to use it
@tool
def uppercase_text(text: str) -> str:
    """Convert text to uppercase. Use this when the user asks to put text in uppercase."""
    return text.upper()

Rules for good names and descriptions:

  • ✅ The name should be descriptive: get_weather, search_web, calculate_total
  • ✅ The description should explain what it does and when to use it
  • ❌ Avoid generic names: process, run, do_stuff
  • ❌ Avoid vague descriptions: "Process data", "Does a thing"

Invoking a tool directly

You can run a tool directly — handy for testing:

from langchain_core.tools import tool

@tool
def add(a: int, b: int) -> int:
    """Add two numbers."""
    return a + b

result = add.invoke({"a": 3, "b": 5})
print(result)
# Expected output: 8

When you invoke it directly, you use .invoke() with a dictionary of arguments. When the model calls it, LangChain does that conversion for you.


Argument schemas with Pydantic

Why Pydantic

Basic type hints (int, str, float) work, but they don't tell the model what each argument means. With Pydantic, you can add detailed descriptions that help the model produce the right arguments.

from langchain_core.tools import tool
from pydantic import BaseModel, Field

class WeatherInput(BaseModel):
    city: str = Field(description="City name (e.g. 'Madrid', 'Buenos Aires')")
    units: str = Field(
        default="celsius",
        description="Temperature unit: 'celsius' or 'fahrenheit'"
    )

@tool(args_schema=WeatherInput)
def get_weather(city: str, units: str = "celsius") -> str:
    """Get the current weather for a city."""
    return f"The weather in {city} is 22°{'C' if units == 'celsius' else 'F'}, sunny"

print(get_weather.args_schema.schema())
# Expected output:
# {
#     'description': 'Get the current weather for a city.',
#     'properties': {
#         'city': {
#             'description': "City name (e.g. 'Madrid', 'Buenos Aires')",
#             'title': 'City',
#             'type': 'string'
#         },
#         'units': {
#             'default': 'celsius',
#             'description': "Temperature unit: 'celsius' or 'fahrenheit'",
#             'title': 'Units',
#             'type': 'string'
#         }
#     },
#     'required': ['city'],
#     'title': 'WeatherInput',
#     'type': 'object'
# }

Field(description=...) is what makes the difference — the model reads those descriptions to work out what value belongs in each argument.


Tools with multiple parameters

With Pydantic you define exactly which parameters are required and which have a default:

from langchain_core.tools import tool
from pydantic import BaseModel, Field

class SearchInput(BaseModel):
    query: str = Field(description="Search term")
    max_results: int = Field(default=5, description="Maximum number of results (1-20)")
    language: str = Field(default="en", description="Language of the results: 'en', 'es', 'fr'")

@tool(args_schema=SearchInput)
def web_search(query: str, max_results: int = 5, language: str = "en") -> str:
    """Search the web for information. Use this tool when you need up-to-date data."""
    return f"Results for '{query}' (max: {max_results}, language: {language})"

result = web_search.invoke({"query": "LangChain tutorial"})
print(result)
# Expected output: Results for 'LangChain tutorial' (max: 5, language: en)

result = web_search.invoke({"query": "AI news", "max_results": 3, "language": "es"})
print(result)
# Expected output: Results for 'AI news' (max: 3, language: es)

Tools with restricted types

You can use Literal to constrain the valid values — it cuts down model errors significantly:

from typing import Literal
from langchain_core.tools import tool
from pydantic import BaseModel, Field

class ConversionInput(BaseModel):
    amount: float = Field(description="Amount to convert")
    from_currency: Literal["USD", "EUR", "MXN", "ARS"] = Field(
        description="Source currency"
    )
    to_currency: Literal["USD", "EUR", "MXN", "ARS"] = Field(
        description="Target currency"
    )

@tool(args_schema=ConversionInput)
def convert_currency(amount: float, from_currency: str, to_currency: str) -> str:
    """Convert an amount between currencies. Supports USD, EUR, MXN and ARS."""
    rates = {
        ("USD", "MXN"): 17.5,
        ("USD", "EUR"): 0.92,
        ("EUR", "USD"): 1.09,
        ("MXN", "USD"): 0.057,
    }
    rate = rates.get((from_currency, to_currency), 1.0)
    result = amount * rate
    return f"{amount} {from_currency} = {result:.2f} {to_currency}"

result = convert_currency.invoke({
    "amount": 100,
    "from_currency": "USD",
    "to_currency": "MXN"
})
print(result)
# Expected output: 100 USD = 1750.00 MXN

When you use Literal, the model knows exactly which values it's allowed to pass.


Async tools

If your tool calls external APIs, you'll want it to be async so it doesn't block the event loop:

import asyncio
from langchain_core.tools import tool

@tool
async def fetch_price(symbol: str) -> str:
    """Get a stock's current price. Use the ticker symbol (e.g. 'AAPL', 'GOOGL')."""
    await asyncio.sleep(0.1)  # Simulating an API call
    prices = {"AAPL": 195.50, "GOOGL": 175.20, "MSFT": 425.80}
    price = prices.get(symbol.upper(), None)
    if price is None:
        return f"Symbol '{symbol}' not found"
    return f"{symbol.upper()}: ${price}"

result = asyncio.run(fetch_price.ainvoke({"symbol": "AAPL"}))
print(result)
# Expected output: AAPL: $195.5

For async tools, you use async def and invoke them with .ainvoke(). LangChain detects automatically whether the tool is sync or async.


Tools that return structured data

A tool can return any serializable data type — strings, dictionaries, lists:

from langchain_core.tools import tool

@tool
def get_user_info(user_id: int) -> dict:
    """Get a user's information by their ID."""
    users = {
        1: {"name": "María García", "email": "maria@example.com", "plan": "pro"},
        2: {"name": "Carlos López", "email": "carlos@example.com", "plan": "free"},
    }
    user = users.get(user_id)
    if user is None:
        return {"error": f"User with ID {user_id} not found"}
    return user

result = get_user_info.invoke({"user_id": 1})
print(result)
# Expected output: {'name': 'María García', 'email': 'maria@example.com', 'plan': 'pro'}

result = get_user_info.invoke({"user_id": 99})
print(result)
# Expected output: {'error': 'User with ID 99 not found'}

The model receives the dictionary as a string and folds it into its answer. Returning structured data is useful when the model needs to pull specific information out of the result.


Customizing name and description

You can override the automatic name and description by passing arguments to @tool:

from langchain_core.tools import tool

@tool("basic_calculator")
def calc(expression: str) -> str:
    """Evaluate a simple math expression. Supports +, -, *, / and parentheses.

    Example inputs: '2 + 3', '(10 * 5) / 2', '100 - 37'
    """
    try:
        result = eval(expression)
        return str(result)
    except Exception as e:
        return f"Error evaluating '{expression}': {e}"

print(calc.name)
# Expected output: basic_calculator

result = calc.invoke({"expression": "(10 * 5) / 2"})
print(result)
# Expected output: 25.0

Built-in tools

LangChain ships with prebuilt tools for common tasks. You don't need to build those from scratch.

DuckDuckGoSearchResults

pip install duckduckgo-search
from langchain_community.tools import DuckDuckGoSearchResults

search = DuckDuckGoSearchResults(max_results=3)
print(search.name)
# Expected output: duckduckgo_results_json

result = search.invoke("LangChain v1.2 release")
print(result)
# Expected output: [snippet: ..., title: ..., link: ...]

WikipediaQueryRun

pip install wikipedia
from langchain_community.tools import WikipediaQueryRun
from langchain_community.utilities import WikipediaAPIWrapper

wiki = WikipediaQueryRun(api_wrapper=WikipediaAPIWrapper(top_k_results=1, doc_content_chars_max=500))
print(wiki.name)
# Expected output: wikipedia

result = wiki.invoke("Python programming language")
print(result[:200])
# Expected output: Page: Python (programming language)
# Summary: Python is a high-level, general-purpose programming language...

When to use built-in vs custom

CriterionBuilt-in toolsCustom tools (@tool)
Web search✅ DuckDuckGoSearchResultsIf you need a specific API
Wikipedia✅ WikipediaQueryRunIf you need another source
Your database✅ Only you know your schema
Your internal API✅ Only you know your endpoints
Custom calculations✅ Your business logic

In most real projects, you'll use a mix of both.


@tool vs StructuredTool.from_function

@tool is the recommended approach, but there's a programmatic alternative: StructuredTool.from_function. Both produce the same result.

from langchain_core.tools import tool, StructuredTool

# Option 1: @tool (recommended — more concise)
@tool
def add_v1(a: int, b: int) -> int:
    """Add two numbers."""
    return a + b

# Option 2: StructuredTool.from_function (programmatic)
def add_v2_func(a: int, b: int) -> int:
    """Add two numbers."""
    return a + b

add_v2 = StructuredTool.from_function(
    func=add_v2_func,
    name="add_v2",
    description="Add two numbers."
)

print(add_v1.invoke({"a": 2, "b": 3}))  # 5
print(add_v2.invoke({"a": 2, "b": 3}))  # 5

When should you use StructuredTool.from_function? When you need to create tools dynamically — for example, generating N tools from a config:

from langchain_core.tools import StructuredTool

def create_math_tools() -> list:
    """Generate math operation tools dynamically."""
    operations = {
        "sum": lambda a, b: a + b,
        "subtract": lambda a, b: a - b,
        "multiply": lambda a, b: a * b,
    }
    descriptions = {
        "sum": "Add two numbers",
        "subtract": "Subtract the second number from the first",
        "multiply": "Multiply two numbers",
    }
    tools = []
    for name, func in operations.items():
        t = StructuredTool.from_function(
            func=func, name=name, description=descriptions[name],
        )
        tools.append(t)
    return tools

math_tools = create_math_tools()
for t in math_tools:
    print(f"{t.name}: {t.description}")
# Expected output:
# sum: Add two numbers
# subtract: Subtract the second number from the first
# multiply: Multiply two numbers

Recommendation: Use @tool as your default. Only reach for StructuredTool.from_function when you need dynamic generation.


Connection to the project

In the Assistant with External Tools (this module's project):

  • You'll build 3 custom tools with @tool: get_weather, web_search, calculate
  • Each tool will have a Pydantic schema with clear descriptions
  • The model will use those descriptions to decide which tool to call based on the user's question
  • In the next capsule (03) you'll learn to connect these tools to the model with bind_tools()

Everything you learn here gets applied directly in Capsule 08.


Troubleshooting

Problem 1: the model doesn't call the tool

Cause: The tool's description is vague or doesn't match the user's intent. Fix:

from langchain_core.tools import tool

# ❌ Vague description
@tool
def search(q: str) -> str:
    """Search for stuff."""
    return f"Results: {q}"

# ✅ Precise description
@tool
def search_web(query: str) -> str:
    """Search the internet for up-to-date information. Use this tool when
    the user asks about recent data, news, or information that may have
    changed since your last training."""
    return f"Results: {query}"

Problem 2: the model passes the wrong arguments

Cause: The parameters have no type hints, or the Field descriptions are ambiguous. Fix:

from langchain_core.tools import tool
from pydantic import BaseModel, Field

# ❌ No descriptions — the model guesses
@tool
def query_db(table: str, limit: int) -> str:
    """Query the database."""
    return f"SELECT * FROM {table} LIMIT {limit}"

# ✅ With clear descriptions — the model knows what to pass
class QueryInput(BaseModel):
    table: str = Field(description="Table name: 'users', 'orders', 'products'")
    limit: int = Field(default=10, description="Number of rows to return (1-100)")

@tool(args_schema=QueryInput)
def query_db(table: str, limit: int = 10) -> str:
    """Query the system database. Returns rows from the given table."""
    return f"SELECT * FROM {table} LIMIT {limit}"

Problem 3: the tool blows up on real data

Cause: There's no error handling inside the tool. Fix:

from langchain_core.tools import tool

@tool
def divide(a: float, b: float) -> str:
    """Divide the first number by the second."""
    if b == 0:
        return "Error: cannot divide by zero"
    return f"{a} / {b} = {a / b:.4f}"

print(divide.invoke({"a": 10, "b": 0}))
# Expected output: Error: cannot divide by zero

Exercises

Exercise 1: a personalized greeting tool (Easy)

Create a tool called greet that takes a name (name) and a language (language: "es", "en", "fr") and returns a greeting in that language. Inspect its name, description, and args_schema.

See solution
from langchain_core.tools import tool
from pydantic import BaseModel, Field
from typing import Literal

class GreetInput(BaseModel):
    name: str = Field(description="Name of the person to greet")
    language: Literal["es", "en", "fr"] = Field(
        default="en",
        description="Greeting language: 'es' (Spanish), 'en' (English), 'fr' (French)"
    )

@tool(args_schema=GreetInput)
def greet(name: str, language: str = "en") -> str:
    """Generate a personalized greeting in the given language."""
    greetings = {
        "es": f"¡Hola, {name}! ¿Cómo estás?",
        "en": f"Hello, {name}! How are you?",
        "fr": f"Bonjour, {name}! Comment ça va?",
    }
    return greetings.get(language, f"Hello, {name}!")

print(greet.name)
# Expected output: greet

print(greet.description)
# Expected output: Generate a personalized greeting in the given language.

print(greet.invoke({"name": "María"}))
# Expected output: Hello, María! How are you?

print(greet.invoke({"name": "John", "language": "fr"}))
# Expected output: Bonjour, John! Comment ça va?

Explanation: Literal["es", "en", "fr"] restricts the valid values. The model sees that constraint in the schema and only generates values from that list.

Exercise 2: a tool with error validation (Easy)

Create a divide tool that divides two numbers. It should handle division by zero by returning an error message instead of raising an exception.

See solution
from langchain_core.tools import tool
from pydantic import BaseModel, Field

class DivideInput(BaseModel):
    a: float = Field(description="Numerator (dividend)")
    b: float = Field(description="Denominator (divisor)")

@tool(args_schema=DivideInput)
def divide(a: float, b: float) -> str:
    """Divide the first number by the second. Returns an error if the divisor is zero."""
    if b == 0:
        return "Error: cannot divide by zero"
    return f"{a} / {b} = {a / b:.4f}"

print(divide.invoke({"a": 10, "b": 3}))
# Expected output: 10.0 / 3.0 = 3.3333

print(divide.invoke({"a": 5, "b": 0}))
# Expected output: Error: cannot divide by zero

Explanation: Always handle errors inside the tool by returning a descriptive string. If you raise an exception, the model gets no useful feedback about what went wrong.

Exercise 3: a query tool with a complex schema (Medium)

Create a search_products tool that takes category (Literal), min_price (float), max_price (float), and in_stock (bool). It should return a filtered list of products from a mock catalog.

See solution
from typing import Literal
from langchain_core.tools import tool
from pydantic import BaseModel, Field

class ProductSearchInput(BaseModel):
    category: Literal["electronics", "clothing", "books"] = Field(
        description="Product category"
    )
    min_price: float = Field(default=0, description="Minimum price in USD")
    max_price: float = Field(default=1000, description="Maximum price in USD")
    in_stock: bool = Field(default=True, description="Only show products in stock")

@tool(args_schema=ProductSearchInput)
def search_products(
    category: str, min_price: float = 0, max_price: float = 1000, in_stock: bool = True
) -> list:
    """Search the catalog for products filtered by category, price, and availability."""
    catalog = [
        {"name": "Laptop Pro", "category": "electronics", "price": 999.99, "stock": True},
        {"name": "BT Headphones", "category": "electronics", "price": 79.99, "stock": True},
        {"name": "USB Cable", "category": "electronics", "price": 12.99, "stock": False},
        {"name": "Dev T-Shirt", "category": "clothing", "price": 25.99, "stock": True},
        {"name": "Clean Code", "category": "books", "price": 35.00, "stock": True},
        {"name": "Design Patterns", "category": "books", "price": 45.00, "stock": False},
    ]
    results = [
        p for p in catalog
        if p["category"] == category
        and min_price <= p["price"] <= max_price
        and (not in_stock or p["stock"])
    ]
    return results

result = search_products.invoke({"category": "electronics", "max_price": 100})
print(result)
# Expected output:
# [{'name': 'BT Headphones', 'category': 'electronics', 'price': 79.99, 'stock': True}]

result = search_products.invoke({"category": "books", "in_stock": False})
print(result)
# Expected output:
# [
#     {'name': 'Clean Code', 'category': 'books', 'price': 35.0, 'stock': True},
#     {'name': 'Design Patterns', 'category': 'books', 'price': 45.0, 'stock': False}
# ]

Explanation: With in_stock=False, every product shows up without filtering by stock. The Literal types restrict the valid category values.

Exercise 4: a complete tool for the project (Hard)

Build the get_weather tool you'll use in the module's project. It should take city and units (celsius/fahrenheit), return a dict with temperature, condition, and humidity, and include error handling for cities it can't find.

See solution
from langchain_core.tools import tool
from pydantic import BaseModel, Field
from typing import Literal

class WeatherInput(BaseModel):
    city: str = Field(description="City name (e.g. 'Madrid', 'CDMX', 'Buenos Aires')")
    units: Literal["celsius", "fahrenheit"] = Field(
        default="celsius",
        description="Temperature unit"
    )

@tool(args_schema=WeatherInput)
def get_weather(city: str, units: str = "celsius") -> dict:
    """Get the current weather for a city. Returns temperature, condition, and humidity.
    Use it when the user asks about the weather, forecast, or temperature of a location."""
    weather_data = {
        "Madrid": {"temp_c": 22, "condition": "Sunny", "humidity": 45},
        "CDMX": {"temp_c": 18, "condition": "Cloudy", "humidity": 65},
        "Buenos Aires": {"temp_c": 15, "condition": "Partly cloudy", "humidity": 72},
        "New York": {"temp_c": 10, "condition": "Rainy", "humidity": 85},
        "Tokyo": {"temp_c": 25, "condition": "Sunny", "humidity": 55},
    }

    data = weather_data.get(city)
    if data is None:
        return {
            "error": f"City '{city}' not found",
            "available_cities": list(weather_data.keys())
        }

    temp = data["temp_c"]
    if units == "fahrenheit":
        temp = round(temp * 9 / 5 + 32, 1)

    unit_symbol = "°C" if units == "celsius" else "°F"

    return {
        "city": city,
        "temperature": f"{temp}{unit_symbol}",
        "condition": data["condition"],
        "humidity": f"{data['humidity']}%"
    }

print(get_weather.invoke({"city": "Madrid"}))
# Expected output:
# {'city': 'Madrid', 'temperature': '22°C', 'condition': 'Sunny', 'humidity': '45%'}

print(get_weather.invoke({"city": "New York", "units": "fahrenheit"}))
# Expected output:
# {'city': 'New York', 'temperature': '50.0°F', 'condition': 'Rainy', 'humidity': '85%'}

print(get_weather.invoke({"city": "London"}))
# Expected output:
# {'error': "City 'London' not found", 'available_cities': ['Madrid', 'CDMX', ...]}

Explanation: This tool has everything you need for the final project: a Pydantic schema with descriptions, unit conversion, structured data as the return value, and error handling that gives useful feedback. In production, you'd swap the hardcoded dictionary for a call to a real API like OpenWeatherMap.


Summary

In this capsule you learned:

  • The @tool decorator turns any Python function into a tool that models can invoke
  • LangChain automatically extracts name, description, and args_schema from your function
  • Clear descriptions are critical — the model uses them to decide when to call each tool
  • With Pydantic (BaseModel + Field) you define detailed schemas with per-argument descriptions
  • Tools can be sync or async — use async def for I/O and .ainvoke() to run them
  • Tools can return any serializable type: strings, dicts, lists
  • Built-in tools like DuckDuckGoSearchResults cover generic tasks
  • StructuredTool.from_function is the alternative for generating tools dynamically
  • Always handle errors inside the tool by returning descriptive messages

Next capsule: bind_tools and the tool calling flow — you'll learn to connect these tools to a model and watch the model decide which one to call.


Further reading

  1. How to create tools — Official step-by-step guide
  2. Tools Conceptual Guide — How the tool system works internally
  3. @tool API Reference — Full reference for the decorator
  4. StructuredTool API Reference — StructuredTool reference
  5. Pydantic Field Documentation — How to use Field for descriptions and validation
  6. LangChain Built-in Tools — Full catalog of prebuilt tools
  7. Tool Calling Conceptual Guide — How the tool calling flow works
  8. OpenAI Function Calling — The original spec that inspired tool calling

Module 2 — LangChain & LangGraph: From Chains to Agents