The wall without tools

Ask a model what the weather is in San Francisco right now and the only honest answer it can give is that it does not know. Its knowledge ends where its training data ended, and this morning was not in it. Tool use is the structured way past that wall, and the structure is the part worth understanding, because the model still does not call anything. It asks. Your application makes the call, hands the result back, and the model writes its answer out of what it was handed.

A tool is two things kept together: a function in your own code, and a description of that function written for the model to read. The description travels to the model with every question. The function never leaves your process, and it runs with your credentials, on your network, under your logging. Adding a tool changes nothing inside the model - it changes what arrives in the model's input, and what the model is allowed to ask you for.

That is the same bargain retrieval makes, and it is worth saying plainly before the mechanics: the model is not gaining access to your systems. It is gaining a vocabulary for requesting that your systems be used.

One tool call: four steps

Every tool call is the same four steps, always in this order.

One tool call, four steps between your app and the model A sequence diagram with two lifelines, your app on the left and Claude on the right. Step one: your app sends the question plus the list of tools. Step two: the model answers with a tool use block naming the tool and its input, and a stop reason of tool use. Step three: a box on your app lifeline, run the function, using your code and your credentials. Step four: your app sends the tool result back, matched by id, and the model returns text written from that result. ONE TOOL CALL · FOUR STEPS Your app Claude 01 the question, plus the list of tools 02 stop_reason = tool_use · name + input 03 Run the functionyour code, your credentials 04 tool_result, matched by id text, written from the result
Four steps, and only step 03 leaves your process · the model asks, your code acts, the model writes
  1. You send the question, and the tools with it. The tool list is part of the request, not a setting on the account: names, descriptions, and the shape of each tool's input, sent again on every call.
  2. The model answers with a request instead of prose. When it judges that a tool fits, the reply contains a tool_use block naming the tool and the input it wants, and the response carries stop_reason: "tool_use". It has stopped mid-answer and is waiting for you.
  3. Your code runs the function. This is the only step that leaves your process: the HTTP call, the database query, the write. The model is not involved and cannot see how it happened.
  4. You send the result back, and the model finishes. The data goes into the conversation as a tool_result, you call the model again, and this time it writes the sentence a person reads.

Hold on to step two, because everything practical follows from it: the model's output is a request. An application that never executes it has no tools at all, whatever its schemas say. An application that executes it without checking has handed the model its credentials. The design is deliberately a handshake, and your half of the handshake is where the judgement lives.

It also means one answered question costs at least two model calls rather than one, and the second call carries everything the first did plus the request and the result. That is the price of the loop, and it is the reason a tool that returns half a megabyte of JSON is a cost problem as much as a quality one.

The same loop, with the weather

Filled in with the question from the top of the article, the four steps stop being abstract.

The same four steps with the weather question filled in The same two lifelines. Your app sends: what is the weather in San Francisco. The model answers with a tool use block, get weather, with location set to San Francisco, CA. A box on your app lifeline calls the weather API and gets back sixty-one degrees and foggy. Your app sends that back as a tool result, temp f sixty-one, conditions foggy, and the model returns the sentence: it is 61 degrees and foggy in San Francisco. THE SAME LOOP · ONE REAL QUESTION Your app Claude 01 "What is the weather in San Francisco?" 02 get_weather · location: "San Francisco, CA" 03 Call the APIone HTTP request, your key 04 {"temp_f": 61, "conditions": "foggy"} "It is 61°F and foggy in San Francisco."
Nothing in the model reached the network · it named a tool and waited for an answer

The exchange itself, both directions, with the parts your code produces marked:

Text one tool call, both directions
you → question: "What is the weather in San Francisco?" tools: [ get_weather ] model → stop_reason: "tool_use" tool_use: get_weather input: {"location": "San Francisco, CA"} you → (your code calls the weather API - one HTTP request) tool_result: {"temp_f": 61, "conditions": "foggy"} model → stop_reason: "end_turn" text: "It is 61°F and foggy in San Francisco."

Two details in the middle are the whole mechanism. The model chose the tool and filled in its argument, turning "San Francisco" into the "San Francisco, CA" the function expects - that is the part people mean when they say a model can use an API. And the temperature in the final sentence came from your JSON, not from the model: it is quoting your data back, which is exactly why the answer can be current and exactly why a broken tool produces a confident wrong answer.

The schema is what the model reads

The model cannot see your function. It sees a schema: a name, a description, and a description of the input, written in JSON Schema. That object is the entire interface, and the model decides whether and how to call your code from nothing else. JSON Schema itself is not an AI invention - it is an ordinary data-validation spec that predates all of this, adopted here because describing the shape of some arguments is exactly what it was built for.

Anatomy of a tool schema A JSON tool schema in a box, with four callouts. The name field is what your code dispatches on. The description is what the model reads to decide whether this tool fits the question. The input schema describes the input as JSON Schema. The required list is what may not be omitted. A note underneath reads: the description is a prompt, not documentation. A TOOL SCHEMA · WHAT EACH PART IS FOR { "name": "get_weather", "description": "Get current weather for one city.", "input_schema": { "type": "object", "properties": { ... }, "required": ["location"] } } the name your code dispatches on what the model reads to decide whether this tool fits the input, as JSON Schema what may not be omitted the description is a prompt, not documentation
The schema is the whole interface the model has to your function · it never sees the code

