"Tool use" gets talked about as if it's magic, but the mechanism is plain: the model never executes anything. It emits a structured block describing what it wants called and with what arguments, your application code runs that call, and you feed the result back in the next request. Everything interesting — retries, authorization, parallelism, loop termination — lives in the code around that exchange, not in the model.
The request/response loop
A tool-enabled conversation is not one API call, it's a loop. You send the message history plus a list of available tools. The model replies with either a normal text answer, or a response containing one or more tool_use blocks — a name and a JSON object of arguments. Your code matches the name against a function it actually has, runs it, and appends the result as a tool_result block keyed to that call's ID. That whole assembled message goes back to the model as the next turn. The model sees the result and decides: answer now, or call another tool. Nothing stops the loop except the model returning a turn with no tool calls, or your code hitting a max-iterations guard.
Every tool result must reference the id of the specific tool_use block it answers. With parallel calls in one turn, mismatching an id (or omitting a result for one of the calls) is the single most common cause of a malformed-request error on the next turn.
Anatomy of a turn, concretely
Here's what the wire format actually looks like with the Anthropic Messages API for a single tool call, end to end. The assistant turn contains the tool_use block; your next user turn carries the tool_result back.
// 1. Model's response to the initial request
{
"role": "assistant",
"content": [
{ "type": "text", "text": "I'll check the invoice status." },
{
"type": "tool_use",
"id": "toolu_01A2b3C4",
"name": "get_ap_bill_status",
"input": { "bill_number": "00123456" }
}
],
"stop_reason": "tool_use"
}
// 2. Your code runs get_ap_bill_status(bill_number="00123456"),
// then sends this back as the next message
{
"role": "user",
"content": [
{
"type": "tool_result",
"tool_use_id": "toolu_01A2b3C4",
"content": "{\"status\":\"On Hold\",\"reason\":\"3-way match variance\"}"
}
]
}
// 3. Model reads the result and either answers or calls another tool
{
"role": "assistant",
"content": [
{ "type": "text", "text": "Bill 00123456 is on hold due to a 3-way match variance." }
],
"stop_reason": "end_turn"
}
Note stop_reason: "tool_use" — that's the signal your loop checks to know whether to keep going or hand the reply to the user. Skipping that check and always treating the first response as final is a common bug: you silently drop the tool call and return the model's filler text ("I'll check the invoice status.") instead of the answer.
Parallel tool calls in one turn
The model can return multiple tool_use blocks in a single response when the calls don't depend on each other — for example, looking up an AR customer and an AP vendor in the same request. Your code should execute those concurrently and return one tool_result block per call, all in the same next message, matched by id. Executing them sequentially still works correctly, it's just slower; the model doesn't know or care about your execution strategy, only that every id it sent gets an id back. Don't assume "one tool call per turn" in your loop code — that assumption breaks silently the first time the model batches two lookups.
The model deciding vs. you forcing it
By default the model chooses whether to call a tool, call several, or just answer in text — this is tool_choice: {"type": "auto"} in Anthropic's API (or omitting tool_choice in OpenAI's). You can instead force a call: require that the model use some tool ("type": "any") or a specific named one ("type": "tool", "name": "..."). Forcing is useful for structured extraction where you want the "tool call" purely as a typed output schema and never want prose back. It is not a substitute for validation — a forced call still means the model filled in the arguments, and arguments from a language model are untrusted input no matter how the call was triggered.
Handling tool errors so the model can recover
When a tool call fails — bad argument, downstream API timeout, record not found — resist the urge to swallow the error and abort the loop. Return a tool_result with is_error: true and a short, specific message describing what went wrong. The model reads that like any other result and can retry with corrected arguments, pick a different tool, or tell the user it can't complete the request. An empty or generic "an error occurred" result gives the model nothing to correct, and it will often just repeat the identical failing call.
{
"type": "tool_result",
"tool_use_id": "toolu_01A2b3C4",
"is_error": true,
"content": "bill_number '00123456' not found in branch 'US-EAST'. Did you mean to search all branches?"
}
Never let the tool call bypass your authorization layer
The tool_use block is a request, not a command — treat it exactly like an HTTP request from an untrusted client. The model has no concept of "this user isn't allowed to void this invoice"; it only knows the tool schema you gave it. Every tool implementation needs to independently check permissions, validate argument shapes and ranges, and apply the same business rules you'd enforce on a REST endpoint, before touching anything. If the underlying action needs a human sign-off in your normal workflow (releasing a payment, deleting a record), the tool should stop short of executing it and instead return a result telling the model to ask the user to confirm — the confirmation has to happen outside the model's control, not inside a system prompt instructing it to "always ask first," which it can and will forget under load.
Telling the model "never call delete_record without confirming" in the prompt is a suggestion, not a guarantee. Enforce it in the delete_record function itself — require an explicit confirmed=true argument that only gets set after a separate user-facing confirmation step your code controls.
Wrapping up
Strip away the framework naming and tool use is a request/response loop: model emits a typed call, your code executes and validates it, the result goes back keyed by id, and the loop ends when the model stops asking. Get that mechanism right — matching ids, handling parallel calls, returning structured errors, and keeping authorization entirely in your code — and the rest is just adding more tools to the list.
Independent software engineer in Nairobi specialising in Acumatica customisations, Laravel backends, and tax fiscalisation integrations across East and Southern Africa.