# Writing tools

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

```ts
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()`:

```ts
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](sdk.md) 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.

| Hint              | Set it when the tool                                             |
| ----------------- | ---------------------------------------------------------------- |
| `readOnlyHint`    | changes nothing                                                  |
| `destructiveHint` | may delete or overwrite data (cannot be combined with read-only) |
| `idempotentHint`  | gives the same effect when called twice with the same input      |
| `openWorldHint`   | reaches 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:

```ts
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](secrets.md).

## Testing

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