Guides

Custom Tools

Deep dive into building custom tools with ToolContext, sandbox execution, auth context, output schemas, and approval integration.

This guide covers patterns beyond the Build Your First Tool intro: ToolContext usage, sandbox execution, auth patterns, typed output, error handling, and approval integration.

Tool Anatomy

Custom tools use tool() from the Vercel AI SDK (ai package). Each tool has:

FieldPurpose
descriptionTells the model when and how to use the tool. Be specific.
parametersZod schema for typed input. Use .describe() on fields to guide the model.
executeAsync function that runs when the tool is called. Receives (input, { experimental_context }).
outputSchema (optional)Zod schema for typed output. Helps the model interpret structured results.
needsApproval (optional)true, false, or (input) => boolean | Promise<boolean>. Per-tool approval gating.
src/tools/example.ts
import { tool } from "ai";
import { z } from "zod";

const MyTool = tool({
  description: "Does something useful with the given input",
  parameters: z.object({
    key: z.string().describe("The key to look up"),
    limit: z.number().min(1).max(100).default(10),
  }),
  outputSchema: z.object({
    items: z.array(z.object({ id: z.string(), value: z.string() })),
  }),
  execute: async ({ key, limit }, { experimental_context }) => {
    // ...
    return { items: [] };
  },
});

ToolContext

Custom tools receive ToolContext via experimental_context in the execute function:

type ToolContext<TContext, TState> = {
  context: TContext;      // Per-request data from contextSchema (auth tokens, etc.)
  state: TState;         // Mutable per-session state from stateSchema
  sessionId: string;     // Current session ID
  sandbox: SandboxInstance; // Execute commands, read/write files
  messages: UIMessage[]; // Conversation history
};
  • context — Transient per-request data passed via session.send(). Use for auth tokens, tenant IDs, etc.
  • state — Mutable per-session state. Persisted across messages. Mutate directly in tools.
  • sandbox — Run commands with exec(), read files with readFile(), write with writeFiles(). All built-in tools run here.
  • messages — Full conversation history for the session.

Example: Tool That Runs in the Sandbox

Use sandbox.exec() to run shell commands. The result is an ExecResult; await result.result for stdout, stderr, and exitCode:

src/tools/run-tests.ts
import { type ToolContext } from "experimental-agent";
import { tool } from "ai";
import { z } from "zod";

const RunTests = tool({
  description: "Run the test suite. Optionally specify a test file to run.",
  parameters: z.object({
    testFile: z.string().optional().describe("Path to a specific test file"),
  }),
  execute: async ({ testFile }, { experimental_context }) => {
    const { sandbox } = experimental_context as ToolContext;
    const result = await sandbox.exec({
      command: "npx",
      args: ["vitest", "run", ...(testFile ? [testFile] : [])],
    });
    const { stdout, stderr, exitCode } = await result.result;
    return {
      stdout,
      stderr,
      exitCode,
      passed: exitCode === 0,
    };
  },
});

sandbox.exec() throws if the sandbox is unavailable.

Example: Tool That Uses Context for Auth

Pass auth tokens via the context option in session.send() and access them in tools:

src/tools/fetch-repo.ts
import { type ToolContext } from "experimental-agent";
import { tool } from "ai";
import { z } from "zod";

const FetchRepo = tool({
  description: "Clone a GitHub repository into the sandbox",
  parameters: z.object({
    repo: z.string().describe("GitHub repo in owner/name format"),
  }),
  execute: async ({ repo }, { experimental_context }) => {
    const { sandbox, context } = experimental_context as ToolContext;
    const token = (context as { authToken?: string }).authToken;
    const url = token
      ? `https://x-access-token:${token}@github.com/${repo}.git`
      : `https://github.com/${repo}.git`;

    const result = await sandbox.exec({
      command: "git",
      args: ["clone", url, "/app/repo"],
    });
    const { stdout, stderr, exitCode } = await result.result;
    return {
      success: exitCode === 0,
      stdout,
      stderr,
    };
  },
});

Define contextSchema on your agent and pass context when sending. See Sessions.

Example: Tool with outputSchema

