Skip to main content

Private beta: Request private beta access

zenitya

Writing tools

Define tools with @zenitya/sdk: schemas, hints, output, logs and errors.

View as markdown

A project's entrypoint (src/index.ts by default) creates a Project, exports it as the default export, and registers tools on it.

typescript
import { Project } from "@zenitya/sdk";
import { s } from "@zenitya/sdk/schema";

const project = new Project();
export default project;

project.tool("findLateOrders", {
  title: "Find late orders",
  description:
    "Lists orders that missed their promised delivery date. Use before deciding on refunds. Returns at most `limit` orders, oldest first.",
  schema: s.object({
    since: s.date().describe("Only orders promised on or after this date (YYYY-MM-DD)."),
    limit: s.integer().describe("How many orders to return, 1 to 100."),
  }),
  outputSchema: s.array(
    s.object({
      orderId: s.string(),
      promisedOn: s.date(),
      daysLate: s.integer(),
    }),
  ),
  hints: { readOnlyHint: true, openWorldHint: true },
  execute: async ({ since, limit }, { logger, signal }) => {
    logger.info({ since, limit }, "searching late orders");
    const orders = await fetchLateOrders(since, Math.min(limit, 100), signal);
    return orders.map((order) => ({
      orderId: order.id,
      promisedOn: order.promisedOn,
      daysLate: order.daysLate,
    }));
  },
});

Agents see this tool as <project>.findLateOrders. A project can register up to 128 tools.

Names, titles and descriptions

  • Key: starts with a letter, then letters, digits and underscores, at most 64 characters. It is the name agents and zya exec use, so treat it as an API: renaming it breaks callers.
  • Title: a short human label, at most 120 characters.
  • Description: at most 2,000 characters. This is what an agent reads to decide whether to call the tool, so say what it does, when to use it, and what it returns. Mention limits and side effects.

Describe every input field with .describe(). Agents fill in arguments from these descriptions; a field described as "Date (YYYY-MM-DD) the order was promised" gets better input than one named d.

Input schema

The input is always an object built with s.object. Every property is required and unknown properties are rejected, so an agent's call is either exactly right or refused before your code runs. When a value may be absent, keep the property and make it .nullable():

typescript
schema: s.object({
  customerId: s.string().describe("The customer's ID."),
  note: s.string().nullable().describe("Optional note for the log, or null."),
}),

Zenitya validates the input against the schema before execute runs. A mismatch fails with invalid_input and your code never sees it. The input type of execute is inferred from the schema. See the SDK reference for every builder.

Output

execute returns any JSON value: an object, array, string, number, boolean or null. Declare outputSchema whenever you can: agents see it before calling, and Zenitya validates what you return against it. Output that does not match fails the call with invalid_output instead of handing an agent a surprise.

Input and output are each limited to 1 MiB of JSON, nested at most 64 levels.

Hints

Hints follow MCP's tool annotations. Clients use them to decide when to ask the person before calling.

HintSet it when the tool
readOnlyHintchanges nothing
destructiveHintmay delete or overwrite data (cannot be combined with read-only)
idempotentHintgives the same effect when called twice with the same input
openWorldHintreaches systems outside your organization, such as a public API

Context: logs and cancellation

The second argument of execute carries:

  • logger: debug, info, warn and error, each taking an object of fields and an optional message. Logs are returned with the call (on stderr in zya exec) and are kept separate from the output. console.log and friends are captured as logs too.
  • signal: an AbortSignal that aborts when the call times out. Pass it to fetch and other cancellable work.
  • invocationId: a unique ID for this call, useful in logs and for idempotency with downstream systems.

Logs are bounded: 1,000 lines and 256 KiB per call, 8 KiB per line.

Errors

Throw an Error with a message that tells the caller what went wrong and what to do:

typescript
if (order === undefined) throw new Error(`No order ${orderId}. Check the ID with findLateOrders.`);

The call fails with code execution_error and your message (up to 512 bytes). Agents read that message and often retry with corrected input, so make it actionable. Never include secrets in error messages or logs.

Time limits

A deployed call may run for 60 seconds; zya exec --local stops a run after 30 seconds. Long jobs should start work elsewhere and return an ID to poll with a second tool.

Dependencies

Add npm packages with bun add <package>. Deploys install exactly what bun.lock pins, so commit it. Deployed tools run on Bun, without internet access unless the project turns it on.

Testing

shell
zya exec findLateOrders --local --data '{"since":"2026-11-01","limit":5}'
echo '{"since":"2026-11-01","limit":5}' | zya exec findLateOrders --local

--local runs your source with Bun on this machine and loads .env from the project directory (--dotenv <path> for another file, --no-dotenv for none). It is your own code with your own permissions, so it can reach anything you can. You can also test execute functions directly with bun test.