All posts
Fundamentals

A tool schema is a user interface, and your user is the model

A tool is a function plus a schema. The function does the work, and the schema is the part that decides whether the work ever happens.

Everything about how a model uses tools follows from one fact about how it learned to.

The model learned from examples that look exactly like yours

Tool calling arrived through training. Models were shown large numbers of pairs: a set of tool definitions and a user request on one side, the correct tool call on the other. Shown enough of them, a model picks up four behaviours.

  1. Noticing that a request needs a tool at all.
  2. Choosing which tool from the ones available.
  3. Pulling the argument values out of what the user wrote.
  4. Producing the call in the expected structure.

Nothing in that list involves your codebase, your naming conventions, or the comment you left above the function. The model sees the schema and the conversation, and nothing else.

So the schema is documentation, written for a reader who cannot ask a follow-up question. A useful test: if a competent new joiner could use your tool correctly from the schema alone, a model can too. If they would need to ask you something first, the model will guess instead.

Four rules cover most of it.

Rule one: name the thing and describe the job

{
  "name": "book",
  "description": "Book something",
  "parameters": {"type": "object", "properties": {"when": {"type": "string"}}}
}

Book what? A table, a flight, a meeting room? And when in which format: "tomorrow at 8", an ISO timestamp, a Unix epoch? The model will pick one, and it will pick differently on different days.

{
  "name": "reserve_table",
  "description": "Create a restaurant table reservation.",
  "parameters": {
    "type": "object",
    "properties": {
      "restaurant_id": {"type": "string", "description": "Internal ID, e.g. rst_123"},
      "datetime":      {"type": "string", "format": "date-time",
                        "description": "ISO-8601 in the user's local time"},
      "party_size":    {"type": "integer", "minimum": 1, "maximum": 20},
      "notes":         {"type": "string", "description": "Allergies, occasion, etc. Optional."}
    },
    "required": ["restaurant_id", "datetime", "party_size"]
  }
}

The name says what happens. Each description gives a format and an example. required separates what the call needs from what it can omit. minimum and maximum turn an impossible party size into a schema violation your code catches rather than a booking someone has to unpick.

Rule two: make invalid calls unrepresentable

Ordinary API design applies here, and one antipattern shows up constantly.

{"name": "toggle_light",
 "parameters": {"type": "object",
   "properties": {"on": {"type": "boolean"}, "off": {"type": "boolean"}}}}

Two booleans allow four combinations and only two of them mean anything. The model can hand you on=true, off=true, and now your code needs a rule for a state that should never have been expressible.

{"name": "toggle_light",
 "description": "Control the light state",
 "parameters": {"type": "object",
   "properties": {"state": {"type": "string", "enum": ["on", "off"],
                            "description": "The desired state of the light"}},
   "required": ["state"]}}

One parameter, two legal values, no contradiction available. The enum also gives you a deterministic rejection: anything outside the list fails validation before it reaches your logic.

Rule three: let code supply what code already knows

This is the rule that changes the most, and it is worth reading twice.

An agent handling a refund has already looked up the order. The order id is sitting in your process. So why ask the model for it?

{"name": "submit_refund",
 "parameters": {"type": "object", "required": ["order_id", "reason"],
   "properties": {"order_id": {"type": "string", "description": "The order ID to refund"},
                  "reason":   {"type": "string", "description": "Reason for the refund"}}}}

Asking the model for order_id means asking it to carry an identifier accurately across a conversation that may contain several similar ones. It costs tokens on every call, and it introduces drift: in a long thread with three order ids in it, the model can hand back the wrong one, and a wrong-but-plausible id is the hardest kind of error to notice.

{"name": "submit_refund",
 "parameters": {"type": "object", "required": ["reason"],
   "properties": {"reason": {"type": "string", "description": "Why the refund is being issued"}}}}
def submit_refund(reason: str):
    # the id comes from YOUR session state, never from the model
    return refunds.create(order_id=session.current_order_id, reason=reason)

Now the model supplies the judgement (should this be refunded, and why) and your code supplies the fact (which order). Each side does the part it is reliable at.

Read that split again, because it is bigger than a token optimisation. A value the model never supplies is a value the model can never get wrong, and nothing that reaches the conversation can steer it either. That property is worth reaching for on every tool that touches something real.

The model supplies the decision. Your code supplies the identifier.The model supplies the decision. Your code supplies the identifier.

Rule four: keep the list short

Keep the tool count under about twenty. Past that, selection errors climb: the model has more near-neighbours to confuse, and the descriptions all compete for attention in the same context.

If you genuinely need more, the answer is usually structural rather than a longer list. Group tools behind a smaller number of dispatchers, or split the work across agents that each hold a focused set.

The four together

RuleAntipatternFixWhat it prevents
Name and describebook(when)reserve_table(restaurant_id, datetime, party_size)Right tool, unusable arguments
Make bad calls impossibleon: bool, off: boolstate: enum[on, off]Contradictory input your code must interpret
Code supplies known factsModel passes order_idCode injects it from sessionRefunding the wrong order
Keep the list short40 toolsUnder 20, or split the agentThe wrong tool chosen confidently

Four rules, and the failure each one removes.Four rules, and the failure each one removes.

What to take from this

  • The schema is the only documentation the model reads, so write it for a capable reader who cannot ask you a question.
  • Constrain what is expressible. An enum, a required list and a numeric range each turn a class of wrong call into a validation error.
  • Let the model supply decisions and let your code supply identifiers. A value the model never provides is a value it can never get wrong.

Good schemas make a tool usable. The next question is what goes inside one, which is where a plain Python function turns into something an agent can actually reach for.