outputSchema gives the model a typed view of your tool's output. Use it when the result has a clear structure:

src/tools/search-docs.ts
import { tool } from "ai";
import { z } from "zod";

const SearchDocs = tool({
  description: "Search documentation by query",
  parameters: z.object({
    query: z.string(),
    limit: z.number().min(1).max(20).default(5),
  }),
  outputSchema: z.object({
    results: z.array(
      z.object({
        title: z.string(),
        description: z.string(),
        slug: z.string(),
      })
    ),
  }),
  execute: async ({ query, limit }) => {
    const matches = await searchIndex(query, limit);
    return { results: matches };
  },
});

The model can reason about results[].title and results[].slug more effectively with a schema.

Tool Naming Conventions

Use PascalCase for tool names. The model sees the tool name in the UI and in tool-call parts. Good names are action-oriented and specific:

  • RunTests, DeployPreview, SearchDocs — Clear and scannable
  • GetGreeting, FetchRepo, CreateBranch — Verb + noun

Avoid generic names like Tool1 or DoThing.

Error Handling in Tools

Throw errors when the tool cannot complete. The agent will surface them as output-error tool parts:

execute: async ({ path }, { experimental_context }) => {
  const { sandbox } = experimental_context as ToolContext;
  const result = await sandbox.exec({
    command: "cat",
    args: [path],
  });
  const { stdout, exitCode } = await result.result;
  if (exitCode !== 0) {
    throw new Error(`Command failed: ${stdout}`);
  }
  return { content: stdout };
},

For user-facing messages, throw descriptive errors. Avoid exposing internal details (paths, tokens) in error messages.

Approval Integration

Require human approval before a tool runs. Two options:

Per-tool — Add needsApproval on the tool definition:

src/tools/run-tests.ts
const RunTests = tool({
  description: "Run the test suite",
  parameters: z.object({ testFile: z.string().optional() }),
  needsApproval: async ({ testFile }) => {
    return testFile?.includes("e2e") ?? false;
  },
  execute: async ({ testFile }, { experimental_context }) => {
    // ...
  },
});

Agent-level — Map tool names in needsApproval:

src/agent.ts
export const myAgent = agent("my-agent", {
  model: "anthropic/claude-opus-4.6",
  tools: { RunTests, FetchRepo },
  needsApproval: {
    RunTests: true,
    FetchRepo: (input) => input.repo.includes("private/"),
  },
});

Per-tool needsApproval takes priority over the agent-level map. When approval is needed, the stream emits tool-approval-request and the workflow suspends until session.send({ type: "approval", approval: { approvalId, approved } }) is called. See Approvals.

Registering Tools on the Agent

Add tools to the agent's tools object:

src/agent.ts
import { agent } from "experimental-agent";
import { vercelSandbox } from "experimental-agent/sandbox";
import { RunTests, FetchRepo, SearchDocs } from "@/tools";

export const myAgent = agent("my-agent", {
  model: "anthropic/claude-opus-4.6",
  system: "You are a coding assistant. Use RunTests, FetchRepo, and SearchDocs when appropriate.",
  sandbox: vercelSandbox(),
  contextSchema: z.object({ authToken: z.string().optional() }),
  tools: {
    RunTests,
    FetchRepo,
    SearchDocs,
  },
});

The agent has access to the 8 built-in tools plus your custom tools. The model chooses which to call based on the system prompt and user request.

Multiple Tools Example

Combine several tools for a cohesive workflow:

src/agent.ts
import { agent } from "experimental-agent";
import { vercelSandbox } from "experimental-agent/sandbox";
import { RunTests, FetchRepo, CreatePR } from "@/tools";

export const myAgent = agent("my-agent", {
  model: "anthropic/claude-opus-4.6",
  system: `You are a code review assistant. You can:
- FetchRepo: clone repositories
- RunTests: run the test suite
- CreatePR: open a pull request
Use these tools as needed to help the user.`,
  sandbox: vercelSandbox(),
  contextSchema: z.object({ authToken: z.string().optional() }),
  tools: {
    RunTests,
    FetchRepo,
    CreatePR,
  },
  needsApproval: {
    CreatePR: true,
  },
});

Next Steps