Part 3 · Letting it act
Build with LLMs · ~7 min
Tool calling
Let the model use your functions, and understand exactly who runs what.
The round trip, in full
Here is the entire mechanic. Five steps, no hidden ones.
- You send tool schemas. Along with the messages, you pass a list of tools. Each one has a name, a description, and a JSON Schema for its arguments — the same schema shape you met in lesson 03.
- The model replies with a
tool_useblock. Instead of prose, part of its response is a structured block: the tool name, an id, and aninputobject matching your schema. Thestop_reasonon the response is"tool_use"— the field that tells you why the model stopped talking, and here it means it is waiting on you. - Your code runs the function. You look at the name, dispatch to your own Python or TypeScript, and get a value.
- You send a
tool_resultback. You append the model's assistant turn, then a new user turn containing atool_resultblock that carries the same id and your output as text. - The model continues. It now has the result in context and writes the answer — or asks for another tool.
The model never executes anything. It writes down what it wants called and waits. Every side effect in your system is code you wrote, running where you chose to run it.
That single fact answers most of the security questions people have. The model cannot reach your database. It can only ask you to, and you decide whether to comply.
One thing that trips people up: the tool result goes back with the role user. That feels wrong — it is not the user speaking. But the conversation only has two roles, and anything the model did not generate is a user turn. Think of user as meaning "from the outside world".
import anthropic
client = anthropic.Anthropic()
tools = [{
"name": "get_order_status",
# the model reads this description - it is prompt text, not a code comment
"description": (
"Look up the current status of a customer order by its id. "
"Use this whenever the user asks where an order is, whether it shipped, "
"or when it will arrive. Returns status, carrier and estimated delivery date."
),
"input_schema": {
"type": "object",
"properties": {
"order_id": {"type": "string", "description": "Order id, like ORD-4821."},
},
"required": ["order_id"],
},
}]
messages = [{"role": "user", "content": "Where is my order ORD-4821?"}]
reply = client.messages.create(
model="claude-sonnet-5", max_tokens=1024, tools=tools, messages=messages
)
if reply.stop_reason == "tool_use":
call = next(b for b in reply.content if b.type == "tool_use")
result = lookup_order(call.input["order_id"]) # your function, your code
messages.append({"role": "assistant", "content": reply.content})
messages.append({"role": "user", "content": [{
"type": "tool_result",
"tool_use_id": call.id, # must match, this is how results are paired to calls
"content": result, # a string, even if your function returned a dict
}]})
final = client.messages.create(
model="claude-sonnet-5", max_tokens=1024, tools=tools, messages=messages
)
print(final.content[0].text)
Note that the second call still passes tools. If you drop it, the model no longer knows what the result it is reading came from.
Schemas are prompts
The description field is not documentation. It goes into the model's context and it is the main thing the model uses to decide whether to call your tool. Write it for the model.
A description your teammates would find fine is often useless to a model, because it says what the function does and not when to use it.
| Weak | Strong | What changed |
|---|---|---|
Gets user data. | Look up a customer profile by email. Use when you need their plan, signup date or billing status. Does not return payment card details. | Says when to call, and what it will not give |
Search. | Full-text search over the help centre. Use for how-to questions about the product. Not for account-specific data - use get_customer for that. | Draws the border with the neighbouring tool |
date: string | date: string - ISO date, YYYY-MM-DD. Use today's date if the user says today. | Removes the guess about format |
Runs a query. | Run a read-only SQL SELECT against the analytics warehouse. Tables: orders, customers, refunds. Returns at most 100 rows. | Names what exists, and the limits |
Rules that hold up:
- Say when to use it, not just what it does. The model's problem is choosing, not understanding.
- Say when not to use it, and name the tool it should use instead. Most bad tool calls are boundary mistakes between two similar tools.
- Put format rules in the parameter description.
YYYY-MM-DDin the description beats fixing dates in your handler. - Say what it returns. The model plans its next step from that.
- Name tools like functions:
get_order_status, notorderTool2.
Fewer, wider tools
The instinct from clean code is one small function per job. That instinct is wrong here. Every extra tool is another choice the model has to get right, and choices are where it fails.
Ten tools like get_user_email, get_user_plan, get_user_signup_date should be one get_customer that returns all of it. The model reads what it needs from the result. One correct decision instead of ten.
The trade-off is real, though. A tool with fifteen optional parameters and a description covering six unrelated jobs is worse than splitting it. The line to hold:
- Combine when the tools hit the same data or system and differ only in which field they return.
- Split when they have genuinely different arguments, different failure modes, or different permissions.
Aim for a handful. If you are past a dozen, look for tools that are the same tool with a filter.
Errors and guardrails
Errors go back as sentences
Your tool will fail. The order id will not exist, the API will time out, the argument will be malformed. What you send back decides whether the model recovers or spirals.
Send a short sentence describing what went wrong and what would fix it. The model reads it and adjusts — often calling the tool again correctly on the next turn.
| Situation | Send this | Not this |
|---|---|---|
| Order not found | No order found with id ORD-9999. Check the id or ask the user to confirm it. | KeyError: 'ORD-9999' |
| Bad argument | The date must be YYYY-MM-DD. Got 'next tuesday'. | ValueError at line 88 |
| Upstream down | The orders service is unavailable. Do not retry; tell the user to try later. | A 40-line traceback |
| Empty result | Search returned no matches for 'xyzzy'. Try broader terms. | [] |
A traceback is noise: hundreds of tokens of file paths the model cannot act on, and it may try to debug your code instead of retrying. Also mark real errors with "is_error": True on the tool_result so the failure is unambiguous.
Notice the third row tells the model not to retry. Without that, a flaky upstream turns into a loop — which is the failure mode lesson 07 is about.
The model will call what you give it
The strongest guardrail is the tool list itself. If a tool exists, assume it will be called at some point, on some input you did not imagine. Availability is permission.
So:
- Do not expose destructive tools. No
delete_records, norefund_payment, nosend_emailthat actually sends. If the job needs one, have the tool write a pending action and let a human approve it. - Enforce limits in your handler, not the schema. The schema is a request from the model; it is not validation. Check the arguments yourself, every time.
- Scope credentials to the tool. A read-only database user for the query tool means a mistake stays cheap.
- Log every call with its arguments. When something odd happens, the tool call log is the only trace of what the model decided to do.
Write your tools as if the caller is a fast, capable stranger who read your descriptions once and has no idea what is expensive. That is roughly true.
Your win
- Trace the full round trip - tool_use out, tool_result back in.
- Remember that your code runs the function, never the model.
- Write tool descriptions for the model to read, not for your teammates.
- Prefer a few wide tools over many narrow ones.
- Return errors as plain sentences the model can recover from.
Retrieval practice — recall, don’t peek
Question 1
The model returns a tool_use block. What happens next?
Question 2
What is stop_reason set to when the model wants a tool?
Question 3
Your search tool fails because the query was malformed. What do you send back?
Question 4
Where does the tool description field end up?
Question 5
You give the model a delete_records tool for convenience. What is the risk?