How to Add a Custom Tool
This guide walks you through adding a custom tool to AgentZero, from implementation to registration to testing.
Overview
AgentZero's tool system is built on the Vercel AI SDK tool() factory. Tools are:
- ›Defined in
features/tools/directory as TypeScript modules - ›Exported from
features/tools/index.ts - ›Registered in
lib/ai/tool-registry.tswith UI metadata - ›Wired into the agent stream in
lib/actions/agent-actions.ts - ›Labeled for UI display in
lib/ai/tool-logs.ts
The tool ecosystem currently includes:
- ›
webSearchTool— Real-time web search via Tavily API - ›
knowledgeSearchTool— Semantic search over organisation knowledge base (invisible RAG, not user-toggleable) - ›
emailAutomateTool— Send transactional email via Resend - ›
dbReadTool— Read rows from a Supabase table - ›
dbWriteTool— Insert/upsert a single row into a Supabase table
Step 1: Create the Tool File
Create a new TypeScript file in features/tools/ named your-tool-name.ts.
File naming convention: Use kebab-case for filenames (e.g., slack-search.ts, weather-lookup.ts).
1.1 Define Input and Output Schemas
Tools use Zod for strict runtime validation. Define separate schemas for inputs and outputs.
$ snippet// features/tools/weather-lookup.ts import { tool } from "ai"; import { z } from "zod"; // Input schema — describes what the LLM must provide export const inputSchema = z .object({ location: z .string() .min(1) .describe("City name or location (e.g., 'San Francisco', 'London, UK')"), unit: z .enum(["celsius", "fahrenheit"]) .optional() .default("celsius") .describe("Temperature unit. Defaults to celsius."), }) .strict(); // Reject unknown fields — prevents silent coercion // Output schema — describes what the tool returns const outputSchema = z .object({ location: z.string().describe("The resolved location name"), temperature: z.number().describe("Current temperature in the specified unit"), condition: z.string().describe("Weather condition (e.g., 'sunny', 'rainy')"), humidity: z.number().min(0).max(100).describe("Humidity percentage"), }) .strict();
Key principles:
- ›
.strict()on all schemas. This rejects unknown fields, preventing the LLM from sneaking in unvalidated data. - ›
.describe()every field. The LLM reads these descriptions to understand what each parameter does. - ›Defaults for optional fields (e.g.,
.default("celsius")) guide the LLM's behavior when it omits them. - ›Enums for constrained choices. Safer than strings.
1.2 Infer TypeScript Types from Schemas
Let Zod infer the types rather than writing them manually. This keeps them in sync with validation.
$ snippet// Single source of truth — types always match schemas type WeatherLookupInput = z.infer<typeof inputSchema>; type WeatherLookupOutput = z.infer<typeof outputSchema>;
1.3 Define External API Response Types (if applicable)
These are internal shapes — not exported. They document the API contract for maintainability.
$ snippet// Internal — represents the shape returned by the weather API interface WeatherAPIResponse { location: string; temp: number; weather: string; humidity: number; }
1.4 Implement the Tool
Use the tool() factory from the Vercel AI SDK.
$ snippetexport const weatherLookupTool = tool({ description: "Fetch current weather for any location. Use this when the user asks about weather, temperature, conditions, or climate.", inputSchema, outputSchema, execute: async (input: WeatherLookupInput): Promise<WeatherLookupOutput> => { const apiKey = process.env.WEATHER_API_KEY; if (!apiKey) { throw new Error("WEATHER_API_KEY environment variable is not set."); } const url = `https://api.openweathermap.org/data/2.5/weather?q=${encodeURIComponent( input.location )}&appid=${apiKey}&units=${input.unit === "fahrenheit" ? "imperial" : "metric"}`; const response = await fetch(url); if (!response.ok) { throw new Error( `Weather API error: ${response.status} ${response.statusText}. ` + `Check WEATHER_API_KEY is valid and the location exists.` ); } const data: WeatherAPIResponse = await response.json(); // Map API response to output schema const raw = { location: data.location, temperature: data.temp, condition: data.weather, humidity: data.humidity, }; // Validate before returning — Zod throws if the shape is wrong return outputSchema.parse(raw); }, });
Best practices:
- ›Check environment variables upfront. Throw early with a clear message.
- ›Handle HTTP errors. Include the status code and suggest debugging steps.
- ›Map external APIs to your schema. Don't assume the API response matches your output shape.
- ›Validate the output. Call
outputSchema.parse(raw)to catch unexpected API changes. Zod errors are informative. - ›Add clear, actionable
.describe()text. The LLM reads this to decide when to call your tool.
Step 2: Export the Tool
Add your tool to the index file so it can be imported elsewhere.
$ snippet// features/tools/index.ts export { webSearchTool } from "./web-search"; export { knowledgeSearchTool } from "./knowledge-search"; export { weatherLookupTool } from "./weather-lookup"; // Add this line
Step 3: Register in the Tool Registry
Update lib/ai/tool-registry.ts to add UI metadata. This metadata appears in the UI and is used for tool filtering.
$ snippet// lib/ai/tool-registry.ts import { Globe, Cloud } from "lucide-react"; // Import a lucide icon import type { LucideIcon } from "lucide-react"; export type ToolMetadata = { id: string; label: string; description: string; icon: LucideIcon; }; export const TOOL_REGISTRY: ToolMetadata[] = [ { id: "web_search", label: "Web Search", description: "Search the web using Tavily and return relevant results.", icon: Globe, }, { id: "weather_lookup", // Tool ID — used for filtering label: "Weather Lookup", description: "Get current weather conditions for any location.", icon: Cloud, }, ];
Naming convention for id: Use snake_case (e.g., weather_lookup, slack_search).
Icons: Choose from Lucide Icons — they're already installed.
Step 4: Wire the Tool into the Agent Stream
Update lib/actions/agent-actions.ts to make the tool available to the agent.
4.1 Update the Tool Filtering Map
The filterToolsByIds() function maps registry IDs to exported tool names. Add your tool:
$ snippet// lib/actions/agent-actions.ts, around line 154 function filterToolsByIds(toolIds: string[]): Record<string, any> { const idToExportName: Record<string, string[]> = { web_search: ["webSearchTool"], knowledge: ["knowledgeSearchTool"], weather_lookup: ["weatherLookupTool"], // Add this line }; // ... rest of the function }
4.2 (Optional) Update System Instructions
If your tool has specific usage rules, add them to BASE_INSTRUCTIONS:
$ snippet// lib/actions/agent-actions.ts, around line 68 const BASE_INSTRUCTIONS = `You are AgentZero, an intelligent AI assistant. \ Always attempt to answer using your available tools before asking for more context. ## Tool-use priority (follow in order) 1. Questions containing "why", "what is the reason", "how does X work", or any strategic / \ contextual question → call knowledgeSearchTool FIRST to check this org's uploaded documents. 2. Questions about weather, conditions, or climate → call weatherLookupTool. 3. Questions requiring up-to-date external information not in the knowledge base → call webSearchTool. 4. Only ask the user for clarification AFTER you have exhausted relevant tool calls and still lack \ enough information to answer. ## Available tools - **knowledgeSearchTool** — Semantic search over this org's memo_summaries... - **webSearchTool** — Real-time web search... - **weatherLookupTool** — Get current weather for any location...`;
Step 5: Add UI Labels
Update lib/ai/tool-logs.ts so the tool's activity appears in the UI with a human-friendly message.
$ snippet// lib/ai/tool-logs.ts export const TOOL_LABELS: Record<string, string> = { webSearchTool: "Searching the web...", knowledgeSearchTool: "Searching knowledge base...", weatherLookupTool: "Checking the weather...", // Add this line };
The key must exactly match the exported tool name (not the registry ID).
Step 6: Test the Tool
6.1 Verify Exports
Make sure your tool exports correctly:
$ snippet# In the project root, check that the tool is exported grep -r "weatherLookupTool" features/tools/ # Should find: features/tools/index.ts and features/tools/weather-lookup.ts
6.2 Create a Test Agent
- ›Open AgentZero in the browser
- ›Create a new agent (or use an existing one)
- ›Enable the weather lookup tool in the UI (if there's a tool toggle)
6.3 Test with Prompts
Try prompts like:
- ›"What's the weather in Paris?"
- ›"Is it raining in New York right now?"
- ›"Tell me the humidity in Sydney"
6.4 Check Agent Logs
Open the browser console (F12) and look for:
- ›Tool execution logs:
[stream] Received chunk type: tool-call - ›Tool output:
[stream] Received chunk type: tool-result
If you see errors, check:
- ›Environment variables: Is
WEATHER_API_KEYset in.env.local? - ›Schema mismatch: Does the API response match your
outputSchema? Zod will throw with details. - ›Tool name: Does
TOOL_LABELShave the correct tool name (e.g.,weatherLookupTool)?
Common Patterns and Best Practices
Pattern 1: Retrying on Zero Results
For search-like tools, offer a retry strategy when no results are found:
$ snippetexport const mySearchTool = tool({ description: "Search for X. If the first call returns 0 results, " + "retry with a broader query (e.g., 'customer plan' → 'revenue strategy roadmap'). " + "Only tell the user nothing was found after two attempts.", inputSchema, outputSchema, execute: async (input) => { // Implementation }, });
See features/tools/knowledge-search.ts for a full example.
Pattern 2: Parameterized Depth/Quality
Let the LLM control the quality/depth of results:
$ snippetexport const inputSchema = z.object({ query: z.string().describe("Your search query"), depth: z .enum(["quick", "thorough"]) .optional() .default("quick") .describe("'quick' for fast results, 'thorough' for comprehensive research"), });
See features/tools/web-search.ts for searchDepth: "advanced".
Pattern 3: API Key Secrets
Always read API keys from environment variables at runtime, never from config files:
$ snippetconst apiKey = process.env.WEATHER_API_KEY; if (!apiKey) { throw new Error( "WEATHER_API_KEY environment variable is not set. " + "Please add it to your .env.local file." ); }
Pattern 4: Descriptive Error Messages
Help developers debug when things fail:
$ snippetif (!response.ok) { throw new Error( `Weather API error: ${response.status} ${response.statusText}. ` + `Location: "${input.location}". ` + `Check that WEATHER_API_KEY is valid and location exists.` ); }
Pattern 5: Output Validation
Always validate external API responses:
$ snippettry { return outputSchema.parse(raw); } catch (err) { throw new Error( `Weather API returned unexpected shape: ${err.message}. ` + `Raw response: ${JSON.stringify(raw)}` ); }
Tool Architecture Overview
$ snippet┌─────────────────────────────────────────────────────────────┐ │ Client (React Component) │ │ - Shows "Checking the weather..." from TOOL_LABELS │ │ - Renders tool name from TOOL_REGISTRY │ └────────────────────┬────────────────────────────────────────┘ │ │ streamAgentAction(prompt, tools=[...]) │ ┌────────────────────▼────────────────────────────────────────┐ │ lib/actions/agent-actions.ts (Server Action) │ │ - Authenticates user, validates prompt │ │ - Filters tools via filterToolsByIds() → registry IDs │ │ - Passes tools to streamText() from AI SDK │ │ - Iterates fullStream, yields StreamEvents │ └────────────────────┬────────────────────────────────────────┘ │ ┌────────────┴────────────┐ │ │ │ tool-call event │ text-delta event │ (LLM wants to call) │ (LLM generated text) │ │ ┌───────▼──────────────┐ ┌──────▼──────────────────┐ │ Tool Execution │ │ Render text to client │ │ (fetch, compute) │ │ │ │ │ │ │ │ weatherLookupTool │ └──────────────────────────┘ │ .execute(input) │ │ → validate output │ │ → return result │ └───────┬──────────────┘ │ │ tool-result event │ ├──> Back to AI SDK for next iteration │ └──> Final text response to client
Troubleshooting
Tool Not Appearing in Agent
Problem: The tool doesn't show up when running an agent.
Causes and solutions:
- ›
Export missing: Check
features/tools/index.tsexports your tool.$ snippetexport { weatherLookupTool } from "./weather-lookup"; - ›
Registry ID mismatch: The registry ID in
tool-registry.tsmust match the one used infilterToolsByIds().$ snippet// tool-registry.ts { id: "weather_lookup", ... } // agent-actions.ts weather_lookup: ["weatherLookupTool"] - ›
Tool not in tool list: Check the UI is actually passing the tool ID. In the agent run,
enabledToolsmust include your tool ID.
"Unexpected shape" Error
Problem: Zod throws an error like "Unexpected shape: property 'temp' is required".
Cause: The API response doesn't match your output schema.
Solution:
- ›
Add logging to see what the API actually returns:
$ snippetconsole.log("Raw API response:", JSON.stringify(data, null, 2)); const raw = { /* ... mapping ... */ }; console.log("Before parse:", JSON.stringify(raw, null, 2)); return outputSchema.parse(raw); - ›
Check the API documentation — did the field name change?
- ›
Update your mapping if the API changed:
$ snippetconst raw = { temperature: data.main.temp, // Check the actual path // ... };
Tool Never Gets Called
Problem: The LLM doesn't call your tool even when the prompt asks for it.
Causes and solutions:
- ›
Description is unclear: The LLM learns from your tool's
description. Make it explicit:$ snippet// Bad description: "Look up information" // Good description: "Get current weather conditions for any location. Use this when the user asks about weather, temperature, rainfall, or climate." - ›
LLM instruction doesn't mention it: Update
BASE_INSTRUCTIONSinagent-actions.tsto guide the LLM:$ snippet// Add to BASE_INSTRUCTIONS "2. Questions about weather, conditions, or climate → call weatherLookupTool." - ›
Tool is disabled: Check the agent isn't filtering out your tool ID in
filterToolsByIds().
API Key Not Found
Problem: Tool throws "WEATHER_API_KEY environment variable is not set.".
Solution:
- ›
Add the key to your
.env.localfile:$ snippetWEATHER_API_KEY=sk_test_xxxxx - ›
Restart the development server.
- ›
Check the variable is readable:
$ snippetconsole.log("Key exists:", !!process.env.WEATHER_API_KEY);
Tool Hangs or Times Out
Problem: The tool takes forever or never returns.
Causes and solutions:
- ›
Network issue: Add a timeout to your fetch:
$ snippetconst response = await fetch(url, { signal: AbortSignal.timeout(5000), // 5 second timeout }); - ›
Infinite loop in tool: Check your
execute()function doesn't have a loop that never exits. - ›
External API is slow: Check the API's status or consider caching results.
Full Example: Time Zone Tool
Here's a complete, minimal tool for reference:
$ snippet// features/tools/timezone-lookup.ts import { tool } from "ai"; import { z } from "zod"; export const inputSchema = z .object({ city: z.string().describe("City name (e.g., 'New York', 'Tokyo')"), }) .strict(); const outputSchema = z .object({ city: z.string().describe("The city name"), timezone: z.string().describe("IANA timezone (e.g., 'America/New_York')"), currentTime: z.string().describe("Current time in ISO 8601 format"), offset: z.string().describe("UTC offset (e.g., '-05:00')"), }) .strict(); type TimezoneLookupInput = z.infer<typeof inputSchema>; type TimezoneLookupOutput = z.infer<typeof outputSchema>; interface TimezoneAPIResponse { timezone: string; datetime: string; utc_offset: string; } export const timezoneLookupTool = tool({ description: "Look up the timezone and current time for a city. " + "Use this when the user asks 'what time is it in...', 'timezone for...', or compares times across cities.", inputSchema, outputSchema, execute: async (input: TimezoneLookupInput): Promise<TimezoneLookupOutput> => { const response = await fetch(`https://worldtimeapi.org/api/timezone/Etc/UTC`); if (!response.ok) { throw new Error(`Timezone API error: ${response.status}`); } const data: TimezoneAPIResponse = await response.json(); const raw = { city: input.city, timezone: data.timezone, currentTime: data.datetime, offset: data.utc_offset, }; return outputSchema.parse(raw); }, });
$ snippet// features/tools/index.ts export { webSearchTool } from "./web-search"; export { knowledgeSearchTool } from "./knowledge-search"; export { timezoneLookupTool } from "./timezone-lookup";
$ snippet// lib/ai/tool-registry.ts (add to TOOL_REGISTRY) import { Clock } from "lucide-react"; { id: "timezone_lookup", label: "Timezone Lookup", description: "Look up current time and timezone for any city.", icon: Clock, }
$ snippet// lib/actions/agent-actions.ts (update filterToolsByIds) const idToExportName: Record<string, string[]> = { web_search: ["webSearchTool"], knowledge: ["knowledgeSearchTool"], timezone_lookup: ["timezoneLookupTool"], };
$ snippet// lib/ai/tool-logs.ts (add entry) export const TOOL_LABELS: Record<string, string> = { webSearchTool: "Searching the web...", knowledgeSearchTool: "Searching knowledge base...", timezoneLookupTool: "Looking up timezone...", };
Summary Checklist
- ›[ ] Created
features/tools/your-tool-name.tswithinputSchema,outputSchema, andexecute() - ›[ ] Exported tool from
features/tools/index.ts - ›[ ] Added tool to
TOOL_REGISTRYinlib/ai/tool-registry.ts - ›[ ] Added tool ID to
idToExportNameinlib/actions/agent-actions.ts - ›[ ] Added tool label to
TOOL_LABELSinlib/ai/tool-logs.ts - ›[ ] (Optional) Updated
BASE_INSTRUCTIONSwith tool-use guidance - ›[ ] Set environment variable (if required)
- ›[ ] Tested with an agent
Additional Resources
- ›Vercel AI SDK
tool()docs - ›Zod validation docs
- ›Lucide Icons
- ›AgentZero architecture: see
ARCHITECTURE.mdin the project root