Cogitator
Tools

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

FieldTypeRequiredDescription
namestringYesUnique tool name (used in LLM function calling)
descriptionstringYesWhat the tool does (shown to the LLM)
parametersZodType<TParams>YesZod schema for input validation
execute(params, context) => Promise<TResult>YesThe function that runs when the tool is called
categoryToolCategoryNoGrouping: math, text, file, network, etc.
tagsstring[]NoTags for discovery and filtering
sideEffectsSideEffectType[]NoDeclares what the tool affects
requiresApprovalboolean | (params) => booleanNoRequire human approval before execution
timeoutnumberNoExecution timeout in milliseconds
sandboxSandboxConfigNoRun 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

TypeDescription
filesystemReads or writes files
networkMakes HTTP/network requests
databaseQueries or mutates a database
processSpawns processes or runs commands
externalCalls 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' runs sh -c <command> in the container, with the command, cwd and env arguments, and returns { stdout, stderr, exitCode, timedOut, duration, command }. Give such a tool a command parameter.
  • type: 'wasm' passes the arguments as JSON to wasmFunction of wasmModule and 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 parameters

The 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_weather

A 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 });

On this page