TechByteByByte

Tools: Giving Models the Ability to Act

Go deep on turning ordinary Python functions into well-described, production-quality tools a model can genuinely understand and reliably request — the foundation every agent in this course builds on.

#LangChain#Tools#Agents#Python

You’ve seen @tool twice already, in passing — a tiny add_numbers function back in Module 2, and get_temperature in Module 4. Both times, we deliberately kept them trivial, just enough to show the shape. This module is where tools get the full, careful treatment they deserve, because every agent you build for the rest of this course depends entirely on tools being defined well.

Why a Python function alone isn’t enough

Here’s the core problem worth understanding clearly before writing any code. A model has no way to read your Python source code. It can’t open your file and see what get_temperature actually does internally. All it ever gets is a description — the function’s name, a summary of what it does, and a list of what parameters it needs and what type each one is. Everything the model knows about your tool comes entirely from how well you describe it, not from the actual code behind it.

This means a badly described tool is genuinely dangerous, not just inconvenient. A vague description can cause a model to call the wrong tool, pass the wrong kind of value, or not realize a tool exists for a task it should have used it for. Writing a good tool is really an exercise in writing a good, precise description — the Python code itself is almost the easy part.

Example 1: the anatomy of a tool, properly explained

from langchain.tools import tool

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

Let’s slow down and name every single piece of this, because each one plays a genuinely distinct role:

  • add, the function name, becomes the tool’s name — the identifier the model will use to refer to it.
  • The docstring, "Add two whole numbers together...", becomes the tool’s description — this is the single most important part, because it’s the model’s only source of information about what this tool actually does.
  • a: int, b: int, the type hints, tell the model exactly what parameters it needs to provide, and what type each one should be.
  • -> int, the return type hint, documents what kind of result to expect back.
  • @tool is the decorator that takes this ordinary Python function and wraps it into something LangChain can hand to a model as a genuine, callable capability.

Example 2: seeing exactly what the model sees

It’s genuinely worth looking at a tool from the model’s own point of view, rather than assuming you know what it “sees.”

from langchain.tools import tool

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

print("Name:", add.name)
print("Description:", add.description)
print("Args schema:", add.args)

Run this, and you’ll see the tool’s name, its description (pulled directly from the docstring), and a structured schema describing its parameters — genuinely everything the model has to work with when deciding whether and how to call this tool. If your docstring is vague, this is exactly the vagueness the model will be reasoning from.

Example 3: a tool with multiple, meaningfully typed parameters

from typing import Literal
from langchain.tools import tool

@tool
def get_weather(city: str, unit: Literal["celsius", "fahrenheit"] = "celsius") -> str:
    """Get the current weather for a given city, in the specified temperature unit."""
    temperature = 21 if unit == "celsius" else 70
    return f"It's currently {temperature}°{'C' if unit == 'celsius' else 'F'} in {city}."

print(get_weather.args)

Notice unit uses Literal["celsius", "fahrenheit"], exactly the same technique from Module 9’s structured output example — constraining a parameter to a small, specific set of valid values, rather than any arbitrary string. This genuinely helps the model: instead of guessing at what a valid “unit” might look like, it knows there are exactly two acceptable options. Notice also the default value, "celsius" — a model isn’t required to specify every parameter if a sensible default exists.

Example 4: a tool that can genuinely fail, handled properly

Real tools interact with real, unreliable things — databases, APIs, files that might not exist. A well-built tool needs to handle failure gracefully, not just assume everything will work.

from langchain.tools import tool

# a small, pretend "database" for this example
ORDERS = {"1001": "Shipped", "1002": "Processing", "1003": "Delivered"}

@tool
def check_order_status(order_id: str) -> str:
    """Look up the current status of a customer order by its order ID."""
    if order_id not in ORDERS:
        return f"No order found with ID {order_id}. Please double-check the order number."
    return f"Order {order_id} status: {ORDERS[order_id]}"

print(check_order_status.invoke({"order_id": "1002"}))
print(check_order_status.invoke({"order_id": "9999"}))

This is genuinely important, and easy to get wrong: notice the “not found” case returns a clear, informative string, rather than raising an exception. A model can read and respond sensibly to a returned string like “No order found” — it can’t gracefully recover from your Python program crashing with an unhandled error. As a rule, a tool should almost always return useful information about failure, rather than letting an exception propagate up and break the whole interaction.

Example 5: a tool wrapping a real, external-style call

Most real, useful tools wrap something happening outside your own code — here, a stand-in for a genuine REST API call, structured exactly the way a real one would be.

import requests
from langchain.tools import tool

@tool
def get_random_fact() -> str:
    """Fetch a random, interesting fact from a public API."""
    try:
        response = requests.get("https://uselessfacts.jsph.pl/api/v2/facts/random", timeout=5)
        response.raise_for_status()
        return response.json()["text"]
    except requests.RequestException as e:
        return f"Couldn't fetch a fact right now: {e}"

print(get_random_fact.invoke({}))

Notice the try/except block here does exactly what Example 4 taught: if the real, external API call fails for any reason — a timeout, a network issue — the tool still returns a clear, readable string explaining what happened, rather than crashing. This pattern — wrap the risky, external part in a try/except, and always return something the model can meaningfully read either way — is the actual, practical shape of most real, production tools.

