All posts
Fundamentals

Building your first real tool

The ticket question needs two capabilities: something that reads our export, and something that looks up a current price. Time to build the first one properly.

Two kinds of tools, and why we build our own

Providers ship built-in tools. Web search, file search, code execution: production-ready, maintained by someone else, available immediately.

Built-inYour own
Effort to useNoneYou write and maintain it
Reaches your systemsNoYes, that is the point
Behaviour when it breaksOpaqueYou can read the code
LifecycleChanges at the provider's discretionYours

Use the built-ins where they fit. Build your own for two reasons: your agent needs to reach systems no provider knows about, and when an agent misbehaves you need to tell whether the fault was the tool, the model's choice of tool, or how the result got handled. A built-in gives you no way to answer that.

Start with a function, not a tool

Here is the first useful discipline. A tool is an ordinary Python function. Write it, and test it, before a model is anywhere near it.

def read_tickets(quarter: str) -> str:
    """Read the support ticket export for one quarter and return it as CSV text.

    quarter: the quarter to read, formatted as YYYY-Qn, e.g. 2026-Q2
    """
    path = EXPORT_DIR / f"tickets-{quarter}.csv"
    return path.read_text()
>>> read_tickets("2026-Q2")[:80]
'ticket_id,category,opened,closed,handling_minutes\n T-8801,password_reset,...'

Two lines of interactive checking, no API key, no tokens spent. When the agent later does something strange, you already know this half works.

Keep that habit. Every tool gets built and verified as a function first, because debugging a tool through an agent means debugging two unpredictable things at once.

Now the schema, and where it comes from

Post 13 covered what a good schema says. Writing one by hand is fine the first time. It rots by the third, because the function changes and the description does not, and a stale description is worse than a missing one: the model follows it confidently.

So generate the schema from the function itself.

import inspect

TYPES = {str: "string", int: "integer", float: "number",
         bool: "boolean", list: "array", dict: "object"}

def to_tool_definition(func) -> dict:
    """Build a tool definition from a function's signature and docstring."""
    sig = inspect.signature(func)
    props, required = {}, []
    for name, p in sig.parameters.items():
        props[name] = {"type": TYPES.get(p.annotation, "string")}
        if p.default is inspect.Parameter.empty:
            required.append(name)
    return {
        "type": "function",
        "function": {
            "name": func.__name__,
            "description": inspect.getdoc(func),   # <- the docstring, verbatim
            "parameters": {"type": "object", "properties": props, "required": required},
        },
    }

Three pieces of the function become three pieces of the schema. The name becomes the tool name. The type annotations become JSON Schema types. Whether a parameter has a default decides whether it lands in required.

And the docstring becomes the description.

The signature and docstring become the schema. Write the docstring for the model.The signature and docstring become the schema. Write the docstring for the model.

Your docstring is now an interface

Stop and look at what that changes.

A docstring used to be a note for whoever reads the code next. Now it is the text a model reads at runtime to decide whether to call this function, and it is the only text it gets.

So the rules from post 13 apply to the thing you type above your code:

def read_tickets(quarter: str) -> str:
    """Read tickets."""                      # the model has to guess almost everything
def read_tickets(quarter: str) -> str:
    """Read the support ticket export for one quarter and return it as CSV text.

    Use this for any question about ticket volume, categories or handling time.
    quarter: formatted as YYYY-Qn, e.g. 2026-Q2. Data exists from 2024-Q1 onward.
    """

The second version says when to reach for it, what format the argument takes, and what range of data exists. A wrong quarter now fails as a clear miss rather than a confusing empty result.

Writing the docstring for two audiences at once takes some getting used to, and it repays more than any other habit in tool building.

What you hand back matters as much as what you take in

A tool's return value goes straight into the conversation, and everything already covered about context applies: it costs tokens on this call and on every call after it.

So a tool that returns everything is a tool that makes your agent worse.

def read_tickets(quarter: str) -> str:
    return path.read_text()          # 412 rows, every column, ~40,000 tokens
def ticket_summary(quarter: str) -> str:
    """Counts and mean handling time per ticket category for one quarter."""
    rows = _load(quarter)
    by_cat = _group(rows)
    return "\n".join(f"{c}: {n} tickets, {m:.1f} min avg" for c, n, m in by_cat)
    # ~40 tokens, and it answers the question that was actually asked

Shape the return around the questions the tool exists to answer. When the agent genuinely needs the raw rows, give it a second, narrower tool that fetches a slice.

Errors are messages to the model

The last piece. Your tool will fail, and what it says when it fails decides what the agent does next.

except FileNotFoundError:
    raise                                    # the agent sees a stack trace, or nothing at all
except FileNotFoundError:
    return (f"No export exists for {quarter}. "
            f"Available quarters: {', '.join(_available())}.")

The second version is a sentence the model can act on: it can retry with a quarter that exists, or tell the user what the range is. Error text is another place your reader is a model, so write it as an instruction rather than a diagnostic.

What to take from this

  • Build and test each tool as a plain function first. Debugging through an agent means debugging two unpredictable things at once.
  • Generate the schema from the signature so it cannot drift, which promotes your docstring from a comment to the interface the model reads.
  • Shape both what you return and what you say on failure. Both land in the context, and both are read by a model rather than a person.

One tool is an afternoon. The trouble starts at the tenth, and at the point where another team wants to use yours.