01. The Full Protocol of a Tool Call
The first foundation stone of an agent is tool calling. Many tutorials describe it as "give the model a list of functions and the model will pick one." That is not wrong, but it is far too coarse. What you actually need to understand is this: tool calling is a message protocol. The model never executes a function directly — it merely declares, inside an assistant message, "I want to call this tool with these arguments." Your runtime reads that declaration, executes the local tool, and sends the result back to the model as a new message.
Why a naive call is not enough
A plain LLM call takes messages as input and produces a block of text as output. That works for "explain this code" or "write a function," but not for "fix the bug in this repository." Fixing a bug requires inspecting files, running commands, and iterating on failures. The model cannot access the file system directly, and it cannot run the tests itself. You have to give it a set of controlled tools and fold each tool result back into the context at every step.
If you stuff the file contents into the prompt all at once, you run into three problems:
- There are too many files, and you exceed the context window.
- The model has no way to verify whether its changes pass the tests.
- The user cannot audit what the model actually read, changed, or executed.
The tool protocol solves "let the model request actions," not "give the model system privileges."
One tool use round trip
A complete tool call involves at least four stages:
- You send the tool schemas and the current messages to the model.
- The model returns an assistant message whose content includes a tool call.
- The runtime executes the corresponding tool, keyed by the tool call id.
- The runtime appends a tool result message and asks the model to continue.
The minimal message types can be written like this:
type TextBlock = {
type: "text";
text: string;
};
type ToolCallBlock = {
type: "toolCall";
id: string;
name: string;
input: unknown;
};
type UserMessage = {
role: "user";
content: TextBlock[];
};
type AssistantMessage = {
role: "assistant";
content: Array<TextBlock | ToolCallBlock>;
stopReason: "stop" | "toolUse" | "length" | "error" | "aborted";
model: string;
usage: { inputTokens: number; outputTokens: number };
};
type ToolResultMessage = {
role: "toolResult";
toolCallId: string;
toolName: string;
isError: boolean;
content: TextBlock[];
};
There are a few key points here. toolCallId must be echoed back verbatim; otherwise the model cannot match results to requests. isError is not UI decoration — it tells the model that this tool call failed and it should try a different approach. stopReason drives the next step of control flow: if it is toolUse, run the tools; if it is stop, the turn is over; if it is length, you usually need to compact or ask the model to wrap up.
The tool schema is a runtime contract
A tool description generally consists of a name, a natural-language explanation, and a parameter schema. The explanation is written for the model; the schema is written for the runtime. The model reads the explanation to decide when to call the tool; the runtime must validate the arguments against the schema, because the model may produce missing fields, wrong types, malformed paths, or extra fields.
The description of a read_file tool should tell the model both its capabilities and its boundaries:
const readFileTool = {
name: "read_file",
description: "Read a UTF-8 text file under the current workspace. Use this before editing a file you have not inspected.",
parameters: {
type: "object",
properties: {
path: { type: "string", description: "Workspace-relative file path." },
},
required: ["path"],
additionalProperties: false,
},
};
Do not write the description as just "reads a file." The model needs to know when to use it, when not to use it, how paths are expressed, and that the output may be truncated. The tool description is part of prompt engineering, but it must match the runtime's actual behavior. Otherwise the model will learn the wrong way to operate.
Watching it run
Here is an idealized transcript. Focus on the message roles, not the text content:
user: fix the divide-by-zero bug in src/math.ts
assistant(stopReason=toolUse):
toolCall read_file { "path": "src/math.ts" }
toolResult(read_file, isError=false):
export function divide(a: number, b: number) { return a / b; }
assistant(stopReason=toolUse):
toolCall edit_file { "path": "src/math.ts", "oldText": "...", "newText": "..." }
toolResult(edit_file, isError=false):
edited src/math.ts
assistant(stopReason=stop):
Handled divide-by-zero input; divide now throws a RangeError when b is 0.
Nowhere in this flow does the model "access a file." It only makes requests. The runtime is the real executor, record keeper, and permission boundary.
Production trade-offs
A mature agent should not feed tool results back to the model verbatim and unbounded. Tool output must serve the model: state success or failure clearly, truncate when necessary, and tell the model how to proceed. When grep produces many results, return only the first N and note "there are more; narrow your query." When bash output is long, keep the beginning and the end, because errors usually appear at the end and command context usually appears at the beginning.
At the same time, tool results and UI details must be kept separate. The model needs concise text; the user interface may need a structured diff, elapsed time, exit codes, and the path to the full stdout. Mixing the two bloats the prompt and makes the UI hard to render reliably.
Exercises
Implement a read-only tool protocol checkpoint:
- Define
UserMessage,AssistantMessage, andToolResultMessage. - Define the tool schema for
read_file. - Write a function that takes an assistant message and extracts all tool calls.
- When the arguments are not
{ path: string }, return a tool result withisError: true. - Use a fixed transcript to verify that tool call ids are echoed back correctly.
Acceptance criteria: given a tool call that is missing path, the model's next context contains an error tool result — instead of the runtime simply crashing.