Appearance
Tools are protocols
A file-edit tool accepted \{path, oldText, newText\}. It worked on a small example, then failed on generated HTML containing the same block twice. The model retried with a longer oldText, the provider resent a malformed JSON argument, and the operation log stored the entire file. One narrow function had become a parser, concurrency control, storage leak, and retry hazard.
The repair started by treating the tool as a protocol between an unreliable caller and authoritative code.
The protocol layers
text
model
|
v
name + JSON arguments
|
v
schema validation
|
v
policy and operation record
|
v
implementation
|
v
typed outcome + compact log
|
v
model-readable resultEach layer answers a different question. Validation asks whether the request has the right shape. Policy asks whether it may run here. The implementation performs the work. Outcome classification asks whether a returned value means success. Logging keeps enough evidence without copying sensitive data.
Terms
- Tool definition: The model-visible name, description, and input schema plus server-side execution metadata.
- Contract error: Invalid arguments, unknown tool name, or unsupported protocol shape.
- Runtime error: A failure while valid work executes.
- Structured failure: A valid return value that reports an unsuccessful business result.
- Effect class: Read, write, or consequential.
- Compact form: A bounded summary retained in logs and older model context.
- Tool registry: The single collection used for selection, prompting, validation, and execution.
One definition should drive the system
Tool lists tend to spread. One file exports functions, another decides which appear in private sessions, and a prompt builder repeats guidance by tool name. Soon a renamed tool executes but has stale instructions, or a scheduled turn receives a browser-only capability.
Keep one registry:
ts
type ToolContext = {
conversationId: string;
operationId: string;
mode: "interactive" | "isolated";
};
type ToolDef<I, O> = {
name: string;
description: string;
inputSchema: Schema<I>;
effect: "read" | "write" | "consequential";
private: boolean;
isolated: boolean;
guidance?: string[];
compactInput?: (input: I) => unknown;
compactOutput?: (output: O) => unknown;
classifyOutcome?: (output: O) => "success" | "failure";
run: (input: I, ctx: ToolContext) => Promise<O>;
};Selection filters definitions by environment, conversation kind, mode, and connected accounts. Prompt guidance comes from the selected definitions. Both providers receive the same schemas and execute through the same wrapper.
This is one place where an abstraction pays for itself. There are several callers: provider adapters, prompt composition, operation logging, policy, and tests.
Validate inside the operation window
Insert the operation row before validation, then finalize it as a contract error if validation fails. Malformed calls are production evidence. If validation happens outside the wrapper, the turn may crash with no visible tool record.
The wrapper should follow a fixed sequence:
- allocate or accept the provider's tool-call ID;
- insert a running operation with compact raw metadata;
- validate arguments;
- enforce policy and approval;
- execute;
- classify a structured result;
- store a compact output and final status;
- return a bounded model-readable result.
Do not let analytics or tracing mask the original result. Their writes are best effort.
Separate operation status from business outcome
Consider a deployment check:
json
{
"ok": false,
"issues": [
{"code": "missing_heading", "path": "index.html"}
]
}The function ran correctly. Marking the operation as a runtime error loses that distinction. Yet counting it as a successful tool outcome hides a product failure.
Keep both:
- operation status:
done; - classified outcome:
failure; - bounded code:
missing_heading.
The model can fix the issue in the same turn. Error analytics can still count unsuccessful checks without filling logs with stack traces that do not exist.
Bound every result
Tool output re-enters the model transcript. A web page, file, or database query can multiply prompt cost on every later iteration. Bound it at the tool boundary.
Useful patterns include:
- clip search and documentation results to a character limit;
- return the first section plus a deterministic outline for large files;
- keep only recent tool results verbatim;
- replace older inputs and outputs with compact forms;
- avoid logging document bodies when revision and character count are enough.
A range read can also detect duplication within one turn. If the model requests the same unchanged range again, return the revision and a pointer to the earlier result. That saves tokens and signals that the strategy is looping.
Compaction must preserve input and output together. Keeping an old tool result while removing the call that produced it can violate provider protocol and confuse the model.
Concurrency belongs in tool contracts
Edits need a revision:
ts
type ReplaceLinesInput = {
path: string;
start: number;
end: number;
replacement: string;
expectedRevision: number;
};The implementation performs a conditional update. If the revision changed, it returns a stale-revision result. This is better than brittle string replacement and better than silently overwriting another turn's work.
Whole-document update tools should follow the same rule. The model can read revision 12, plan an edit, and learn that revision 13 arrived before its write. That conflict is useful information.
Decisions and rejected designs
Prefer task-shaped tools over database-shaped tools. Giving the model arbitrary SQL or generic HTTP shifts validation and policy into prompts. A tool such as schedule_followup can enforce time parsing, privacy mode, idempotency, and ownership.
Return errors selectively. Read-only knowledge tools may return a short error result so a rate limit does not consume a whole-turn retry. Side-effecting tool failures should usually stop or visibly pause because replay can be unsafe.
Use stable machine codes. Error prose changes and may contain private data. Store allowlisted codes such as stale_revision, quota, or forbidden_mode. Show richer text to the current user when safe.
Do not expose every capable function. Internal repair, deletion, and force flags often exist for operators. Model-visible schemas should omit bypasses. A publish tool should not accept skipChecks: true because the model will eventually use it.
Keep provider adapters thin. If one provider validates schemas or logs failures differently, observability depends on routing. Normalize both through the same registry wrapper.
Common breakage
Descriptions can contradict schemas. Models follow both. Generate reference docs from the definition where possible and test examples against the schema.
An unknown tool name can cause an unhandled lookup. Return a contract failure to the provider loop, record the model and iteration, and count repeated names.
Compacted logs can retain secrets if the compact function defaults to the raw input. Sensitive tools need explicit compactors that store account labels, byte counts, or hashes.
Tool output can claim success while required postconditions failed. A publishing tool should verify the live revision or return a structured failure. Model prose saying "deployed" is not evidence.
Tool order can become accidental API. Tests that pin one registry order discourage useful refactors. Test selected name sets, gating behavior, schemas, and outcomes instead.
Protocol checklist
The outbox in Outboxes before agents delivers work; a tool protocol defines each action it can request. The next question is what happens when an action is requested twice. Continue with Idempotency is a user feature.