Published Build3 min read
Claude tool use is two content blocks and a loop you write yourself
The description field, not the JSON schema, is what decides whether the model reaches for a tool. And a forced tool_choice left inside the loop bills you at HTTP 200.
Written for builders.See today for builders

What happened
- Tool use on the Claude Messages API is not a special mode; it is two content block types and a loop the caller writes, and the entire protocol fits on one page.
- Tools are declared in a top-level tools array; each entry has a name, a description, and a JSON Schema describing its input.
- The tool description is not documentation: it is the only thing the model has to decide when the tool applies, so it does more work than the schema does.
- The model does not call anything itself; it returns a response whose content array contains a block of type tool_use and whose stop_reason is "tool_use".
- The tool_use block's id is a toolu_-prefixed identifier for that specific call; it must be echoed back, it is not the tool name, and it is not stable across calls.
Compiled by The EngineerSomething wrong?How this is made
Why it matters
Two write-ups of the Claude Messages API make the same unglamorous point: tool use is not a mode you switch on. It is two content block types and a loop the caller writes, and knowing that changes where you look when an integration misbehaves [1].
Tools go in a top-level `tools` array, each with a name, a description, and a JSON Schema for its input [2]. The description is not documentation. According to the dev.to walkthrough it is the only signal the model has for deciding when the tool applies, which means it does more work than the schema does [3]. The schema governs the shape of the arguments; the prose governs whether you get called at all.
The model does not execute anything. It returns a `tool_use` block and sets `stop_reason` to `"tool_use"` [4]. The block's `id` is `toolu_`-prefixed, must be echoed back, is not the tool name, and is not stable across calls [5]. Dispatch on `name` [6]. The `input` arrives as an already-parsed object, not a string, so there is no second parse step [7]. Note the text block that can sit beside the tool call: one assistant turn may contain prose and one or more calls, so any code that assumes `content[0]` is the tool call breaks the first time the model narrates itself [8].
Then you append two messages: the assistant turn verbatim, including the `tool_use` block, and a user turn carrying the results, because `tool_result` is a user-role block rather than a role of its own [9]. `tool_use_id` must match the originating `id`; that pairing is what the API validates, and a mismatch is a 400 [10]. Result `content` can be a string or an array of blocks, which is how screenshot-producing tools return an image alongside text [11]. On failure, return the result with `is_error` set instead of faking success or dropping the block [12]. Send the whole array back and the model answers from the output with `stop_reason` at `end_turn` [13].
That is the loop: call, check `stop_reason`, execute the calls, append, call again, until `stop_reason` is not `"tool_use"` [14]. Every framework that sells you an agent loop is wrapping those four steps [14].
Two places it bites. Streaming: buffered responses hand you a parsed object, but a stream delivers `input` as string fragments across many `input_json_delta` events, which is exactly the difference that breaks code ported from the buffered path [15]. And `tool_choice`, which has four forms - `auto`, `any`, a named `tool`, and `none` [16]. Forcing removes things: `stop_reason` is `tool_use` on every response and the preamble text disappears, so a UI expecting prose renders a blank turn [19]. Set `none` explicitly with the `tools` array still present to keep definitions in the cached prefix while forbidding calls for a turn [17].
The expensive mistake follows directly. If your request builder sets a forced `tool_choice` on every iteration, the model is required to call again, so the loop condition never goes false [23][24]. Every response is a valid 200, and nothing errors until an iteration cap catches it or someone notices the spend [23].
Watch the extraction case. A forced call means the model produced a value for each required field, not that the value existed in the document, so an `enum` with no `"unknown"` member guarantees a confident wrong answer [22][21].
Claim ledger
Ranked by verification strength, evidence, and original report placement.
- [1]
Tool use on the Claude Messages API is not a special mode; it is two content block types and a loop the caller writes, and the entire protocol fits on one page.
ReportedSource: dev.to, Claude's tool_use and tool_result Content Blocks, End to EndView cited source - [2]
Tools are declared in a top-level tools array; each entry has a name, a description, and a JSON Schema describing its input.
ReportedView cited source - [3]
The tool description is not documentation: it is the only thing the model has to decide when the tool applies, so it does more work than the schema does.
- [4]
The model does not call anything itself; it returns a response whose content array contains a block of type tool_use and whose stop_reason is "tool_use".
ReportedView cited source - [5]
The tool_use block's id is a toolu_-prefixed identifier for that specific call; it must be echoed back, it is not the tool name, and it is not stable across calls.
ReportedView cited source
Sources & coverage · 1 publisher
The reporting this story was synthesized from, earliest first. Every link goes to the original.
- dev.toMultigridAug 12Forcing a Specific Tool Call in the Claude API
Cited in this coverage: dev.to, Claude's tool_use and tool_result Content Blocks, End to End
Cited in this coverage: dev.to walkthrough
Cited in this coverage: dev.to, Forcing a Specific Tool Call in the Claude API
- dev.toMultigridAug 12Claude's tool_use and tool_result Content Blocks, End to End