Written out, the weather tool is four lines of meaning and a little punctuation:

JSON the get_weather schema
{ "name": "get_weather", "description": "Get the current temperature and conditions for one city. Use for questions about weather right now, not forecasts.", "input_schema": { "type": "object", "properties": { "location": { "type": "string", "description": "City and state or country, e.g. 'San Francisco, CA'" } }, "required": ["location"] } }

The description is a prompt, not documentation. It is read by a model that is choosing, so write it for the choice:

  • Say what it returns, not only what it does. "Returns temperature in Fahrenheit and a one-word condition" tells the model whether the answer it needs is even in there.
  • Say when not to use it. One clause - "not for forecasts" - prevents most wrong picks, and it is the clause people leave out.
  • Three or four sentences, not one line. A working description says what the tool does, when to reach for it, and what comes back - and every argument gets a sentence of its own. Under-describing is the common failure and the cheapest one to fix.
  • Name parameters the way a person would say them. location gets filled correctly far more often than q or loc_id, because the name is half the instruction.
  • Constrain in the schema, not in prose. An enum of three units, or a format of date, is checked before your code runs; a sentence asking politely for ISO dates is not.
  • Optional arguments are a schema feature, not a convention. Give the property a default and leave it out of required. The model then fills it only when the question calls for it, and your function keeps its own default for every other call.
  • One job per tool. A tool that reads or writes depending on a mode flag is two tools wearing one schema, and the model will pick the wrong mode eventually.

Schemas are also the cheapest thing to get wrong quietly. Nothing errors when a description is vague - the model simply picks a little worse, on some questions, in ways only an evaluation will show you.

The function behind the schema

The function itself is ordinary code, and it should stay ordinary. The only unusual thing about it is who chose its arguments.

Python tools.py
def get_weather(location): """Current conditions for one city. Raises on bad input.""" if not location or not location.strip(): raise ValueError("location cannot be empty") data = weather_api.current(location, units="imperial") return {"temp_f": data["temp"], "conditions": data["summary"]}

The validation is not defensive habit, it is part of the interface. Error text goes back to the model as the tool result, and the model reads it: given "location cannot be empty" it will usually try again with a real city, where an unexplained 500 ends the conversation. Write the message for the reader you have.

The second piece is dispatch. The model returns a tool name as a string, so something has to map that string to a function, and that something should refuse anything it does not recognise.

Python dispatch.py
TOOLS = {"get_weather": get_weather, "get_forecast": get_forecast} def run_tool(name, args): fn = TOOLS.get(name) if fn is None: raise ValueError(f"unknown tool: {name}") return fn(**args)

A dictionary rather than a chain of conditionals, because the same dictionary can produce the schema list you send with the request - and a tool that exists in one place and not the other is the most common bug in a tool-using app. The keys are also the only names the model can ever invoke, which makes this seven-line function the narrowest place to put an approval check, a rate limit, or an audit log.

Keep the schema in the same file as the function it describes, under the same name with a suffix - get_weather beside get_weather_schema - and build the list you send to the model out of the same dictionary your dispatcher reads. That convention is what stops the two from drifting apart. You do not have to hand-write the JSON either: paste the function into Claude, ask for a tool-calling schema, and read what comes back. And on the Python SDK, wrapping the object in ToolParam from anthropic.types turns a mistyped key into an error at your desk rather than a silently worse tool choice in production.

Content blocks and the message list

Up to here a message has been "the question" or "the answer". Underneath, a message is a list of content blocks, and that is what makes a tool call expressible at all: one assistant turn can hold a sentence of prose and a tool request side by side.

The message list across one tool call Four stacked messages. A user message holding one text block with the question. An assistant message holding two content blocks, a text block and a tool use block with an id, and a stop reason of tool use. A user message holding one tool result block carrying the same id and the data. An assistant message holding the final text. Notes underneath: each tool use needs a tool result with the same id in the next message, and the whole list is re-sent on every call. ONE TOOL CALL · FOUR MESSAGES USER text · "What is the weather in San Francisco?" ASSISTANT text · "Let me look that up." tool_use · get_weather · id: toolu_01A stop_reason = tool_use USER tool_result · id: toolu_01A · {"temp_f": 61} ASSISTANT text · "It is 61°F and foggy in San Francisco." each tool_use needs a tool_result with the same id, in the next message the whole list is re-sent on every call
A message is a list of blocks, not a string · the tool_use and its tool_result are paired by id

The pairing has rules, and they are the rules that break first in real code. Every tool_use block needs a tool_result carrying the same tool_use_id, and it has to arrive in the very next message. If the model asked for two tools in one turn, both results go in that one message. The result is sent as a user message even though no user typed it, because in this protocol "user" means "the side that is not the model".

