Skip to content

Transcript Formats: How Providers Represent Tool Use

If you've built a tool-using agent with more than one provider, you've probably noticed something: the docs feel like they're describing three different problems. Anthropic wants you to build a list of content blocks. OpenAI wants you to set a tool_calls field and remember to send back a tool-role message. Gemini wants functionCall and functionResponse parts. Each provider writes up tool use as if it's the platonic shape of the thing, and the three shapes don't match.

I spent a while confused by this before I realized what was going on. The wire formats are different. The thing underneath is the same. Every provider is trying to encode the same abstract object: an ordered list of turns, each tagged with a role, each carrying a sequence of content blocks that can be plain text, a tool call, or a tool result. That's the transcript. Once you see it that way, the three providers' formats stop looking like different designs and start looking like three ways to serialize the same data structure.

The handle I use for this: format is plumbing; structure is teaching. The provider's JSON is plumbing, interchangeable and SDK-dependent, honestly a little boring. The transcript structure is what the model was trained on, and it's what shows up as tokens in the prompt. Those tokens are what teaches the model what a tool call is.


The problem this layer solves

Chat completion APIs were not designed for tool use. The original 2022-era shape was simple: a list of {role, content} messages where role was system, user, or assistant, and content was a string. That shape has no slot for "the model wants to call a function," and it has no slot for "here is the output of the function you called." The string is a blob of text. If you want to smuggle structure in, you have to invent a convention on top.

For about a year, everyone did exactly that. ReAct-style agents concatenated Thought:, Action:, Observation: blocks into the assistant's text and parsed them out with regex. It worked, more or less. It also broke constantly, because the model was emitting pseudo-structured text into a slot the API treated as unstructured. A stray bracket, a missing newline, and your agent was done.

The providers' fix was to promote tool calls and tool results to first-class citizens in the transcript. Instead of a string with JSON embedded in it, the assistant message could contain a typed block that said "I am calling the function get_weather with these arguments." Instead of a user message pretending to be the tool, there was a dedicated place for the tool's output, linked back to the call by id.

Three providers, three shapes, same idea.


Prereqs

Two things from earlier in the series are load-bearing here. First, chat templates from arc 3: the provider-side JSON you send is not what the model sees. A chat template flattens structured messages down to a raw token stream, injecting special tokens like <|im_start|> or <|start_header_id|> that delimit roles. The transcript formats in this post are inputs to that rendering step; they are the structured side, not the token side.

Second, function calling from earlier in this arc: the "model emits a tool call" step is structured generation constrained to a JSON schema. What we're focused on now is how the resulting call, and the tool's response, are expressed in the next request you send.

If either of those feels fuzzy, come back. The rest of this post assumes you're comfortable with a chat transcript being a list of role-tagged messages that get templated into tokens before the model ever sees them.


The underlying structure

Forget provider syntax for a moment. The abstract object every provider is trying to represent is this:

Transcript=[(role1,[block1,1,block1,2,]),  (role2,[block2,1,]),  ]\text{Transcript} = [\,(\text{role}_1, [\text{block}_{1,1}, \text{block}_{1,2}, \ldots]),\ \ (\text{role}_2, [\text{block}_{2,1}, \ldots]),\ \ \ldots\,]

An ordered list of turns. Each turn has a role (user, assistant, sometimes system or tool). Each turn carries an ordered list of content blocks. A block is one of:

  • text — ordinary natural-language content.
  • tool call — "I want to run name with these arguments, under this id."
  • tool result — "Here is the output of the call with this id."
  • (optional) reasoning / thinking — private scratchpad that may or may not go back to the model on the next turn.

Every provider's format is some encoding of that list. Anthropic keeps blocks inside a content array on every message. OpenAI flattens most of it into fields on the message and promotes tool results to a separate tool-role message. Gemini renames blocks to "parts" and messages to {role, parts}. The names shuffle. The graph of turns, blocks, and ids does not.

