Creating Custom Tools
Build type-safe tools for your agents using the tool() factory, Zod schemas, and structured error handling.
Overview
The tool() factory is how you give agents new capabilities. Every tool has a name, description, a Zod schema for parameter validation, and an async execute function. The LLM sees the name and description to decide when to call the tool, and the schema ensures parameters are always valid.
import { tool } from '@cogitator-ai/core';
import { z } from 'zod';
const weatherTool = tool({
name: 'get_weather',
description: 'Get current weather for a city',
parameters: z.object({
city: z.string().describe('City name'),
units: z.enum(['celsius', 'fahrenheit']).default('celsius'),
}),
execute: async ({ city, units }) => {
const res = await fetch(`https://api.weather.com/v1/${city}?units=${units}`);
return res.json();
},
});The tool() Factory
Signature
function tool<TParams, TResult>(config: ToolConfig<TParams, TResult>): Tool<TParams, TResult>;ToolConfig Fields
| Field | Type | Required | Description |
|---|---|---|---|
name | string | Yes | Unique tool name (used in LLM function calling) |
description | string | Yes | What the tool does (shown to the LLM) |
parameters | ZodType<TParams> | Yes | Zod schema for input validation |
execute | (params, context) => Promise<TResult> | Yes | The function that runs when the tool is called |
category | ToolCategory | No | Grouping: math, text, file, network, etc. |
tags | string[] | No | Tags for discovery and filtering |
sideEffects | SideEffectType[] | No | Declares what the tool affects |
requiresApproval | boolean | (params) => boolean | No | Require human approval before execution |
timeout | number | No | Execution timeout in milliseconds |
sandbox | SandboxConfig | No | Run in Docker or WASM sandbox |
Defining Parameters with Zod
Every tool uses a Zod schema that gets converted to JSON Schema for the LLM. Use .describe() on each field -- this text is what the LLM reads to understand each parameter.
Basic Schema
const params = z.object({
query: z.string().describe('Search query'),
limit: z.number().int().min(1).max(100).default(10).describe('Max results'),
});Optional and Default Values
const params = z.object({
path: z.string().describe('File path'),
encoding: z.enum(['utf-8', 'base64']).default('utf-8').describe('File encoding'),
createDirs: z.boolean().optional().describe('Create parent directories'),
});Complex Schemas
const params = z.object({
to: z.union([z.string().email(), z.array(z.string().email())]).describe('Recipient email(s)'),
headers: z
.record(z.string(), z.string())
.optional()
.describe('Custom headers as key-value pairs'),
filters: z
.array(
z.object({
field: z.string(),
operator: z.enum(['eq', 'gt', 'lt', 'contains']),
value: z.unknown(),
})
)
.optional()
.describe('Filter conditions'),
});Cogitator converts Zod schemas to OpenAPI 3.0-compatible JSON Schema via z.toJSONSchema(). This means z.string(), z.number(), z.enum(), z.object(), z.array(), z.union(), z.record(), z.boolean(), and z.unknown() all work correctly with every LLM provider.
The Execute Function
The execute function receives validated parameters and a ToolContext:
interface ToolContext {
agentId: string;
runId: string;
signal: AbortSignal;
}Async Execution
All execute functions are async. You can call APIs, read files, query databases, or run any asynchronous operation:
const searchTool = tool({
name: 'search_docs',
description: 'Search internal documentation',
parameters: z.object({
query: z.string().describe('Search query'),
department: z.enum(['engineering', 'sales', 'support']).optional(),
}),
execute: async ({ query, department }, context) => {
const results = await db.search({
query,
department,
signal: context.signal,
});
return { results, count: results.length };
},
});Using AbortSignal
The context.signal is tied to the agent's run timeout. Pass it to fetch calls and other cancellable operations so they abort cleanly when the run times out:
execute: async ({ url }, context) => {
const response = await fetch(url, { signal: context.signal });
return response.json();
};Return Types
Tool results can be any serializable value. Return structured objects so the LLM gets clear, actionable data:
// structured result
return {
results: searchResults,
count: searchResults.length,
query,
truncated: searchResults.length > maxResults,
};
// error as result (agent can interpret and retry)
return {
error: 'File not found',
path: requestedPath,
suggestion: 'Check the file path or list the directory first',
};Avoid returning raw strings when you can return an object. Structured results let the LLM make better decisions about what to do next.
Error Handling
There are two approaches to errors in tools:
Recoverable Errors -- Return Them
When the error is something the agent can understand and act on, return it as part of the result:
const fileTool = tool({
name: 'read_config',
description: 'Read a configuration file',
parameters: z.object({ path: z.string() }),
execute: async ({ path }) => {
try {
const content = await readFile(path, 'utf-8');
return { content, path };
} catch (err) {
const error = err as NodeJS.ErrnoException;
if (error.code === 'ENOENT') {
return { error: `File not found: ${path}`, path };
}
return { error: error.message, path };
}
},
});Fatal Errors -- Throw Them
When something is fundamentally broken and the agent cannot recover, throw:
execute: async ({ connectionString }) => {
if (!connectionString && !process.env.DATABASE_URL) {
throw new Error('No database connection configured');
}
// ...
};Thrown errors surface as tool execution failures in the agent's run trace.
Side Effects and Approval
Declare side effects so the runtime can audit and control tool behavior:
const deployTool = tool({
name: 'deploy_service',
description: 'Deploy a service to production',
parameters: z.object({
service: z.string(),
version: z.string(),
}),
sideEffects: ['network', 'external'],
requiresApproval: true,
execute: async ({ service, version }) => {
// ...
},
});requiresApproval can also be a function for conditional approval:
requiresApproval: (params) => {
const dangerous = ['rm', 'sudo', 'chmod', 'kill'];
return dangerous.some((cmd) => params.command.includes(cmd));
};Side Effect Types
| Type | Description |
|---|---|
filesystem | Reads or writes files |
network | Makes HTTP/network requests |
database | Queries or mutates a database |
process | Spawns processes or runs commands |
external | Calls third-party APIs or services |
Sandbox Configuration
Tools can declare a sandbox for isolated execution:
const unsafeTool = tool({
name: 'run_code',
description: 'Execute user-provided code safely',
parameters: z.object({ code: z.string(), language: z.enum(['python', 'javascript']) }),
sandbox: {
type: 'docker',
image: 'cogitator/sandbox:python3.11',
resources: { memory: '512MB', pidsLimit: 100 },
network: { mode: 'none' },
timeout: 30_000,
},
execute: async ({ code, language }) => {
return { code, language };
},
});For lightweight computation, use WASM sandboxing instead:
sandbox: {
type: 'wasm',
wasmModule: './plugins/parser.wasm',
wasmFunction: 'parse',
timeout: 5000,
}Schema Serialization
Every tool created with tool() has a toJSON() method that converts it to the JSON Schema format used by LLM providers:
const schema = weatherTool.toJSON();
// {
// name: 'get_weather',
// description: 'Get current weather for a city',
// parameters: {
// type: 'object',
// properties: {
// city: { type: 'string', description: 'City name' },
// units: { type: 'string', enum: ['celsius', 'fahrenheit'], default: 'celsius' },
// },
// required: ['city'],
// },
// }This conversion happens automatically when the agent sends tools to the LLM backend. You never need to write JSON Schema by hand.