And the list only grows. Every call re-sends the whole conversation, including the requests and results of every earlier turn, which is why a chatty tool gets expensive twice: once when its output is read, and again on every turn after that.

Several tools, one choice

Nothing changes structurally when there is more than one tool. You send all their schemas with the question; the model returns the ones it wants.

Three tools offered, one picked The question, will it rain in San Francisco tomorrow, fans out to three offered tools: get weather for current conditions, get forecast for the next seven days, and set reminder which writes a reminder. The middle one, get forecast, is accented as the one the model asked for, because the question is about tomorrow. A note reads: overlapping descriptions are how the wrong tool gets picked. THREE TOOLS OFFERED · ONE PICKED "Will it rain in SF tomorrow?" get_weathercurrent conditions get_forecastthe next 7 days set_reminderwrites a reminder the only one it asked for overlapping descriptions are how the wrong tool gets picked
All three schemas went out with the question · the model returned exactly one, chosen from the descriptions

Which it picks is decided almost entirely by the descriptions, and that has consequences worth designing for. Two tools whose descriptions overlap - a get_weather and a get_conditions that sound alike - produce a coin flip, and the flip is invisible in your logs unless you record which tool was chosen. A long list dilutes: past a couple of dozen tools, the useful move is usually to narrow the set per request rather than to keep writing better prose.

Two behaviours surprise people the first time. The model can pick no tool and answer directly, which is correct for "what is a barometer" and wrong if your code assumes a tool_use block will always be there. And it can ask for several at once, which is faster when the calls are independent and a trap if your dispatcher assumes one.

When one turn is not enough

"Will it rain tomorrow, and remind me to take an umbrella if it will" is two tools, and the second depends on the first. The model cannot know whether to set a reminder until it has the forecast, so it asks once, reads the answer, and asks again. Your code is not orchestrating that sequence; it is servicing it.

The loop that runs until the model stops asking Call the model, then check the stop reason. If it is tool use, run the tool, append the result to the message list, and call the model again, shown as an arrow looping back. If it is end turn, the model has written its answer and the loop ends. A note reads: the number of turns is the model decision, not yours. MANY TURNS · ONE CONDITION Call the model stop_reason? tool_use Run the tool Append the result one more turn, same message list end_turn The answer
The same four steps, repeated · how many turns it takes is the model decision, and your loop has to allow for it

Which turns the four steps into a loop with one condition: while the response comes back with stop_reason: "tool_use", run what it asked for, append the result, and call again. When the stop reason is end_turn, the text in that response is the answer and the loop is done.

Two guards belong in that loop from the first version. A turn cap, because a model that keeps asking - a tool that always errors, a task it cannot finish - will otherwise spend your budget in a tidy while-loop. And a record of every request and result, because when the final answer is wrong, the only way to tell a bad tool from a bad choice of tool is to read what was asked and what came back.

This loop is also the shortest honest definition of an agent. Give it tools that change things rather than only read them, let it run more turns, and the machinery in Inside an AI agent is what you have built - the same handshake, with state and termination rules around it.

Tool use beside RAG

Retrieval and tool use are easy to confuse because the end state looks identical: text the model did not have arrives in the prompt, and the answer is written from it. The difference is upstream of that, and it is a difference of ownership.

RAG and tool use, side by side Two columns. On the left, RAG, where your code decides: a question arrives, the search runs always, and the answer comes from the passages. On the right, tool use, where the model decides: a question arrives, the model asks only when it judges it needs to, and the answer comes from the tool result. A note reads: the same passages can reach the prompt either way, and what differs is who decided to fetch them. RAG · YOUR CODE DECIDES TOOL USE · THE MODEL DECIDES A question arrives The search runs, always Answer from the passages A question arrives The model asks, sometimes Answer from the result the same passages can reach the prompt either way what differs is who decided to fetch them
Not two technologies but two owners of one decision · a pipeline retrieves on every call, a tool retrieves when asked

Which to reach for is a real decision with real trade-offs - always-on cost against a round trip, build-time certainty against per-question judgement - and it is the whole subject of RAG vs MCP. The pipeline side is taken apart in its own article too.

Where MCP takes over

Everything so far lives in your application. You wrote the function, you wrote the schema, you wrote the dispatcher, and all three ship together. That is the right shape for a tool only your app needs, and the wrong shape the moment a second application wants the same capability, because the copy drifts.

The Model Context Protocol answers that by moving the definition out. A server declares its tools, any client can ask it what they are and ask it to run them, and the tools stop being part of any one application. What does not change is this article: the model still returns a request, something still executes it, the result still goes back as a tool_result, and the loop still ends on end_turn. MCP standardises where tools come from, not how a model calls them.

So the order to learn it in is this one: the handshake first, then the protocol. What is MCP? covers the 101, Inside MCP the parts underneath it, and Functions, MCP, and Skills how the three layers stacked up in the order they arrived.

The sentence to keep from all of it: a model with tools has not gained access to anything. It has gained the ability to ask, and every question of what it may actually do is answered by your code, on your side of the handshake.