Same concepts · different wire shapes
ConceptAnthropicOpenAIGemini
Assistant tool call
tool_use block inside assistant messagetool_calls array on assistant messagefunctionCall part inside model content
wire JSON
{
  "role": "assistant",
  "content": [
    { "type": "text", "text": "Let me check." },
    { "type": "tool_use",
      "id": "toolu_A1",
      "name": "get_weather",
      "input": { "location": "Tokyo" } }
  ]
}
{
  "role": "assistant",
  "content": "Let me check.",
  "tool_calls": [
    { "id": "call_1",
      "type": "function",
      "function": {
        "name": "get_weather",
        "arguments": "{\"location\":\"Tokyo\"}"
      } }
  ]
}
{
  "role": "model",
  "parts": [
    { "text": "Let me check." },
    { "functionCall": {
        "name": "get_weather",
        "args": { "location": "Tokyo" } } }
  ]
}
+Tool result block
tool_result block inside a user messageseparate message with role: 'tool'functionResponse part inside a user message
Roles used
system · user · assistant (tool results ride in 'user')system · user · assistant · tooluser · model (tool results ride in 'user')
Multi-call in one turn
multiple tool_use blocks in one assistant messagemultiple entries in the tool_calls arraymultiple functionCall parts in one model message
Pairing ID
tool_use.id ⇄ tool_result.tool_use_idtool_calls[i].id ⇄ tool message's tool_call_idmatched by name + order (no explicit ID)
Reasoning block
thinking / redacted_thinking blocksreasoning items (Responses API) / Harmony 'analysis' channelthoughtSignature opaque token on parts

The same six concepts across three providers. Click a row to see the wire JSON side by side. The concepts are stable; the names and nesting are not.

Once you internalize the abstract shape, you can read any provider's docs in about ten minutes. You're not learning a new data model, you're learning a new labeling scheme on a data model you already know.


What the SDK actually does with this

When you call client.messages.create or client.chat.completions.create or the Gemini equivalent, the SDK takes your structured messages, applies the provider's chat template server-side, and renders them into a single token stream. The model sees tokens. It never sees your JSON.

