Skip to main content
Tools define what agents can DO. Each tool implements the ToolDefinition interface and provides an execute function that runs when the LLM calls it.

Tool Architecture

Tools in LLM Gateway:
  • Define capabilities available to the agent (read files, run commands, spawn subagents)
  • Use Zod schemas for input validation and JSON Schema generation
  • Return both context (shown to LLM) and result (programmatic return value)
  • Support permission derivation for allowlist patterns
The agent harness (packages/ai/harness/agent.ts:262) executes tools concurrently after permission checks, then injects their output into the conversation.

Interface Requirements

Tools implement ToolDefinition from packages/ai/types.ts:32:
interface ToolDefinition<
  TSchema extends z.ZodTypeAny = z.ZodTypeAny,
  TResult = unknown
> {
  name: string;
  description: string;
  schema: TSchema;
  execute?: (
    input: z.infer<TSchema>,
    ctx: ToolContext
  ) => Promise<ToolExecutionResult<TResult>>;
  derivePermission?: (params: Record<string, unknown>) => ToolPermission;
}

Tool Context

The execute function receives a ToolContext (packages/ai/types.ts:26):
interface ToolContext {
  parentId?: string;           // The tool_call ID that spawned this
  spawn?: (task: string) => Promise<string>;  // Spawn subagent
  fileTime?: FileTime;         // File timestamp tracking
}

Tool Execution Result

interface ToolExecutionResult<T = unknown> {
  context?: string;  // Injected into agent's conversation (what LLM sees)
  result?: T;        // Programmatic return value (available to harness)
}
The context field is what the LLM receives as tool output. Keep it concise but informative.

Example: Bash Tool

The bash tool (packages/ai/tools/bash.ts) is the simplest canonical implementation:
import { z } from "zod";
import type { ToolDefinition, ToolPermission } from "../types";
import { execShell, type ShellResult } from "./lib/shell";

const schema = z.object({
  command: z.string().describe("The shell command to execute"),
  timeout: z.number().positive().default(5).describe("Timeout in seconds (default: 5)"),
});

export const bashTool: ToolDefinition<typeof schema, ShellResult> = {
  name: "bash",
  description: "Execute a non-sudo shell command. Returns stdout, stderr, and exit code.",
  schema,
  
  derivePermission: (params): ToolPermission => {
    const command = String(params.command ?? "");
    const spaceIndex = command.indexOf(" ");
    if (spaceIndex === -1) {
      return { tool: "bash", params: { command } };
    }
    // Extract first word + glob pattern for allowlist matching
    return { tool: "bash", params: { command: command.slice(0, spaceIndex) + " **" } };
  },
  
  execute: async ({ command, timeout }) => {
    const result = await execShell({ command, timeout });

    if (result.exitCode === -1 && !result.stdout && !result.stderr) {
      return {
        context: `Command timed out after ${timeout} seconds`,
        result: { exitCode: -1, stdout: "", stderr: "" },
      };
    }

    const { stdout, stderr, exitCode } = result;

    let context = "";
    if (stdout) context += `stdout:\n${stdout}\n`;
    if (stderr) context += `stderr:\n${stderr}\n`;
    context += `exit code: ${exitCode}`;

    return { context, result: { exitCode, stdout, stderr } };
  },
};

Example: Read Tool

The read tool (packages/ai/tools/read.ts) shows handling multiple content types:
import { z } from "zod";
import { readFile, stat } from "fs/promises";
import { extname } from "path";
import type {
  ToolDefinition,
  ToolContext,
  ContentPart,
  ImageContentPart,
  DocumentContentPart,
} from "../types";

const schema = z.object({
  filePath: z.string().describe("Absolute path to the file to read"),
  offset: z.number().optional().describe("0-based line number to start reading from"),
  limit: z.number().optional().describe("Maximum number of lines to read (default 2000)"),
});

const IMAGE_EXTENSIONS = new Set([".png", ".jpg", ".jpeg", ".gif", ".webp"]);
const MAX_TEXT_BYTES = 50 * 1024; // 50KB
const MAX_BINARY_BYTES = 5 * 1024 * 1024; // 5MB

