Defining tools
Zod schemas, error handling, timeouts and danger flags.
Shape
import { tool } from '@neoxlabs/sdk';
import { z } from 'zod';
const refundOrder = tool({
name: 'refund_order',
description: 'Refund a paid order. Only when the customer explicitly asked; never to cancel an unpaid order.',
schema: z.object({
orderId: z.string(),
amountCents: z.number().optional(),
reason: z.string(),
}),
handler: async ({ orderId, amountCents, reason }, ctx) => {
ctx.logger.info('refunding', orderId);
return payments.refund(orderId, { amountCents, reason });
},
timeout: 30_000,
dangerous: true,
});The schema type flows into the first handler argument — no hand-written interfaces.
Fields
| Field | Required | Notes |
|---|---|---|
name | yes | unique within the tool table |
description | yes | written for the model; decides when it is called |
schema | yes | Zod schema: runtime validation, converted to JSONSchema for the model |
handler | yes | (input, ctx) => result; the return value enters context |
timeout | no | milliseconds, default 60000 |
cacheable | no | mark when the same input yields the same output |
dangerous | no | high risk; denied under permission:'auto' unless a handler allows it |
readOnly | no | no side effects; the only tools that run under permission:'readonly' (inferred from !dangerous when omitted) |
Writing the description
The description is the only signal for when a tool applies. Put the boundary in it:
| Don't | Do |
|---|---|
refunds | Refund a paid order. Only when the customer explicitly asked |
queries data | Look up orders by customer id; returns the 20 most recent with status and amount |
Negative constraints ("never use this for X") usually outperform three lines of positive description.
Return values are context
Whatever handler returns goes into the model's context verbatim:
- Return only the fields needed. A whole ORM object eats the context budget.
- Keep values self-describing:
{ status: 'refunded' }beats{ status: 2 }.
Errors and timeouts
Exceptions thrown by handler are caught and surfaced as tool_error; repeated failures end the run with stopReason: 'tool_error'.
So express expected failures as return values instead of throwing:
handler: async ({ orderId }) => {
const order = await db.orders.get(orderId);
if (!order) return { error: `No order ${orderId}` }; // the model can route around it
return order;
}Timeouts follow timeout (60s default) and also surface as tool_error.
ToolContext
The second handler argument:
| Member | Use |
|---|---|
ctx.signal | upstream abort signal; long work should respect it |
ctx.logger | debug / info / warn / error |
ctx.emit | push custom events into the agent stream (advanced) |
Built-in tools
Skip writing file tools from scratch:
import { builtinTools } from '@neoxlabs/sdk/tools';
const agent = new Agent({
model: 'claude-sonnet-4-6',
tools: [
...builtinTools.fs({ root: './src', allowWrite: true }),
...builtinTools.shell({ allowedCommands: ['npm', 'git'] }),
],
});| Factory | Produces | Boundary |
|---|---|---|
fs(opts) | read_file list_files search_files (+ write_file edit_file with allowWrite) | every path pinned inside root, symlink escapes included; read-only by default; write tools are dangerous |
shell(opts) | run_command | execFile, never a shell (no interpolation); denies everything without an allow-list; always dangerous |
agent(opts) | one sub-agent tool | the sub-agent runs its own loop and returns text; may use a different model and tool set |
web() and mcp() are not implemented and throw explicitly.
Boundaries that hold up
- One tool, one job. A do-everything tool with an
actionparameter is measurably misused more. - Prefer ids and enums over free text.
- Keep the table under ~20 tools.
- Make long work async: return a job id, then poll with a second tool.

