What a Tool Really Is
A tool is two things that live in different places. One is a function in your codebase — ordinary code, ordinary timeouts, tests you already write. The other is a declaration of that function that travels to the model on every request: a name, a description of when to use it, and a schema for its parameters. The model only ever sees the second half, and it never runs anything.
What the model emits is a structured request — this tool, these arguments — and then it stops. Your code reads that request and decides whether to run it, with which credentials, against which account, and what to hand back. The split takes one sentence to state and it decides the entire permission model: every control in Chapter 12 lives on your side of it, because there is nothing enforceable on the other.
The Two Halves
The implementation half is not new. Sundry's get_order is forty lines that call an internal orders service, apply a timeout, handle a 404, and return a dictionary. It has the problems every backend function has — connection pools, upstream latency, an occasional 502 — and it is tested the way you already test things. If any page of this book could be replaced by a chapter of an ordinary backend book, it is this half.
The declaration is the unfamiliar half, because its audience is a language model reading English under time pressure. Name, description, parameter schema: that is the entire brief, and it arrives fresh on every turn, because nothing is retained between calls. At Sundry the system message and the nine schemas together are 1,100 tokens on every single request. Most of the quality gap between a good tool surface and a bad one lives inside those 1,100 tokens, so the next topic is a page about writing English.
The Model Asks, Your Code Acts
"The model called the tool" is a figure of speech that hides the mechanism. What actually arrives is an assistant message containing a block with three fields: a request id, a tool name, and an arguments object. It is data in the transcript, exactly like text is. No HTTP request has been made, no row has been written, no money has moved. The model has produced a sentence in a structured dialect and stopped.
Then your dispatcher decides. That is why authorization cannot live in the prompt. "Never refund more than $150 without approval" in the system message is an instruction the model follows most of the time; the same rule as an if statement in the dispatcher is checked on 100% of calls, including the ones where the model has been convinced by something it read in a product description. A prompt is a strong suggestion to a component that is not obliged to comply. Chapter 12 is largely the consequences of taking that seriously.
Why Tools Beat Prompt Instructions
Ask a model with no tools for the status of order SU-88421 and it will answer. Fluently, with a carrier, a plausible date and a confident tone. The answer is fiction, because the model has no access to Sundry's order table and no way to say so with the certainty the question invites. Attach get_order and the same question produces a request for the record and an answer built on what came back. The model did not get smarter between those two runs. It got connected.
State it as a rule worth applying without exception: anything the answer depends on that cannot be derived from the conversation must arrive through a tool result or through retrieval, never through the model's recall. No instruction fixes this — "look up the order before answering" in a prompt, with no order tool attached, produces a more confident invention. There is a corollary that Chapter 12 collects and this chapter only flags: a tool result is not trusted data. It is text from a carrier API you do not own, or a product description a seller wrote, landing in the context with exactly the same status as the customer's own words.
Read Tools and Write Tools Are Different Animals
A wrong get_order costs a few hundred tokens and one confused sentence, and the next turn can recover from it. A wrong issue_refund moves money out of a seller's balance, and recovering from it is a phone call. The two have identical shape in code and belong to different categories entirely, which is exactly why they get lumped together and why ceilings, approval gates and audit records go missing.
| Read tools | Write tools | |
|---|---|---|
| Sundry's | search_orders, get_order, track_parcel, search_policy | start_return, offer_replacement, issue_refund, message_seller |
| A wrong call costs | Tokens and a confused sentence | Money, stock, a courier, or words to a third party |
| Safe to retry | Freely, as often as you like | Only behind an idempotency key (Topic 18) |
| Needs a ceiling | A rate limit at most | Yes — $150 before a human approves |
| Audit record | Name, arguments, latency | All of that, plus outcome and who authorized it |
The ninth tool, escalate_to_human, sits outside the table: it changes the world only by handing the ticket to a person, and it never needs a ceiling because that is what a ceiling escalates to. In code the split is a flag on the tool rather than a convention in a document — writes=True, and a dispatcher that refuses to execute a writing tool without an authorization check and an audit line. Adding that flag while the surface is three tools is a single line. Retrofitting it across twelve tools and the code that logs them is a day nobody has budgeted.
The Dispatcher
One function, one place, one path. It resolves a requested name to a callable, rejects a name that is not in the table, validates the arguments against the schema you published, applies authorization and ceilings to anything that writes, times the call, catches everything the tool can raise, and hands back a result the model can read. Every item on that list has been skipped by somebody in production, usually the first and the last.
def dispatch(call, ctx): spec = TOOLS.get(call.name) if spec is None: # invented or renamed tool return err(call.id, "unknown_tool", f"no tool named {call.name}") args, problem = spec.validate(call.args) # types, enums, units if problem: return err(call.id, "bad_arguments", problem) if spec.writes: # the flag, not a convention allowed, why = authorize(spec, args, ctx) # scopes, $150 ceiling if not allowed: return err(call.id, "not_permitted", why) started = time.monotonic() try: value = spec.fn(**args, actor=ctx.agent_id) except Exception as exc: return err(call.id, "tool_failed", sanitize(exc)) finally: log.tool(call, spec, ms=(time.monotonic() - started) * 1000, ctx=ctx) return ok(call.id, spec.shape(value)) # shaped and capped (Topic 15)
In words: look the name up first and fail politely when it is missing, because a model that invents a tool name should get a message it can correct on the next turn rather than a KeyError that ends a ticket which was otherwise going fine. Validate the arguments against the schema you actually published. If the tool writes, run authorization before anything happens rather than after. Time it, log it, and let no exception escape — every failure comes back as a result the model can read and act on, which is the whole of Topic 16. Then shape the return value before it enters the context, because from that moment you pay for it on every remaining turn.
That single function is the object the rest of the book keeps returning to. Chapter 12 hardens it into the permission boundary, with scopes, the refund ceiling and the approval gate all enforced in exactly these twenty-three lines. Chapter 13 instruments it, because latency and cost per tool are measured here or nowhere. Chapter 4 lets it accept tools from a process you did not write. Keep it small and boring, and make sure it is the only route from a model request to a running function — a second path around it is a permission model with a hole in it.
- Executing whatever name arrived —
DISPATCH[call.name](**call.args)raises aKeyErroron one misspelled name and kills a ticket mid-run, where anunknown_toolresult would have been corrected on the next turn at the cost of one model call. - Putting authorization in the system prompt — "never refund more than $150 without approval" is followed most of the time, and the exceptions are exactly the tickets where something in the context argued otherwise (Chapter 12).
- Exposing an internal API function directly as a tool — internal signatures carry parameters the model must never set, such as the account a refund is drawn from or the
skip_checksflag somebody added during a migration in 2019. - Logging read and write calls in the same shape — after an incident the question is "what did it change, with what arguments, and who allowed it", and no amount of grepping recovers a field the write path never wrote (Chapter 13).
- Treating a tool result as trusted because the model asked for it —
get_orderreturns a seller-written product description, and whatever is inside it enters the context with the same standing as the buyer's own sentence (Chapter 12).
- Route every model-requested call through one dispatcher that validates, authorizes, times and logs, and make sure no other code path can reach a tool function.
- Split the tool list into read and write in code with an explicit flag, and have the dispatcher refuse to run a writing tool that has not declared itself one.
- Write tools for the agent's task rather than as wrappers over whatever the internal API happens to expose — Topic 17 does exactly that for Sundry's nine.
- Return every failure as a result the model can act on rather than an exception that escapes into the loop (Topic 16).
Knowledge Check
What has physically happened at the moment the model "calls" issue_refund?
- A message has arrived carrying a request id, a tool name and arguments, and nothing else
- The provider has called the endpoint named in the schema and is now waiting on its response
- The payment provider has been charged and the result is on its way back to the model
- A snippet of code has been generated and then evaluated inside a sandbox on the provider's side
Vera adds "never refund more than $150 without human approval" to the system prompt. Why is that not the control the business asked for?
- The prompt is followed most of the time, and a ceiling in the dispatcher is enforced every time
- The model cannot compare dollar amounts reliably, so the limit has to be expressed in cents
- The extra sentence costs tokens on every turn, which is what makes a prompt-based rule expensive
- The limit belongs in the tool's description instead, where the model reads it before choosing
A model with no tools answers a question about order SU-88421 with a carrier name and a delivery date, both invented. What is the fix?
- Give it a tool that reads the order, because the missing thing is access rather than instruction
- Lower the sampling temperature, which stops the model from generating unsupported details
- Instruct it more firmly to look the order up before answering any status question
- Put Sundry's order id format into the system prompt so that the model can recognize a real one
What actually distinguishes a write tool from a read tool in an agent's design?
- The damage from a wrong call outlives the run, so it needs a ceiling, an authorization check and an audit record
- Write tools take more parameters, so argument validation matters more than it does for reads
- Write tools are slower, so they need longer timeouts and a different retry policy than reads
- Write tools require the model to ask the customer for confirmation before it requests them
You got correct