export const readTool: ToolDefinition<typeof schema, ContentPart[]> = {
  name: "read",
  description:
    "Read a file from the filesystem. Returns text with line numbers for code files, " +
    "base64-encoded content for images (png, jpg, gif, webp) and PDFs.",
  schema,
  
  execute: async ({ filePath, offset = 0, limit = 2000 }, ctx) => {
    const { fileTime } = ctx as ToolContext & { fileTime: FileTime };
    const ext = extname(filePath).toLowerCase();

    // Check file exists
    let fileSize: number;
    try {
      const s = await stat(filePath);
      fileSize = s.size;
    } catch {
      return { context: `File not found: ${filePath}` };
    }

    // Handle images
    if (IMAGE_EXTENSIONS.has(ext)) {
      if (fileSize > MAX_BINARY_BYTES) {
        return { context: `Image file exceeds 5MB limit: ${filePath}` };
      }
      const data = await readFile(filePath);
      await fileTime.read(filePath);
      
      const part: ImageContentPart = {
        type: "image",
        mediaType: MIME_TYPES[ext] || "application/octet-stream",
        data: data.toString("base64"),
      };
      return { context: `Read image: ${filePath}`, result: [part] };
    }

    // Handle text files
    const buffer = await readFile(filePath);
    const text = buffer.toString("utf-8");
    const allLines = text.split("\n");
    const sliced = allLines.slice(offset, offset + limit);

    const outputLines = sliced.map((line, i) => {
      const lineNum = offset + i + 1;
      return `${String(lineNum).padStart(4)} | ${line}`;
    });

    const output = `<file path="${filePath}" lines="${offset + 1}-${offset + outputLines.length}" total="${allLines.length}">\n${outputLines.join("\n")}\n</file>`;

    await fileTime.read(filePath);
    return { context: output };
  },
};

Example: Patch Tool with FileTime

The patch tool (packages/ai/tools/patch.ts) shows file conflict detection using FileTime:
import { z } from "zod";
import { readFile, writeFile, unlink, mkdir } from "fs/promises";
import { existsSync } from "fs";
import { dirname } from "path";
import type { ToolDefinition, ToolContext } from "../types";
import { parsePatch } from "./lib/patch-parser";
import { applyHunks } from "./lib/patch-apply";

const schema = z.object({
  patch: z.string().describe("The patch to apply, using the patch grammar format"),
});

export const patchTool: ToolDefinition<typeof schema> = {
  name: "patch",
  description:
    "Apply file changes using a patch. Supports creating new files (Add File), " +
    "deleting files (Delete File), and editing existing files (Update File).",
  schema,
  
  derivePermission: (params): ToolPermission => {
    const patchText = String(params.patch ?? "");
    const paths = extractPaths(patchText);
    const glob = commonDir(paths);
    return { tool: "patch", params: { patch: glob } };
  },
  
  execute: async ({ patch: patchText }, ctx) => {
    const { fileTime } = ctx as ToolContext & { fileTime: FileTime };

    // Parse patch operations
    let ops: PatchOp[];
    try {
      ops = parsePatch(patchText);
    } catch (e) {
      return { context: `Patch parse error: ${e.message}` };
    }

    const results: string[] = [];

    for (const op of ops) {
      try {
        if (op.type === "add") {
          if (existsSync(op.path)) {
            return { context: `File already exists: ${op.path}` };
          }
          await mkdir(dirname(op.path), { recursive: true });
          await writeFile(op.path, op.content);
          await fileTime.read(op.path);  // Track new file timestamp
          results.push(`Added ${op.path}`);
          
        } else if (op.type === "delete") {
          await fileTime.assert(op.path);  // Check for concurrent edits
          await unlink(op.path);
          results.push(`Deleted ${op.path}`);
          
        } else if (op.type === "update") {
          // Lock file during update to prevent race conditions
          await fileTime.assert(op.path);
          await fileTime.withLock(op.path, async () => {
            const content = await readFile(op.path, "utf-8");
            const updated = applyHunks(content, op.hunks);
            await writeFile(op.path, updated);
            await fileTime.read(op.path);  // Update tracked timestamp
          });
          results.push(`Updated ${op.path}`);
        }
      } catch (e) {
        return { context: `Patch failed on ${op.path}: ${e.message}` };
      }
    }

    return { context: results.join("\n") };
  },
};
Always use fileTime.assert() before modifying files and fileTime.read() after reading/writing to prevent concurrent modification conflicts.

