Pydantic AIPydantic AI 2.51 · Python 3.10+
Dashboard
0%
1
Curious builder0 XP earned · 300 to level 2
0 daysFinish a lesson to begin
Badge collection0 of 6 unlocked
29 small wins to finish your pathNext lesson →

Function tools: letting the model look things up

A function tool is a Python function the model can choose to call during a run. Pydantic AI builds the tool's description from your type hints and docstring, and runs the function when the model asks for it.

Last updated: 28 Sep, 2026 · Pydantic AI 2.51

A model on its own can only write text. A tool lets it fetch a real value first, like an order's status, and answer from what came back.

Storing the order data

Start with a small table of orders the tool can read. In a real desk this would be a database call.

python
ORDERS = {
    "A-1001": "shipped on 12 March",
    "A-1002": "waiting for stock",
}

Writing the lookup tool

@agent.tool_plain registers a plain function, one that needs nothing from the run itself. The docstring's Args: section describes each argument to the model.

python
@agent.tool_plain
def lookup_order(order_id: str) -> str:
    """Look up the status of an order.

    Args:
        order_id: The order id, like A-1001.
    """
    return ORDERS.get(order_id, "not found")
Project files used on this pageThis lesson builds on a project from earlier lessons. The code below imports this file. Click a file to see its code, or follow the link to the lesson that wrote it. To run the code yourself, keep it in the same folder.
View the code here
shop_model.py
import re

from pydantic_ai import ModelResponse, TextPart, ToolCallPart
from pydantic_ai.models.function import AgentInfo, FunctionModel


def sort_ticket(text):
    text = text.lower()
    if "charged" in text or "refund" in text:
        return "billing", 4
    if "parcel" in text or "arrived" in text:
        return "shipping", 3
    return "other", 1


def shop_reply(messages, info: AgentInfo) -> ModelResponse:
    prompts = [p.content for m in messages for p in m.parts if p.part_kind == "user-prompt"]
    ticket = prompts[-1]
    last = messages[-1].parts[-1]
    order = re.search(r"A-\d{4}", ticket)

    # 1. The ticket names an order and the agent has a tool: ask for it.
    if order and info.function_tools and last.part_kind == "user-prompt":
        tool = info.function_tools[0].name
        return ModelResponse(parts=[ToolCallPart(tool, {"order_id": order.group()})])

    # 2. A tool answered: write the reply from what it said.
    if last.part_kind == "tool-return" and info.allow_text_output:
        return ModelResponse(parts=[TextPart(f"Order {order.group()}: {last.content}.")])

    # 3. The agent wants a typed answer: fill in its output tool.
    category, priority = sort_ticket(ticket)
    if info.output_tools:
        args = {"category": category, "priority": priority}
        return ModelResponse(parts=[ToolCallPart(info.output_tools[0].name, args)])

    # 4. Otherwise, plain text.
    return ModelResponse(parts=[TextPart(f"Sorted as {category}.")])


shop_model = FunctionModel(shop_reply, model_name="shop")

Calling the tool in a run

Ask about an order the table knows. The stand-in model reads the order id from the ticket, calls the tool, and writes its reply from the result.

Example
result = agent.run_sync("Where is my order A-1001?")
print(result.output)
print(result.usage)

What the tool call cost

  • The answer came from the tool: the model turned shipped on 12 March into a sentence.
  • requests=2 because the run went to the model twice: once to get the tool call, once to write the reply after the result came back.
  • tool_calls=1 records the single call to lookup_order.

What the model reads about a tool

The model never sees your function body. It sees a schema built from the name, the docstring and the type hints. Print it to see what the model chooses from.

Example
def peek(messages, info):
    tool = info.function_tools[0]
    print(tool.name)
    print(tool.description)
    print(json.dumps(tool.parameters_json_schema, indent=2))
    return ModelResponse(parts=[TextPart("ok")])


agent = Agent(FunctionModel(peek))
agent.tool_plain(lookup_order)
agent.run_sync("hi")

The name is the function's name, the description is the docstring, and the Args: line became the description of order_id. Pydantic AI reads Google, NumPy and Sphinx docstring styles. The model picks tools from these words alone, so they are worth writing well.

Three ways to register a tool

HowWhen to use it
@agent.tool_plainA function that needs no run context, defined next to the agent.
agent.tool_plain(fn)The same, without the decorator, for a function defined elsewhere.
Agent(..., tools=[fn])Pass a list of functions when you create the agent.

Arguments are validated before your function runs: Pydantic checks them against the type hints, and a wrong type goes back to the model as a retry, the same way a bad structured output does. A function with order_id: str can rely on getting a string.

When you reach for a tool

  • Looking a value up: an order's status, a customer's plan, stock on hand.
  • Doing something with an effect: send an email, open a ticket, start a refund.
  • Fetching fresh data the model cannot know: today's price, the current queue length.
Watch out. The model chooses a tool from its name and description only. A vague docstring, or two tools that sound alike, leads it to call the wrong one or pass poor arguments. Write the docstring for the model, not only for a human reading the code.
Try it yourself
  • Add refund_order(order_id: str, amount: float) -> str and print both tools' schemas.
  • Ask about A-1002, then about A-5555, and read each answer.
  • Remove the docstring and print the description again.

Little by little, you're building something great.