Skip to content
Agent SDK

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

FieldRequiredNotes
nameyesunique within the tool table
descriptionyeswritten for the model; decides when it is called
schemayesZod schema: runtime validation, converted to JSONSchema for the model
handleryes(input, ctx) => result; the return value enters context
timeoutnomilliseconds, default 60000
cacheablenomark when the same input yields the same output
dangerousnohigh risk; denied under permission:'auto' unless a handler allows it
readOnlynono 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'tDo
refundsRefund a paid order. Only when the customer explicitly asked
queries dataLook 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:

MemberUse
ctx.signalupstream abort signal; long work should respect it
ctx.loggerdebug / info / warn / error
ctx.emitpush 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'] }),
  ],
});
FactoryProducesBoundary
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_commandexecFile, never a shell (no interpolation); denies everything without an allow-list; always dangerous
agent(opts)one sub-agent toolthe 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

  1. One tool, one job. A do-everything tool with an action parameter is measurably misused more.
  2. Prefer ids and enums over free text.
  3. Keep the table under ~20 tools.
  4. Make long work async: return a job id, then poll with a second tool.