Key takeaways
A model never runs your code. It emits a structured request, your process executes it, and you hand the result back. Everything else about tool calling is bookkeeping on that one exchange.
Tool descriptions do more work than tool names or schemas. Widening one description from a single line to four sentences cut wrong tool selection on my eval set from 22 percent to 4 percent.
Forcing a call with tool_choice any is worse than it looks. It suppresses the clarifying question and converts an ambiguous query into a confidently wrong lookup.
Tool calling is not a capability the model acquires. No weights change, no sandbox opens, nothing on your network becomes reachable. What actually happens is that you describe some functions in JSON, the model writes down which one it would like called and with what arguments, and your own process decides whether to honour that request. Framed that way, most of the confusion goes away, and so does most of the bad security reasoning I hear in design reviews.
Tool calling as a protocol, not a capability
Three pieces make up the protocol. First, a tool definition: a name, a plain English description, and a JSON Schema for the arguments. Second, a request from the model, which arrives as a content block carrying an id, the tool name and an arguments object. Third, a result you send back, tagged with that same id so the model can match it to what it asked for.
Notice what is absent. No execution happens on the provider side for tools you define, and no connection is opened from the provider to your database. Your process is the only thing that ever touches a system of record, which means every authorisation decision stays exactly where it already was. When someone in a review says the model will have access to production, the accurate correction is that your service will make the same calls it always could, with arguments a language model suggested. That reframing changes the threat model from model containment to argument validation, and argument validation is a problem you already know how to solve.
Vendors differ only in field names. Anthropic puts tools in a top level tools array with an input_schema per tool, returns tool_use content blocks and expects tool_result blocks in the following user message. OpenAI wraps each function in a type function envelope, returns tool_calls on the assistant message with arguments as a JSON string, and expects a message with role tool carrying tool_call_id. Same protocol, different spelling.
Where the assistant stands, and what a tool adds
Last part the internal documentation assistant got its first honest scorecard: hybrid retrieval with reranking hit 0.86 on retrieval at 6 while answer correctness sat at 0.62, and we learned to attribute each failure to a specific gate. Digging into the residual, one cluster was not a retrieval problem at all. Fourteen of the 120 labelled queries asked about live state. Whether SUP-4412 was resolved. Who owns the escalation opened on Tuesday. Which release the fix shipped in.
No amount of chunking rescues those. Answers do not exist in the docs corpus, because the answer changes hourly and lives in the ticket system. This part gives the assistant one tool, get_ticket_status, and builds the loop that lets it use that tool without us hand holding each turn.
First tool call, end to end
Here is a complete definition and the request that triggers it. Read the description closely, because it is doing more work than anything else on the page.
Two things in that output deserve attention. Stop reason is tool_use, which is your signal that the turn is unfinished and the model is waiting on you. And a text block arrived alongside the tool_use block, because models often narrate before calling. Code that assumes content[0] is the tool call will break the first time the model says something friendly first. Iterate over the list.
Handing the result back means appending the assistant message unchanged, then a user message whose content begins with a tool_result block carrying the matching id.
Passing tools on the second call is not optional decoration. Drop it and you will get a 400 telling you the conversation references tools that were not supplied. Tool definitions must accompany every request in a conversation that used them.
Cost nobody budgets for
Tool schemas are prompt tokens, and they are resent on every turn of the loop. Six tools with the description quality shown above measured 1,180 input tokens per request in my project, before a single word of user text. A three step tool conversation therefore pays for them four times. Marking the tools block as cacheable dropped my measured input token spend on multi step turns by 61 percent. Budget for schemas the same way you budget for retrieved context in Part 5.
Loop that runs until the model stops asking
One exchange is rarely enough. A question such as which release fixed the ticket needs a status lookup and then a changelog lookup. Generalising the two step exchange into a loop takes about forty lines, and I would write those forty lines before reaching for a framework, because when the loop is yours you can see exactly which turn burned the tokens.
Four details in that loop earn their place. Every branch appends a tool_result, including the failure branches, because a missing result is a hard 400 rather than a degraded answer. Exceptions are returned to the model with is_error set instead of being raised, which lets it apologise or retry with a corrected argument. MAX_STEPS is a hard stop, not a suggestion; without it a model that keeps requesting the same lookup will spend your budget quietly. And results for all tool_use blocks in one turn go into a single user message, which matters once parallel calls appear.
Failures a tool calling loop actually produces
My first version of that loop appended a friendly text block before the tool results, on the reasonable theory that more context helps. Reasonable theories lose to validators:
Ordering inside the content array is load bearing. Every tool_result block must come first, and any text you want to add goes after all of them. Put text first and the API reads it as the turn ending early. Cost me about ninety minutes, mostly because the message names a block id and I went looking for a bug in id propagation rather than in list order.
Second failure is provider specific and hits OpenAI style APIs, where arguments arrive as a string you must parse. Set max_tokens too low and generation stops mid argument:
Fix is to check finish_reason before parsing. Anything other than tool_calls means the arguments are incomplete, and json.loads on a truncated object throws rather than returning something partial. Raising a clear error beats letting a JSONDecodeError surface three frames up in a request handler at midnight.
Keep this lookup somewhere your on call rota can find it.
| Symptom | Cause | Fix |
|---|---|---|
| 400, tool_use ids found without tool_result blocks | Text block placed before tool_result, or a result omitted for one of several calls | Emit one result per tool_use, results first, text last |
| 400 naming a tool the conversation used | tools omitted on a follow up request | Send the full tools array on every request in the thread |
| JSONDecodeError on arguments | finish_reason of length, arguments truncated | Assert finish_reason is tool_calls, then raise your own error |
| Model calls the same tool repeatedly with identical input | Result content is empty or unhelpful, so nothing new entered context | Return an explicit not_found string, never an empty object |
| Confident answer with an invented ticket id | tool_choice forced a call on a query with no id in it | Return to auto and let the model ask a clarifying question |
| TypeError on unexpected keyword argument | Model invented a property your schema does not declare | Validate input against the schema before the splat, return is_error |
Schema design that reduces wrong calls
Descriptions are where accuracy comes from, and I learned that expensively. My original get_ticket_status description read, in full: Gets ticket status. Six words. With four tools available and 50 tool relevant queries in the eval set, the model picked the wrong tool or skipped tool use entirely on 11 of them, a 22 percent error rate. My instinct was to blame the model and consider a bigger one. Instead I rewrote every description to four sentences covering what the tool does, when to reach for it, what each argument means, and crucially what the tool does not return. Same model, same schema shapes. Wrong calls dropped to 2 of 50, so 4 percent. That rewrite took an afternoon and outperformed every prompt change I tried in the following fortnight.
Negative space in a description carries most of that gain. Stating that the tool does not return comments is what stops the model calling it when a user asks what the customer said. Anthropic makes the same recommendation in its tool definition guidance, suggesting three to four sentences minimum, and my numbers say the advice is understated rather than generous.
Now for a place I disagree with common practice. Teams routinely reach for forced tool use, setting tool_choice to any so that a call is guaranteed. It feels like a reliability win and it is the opposite. Forcing a call prefills the assistant turn, which means the model cannot emit natural language first, which means it cannot ask which ticket you meant. On my eval slice, forcing the call on ambiguous queries produced a plausible invented ticket key roughly one time in six. Leave tool_choice on auto, write a description good enough that auto picks correctly, and accept a clarifying question as the right answer to an ambiguous request. Forced tool use belongs in a narrow place: extraction endpoints where the input is guaranteed to contain the entity and a clarifying question would be a bug.
| Concept | Anthropic Messages API | OpenAI Chat Completions |
|---|---|---|
| Schema field | input_schema on the tool | parameters inside a function object |
| Model request | tool_use content block, input is a dict | tool_calls on the message, arguments is a JSON string |
| Signal to act | stop_reason equals tool_use | finish_reason equals tool_calls |
| Returning a result | user message, tool_result block, tool_use_id | message with role tool, tool_call_id |
| Flagging a tool error | is_error true on the result block | no dedicated field, put the error in content |
| Schema enforcement | strict true on the tool definition | strict true, off by default on Chat Completions |
Parallel calls and what they cost in wall clock time
A single turn can contain several tool_use blocks when the model decides two lookups are independent. Loop code above already handles that, because it iterates over every block and puts all results into one user message. Executing them concurrently is your job, and it is the difference between a snappy assistant and a sluggish one.
Read those bars carefully, because they set your design constraint. Adding a tool costs roughly 1.1 seconds of user visible latency, and it costs that once per additional round trip, not once per tool executed. Two lookups the model requests together add 0.1 seconds over one. Two lookups it must make in sequence, because the second depends on the first, add 1.2 seconds. Designing a schema so that arguments can be gathered in one turn is therefore a latency decision as much as an accuracy one, and it is the single most effective thing I have done for perceived speed in an assistant.
Ship one tool with a four sentence description before you ship five
Recommendation for this part is narrow on purpose. Pick the single question your users ask that retrieval provably cannot answer, expose exactly one tool for it, and spend an hour on its description rather than fifteen minutes on its schema. Keep tool_choice on auto. Write the loop yourself, cap it at six steps, and always return a result block even when the call blew up. That configuration got my documentation assistant answering live state questions correctly on 13 of the 14 queries it previously had no hope on, with one added round trip of latency and no framework.
Monday action: open your existing tool definitions and count the sentences in each description. If any is under three, rewrite it to say what the tool does not return, then rerun your eval set before changing anything else.
One tool and a bounded loop is not an agent, and Part 15 argues that many teams reaching for an agent should stop at exactly this point. Prompt discipline from the GenAI Series piece on prompting applies to tool descriptions more than most people expect, and the packaging habits in the Data Science Series part on turning a notebook into a package are what keep a growing tool registry testable.
References
- Define tools, Claude Platform Docs, for the name, description and input_schema fields, the tool_choice options and the description length guidance quoted above.
- Handle tool calls, Claude Platform Docs, for tool_use_id matching, the is_error flag and the ordering rule that produces the 400 shown in this part.
- Function calling, OpenAI API docs, for the function envelope, tool_calls, the arguments string and strict mode defaults on Chat Completions.
- openai-python releases and anthropic-sdk-python releases, for the SDK versions the code in this part was tested against.


DrJha