Example 6: precise validation with a dedicated schema

Sometimes a parameter needs more validation than a simple type hint can express — a specific numeric range, or a more complex, multi-field structure. For this, you can define a dedicated Pydantic schema and attach it directly.

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

class DiscountInput(BaseModel):
    order_total: float = Field(description="The total order amount in dollars, before any discount.")
    discount_percent: float = Field(ge=0, le=50, description="Discount percentage, between 0 and 50.")

@tool(args_schema=DiscountInput)
def apply_discount(order_total: float, discount_percent: float) -> str:
    """Calculate the final price after applying a discount to an order total."""
    final_price = order_total * (1 - discount_percent / 100)
    return f"Final price after {discount_percent}% discount: ${final_price:.2f}"

print(apply_discount.invoke({"order_total": 200.0, "discount_percent": 15}))

Field(ge=0, le=50, ...) genuinely constrains discount_percent to a valid range — “greater than or equal to 0, less than or equal to 50” — enforced automatically before your function’s own code ever runs. This matters for a real, practical reason: it stops a model from confidently requesting a nonsensical 300% discount, catching the problem at the validation layer rather than letting broken business logic slip through into your actual function.

Example 7: a genuinely realistic business tool

Let’s put several of these ideas together into something you might actually deploy — checking whether a customer is eligible for a loyalty reward.

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

CUSTOMERS = {
    "cust_001": {"name": "Priya", "loyalty_points": 1200},
    "cust_002": {"name": "Sam", "loyalty_points": 340},
}

class LoyaltyCheckInput(BaseModel):
    customer_id: str = Field(description="The unique ID of the customer to check.")

@tool(args_schema=LoyaltyCheckInput)
def check_loyalty_reward_eligibility(customer_id: str) -> str:
    """Check whether a customer has enough loyalty points to redeem a reward (requires 1000+ points)."""
    customer = CUSTOMERS.get(customer_id)
    if not customer:
        return f"No customer found with ID {customer_id}."
    if customer["loyalty_points"] >= 1000:
        return f"{customer['name']} is eligible for a reward with {customer['loyalty_points']} points."
    return f"{customer['name']} has {customer['loyalty_points']} points — not yet eligible (needs 1000)."

print(check_loyalty_reward_eligibility.invoke({"customer_id": "cust_001"}))

Notice this tool’s docstring states the actual business rule directly — “requires 1000+ points” — right inside the description the model reads. This is a genuinely useful habit: when a tool encodes a specific business rule, stating that rule plainly in the docstring helps the model reason correctly about when this tool is actually relevant to call, not just how to call it.

Example 8: several tools together, and watching a model choose between them

Let’s finally see multiple tools handed to a model at once, and observe how it decides which one — if any — actually fits a given question.

from langchain.chat_models import init_chat_model

model = init_chat_model("openai:gpt-4o-mini")
model_with_tools = model.bind_tools([add, get_weather, check_order_status])

response = model_with_tools.invoke("What's the weather like in Nairobi?")
print(response.tool_calls)

response = model_with_tools.invoke("What's 47 plus 89?")
print(response.tool_calls)

response = model_with_tools.invoke("Can you check on order 1001 for me?")
print(response.tool_calls)

Run this, and inspect each tool_calls list. Even though the model was handed all three tools at once, it correctly picks the one genuinely relevant to each specific question — because each tool’s name and description gave it enough real information to distinguish them clearly. This is precisely why the careful description work throughout this entire module matters so much: with three vague, poorly described tools, a model’s choices here would become far less reliable.

Common mistakes worth avoiding

Writing a vague or missing docstring. A tool with no docstring, or one like """Does stuff.""", gives the model almost nothing to reason from. Recall Example 2 — the docstring is the description the model sees. Treat it with the same care you’d give a prompt, because functionally, that’s exactly what it is.

Letting exceptions propagate out of a tool instead of returning a clear message. Recall Examples 4 and 5 — a tool that crashes on bad input or a failed API call breaks the entire interaction. A tool should almost always catch its own likely failures and return something readable instead.

Giving two tools confusingly similar names or descriptions. If a model is handed both get_weather and check_weather_conditions, with near-identical descriptions, it has a genuinely harder time reliably choosing between them — and so would a human reading your code six months later. Keep tool names and purposes clearly, deliberately distinct.

What you should take away from this module

  • A model only ever sees a tool’s description — its name, docstring, and parameter schema — never its actual code. Writing a good tool means writing a good description.
  • @tool turns an ordinary Python function into something a model can request, using the function name, docstring, and type hints as its full source of information.
  • Tools should handle their own likely failures internally, returning a clear, readable message rather than letting an exception crash the interaction.
  • args_schema, built with Pydantic, lets you enforce real validation rules — ranges, formats — beyond what a simple type hint can express.
  • With several well-described tools bound to a model at once, it can reliably distinguish which one actually fits a given request.

Where this goes next

The next module goes back to something you’ve now seen in small pieces across several examples — response.tool_calls, bind_tools, ToolMessage — and finally puts them together properly: Tool Calling, the complete mechanism by which a model requests a tool and your own code decides how to respond.

Author
TechByteByByte Editorial Team
Reviewed by
TechByteByByte Admin
Published
Last reviewed