Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Model: openai/gpt-5.4
Generated: 2026-04-01
Book: Claude Code VS OpenCode: Architecture, Design and The Road Ahead
Chapter: 4 — Tool System Design
Token Usage: ~7,600 input + ~1,500 output

4.1 Tool Definition Paradigm

The first design question in any coding agent is deceptively simple: what is a tool? In practice, the answer determines how safely the model can act, how much context the runtime can supply, how richly the UI can render execution, and how easy it is for plugin authors to extend the system. OpenCode, Claude Code, and Oh-My-OpenCode (OMO) all treat tools as first-class architectural objects rather than as thin function-calling wrappers, but they express that idea through different definition paradigms.

OpenCode’s core pattern appears in /packages/opencode/src/tool/tool.ts. The center of gravity is Tool.define(), which takes an identifier and an initializer. That initializer returns a description, a parameter schema, and an execute() function. The parameter schema is expressed with Zod, a TypeScript runtime schema validation library. Zod is not a classic textbook CS concept; it is a developer tool for declaring data structure expectations in code and then checking them at runtime. That matters in agent systems because LLMs often produce syntactically plausible but structurally wrong arguments. Compile-time types alone cannot protect against that, because the model’s tool call arrives at runtime.

OpenCode therefore places validation directly at the execution boundary. Tool.define() wraps the supplied execute() implementation and calls toolInfo.parameters.parse(args) before the real body runs. If the schema check fails, the system throws an error telling the model to rewrite the input so it satisfies the expected schema. This is more than defensive programming. It is a form of machine-facing affordance: the runtime is teaching the agent how to recover from malformed calls.

The execution context supplied by OpenCode is also unusually rich. A tool’s execute() receives not just arguments but a context containing sessionID, messageID, agent, an AbortSignal, optional callID, message history, a metadata() callback, and an ask() permission callback. This means tools are session-aware, cancellable, permission-aware, and capable of annotating their own results. In other words, OpenCode does not model tools as stateless RPC endpoints. It models them as capabilities operating inside an ongoing agent runtime.

There is one more important move in Tool.define(): output truncation is applied automatically unless the tool explicitly marks that it already handled truncation. After execution, OpenCode calls Truncate.output(...) and returns a modified result with truncated metadata and, when needed, an outputPath. This turns output control into a framework guarantee rather than a per-tool courtesy.

Claude Code’s pattern, defined in /src/Tool.ts, is broader and more industrialized. Instead of Tool.define(), it uses a buildTool() factory. The file is much larger because it does more than register a tool; it defines the full interface between the model, the runtime, the permission layer, the UI renderer, and the classifier stack. Claude Code tools can specify an inputSchema using Zod, but the system also explicitly supports inputJSONSchema for tools whose schemas arrive in raw JSON Schema form, especially MCP-related tools.

JSON Schema is different from Zod. It is an IETF-standard way to describe the shape of JSON documents: object properties, required fields, enums, nested arrays, and similar constraints. Unlike Zod, which is a TypeScript library with executable validators, JSON Schema is a language-agnostic specification format. In agent systems, JSON Schema is useful because it travels well across APIs, SDKs, and external tool protocols.

Claude Code’s buildTool() factory fills in safe defaults for many behaviors: whether a tool is enabled, concurrency-safe, read-only, destructive, how permissions are checked, how user-facing names are rendered, and what classifier-facing input should look like. This is a subtle but important design improvement. It means tool authors write only the deltas while the system preserves fail-closed defaults. For example, isConcurrencySafe defaults to false, isReadOnly defaults to false, and isDestructive defaults to false unless the tool says otherwise. The factory is therefore not just ergonomic; it is policy-bearing.

The heart of Claude Code’s tool implementation is the run() method, which receives a ToolUseContext. That context is much larger than OpenCode’s, including available commands, the full tool list, MCP clients, MCP resources, app state getters and setters, notification hooks, prompt request handlers, file-reading limits, glob limits, progress emitters, query tracking, and more. The practical meaning is that Claude Code tools are deeply integrated into the application shell. A tool can stream progress, update UI state, coordinate with background tasks, and interact with memory and session infrastructure without escaping the formal interface.

