# Creating Custom Tools

URL: https://cogitator.app/docs/tools/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.

```typescript
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

```typescript
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

```typescript
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

```typescript
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

```typescript
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](#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`:

```typescript
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:

```typescript
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:

```typescript
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:

```typescript
// 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](/docs/core/tools#images-in-tool-results)).

```typescript
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:

```typescript
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:

```typescript
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:

```typescript
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:

```typescript
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](/docs/tools/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'`** 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.

```typescript
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](/docs/deployment/sandbox) for the configuration.

For lightweight computation, use WASM sandboxing instead (or [`defineWasmTool()`](/docs/tools/wasm-tools), which sets this up):

```typescript
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.

```typescript
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:

```typescript
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):

```typescript
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:

```typescript
import { Agent, ToolRegistry } from '@cogitator-ai/core';

const registry = new ToolRegistry();
registry.registerMany([weatherTool]);

const restored = Agent.deserialize(snapshot, { toolRegistry: registry });
```
