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:
| Field | Purpose |
|---|---|
| description | Tells the model when and how to use the tool. Be specific. |
| parameters | Zod schema for typed input. Use .describe() on fields to guide the model. |
| execute | Async 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. |
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 withreadFile(), write withwriteFiles(). 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:
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:
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:
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 scannableGetGreeting,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:
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:
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:
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:
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
- Build Your First Tool — Intro to tools, ToolContext, and approval
- Extending Built-in Tools — Wrap or augment Read, Write, Bash, etc.
- Tools — Built-in tools, ToolContext, and activeTools
- Hooks — Intercept all tools with tool.before and tool.after
- Approvals — Frontend integration and resolution flow
React Hooks
Build chat UIs with useAgent, useSessionHistory, and useInterruptSession. Handles streaming, reconnection, and status updates automatically.
Extending Built-in Tools
Customize built-in tools without replacing them. Use tool.before, tool.after, needsApproval, and activeTools to add validation, transformation, and restrictions.