This leads to a major philosophical difference. OpenCode’s tool system is elegant and compact; Claude Code’s is expansive and productized. OpenCode defines the minimum strong contract for runtime tool execution. Claude Code defines a full operating environment for tools as product components.

OMO sits between those two. In /src/tools/index.ts, OMO largely reuses OpenCode’s SDK-style tool definitions through ToolDefinition objects imported from @opencode-ai/plugin, but then layers additional context and orchestration behavior on top. OMO exports tool factories such as createBackgroundOutput, createBackgroundCancel, createCallOmoAgent, createDelegateTask, and various LSP and skill tools. The important point is not merely that OMO adds more tools. It wraps OpenCode’s tool substrate and injects extra lifecycle semantics through surrounding hooks and feature modules.

That is why OMO should be understood as an extension architecture rather than an alternative tool kernel. OpenCode gives it the base definition model. OMO then enriches execution through metadata restoration, extra session awareness, background-agent integration, and pre/post-execution hooks. The result is a hybrid paradigm: tools are still defined in OpenCode’s style, but they operate inside a denser orchestration layer.

Anthropic’s broader ACI principle helps explain why these details matter. The guideline can be summarized as: invest as much effort in ACI, the Agent-Computer Interface, as in HCI, the Human-Computer Interface. In practice, that means tool descriptions should read like docstrings written for a junior developer: concrete, constrained, explicit about inputs, outputs, and failure modes. Tool definition is therefore not only a type problem. It is also an instruction design problem. The schema tells the runtime what valid input looks like; the description tells the model what good usage looks like.

Across the three systems, the pattern is converging. A modern agent tool definition has at least five layers: a natural-language contract for the model, a machine-checked input schema, a rich runtime context, a controlled execution body, and a post-processing stage for output and metadata. OpenCode expresses this in a compact kernel form. Claude Code expands it into a product-grade interface with rendering and policy hooks. OMO proves that once the base abstraction is sound, an orchestration layer can reuse it and still add substantial new behavior.

The design lesson is clear. Tool definition should not be treated as boilerplate around function calling. It is the main programming model of the agent runtime. The more carefully that contract is shaped, the more reliable, safe, and composable the entire agent becomes.

Deep Dive: Zod Schema as Full-Chain Penetration

Model: openai/gpt-5.4
Token Usage: not exposed in this local append operation

If the previous section explained that tools need schemas, this section asks a deeper question: why does a schema matter so much in an agent system? The short answer is that a schema is not just a form. It is a control surface that reaches through the entire system. In OpenCode-style architectures, Zod does not merely validate one function call. It acts like a structured checkpoint that keeps meaning stable as data moves from prompt, to model output, to tool execution, to storage, to APIs, and back again.

Start with a high-school-level analogy: ordering at McDonald’s. Imagine a restaurant with no fixed menu fields. A customer says, “I want that burger thing, maybe large, no, medium, and add something cold.” The cashier guesses. The kitchen guesses again. The packer guesses whether “cold” means Coke, Sprite, or ice cream. By the time the tray reaches the customer, everyone has interpreted the sentence differently. The system did not fail because people were stupid. It failed because each stage had to invent structure for itself.

Now compare that with a structured order system. The cashier screen requires: main item, size, drink, remove ingredients, extra items. If the customer says something vague, the cashier cannot finalize the order until the ambiguity is resolved. The kitchen receives the exact same structured record. The drink station receives a smaller but still validated subset. The receipt reflects the same fields. In other words, the order is not “understood” separately by each person. It is validated once and then trusted everywhere else.

That is what schema does in software. Without schema, every layer guesses meaning. With schema, meaning is declared once and checked at every step.

In the TypeScript ecosystem, Zod is one of the most popular tools for doing this. Zod is a runtime schema validation library. “Schema” here means a precise description of what data should look like: which fields exist, what type each field has, which ones are required, which values are allowed, and how nested structures are organized. This is not just a textbook “type system” idea. A normal TypeScript type disappears after compilation. Zod does not disappear. It stays alive at runtime and can inspect real incoming data.

That distinction matters enormously. TypeScript can tell a programmer, during development, that lineNumber should be a number. But if an LLM later emits { "lineNumber": "42" }, the TypeScript compiler is no longer there to protect you. Zod is. It checks the actual payload when the program is running.

