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. Types JSON Schema cannot express are sent as "any".
Before execute runs, the runtime parses the model's arguments with your schema: execute receives the parsed values (defaults applied, transforms run), and arguments that do not match are answered with Invalid arguments: … so the model can correct itself. A field with .default() is optional in the schema the model sees (see Schema Serialization), and execute receives the default when the model leaves it out.
The Execute Function
The execute function receives validated parameters and a ToolContext:
interface ToolContext {
agentId: string;
runId: string;
signal: AbortSignal;
threadId?: string;
userId?: string; // the run's userId — scope data access by it
channelType?: string; // set when the run comes from a channel (Telegram, Slack, …)
channelId?: string;
}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 aborts when the run times out or is cancelled, and when the tool's own timeout elapses. Pass it to fetch calls and other cancellable operations so they stop cleanly. A tool that outlives its timeout fails with Tool "<name>" timed out after <ms>ms either way:
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.
To show the model an image, return it base64-encoded in an image or imageBase64 field: the model receives it as an image next to the rest of the result (see Images in Tool Results).
return { width: 1280, height: 720, image: screenshot.toString('base64') };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');
}
// ...
};The runtime catches thrown errors: the model receives { "error": "<message>" } as the tool result and the run goes on, so it can still explain the failure to the user. result.toolCalls keeps the call.
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:
const shell = tool({
name: 'shell',
description: 'Run a shell command',
parameters: z.object({ command: z.string() }),
requiresApproval: (params) => {
const dangerous = ['rm', 'sudo', 'chmod', 'kill'];
return dangerous.some((cmd) => params.command.includes(cmd));
},
execute: async ({ command }) => runCommand(command),
});The check receives the model's arguments as sent, before schema parsing and defaults; a check that throws counts as "needs approval".
A call that needs approval pauses the run until someone decides — see Approvals.
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. A sandboxed tool is not run through its execute inside a Cogitator — the runtime hands the arguments to @cogitator-ai/sandbox instead:
type: 'docker'runssh -c <command>in the container, with thecommand,cwdandenvarguments, and returns{ stdout, stderr, exitCode, timedOut, duration, command }. Give such a tool acommandparameter.type: 'wasm'passes the arguments as JSON towasmFunctionofwasmModuleand returns its output, parsed as JSON when possible.
const runPython = tool({
name: 'run_python',
description: 'Run a Python one-liner, e.g. python3 -c "print(2 ** 10)"',
parameters: z.object({ command: z.string().describe('Shell command that runs python3') }),
sandbox: {
type: 'docker',
image: 'python:3.12-alpine',
resources: { memory: '512MB', pidsLimit: 100 },
network: { mode: 'none' },
timeout: 30_000,
},
execute: async ({ command }) => ({ error: 'sandbox unavailable', command }),
});When @cogitator-ai/sandbox is not installed or cannot start, the runtime logs a warning and calls execute on the host instead — make execute safe for that case, as above; when Docker is unreachable, the command runs on the host shell. sandbox.allowNativeFallback: false on the Cogitator makes both fail instead. See Sandbox for the configuration.
For lightweight computation, use WASM sandboxing instead (or defineWasmTool(), which sets this up):
sandbox: {
type: 'wasm',
wasmModule: './plugins/parser.wasm',
wasmFunction: 'parse',
timeout: 5000,
}Tool Sets
A function that builds several related tools can return them with toolset(): the result is an ordinary array an agent takes, and each element keeps its own parameter and result types.
import { Agent, tool, toolset } from '@cogitator-ai/core';
export function createOrderTools(api: OrdersApi) {
return toolset(
tool({
name: 'find_order',
description: 'Find an order by id',
parameters: z.object({ id: z.string() }),
execute: async ({ id }) => api.find(id),
}),
tool({
name: 'cancel_order',
description: 'Cancel an order',
parameters: z.object({ id: z.string(), reason: z.string() }),
execute: async (p) => api.cancel(p),
})
);
}
const tools = createOrderTools(api);
const agent = new Agent({
name: 'support',
model: 'openai/gpt-6.1-sol',
instructions: 'You help customers with their orders.',
tools,
});
const [findOrder] = tools;
await findOrder.execute({ id: 'A-1' }, context); // typed as find_order's parametersThe built-in factories (createMemoryTools, createSchedulerTools and the browser tool sets) return their tools this way.
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: { default: 'celsius', type: 'string', enum: ['celsius', 'fahrenheit'] },
// },
// required: ['city'],
// },
// }This conversion happens automatically when the agent sends tools to the LLM backend; toolToSchema(tool) does the same for any tool. You never need to write JSON Schema by hand.
Registering Tools Globally
cogitator.tools is a ToolRegistry (register, registerMany, get, has, getAll, getNames, getSchemas, clear). Tools registered there are offered to every agent this Cogitator runs, next to the agent's own tools (an agent tool with the same name wins):
cog.tools.register(weatherTool);
const result = await cog.run(agent, { input: 'Weather in Paris?' }); // agent can call get_weatherA standalone ToolRegistry also resolves tool names when you restore a serialized agent:
import { Agent, ToolRegistry } from '@cogitator-ai/core';
const registry = new ToolRegistry();
registry.registerMany([weatherTool]);
const restored = Agent.deserialize(snapshot, { toolRegistry: registry });