Tool calling in Go, on the wire
What a tool call looks like as JSON on an OpenAI-compatible chat completions API, how a Go client sends tool definitions and reads calls back, and the rules that keep a conversation valid.
This page works on one stage of the loop
- 1Build the request
- 2Call the model
- 3Run the tools (this page)
- 4Decide
A model never runs a tool. It writes a request to run one, and your program decides what that request means. The request travels as JSON, and getting the JSON right in both directions is most of what “tool calling” means for the program that does it.
This page shows the real JSON from one recorded run of chapter 4’s agent, on anthropic/claude-haiku-5.5 in October 2026: the request that offers tools, the reply that asks for them, and the messages that answer.
Then it shows the Go types that carry them, from the companion code’s openrouter package.
The API is OpenRouter’s chat completions endpoint, POST https://openrouter.ai/api/v1/chat/completions, which uses the OpenAI-compatible format that many providers and local servers share.
Three additions to a chat request
Without tools, a request is just a model ID plus messages, each a role and its text. Tool calling extends that JSON in three places:
- a
toolsarray in the request, one definition per tool; - a
tool_callsarray in assistant messages, where the model asks for tools; - a
tool_call_idin tool messages, naming the call each result answers.
Every request carries the tool definitions
Here is the first request of the run, cut to one of its four tools:
{
"model": "anthropic/claude-haiku-5.5",
"messages": [
{
"role": "user",
"content": "The tests in this project fail. Find out why and fix it."
}
],
"tools": [
{
"type": "function",
"function": {
"name": "read_file",
"description": "Read a file in the project.",
"parameters": {
"type": "object",
"properties": {
"path": {
"type": "string"
}
},
"required": [
"path"
]
}
}
}
]
}
A tool definition is a name, a description in plain language, and a JSON Schema for the arguments.
The API wraps each definition in an object with a type, always "function" here, which leaves room for other kinds of tools.
The model reads the name and the description to decide when to call the tool, and the schema to decide what to send, so both are part of your prompt.
The definitions go out with every request, and they are not free.
In this run the first request was 583 tokens, although the task is one sentence: the four definitions came to about 270 tokens, and the provider added about 290 more of its own instructions for using tools, text you never see but pay for.
How much a provider adds varies; across chapter 1’s 40 runs (ten on each of four models, October 2026), the same first request came to 126 tokens on gpt-5.6-luna, whose provider encodes tool definitions in a compact format of its own, and at 441 on deepseek-v4.1-flash.
One reply can ask for several tools
The model answered the first request like this (the part of the response that matters):
{
"index": 0,
"logprobs": null,
"finish_reason": "tool_calls",
"native_finish_reason": "tool_use",
"message": {
"role": "assistant",
"content": "I'll run the tests and list the project files to see what's failing.",
"refusal": null,
"reasoning": null,
"tool_calls": [
{
"type": "function",
"index": 0,
"id": "toolu_01KTrJCSyL2uXHin8u5MXnjf",
"function": {
"name": "run_tests",
"arguments": "{}"
}
},
{
"type": "function",
"index": 1,
"id": "toolu_01RZ8bLpiP9Jvkeymkqpkmnd",
"function": {
"name": "list_files",
"arguments": "{}"
}
}
],
"reasoning_details": [
{
"type": "reasoning.text",
"signature": "CAQSsgUKEAgSGAI4AUIIdGhp...",
"format": "anthropic-claude-v1",
"index": 0
}
]
}
}
Four things in it are worth reading closely.
One reply, two calls. The model asked to run the tests and list the files at once.
A reply can hold several tool calls, and text alongside them.
Every model in chapter 1’s 40 runs did this in every run.
The parallel_tool_calls setting can turn it off, but the name misleads: it decides whether a single reply is allowed to contain more than one call, and says nothing about whether your harness runs them concurrently.
The companion code leaves it on: every turn resends the whole conversation, so asking for two things at once saves a turn’s worth of tokens and time.
The arguments are a string. "arguments": "{}" is a string holding JSON, not a JSON object.
The client has to unwrap it, and it can’t assume the JSON inside is valid: nothing forces the model to write arguments that match the schema, or even to write well-formed JSON.
The ID comes from the provider. Anthropic’s IDs start with toolu_.
The harness copies each ID into the message that answers the call.
reasoning_details go back unchanged. This model thought for 24 tokens before replying, and the provider returned a signed record of that thinking, shortened above.
The harness never reads it, but it must send it back, exactly as it came, with the assistant message it belongs to; without it, the model starts its next turn without the reasoning behind its own call, and some providers reject the conversation.
finish_reason is tool_calls, OpenRouter’s translation of Anthropic’s own tool_use in native_finish_reason.
Don’t lean on it.
Chapter 2 found the translation right in all 194 replies of the 40 runs, but other servers that accept the same requests don’t all translate as carefully, and some report stop for a reply that holds tool calls.
The loop in the companion code decides from the reply itself: a reply with tool calls is a request for tools, whatever its finish reason says.
Each result answers one call, by its ID
After running the tools, the harness appends one message per call, each with the role tool and the ID it answers, and sends the whole conversation again.
Here are three messages from the fourth request of the run, with the file contents cut short:
[
{
"role": "assistant",
"tool_calls": [
{
"id": "toolu_01MoLdUf4Ax6WqYc6uyZv3YU",
"type": "function",
"function": {
"name": "read_file",
"arguments": "{\"path\": \"duration.go\"}"
}
},
{
"id": "toolu_01896xb2GNHZj7Ef4Cyd39AF",
"type": "function",
"function": {
"name": "read_file",
"arguments": "{\"path\": \"duration_test.go\"}"
}
}
]
},
{
"role": "tool",
"content": "// Package duration parses durations ...",
"tool_call_id": "toolu_01MoLdUf4Ax6WqYc6uyZv3YU"
},
{
"role": "tool",
"content": "package duration ...",
"tool_call_id": "toolu_01896xb2GNHZj7Ef4Cyd39AF"
}
]
The rule the API enforces: each call needs exactly one tool message, and that message has to come later in the list than the assistant message holding the call. Drop a result, or send one without its call, and the next request is rejected. If your loop adds the reply to the history first and then writes one result per call, in the order the calls appear, the rule holds by construction; the agent loop in Go shows one.
The wire types stay inside the client
The client keeps the API’s JSON in its own unexported types, separate from the agent’s Message, which keeps everything specific to this API inside one file of the client:
type chatMessage struct {
Role string `json:"role"`
Content string `json:"content,omitzero"`
ToolCalls []chatToolCall `json:"tool_calls,omitzero"`
ToolCallID string `json:"tool_call_id,omitzero"`
ReasoningDetails jsontext.Value `json:"reasoning_details,omitzero"`
}
// chatToolCall is one tool call. The arguments are a string holding
// JSON, not a JSON object.
type chatToolCall struct {
ID string `json:"id"`
Type string `json:"type"` // always "function"
Function struct {
Name string `json:"name"`
Arguments string `json:"arguments"`
} `json:"function"`
}
Three details carry most of the weight:
Contentisomitzero, so when a reply is nothing but tool calls, the field is left out of the JSON entirely, as in the third listing above.Argumentsis astring, matching the wire. When the client reads a reply, it keeps the arguments as raw JSON (jsontext.Value) in the agent’s ownToolCall, valid or not, and each tool decodes its own; a model that sends an empty string for a tool with no arguments gets{}.ReasoningDetailsis ajsontext.Value, raw JSON the client carries without looking inside.
Every field has an explicit json tag because encoding/json/v2, in the standard library from Go 1.27, matches field names exactly.
An untagged Path field would never receive "path", and the tool would get an empty value with no error.
The API has no field for a failed tool.
The agent’s Message has an IsError flag, and when the client converts a tool message, it turns that flag into a prefix, so the model reads Error: old text appears 0 times in duration.go, want exactly once and knows the call failed.
An empty result becomes (no output), since an empty message can trip up some providers and the text tells the model the tool ran and printed nothing.
A wrong call gets an error the model can fix
Because the arguments are text the model wrote, the harness checks every call before running it: is the tool name real, do the arguments decode into the tool’s argument struct, and are there fields the struct doesn’t have?
Chapter 4’s decoder rejects unknown fields, because models guess names (old_str for old), and ignoring a guess would leave the real field empty, which can turn an edit into a deletion that reports no error.
Each failed check goes back to the model as a tool message that says what was wrong and what to send instead:
turn 1
model asks: read_files {"path": "duration.go"}
tool error: no tool named "read_files"; the tools are edit_file,
list_files, read_file, run_tests
turn 2
model asks: read_file {"path": ["duration.go"]}
tool error: bad arguments: "path": got an array, want a string; send JSON
like {"path": "a.go"}
The model can fix its call on the next turn, and the run goes on.
Some APIs can constrain the arguments to match the schema exactly (strict mode, a form of structured outputs), at the price of a restricted schema; even then, a well-formed path may still point at a missing file, so the checks in the harness stay on regardless.
Run it
From the code directory of a clone of the repository, with no API key set:
$ go run ./ch04
$ go run ./ch04 -replay recover
The first replays the recorded run this page quotes; only the model’s replies are saved, and the tools, go test included, run for real.
The second replays the hand-written run with three bad calls.
The ch04 example has more to try.