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.


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

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:

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.

On this page