That's worth dwelling on, because it's the source of several persistent misconceptions. The assistant doesn't "see" an Anthropic tool_use block or an OpenAI tool_calls array. It sees whatever tokens the provider's chat template chose to emit for that block (things like <|python_tag|> on Llama 3, <tool_call>…</tool_call> on ChatML-based models, or Harmony's channel-tagged segments on gpt-oss). The block structure is there so the SDK can render it correctly, and so the next turn can reference it by id. It is not there for the model to read as JSON.

The inverse also matters. When the model emits a tool call, what actually comes back from the inference engine is tokens. Those tokens get parsed by the provider into the structured block you see in the response. If the model's tokens are malformed (bad JSON in the arguments, a missing close-tag), the parse can fail in ways that are hard to debug from the SDK side. The provider is doing serialization in both directions, and the transcript format is the schema that serialization follows.

Anthropic content blocks · building a 2-tool turn
step 0 / 7

Scrub through a two-tool interaction built up one content block at a time. Anthropic style: tool_use blocks live in the assistant's content array, tool_result blocks live in the next user message, and the two are paired by matching ids.

The id pairing is the part that silently breaks things. Every provider except Gemini requires a stable id on tool calls that you must echo back on the matching result. Drop the id, reuse an old one, or mismatch it against a stale call, and the model sees either no answer at all or the wrong answer glued to the wrong question. The API will not always reject this. It will often send a malformed transcript to the model, and the symptom shows up later as the agent hallucinating city names it never looked up.


The same turn in three formats

Let me make this concrete. One user asks a question. The assistant decides to call one tool. The tool returns a result. The assistant answers. Here's what that looks like on the wire for each provider:

Anthropic puts everything in a content array. A tool call is a block inside the assistant message; the tool result is a block inside the next user message, because Anthropic's format doesn't have a dedicated tool role at all.

[
  { "role": "user",
    "content": [{ "type": "text", "text": "Weather in Tokyo?" }] },
  { "role": "assistant",
    "content": [
      { "type": "text", "text": "Let me check." },
      { "type": "tool_use",
        "id": "toolu_A1",
        "name": "get_weather",
        "input": { "location": "Tokyo" } }
    ] },
  { "role": "user",
    "content": [
      { "type": "tool_result",
        "tool_use_id": "toolu_A1",
        "content": "18C, cloudy" }
    ] },
  { "role": "assistant",
    "content": [{ "type": "text", "text": "It's 18C and cloudy in Tokyo." }] }
]

OpenAI splits the same conversation across more messages. The assistant's tool call is a sibling field (tool_calls) on the assistant message, not a block inside content. The tool's response is its own message with a dedicated role: "tool" and a tool_call_id linking it back.

[
  { "role": "user", "content": "Weather in Tokyo?" },
  { "role": "assistant",
    "content": "Let me check.",
    "tool_calls": [
      { "id": "call_1",
        "type": "function",
        "function": {
          "name": "get_weather",
          "arguments": "{\"location\":\"Tokyo\"}"
        } }
    ] },
  { "role": "tool",
    "tool_call_id": "call_1",
    "content": "18C, cloudy" },
  { "role": "assistant", "content": "It's 18C and cloudy in Tokyo." }
]

Gemini renames messages to contents, roles to user and model, and blocks to parts. Tool calls become functionCall parts inside the model's message. Tool results become functionResponse parts inside a user message. Gemini, like Anthropic, has no tool role.

{
  "contents": [
    { "role": "user",
      "parts": [{ "text": "Weather in Tokyo?" }] },
    { "role": "model",
      "parts": [
        { "text": "Let me check." },
        { "functionCall": {
            "name": "get_weather",
            "args": { "location": "Tokyo" } } }
      ] },
    { "role": "user",
      "parts": [
        { "functionResponse": {
            "name": "get_weather",
            "response": { "content": "18C, cloudy" } } }
      ] },
    { "role": "model",
      "parts": [{ "text": "It's 18C and cloudy in Tokyo." }] }
  ]
}

Read all three carefully. Every one encodes the same graph: a question, an assistant saying "let me check" and calling one function, the function's answer, a final assistant reply. The roles shift, the nesting shifts, the block names shift. The structure is identical.


An implementation sketch: normalize to the underlying shape

The cleanest way to hold these three formats in your head is to normalize them to the abstract object on the way in and re-serialize on the way out. Here's the smallest version of that I use:

from dataclasses import dataclass
from typing import Literal
 
# The abstract shape. Every provider serializes to/from this.
Role = Literal["user", "assistant", "system"]
 
@dataclass
class TextBlock:
    text: str
 
@dataclass
class ToolCall:
    id: str
    name: str
    args: dict
 
@dataclass
class ToolResult:
    call_id: str
    output: str
 
Block = TextBlock | ToolCall | ToolResult
 
@dataclass
class Turn:
    role: Role
    blocks: list[Block]
 
# --- Parsing three providers into the internal shape ---
 
def from_anthropic(msg: dict) -> Turn:
    role = "assistant" if msg["role"] == "assistant" else "user"
    blocks: list[Block] = []
    for b in msg["content"]:
        if b["type"] == "text":
            blocks.append(TextBlock(b["text"]))
        elif b["type"] == "tool_use":
            blocks.append(ToolCall(b["id"], b["name"], b["input"]))
        elif b["type"] == "tool_result":
            blocks.append(ToolResult(b["tool_use_id"], b["content"]))
    return Turn(role, blocks)
 
def from_openai(msg: dict) -> Turn:
    if msg["role"] == "tool":
        # OpenAI's tool role folds into 'user' in our model.
        return Turn("user", [ToolResult(msg["tool_call_id"], msg["content"])])
    blocks: list[Block] = []
    if msg.get("content"):
        blocks.append(TextBlock(msg["content"]))
    for tc in msg.get("tool_calls") or []:
        import json
        blocks.append(ToolCall(
            tc["id"], tc["function"]["name"], json.loads(tc["function"]["arguments"])
        ))
    return Turn(msg["role"], blocks)
 
def from_gemini(msg: dict) -> Turn:
    role = "assistant" if msg["role"] == "model" else "user"
    blocks: list[Block] = []
    for p in msg["parts"]:
        if "text" in p:
            blocks.append(TextBlock(p["text"]))
        elif "functionCall" in p:
            fc = p["functionCall"]
            # Gemini has no id; synthesize one from name + position.
            blocks.append(ToolCall(f"{fc['name']}:{len(blocks)}", fc["name"], fc["args"]))
        elif "functionResponse" in p:
            fr = p["functionResponse"]
            blocks.append(ToolResult(fr["name"], fr["response"]["content"]))
    return Turn(role, blocks)
 
# Three different payloads in; one Turn type out. Your agent loop only
# ever reasons about Turn and Block, not about which SDK is speaking.

Two things to notice. The from_gemini parser has to invent an id, because Gemini relies on name-plus-order matching rather than explicit ids. The from_openai parser has to json.loads the arguments string, because OpenAI ships function arguments as a JSON-encoded string inside the JSON message, not as a nested object. Those are real serialization quirks, and they're exactly the kind of thing a normalizer makes you confront once and then ignore.

Writing a serializer in the other direction is a useful exercise. It forces you to pick an id scheme, decide how "tool result" maps to each provider's role conventions, and confront the reasoning-block differences we're about to get to.


Thinking, reasoning, and 2026's new twist

As reasoning models landed in late 2024 and matured through 2025, every provider had to add a slot for private scratchpad content that flows between turns. The designs differ sharply.

Anthropic added thinking and redacted_thinking blocks as first-class content types. On extended-thinking requests, the assistant message contains a thinking block before any user-visible text or tool calls. You pass those blocks back verbatim on the next turn when tools are in play. The model relies on its own reasoning being in context to pick up where it left off.

Gemini introduced thoughtSignature: an opaque base64 token attached to parts of the model's response. You don't read the reasoning; you just round-trip the signature on subsequent turns so the server-side reasoning state stays coherent.

OpenAI went a different direction entirely with the Responses API and the Harmony format for gpt-oss. Instead of a reasoning block, Harmony introduces typed channels (analysis, commentary, final) and addresses tool calls to a recipient (to=functions.get_weather) as their own message ending in <|call|>. The old tool_calls field is gone; the whole transcript is restructured to make reasoning and tools parallel peers of final answers.

The question that sits underneath all three designs is: who owns the reasoning between turns? Anthropic hands it to you verbatim and asks you to replay it. Gemini hands you an opaque token and asks you to round-trip it without looking inside. OpenAI's Responses API holds the whole transcript server-side and just needs a pointer back. The slot in the transcript where reasoning lives looks superficially similar. The ownership model underneath is not.

Where does reasoning live between turns?
phase 1 · server emits reasoning
provider serveryour client / agentAnthropicmodel · generates turnagenttranscript storethinking: "user wants Tokyo weather; call get_…thinking block travels verbatimclient can read itGeminimodel · generates turnagenttranscript storethoughtSignature: "CiwKDAgGEg…wZ=="opaque thoughtSignature round-tripsclient never reads itOpenAI (Responses)model · generates turnagenttranscript storeprevious_response_id: "resp_Q9k…f2"server keeps state; client sends only an idclient never reads itsame logical job · three ownership models for the reasoning slot

The same logical job — carry reasoning from one turn to the next — solved three ways. Anthropic's thinking block is readable and replayed. Gemini's thoughtSignature is opaque and round-tripped. OpenAI's Responses API keeps the state server-side and hands the client an id. The ownership of the reasoning is the design choice, not the wire shape.

OpenAI Chat Completions vs. Harmony
Chat Completions (JSON)
{
"role": "assistant",
"content": "Let me check.",
"tool_calls": [
{ "id": "call_1",
"type": "function",
"function": {
"name": "get_weather",
"arguments": "{\"location\":\"Tokyo\"}"
} }
]
}
One assistant message with a tool_calls field. Reasoning isn't represented on the wire.
Harmony (rendered tokens)
<|start|>assistant
<|channel|>analysis<|message|>
The user asked about Tokyo weather. I should call get_weather.
<|end|>
<|start|>assistant
<|channel|>commentary to=functions.get_weather
<|constrain|>json<|message|>{"location":"Tokyo"}
<|call|>
Each assistant utterance carries a channel (analysis / commentary / final) and tool calls are their own recipient-tagged message ending in <|call|>.

Chat Completions bolts tool calls onto the assistant message. Harmony makes them a distinct message with a recipient, and separates reasoning into its own channel. Both render the same logical turn; the boundaries live in different places.

Why did OpenAI break format compatibility for gpt-oss? tool_calls as a sibling field always felt like a wart. It made training awkward (the model has to learn when to emit content versus when to emit structure) and it made multi-step agent traces hard to represent cleanly. Harmony pushes everything into the message stream itself with channels and recipients. It's a better substrate for agent training, at the cost of making your old code not work.

Format is plumbing. When the plumbing starts to leak hard enough, providers replace it.


Misconceptions

"All three formats are equivalent, so it doesn't matter which you use." The formats encode the same logical object, but the encoding is not lossless in practice. Anthropic's tool_result can be a list of content blocks including images; OpenAI's tool message content is historically a string. Gemini's no-ID scheme means two identical parallel calls to the same tool can be ambiguous to match back. If you switch providers for the same agent without updating the transcript handling, you can get subtle misbehavior that's hard to trace to the transcript layer.

"The model sees the JSON I sent." No. The model sees tokens. The JSON is input to a chat-template step that renders structured messages down to a raw token stream with provider-specific special tokens. When Anthropic says the model "handles tool_use blocks natively," they mean the model was trained on token sequences that encoded tool_use blocks in a particular way, and the SDK renders your JSON into that same token shape. Your JSON and the model's prompt are related by a formatter, not by identity.

"Tool results come from the tool role." Only on OpenAI. Anthropic puts tool results inside a user message with a tool_result block. Gemini does the same with a functionResponse part inside a user message. This is easy to miss if your agent code assumes there's a tool role on the wire and tries to route tool outputs that way on providers that don't use one.

"You can skip the id field and the model will figure it out." It won't, and the failure mode is quiet. On a single tool call the id mostly doesn't matter, because there's only one thing to match. On parallel calls or multi-turn conversations with several tools, id mismatches cause the model to see results glued to the wrong calls, and it confidently answers with data that doesn't correspond to the question it asked.

What's next

This post covered how three providers serialize the same transcript shape in three different ways, how the SDK renders those structured messages down to tokens before the model ever sees them, and how reasoning slots opened up a new wrinkle in the 2025–2026 formats. The atom the whole arc rests on is the turn-list with typed content blocks, and every provider's API is a labeling scheme over that atom.

The next post covers the Model Context Protocol, a cross-system standard for the tool surface that lets one MCP server serve Anthropic, OpenAI, and Gemini clients without caring which transcript format each one is speaking.


Additional reading (and watching)

  • Yao, S., et al. (2022). ReAct: Synergizing Reasoning and Acting in Language Models. arXiv:2210.03629. The original pattern of interleaving reasoning and action strings inside a single text completion, before provider APIs had typed tool-use blocks.

  • Hugging Face. (2025). Chat Templates. Reference for how structured messages (including tool calls) get rendered to raw token strings via Jinja templates. Makes the "model sees tokens, not JSON" point concrete.

  • Anthropic. (2025). Messages API: Tool use. Describes the tool_use / tool_result block pair and the tool_use_id linking convention.

  • Anthropic. (2025). Messages API reference. Canonical definition of the content-block array and the set of block types (text, tool_use, tool_result, thinking, redacted_thinking, server_tool_use).

  • OpenAI. (2025). Chat Completions API: Function calling. The tool_calls field on assistant messages and the tool role with tool_call_id.

  • Google. (2025). Gemini API: Function calling. The functionCall and functionResponse parts inside contents, and the user/model role convention.

  • Anthropic. (2025). Extended thinking. Explains thinking and redacted_thinking blocks, and why they must be passed back verbatim on subsequent turns when tools are involved.

  • Google. (2025). Thinking with Gemini. Describes thoughtSignature and how it preserves reasoning state across turns without exposing the reasoning contents to the client.

  • OpenAI. (2025). Harmony response format and the Responses API. Harmony's channel-based messaging and the Responses API's server-stored conversation state.

  • Vercel. (2025). AI SDK: Message and tool-call shapes. One of the clearest writeups of an agent framework's provider-neutral message normalization layer.

  • Anthropic. (2024). Introducing the Model Context Protocol and the MCP specification. MCP deliberately defines the tool surface and leaves the host-side transcript format out of scope.