Skip to content
Helix Docs

Defining tools

Give an agent a capability with defineTool: a name, a description the model reads, a Zod input schema, and a handler that gets the caller's ctx.

For developers Updated Oct 20, 2026
View as Markdown

A tool is one thing an agent can do: look up a record, send a message, run a calculation. defineTool bundles its name, a description the model reads to decide when to call it, a Zod schema for its input, and the handler that does the work. The same definition can be called directly from your own code, so a tool is also an ordinary typed function.

Anatomy of a tool

// functions/_shared/tools/create-ticket.ts
import { z } from 'zod';
import { defineTool } from '@trayai/helix-sdk';

export default defineTool({
  name: 'create_ticket',
  description: 'Open a support ticket for the customer. Use when the issue cannot be resolved in chat.',
  input: z.object({
    customerId: z.string().min(1),
    summary: z.string().min(1).max(200),
    priority: z.enum(['low', 'normal', 'high']).default('normal'),
  }),
  handler: async (ctx) => {
    const { body } = await ctx.http
      .authed('zendesk')
      .post('https://example.zendesk.com/api/v2/tickets', {
        body: { ticket: { subject: ctx.input.summary, priority: ctx.input.priority } },
      });
    const ticket = body as { ticket: { id: number } };

    ctx.log.info('Ticket created', { ticketId: ticket.ticket.id });
    return { ticketId: ticket.ticket.id };
  },
});
FieldRequiredWhat it does
nameYesWhat the model calls. Lowercase snake case, starting with a letter, up to 64 characters. Must be unique within an agent
descriptionYesWhat the model reads to decide when to use the tool and what it returns. Write it for the model, not for a person
inputYesA Zod schema. The runtime validates every call against it before the handler runs, so ctx.input is typed and trusted
needsApprovalNotrue, or a function of the validated input, to pause an agent before the tool runs. See tool approval
handlerYesReceives the caller’s ctx with input added. Return anything JSON-serializable

Writing the description

The description is the only thing the model knows about the tool besides its input shape. Say what the tool does, when to use it, and what comes back:

description: 'Look up a customer by email. Returns their id, plan, and open ticket count, or found: false if there is no match. Use before answering any account question.',

Field descriptions on the schema reach the model too, so describe anything that is not obvious from the name:

input: z.object({
  email: z.string().email().describe('The customer login email, lowercase'),
  includeHistory: z.boolean().default(false).describe('Also return the last 10 orders'),
}),

The handler and ctx

The handler receives the same ctx the calling function has, with input replaced by the validated tool input. Everything a function can do, a tool can do: connected services with ctx.http.authed, the key-value store, ctx.log, and ctx.ai for a nested model call.

Inside an agent run, the tool runs with the ctx of the function that called ctx.agent(). Identity, project, and workspace are the caller’s. A tool that writes to ctx.kv() writes to the project store like any other code.

Thrown errors do not end the agent run. The runtime turns a thrown error into a tool error result the model can read, and the model decides what to do next, which is usually to tell the user or try a different approach. Throw with a clear message:

handler: async (ctx) => {
  const order = await ctx.kv().getItem(`order:${ctx.input.orderId}`);
  if (!order) throw new Error(`No order with id ${ctx.input.orderId}`);
  return order;
},

Invalid input never reaches the handler. The model sees the validation failure and can correct its call.

Keep results small

Whatever the handler returns is serialized into the conversation and sent to the model on every later step. A tool that returns a 50 KB API response makes every following model call slower and more expensive. Return the fields the model needs:

handler: async (ctx) => {
  const { body } = await ctx.http.authed('crm').get(`https://crm.example.com/accounts/${ctx.input.id}`);
  const account = body as any;
  return { id: account.id, name: account.name, plan: account.subscription.plan, renewsOn: account.subscription.renews_at };
},

Results are also stored in the session and sent again on later turns while they remain in the model’s window, so a large result is paid for more than once. See agent sessions.

Calling a tool directly

A tool definition is callable. Pass the input and it validates, runs the handler with the current execution’s ctx, and returns the result. No agent is involved:

// functions/api/v1/tickets.post.ts
import { z } from 'zod';
import { defineFunction } from '@trayai/helix-sdk';
import createTicket from '../../_shared/tools/create-ticket';

export const input = z.object({
  customerId: z.string(),
  summary: z.string(),
});

export default defineFunction<typeof input>(async (ctx) => {
  const result = await createTicket({
    customerId: ctx.input.customerId,
    summary: ctx.input.summary,
    priority: 'high',
  });
  return result;
});

A direct call is not gated by needsApproval. Calling the tool yourself is the approval. Invalid input throws SdkError.inputValidationFailed naming the failing fields.

The call must happen inside a function execution, because the tool reads the current ctx with getContext(). Outside one, for instance from a script or a bare test, there is no ctx and the call throws; the testing section below shows how to supply one.

The definition also exposes its parts, which is what the agent runtime reads:

PropertyType
namestring
descriptionstring
inputSchemaThe Zod schema you passed as input
needsApprovalWhat you passed, or undefined
execute(input, ctx)Validate and run with an explicit ctx

Sharing tools between agents

A tool is a module. Import it into as many agents as need it. Each agent compiles its own copy, so one agent can hold a tool that needs approval while another does not, by wrapping the same handler in two definitions.

// functions/_agents/sales-agent.ts
import { defineAgent } from '@trayai/helix-sdk';
import lookupCustomer from '../_shared/tools/lookup-customer';
import createTicket from '../_shared/tools/create-ticket';

export default defineAgent({
  name: 'sales-agent',
  description: 'Handles pricing and upgrade questions.',
  model: 'claude-sonnet-4-5',
  auth: 'anthropicKey',
  instructionsFile: './sales-agent.md',
  tools: [lookupCustomer, createTicket],
});

Testing a tool

Because a tool runs under getContext(), test it the way you test shared code, with runWithContext from the testing module:

// tests/tools/create-ticket.test.ts
import { runWithContext } from '@trayai/helix-sdk/testing';
import createTicket from '../../functions/_shared/tools/create-ticket';

test('creates a ticket', async () => {
  const result = await runWithContext(
    { http: mockHttp, log: mockLog },
    () => createTicket({ customerId: 'c_1', summary: 'Login fails', priority: 'high' }),
  );
  expect(result).toEqual({ ticketId: 42 });
});

Full signature and property list: agent definition reference.

Loading search…

Jump to a section

tab to move · esc to close