Here is a very small example:

import { z } from "zod"

const ToolInput = z.object({
  filePath: z.string().min(1),
  lineNumber: z.number().int().positive(),
  includeContext: z.boolean().default(false),
})

const rawInput = {
  filePath: "/project/src/app.ts",
  lineNumber: "18",
  includeContext: true,
}

const result = ToolInput.safeParse(rawInput)

if (!result.success) {
  console.error(result.error.issues)
} else {
  const input = result.data
  console.log(input.lineNumber)
}

The key call is safeParse. It does not crash the process immediately. Instead, it returns either a success result with validated data, or a failure result with structured error details. That means the system can respond precisely: “lineNumber must be a positive integer, but received a string.” This is much better than letting bad data drift deeper into the stack.

Why do AI agents especially need this? Because LLMs are powerful, but they are also non-deterministic. “Non-deterministic” means the same instruction can produce slightly different outputs on different runs. That is acceptable for language generation. It is not acceptable at system boundaries. A tool call is not poetry. It is an operation that may read files, write files, call an API, or mutate state. Once we cross into execution, the system must become deterministic.

Typical failures look almost trivial, but they are exactly the kinds of tiny mismatches that break tool systems:

  • path is given as src/main.ts when the tool contract requires an absolute path like /Users/.../src/main.ts
  • lineNumber is emitted as the string "88" instead of the number 88
  • a required field such as session_id is missing entirely
  • a tool expecting { mode: "fast" | "safe" } receives { mode: "quick" }
  • a union-shaped object is half one format and half another, so downstream code does not know which branch to trust

These mistakes are common because the model is predicting tokens, not executing a proof. It may output something that looks reasonable to a human reader but is invalid for a machine. That is why agent architecture must follow a strict philosophy: LLMs may be probabilistic at the center, but boundaries must be deterministic at the edges.

The danger without schema is what we can call silent failure propagation. Suppose the model emits a relative path. The tool implementation assumes absolute paths and tries to resolve it using the current working directory. That accidentally points to the wrong file. The wrong file produces the wrong diff. The wrong diff is stored in message history. Another model step reads that history and makes a wrong repair. The system still “works” in the sense that no hard exception happened, but the error has contaminated multiple layers.

That flow looks like this:

LLM emits fuzzy arguments
  -> tool accepts them anyway
  -> code makes hidden assumptions
  -> wrong file / wrong state / wrong result
  -> misleading output enters conversation history
  -> later steps build on corrupted context
  -> failure spreads silently

Now compare that with a schema-first flow:

LLM emits fuzzy arguments
  -> Zod validates at boundary
  -> validation fails immediately and specifically
  -> system returns exact correction message
  -> LLM retries with corrected structure
  -> tool executes on trusted data
  -> clean result flows downstream

This is the difference between “the system discovers the problem after damage” and “the system contains the problem at the smallest possible scope.” Good schema design makes errors local.

In OpenCode-like systems, Zod’s importance goes beyond the single execute() call. It penetrates the full chain. Think of the entire runtime as a sequence of borders, and every border needs a customs checkpoint.

graph LR
    A["System Prompt"] -->|"Zod ✓"| B["LLM Inference"]
    B -->|"Zod ✓"| C["Tool Parameters"]
    C -->|"Zod ✓"| D["Tool Execution"]
    D -->|"Zod ✓"| E["Tool Result"]
    E -->|"Zod ✓"| F["Message History"]
    F -->|"Zod ✓"| G["SQLite Storage"]
    G -->|"Zod ✓"| H["Event Bus"]
    H -->|"Zod ✓"| I["HTTP API"]
    
    style A fill:#4a9eff,color:#fff
    style I fill:#51cf66,color:#fff
  1. System prompt assembly: tool definitions, parameter names, and descriptions are assembled into the prompt. The schema indirectly shapes how the model understands the tool before generation even starts.
  2. LLM inference: the model decides to call a tool and emits arguments. This is the most probabilistic point in the chain.
  3. Tool call parameters: Zod parses the emitted payload and checks whether it matches the declared structure.
  4. Tool execution: only validated data reaches the real execution body. The tool can now operate with stronger assumptions.
  5. Tool result: output often has its own structure, truncation rules, metadata flags, or discriminated states like success vs error.
  6. Message history (MessageV2 parts): the result is turned into structured message parts that later inference steps will consume.
  7. SQLite storage: persisted records need stable shapes so sessions can be resumed without guessing old formats.
  8. Event bus payload: UI updates, notifications, and observers depend on consistent event objects.
  9. HTTP API response: external callers need the same contract discipline, otherwise integrations become fragile.

