App~/docsmenu+close×
01Quickstart/start02Environment/start03Architecture/system04API Map/reference05Migrations/data06Custom Tools/extend07Customization/extend08Licence/legal
Back to App
~/docs
01Quickstart→02Environment→03Architecture→04API Map→05Migrations→06Custom Tools→07Customization→08Licence→
AgentZero / docs
v0.1 · built in SA

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.ts with 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
end

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.

$  snippetread-only
// 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.

$  snippetread-only
// 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.

$  snippetread-only
// 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.

$  snippetread-only
export 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.
end

Step 2: Export the Tool

Add your tool to the index file so it can be imported elsewhere.

$  snippetread-only
// features/tools/index.ts export { webSearchTool } from "./web-search"; export { knowledgeSearchTool } from "./knowledge-search"; export { weatherLookupTool } from "./weather-lookup"; // Add this line
end

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.

$  snippetread-only
// 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.

end

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:

$  snippetread-only
// 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:

$  snippetread-only
// 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...`;
end

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.

$  snippetread-only
// 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).

end

Step 6: Test the Tool

6.1 Verify Exports

Make sure your tool exports correctly:

$  snippetread-only
# 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

  1. ›Open AgentZero in the browser
  2. ›Create a new agent (or use an existing one)
  3. ›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_KEY set in .env.local?
  • ›Schema mismatch: Does the API response match your outputSchema? Zod will throw with details.
  • ›Tool name: Does TOOL_LABELS have the correct tool name (e.g., weatherLookupTool)?
end

Common Patterns and Best Practices

Pattern 1: Retrying on Zero Results

For search-like tools, offer a retry strategy when no results are found:

$  snippetread-only
export 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:

$  snippetread-only
export 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:

$  snippetread-only
const 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:

$  snippetread-only
if (!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:

$  snippetread-only
try { return outputSchema.parse(raw); } catch (err) { throw new Error( `Weather API returned unexpected shape: ${err.message}. ` + `Raw response: ${JSON.stringify(raw)}` ); }
end

Tool Architecture Overview

$  snippetread-only
┌─────────────────────────────────────────────────────────────┐ │ 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
end

Troubleshooting

Tool Not Appearing in Agent

Problem: The tool doesn't show up when running an agent.

Causes and solutions:

  1. ›

    Export missing: Check features/tools/index.ts exports your tool.

    $  snippetread-only
    export { weatherLookupTool } from "./weather-lookup";
  2. ›

    Registry ID mismatch: The registry ID in tool-registry.ts must match the one used in filterToolsByIds().

    $  snippetread-only
    // tool-registry.ts { id: "weather_lookup", ... } // agent-actions.ts weather_lookup: ["weatherLookupTool"]
  3. ›

    Tool not in tool list: Check the UI is actually passing the tool ID. In the agent run, enabledTools must 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:

  1. ›

    Add logging to see what the API actually returns:

    $  snippetread-only
    console.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);
  2. ›

    Check the API documentation — did the field name change?

  3. ›

    Update your mapping if the API changed:

    $  snippetread-only
    const 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:

  1. ›

    Description is unclear: The LLM learns from your tool's description. Make it explicit:

    $  snippetread-only
    // 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."
  2. ›

    LLM instruction doesn't mention it: Update BASE_INSTRUCTIONS in agent-actions.ts to guide the LLM:

    $  snippetread-only
    // Add to BASE_INSTRUCTIONS "2. Questions about weather, conditions, or climate → call weatherLookupTool."
  3. ›

    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:

  1. ›

    Add the key to your .env.local file:

    $  snippetread-only
    WEATHER_API_KEY=sk_test_xxxxx
  2. ›

    Restart the development server.

  3. ›

    Check the variable is readable:

    $  snippetread-only
    console.log("Key exists:", !!process.env.WEATHER_API_KEY);

Tool Hangs or Times Out

Problem: The tool takes forever or never returns.

Causes and solutions:

  1. ›

    Network issue: Add a timeout to your fetch:

    $  snippetread-only
    const response = await fetch(url, { signal: AbortSignal.timeout(5000), // 5 second timeout });
  2. ›

    Infinite loop in tool: Check your execute() function doesn't have a loop that never exits.

  3. ›

    External API is slow: Check the API's status or consider caching results.

end

Full Example: Time Zone Tool

Here's a complete, minimal tool for reference:

$  snippetread-only
// 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); }, });
$  snippetread-only
// features/tools/index.ts export { webSearchTool } from "./web-search"; export { knowledgeSearchTool } from "./knowledge-search"; export { timezoneLookupTool } from "./timezone-lookup";
$  snippetread-only
// 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, }
$  snippetread-only
// lib/actions/agent-actions.ts (update filterToolsByIds) const idToExportName: Record<string, string[]> = { web_search: ["webSearchTool"], knowledge: ["knowledgeSearchTool"], timezone_lookup: ["timezoneLookupTool"], };
$  snippetread-only
// 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...", };
end

Summary Checklist

  • ›[ ] Created features/tools/your-tool-name.ts with inputSchema, outputSchema, and execute()
  • ›[ ] Exported tool from features/tools/index.ts
  • ›[ ] Added tool to TOOL_REGISTRY in lib/ai/tool-registry.ts
  • ›[ ] Added tool ID to idToExportName in lib/actions/agent-actions.ts
  • ›[ ] Added tool label to TOOL_LABELS in lib/ai/tool-logs.ts
  • ›[ ] (Optional) Updated BASE_INSTRUCTIONS with tool-use guidance
  • ›[ ] Set environment variable (if required)
  • ›[ ] Tested with an agent
end

Additional Resources

  • ›Vercel AI SDK tool() docs
  • ›Zod validation docs
  • ›Lucide Icons
  • ›AgentZero architecture: see ARCHITECTURE.md in the project root