Subagent Spawning Tool

The agent tool (packages/ai/tools/agent.ts) shows how to use ctx.spawn for subagents:
import { z } from "zod";
import type { ToolDefinition } from "../types";

const schema = z.object({
  task: z.string().describe("Description of the task for the subagent"),
});

export const agentTool: ToolDefinition<typeof schema, string> = {
  name: "agent",
  description: "Spawn a subagent to handle a task. Returns the final assistant message.",
  schema,
  
  execute: async ({ task }, ctx) => {
    if (!ctx.spawn) {
      return { context: "Subagent spawning not available in this context" };
    }

    try {
      const result = await ctx.spawn(task);
      return {
        context: `Subagent completed task. Result:\n${result}`,
        result,
      };
    } catch (error) {
      return {
        context: `Subagent error: ${error.message}`,
      };
    }
  },
};

Permission Derivation

The derivePermission function converts tool parameters into glob patterns for allowlist matching:
derivePermission: (params): ToolPermission => {
  // Extract the first word of a bash command
  const command = String(params.command ?? "");
  const firstWord = command.split(" ")[0];
  return { 
    tool: "bash", 
    params: { command: `${firstWord} **` } 
  };
}
This allows users to allowlist patterns like:
const permissions: Permissions = {
  allowlist: [
    { tool: "bash", params: { command: "git **" } },
    { tool: "bash", params: { command: "npm **" } },
    { tool: "read", params: { filePath: "/workspace/**" } },
  ],
};

Testing Your Tool

Create tests alongside your tool implementation:
import { describe, test, expect } from "bun:test";
import { myTool } from "../my-tool";
import { FileTime } from "../lib/filetime";

describe("myTool", () => {
  test("executes successfully", async () => {
    const fileTime = new FileTime();
    const result = await myTool.execute!(
      { input: "test" },
      { fileTime }
    );

    expect(result.context).toContain("success");
    expect(result.result).toBeDefined();
  });

  test("handles errors gracefully", async () => {
    const result = await myTool.execute!(
      { input: "invalid" },
      { fileTime: new FileTime() }
    );

    expect(result.context).toContain("error");
  });

  test("derives permissions correctly", () => {
    const permission = myTool.derivePermission!({
      input: "/workspace/src/file.ts",
    });

    expect(permission.tool).toBe("my_tool");
    expect(permission.params?.input).toMatch(/\/workspace\/\*\*/);
  });
});

Registering Your Tool

Tools are passed to the harness at invocation time:
import { createAgentHarness } from "@llm-gateway/ai/harness/agent";
import { zenHarness } from "@llm-gateway/ai/harness/providers/zen";
import { bashTool, readTool } from "@llm-gateway/ai/tools";
import { myCustomTool } from "./my-custom-tool";

const harness = createAgentHarness({ harness: zenHarness });

for await (const event of harness.invoke({
  model: "claude-sonnet-4-5",
  messages: [{ role: "user", content: "Use the custom tool" }],
  tools: [bashTool, readTool, myCustomTool],  // Add your tool here
})) {
  console.log(event);
}

Best Practices

Use context for human-readable output shown to the LLM, and result for structured data:
return {
  context: "File written successfully",
  result: { path, bytes: content.length },
};
Return errors in context rather than throwing, so the agent can see them:
try {
  const data = await fetchData();
  return { context: `Fetched ${data.length} items`, result: data };
} catch (error) {
  return { context: `Error fetching data: ${error.message}` };
}
Use Zod’s .describe() to provide clear parameter descriptions:
const schema = z.object({
  path: z.string().describe("Absolute path to the target file"),
  mode: z.enum(["append", "overwrite"]).describe("Write mode (default: overwrite)"),
});
Always use FileTime for file conflict detection:
await fileTime.assert(path);     // Before writing
await fileTime.read(path);       // After reading/writing
await fileTime.withLock(path, async () => {
  // Atomic operations
});

Built-in Tools

Explore bash, read, patch, and agent tools

Permissions System

Learn about tool allowlists and permission checking

Agent Harness

See how the agent executes tools

Tool Calling Guide

End-to-end guide to tool calling

Build docs developers (and LLMs) love