DEV Community

Multigrid
Multigrid

Posted on • Originally published at multigrid.ai

Why Claude Sometimes Returns Text and a Tool Call in the Same Turn

A Claude response that calls a tool often contains a sentence of prose as well. This is not a formatting quirk to be filtered out — it is the documented shape of the API, and code that reads content[0] and expects a tool call is broken in a way that only shows up sometimes.

The content array is a list, not a value

Every Claude response returns content as an array of typed blocks. There are several block types — text, tool_use, thinking, redacted_thinking, and server-tool blocks — and nothing in the API says a turn contains exactly one of them. A single assistant turn is one uninterrupted stretch of generation, and the model can produce prose and then a tool call within it, because from the model’s side they are the same token stream, segmented by the API into blocks on the way out.

{
  "role": "assistant",
  "content": [
    { "type": "text",
      "text": "I'll check the current stock level for that SKU." },
    { "type": "tool_use",
      "id": "toolu_01D9Vx...",
      "name": "get_inventory",
      "input": { "sku": "MG-4410" } }
  ],
  "stop_reason": "tool_use"
}
Enter fullscreen mode Exit fullscreen mode

Note stop_reason: it is tool_use, not end_turn, even though there is prose in the turn. The presence of a text block does not make the turn complete. Every value that field can carry is on Claude’s stop_reason field.

The reason this bites is that the failure is intermittent. Whether Claude narrates before calling a tool depends on the prompt, the system prompt, the tool descriptions and sampling. An integration tested against a handful of terse responses ships with content[0].input hard-coded, and then breaks in production on the first request where the model says what it is about to do.

The documented ordering

Blocks arrive in the order the model produced them, and that order is constrained. Where present, the sequence is:

  • thinking or redacted_thinking first, when extended thinking is enabled. Reasoning precedes the visible turn.
  • text next, if the model narrated.
  • tool_use last — and there may be several, which is a parallel tool call. Multiple tool_use blocks in one turn are independent calls the model wants run together.

The robust way to read a response is therefore to filter by type, never to index by position:

const toolCalls = message.content.filter((b) => b.type === "tool_use");
const narration = message.content
  .filter((b) => b.type === "text")
  .map((b) => b.text)
  .join("");

if (message.stop_reason === "tool_use") {
  // narration may be "" — that is normal, not an error
  const results = await Promise.all(toolCalls.map(runTool));
}
Enter fullscreen mode Exit fullscreen mode

In a stream the same structure arrives as content_block_start / content_block_delta / content_block_stop events carrying an index. Tool arguments stream as input_json_delta fragments that are not valid JSON until the block stops, so a streaming client must accumulate the partial string per index and parse only at content_block_stop.

Why the model narrates at all

It is tempting to read the prose as noise the API should have suppressed. It is more useful to understand why it is there, because that tells you when you can expect it and what suppressing it costs.

A tool call is not a separate channel. From the model’s side there is one sequence of tokens, and a tool call is a structured region inside it that the API extracts into a tool_use block on the way out. Nothing prevents ordinary prose preceding that region, and several things encourage it. Assistant models are trained towards being conversational and towards explaining what they are doing, so announcing an action before taking it is the high-probability continuation. And writing out the intention first is a mild form of reasoning: the tokens that state “I need the stock level for MG-4410” are in context when the arguments are generated immediately afterwards, which makes the arguments more likely to be right.

That last point is the reason to be careful about suppressing it. A system prompt that forbids commentary removes the scaffolding as well as the noise, and on tasks where argument selection is delicate it can make tool calls slightly worse. With extended thinking enabled the trade disappears — the reasoning happens in a thinking block instead, so suppressing narration costs nothing. Without it, weigh the two.

Whether narration appears also varies with the tool descriptions themselves. A tool whose description reads like an instruction to the user (“Use this when the customer asks about stock”) invites a conversational lead-in more than one written as a plain capability statement. If you want less prose, that is a cheaper place to change than a directive in the system prompt.

What you must send back

This is the rule that most often gets broken once people know the text block exists, because dropping it looks harmless.

The next request must contain the assistant message with its entire content array unchanged — text blocks, thinking blocks, every tool_use block, in the original order — followed by a user message whose leading blocks are the tool_result entries:

messages: [
  { role: "user", content: "Do we have MG-4410 in stock?" },

  // exactly what came back, unedited
  { role: "assistant", content: [
      { type: "text", text: "I'll check the current stock level for that SKU." },
      { type: "tool_use", id: "toolu_01D9Vx...", name: "get_inventory",
        input: { sku: "MG-4410" } }
  ] },

  { role: "user", content: [
      { type: "tool_result", tool_use_id: "toolu_01D9Vx...",
        content: "{\"on_hand\": 12, \"reserved\": 4}" }
  ] }
]
Enter fullscreen mode Exit fullscreen mode
  • Every tool_use needs a matching tool_result with the same tool_use_id, in the immediately following user message. Two parallel calls means two results in one user message, not two user messages.
  • Tool results come first in that user message. Any additional text you want to add goes after them.
  • Errors are results too. A failed tool returns a tool_result with is_error: true and a message, not a missing block. An unanswered tool_use is a malformed conversation and is rejected.

What to do with the text

The narration is genuinely useful and the instinct to discard it is usually wrong. In a chat interface it is the natural progress indicator — showing “I’ll check the current stock level” while the tool runs is better than a spinner, and it is the model’s own account of why it is calling what it is calling, which makes wrong tool selection visible to the user rather than silent.

In a non-interactive pipeline, log it and drop it. It is a real signal when tool calls go wrong: prose describing a different intention from the arguments in the call is a strong hint that the tool description is ambiguous.

What you should not do is show it and then discard it from the message history. That produces the worst of both — the user saw the model say it would check stock, and the model has no record of having said so, so on a later turn it can contradict itself about what it did. The displayed text and the stored content array should be two views of the same object, not two independently maintained things.

There is one more case worth naming because it looks like a bug and is not. A turn can contain a text block and no tool call while tools are available — the model simply answered. It can also contain a tool call and no text. Both are normal, both are common, and any code that assumes a fixed pairing will break on one of them. The invariant is the stop_reason, not the block layout.

If you want the narration gone entirely, the lever is the system prompt — instructing the model to call tools without commentary — not a parameter. There is no flag that suppresses text blocks. There is a flag that constrains the model to a single tool call per turn, which is a different question, covered in disable_parallel_tool_use.

This block-array shape is Anthropic’s, and it is not what other providers return: an OpenAI-shaped response puts narration in message.content and calls in a separate tool_calls array, so code written against one shape does not read the other. Routing the same agent across providers means either writing both readers or normalising to one block model — which is the specific job a gateway such as Multigrid does for tool calls, streaming events included.

Related

Top comments (0)