Seen this way, schema is not a local helper. It is an architectural membrane. Each boundary says: “Before you pass, prove your shape.”

This is why “full-chain penetration” is a useful phrase. The schema is not sitting at the edge like a decorative label. It is drilling through the whole stack. Prompt design, tool calling, storage format, event payloads, and transport APIs all become more reliable because they are speaking through declared structures instead of informal guesses.

Another analogy makes the same point from a different angle: airport security. The LLM is the passenger. Most passengers are harmless, many are messy, some forget things, some carry items in the wrong bag, and a few behave unpredictably. The tool execution environment is the boarding gate. Once you let someone through incorrectly, the cost of a mistake rises quickly. The schema is the X-ray machine and document check. It does not try to predict human intention philosophically. It checks whether what is presented satisfies concrete rules right now.

This analogy matters because agent engineers sometimes make a category mistake. They think, “The model is smart enough; it probably meant the right thing.” Airport security does not work that way. “Probably fine” is not a policy. The checkpoint must be explicit, repeatable, and inspectable. Zod gives software a machine-readable version of that checkpoint.

The core design philosophy can be pictured like this:

+-------------------------------------------------------------+
| Deterministic system shell                                  |
|                                                             |
|  Prompt assembly -> Schema check -> Tool exec -> Storage    |
|        |                 |              |           |        |
|        v                 v              v           v        |
|                 [ Non-deterministic LLM core ]              |
|                                                             |
|  Events -> API responses -> Session resume -> UI rendering  |
|                                                             |
+-------------------------------------------------------------+

Or even more simply:

deterministic boundary
    -> probabilistic generation
        -> deterministic validation
            -> deterministic execution
                -> deterministic persistence

That shell is what makes an agent usable in production. You do not remove non-determinism from the model; you surround it with deterministic contracts.

There are at least four major implications.

First, errors are contained to the minimum scope. If argument validation fails before execution, you have not touched the file system, network, database, or downstream context. The blast radius stays small.

Second, error messages become precise. Instead of “tool failed,” you can say “filePath must be absolute” or “lineNumber must be an integer greater than 0.” Precise feedback is not only useful for humans. It is useful for the LLM itself, because the model can often repair its next attempt when given a sharply bounded correction.

Third, the system can self-heal. A schema failure is often recoverable. The model can retry with corrected arguments. This is much harder when the failure appears late as a vague side effect, such as corrupted history or an invalid stored record.

Fourth, subsystems can collaborate safely. Prompt builders, tool runners, storage layers, event emitters, and HTTP handlers do not need to rely on tribal knowledge. Shared schemas become shared truth. That reduces accidental coupling and makes extension safer.

One more technical concept is worth naming because it often appears in robust agent systems: the discriminated union. A union means data may legally take one of several shapes. A discriminated union adds a clear tag field such as type or kind so the system knows which branch it is looking at. For example, a tool result might be either { type: "success", data: ... } or { type: "error", message: ... }. Without the discriminator, downstream code may guess the branch. With it, downstream code can route confidently. This is the same philosophy again: no guessing at boundaries.

So when we say “Zod Schema as full-chain penetration,” we are really stating a broad architectural principle. A schema is not just validation glue for nervous developers. In an AI agent, it is the mechanism that keeps a probabilistic reasoning engine connected to a deterministic software world. It turns fuzzy generation into accountable execution. It transforms silent corruption into precise correction. It allows many subsystems to interoperate without inventing their own private meanings.

That is why the best summary is simple and worth repeating: LLMs can be non-deterministic, but system boundaries must be deterministic. Zod is one of the clearest ways to enforce that rule all the way through the stack.