Guide

Defining Tools

Every field of defineTool and the two schema rules it enforces. Plus the one import you must never write

defineTool takes one object and hands it back typed. It also checks the schema on the spot, so a mistake shows up when the module loads, not when a model finally calls the tool.

tstools.ts
import { defineTool, Type } from "@agntn/tools";

export const slugTool = defineTool({
  name: "text_slug",
  title: "Text Slug",
  description: "Turn a title into a lowercase URL slug.",
  snippet: "Use text_slug to turn a title into a URL slug.",
  guidelines: ["Pass separator '_' for identifiers. The default '-' is for URLs."],
  effect: "read",
  input: Type.Object(
    {
      text: Type.String({ minLength: 1, maxLength: 200, pattern: "\\S" }),
      separator: Type.Optional(Type.Enum(["-", "_", "."])),
    },
    { additionalProperties: false },
  ),
  execute: ({ text, separator = "-" }) => slugify(text, separator),
});

The fields

FieldWhat it's for
nameThe wire name. Lowercase letters, digits and _, starting with a letter, up to 64 characters. <package>_<operation> reads best. Say one twice in a list and every adapter throws, instead of letting each host pick its favourite
titleThe human label. MCP calls it title, Pi and OMP call it label
descriptionThe whole instruction for the model. An MCP client sees nothing else, so don't save it for the guidelines
iconsPictures for MCP clients that draw tools: src, then optional mimeType, sizes and theme. Optional, and only MCP looks
snippetPi's promptSnippet. Optional
guidelinesPi's promptGuidelines. Optional. They may add advice, never a promise the description doesn't make
effectread, write or destructive. Drives the MCP hints and the OMP approval tier
idempotentWhether calling twice changes nothing more. Defaults to effect === "read". Set it to false for a read that draws something random
openWorldWhether the call leaves the machine. Defaults to false
inputA TypeBox object schema built with Type from this package
executeGets the checked input and { signal, progress }, returns a ToolResult or a promise of one

A ToolResult is { content, details, isError? }. content is what the model reads, text blocks or an image. details is the same facts as data, for the hosts that keep it. isError marks an answer that's a failure but not a crash, like "nothing to slug in there".

Import Type from here. Really

ts
import { Type } from "@agntn/tools"; // yes
import { Type } from "typebox"; // no, and OMP is why

OMP rewrites every bare typebox import in an extension, and in everything that extension imports, to its own omptype facade. Its schemas are functions, not JSON Schema. A validator from typebox/value, which OMP leaves alone, then looks at a function and happily accepts any input you throw at it. No error, no warning, just validation quietly gone.

@agntn/tools bundles its own TypeBox, so there's no bare import left for OMP to touch. defineTool also refuses a schema that turned out to be a function and tells you where the Type came from. The schema types come from here too (Static, TObject and the rest), so your declarations name the same TypeBox the runtime uses.

The two schema rules

defineTool walks the whole schema: properties, record values, array items and union branches. Two things fail it.

An open object with properties

Every object that declares keys must say additionalProperties: false. Otherwise a misspelled key just disappears. read_only instead of readOnly once ran a tool with full access, and nothing complained. An empty Type.Object({}) stays open, because models like to send { _: "" } to a tool without arguments and that's harmless.

A union of literals

Type.Union([Type.Literal("a"), Type.Literal("b")]) has to be Type.Enum(["a", "b"]). The union serializes as a pile of anyOf objects about three times the size, and when it fails TypeBox says "must be equal to constant" about the first literal only. The enum says must be one of a, b. The model reads that and picks one. Good model.

Optional text that may come empty

Some hosts fill every optional field, and an empty string is what they fill it with. If an empty value should mean "not given", let the schema accept it: no minLength on an optional string. Then trim in execute and treat blank as absent. A schema that refuses "" pushes the model into inventing something, and invented arguments are worse than missing ones.

Say it's still working

A tool that waits a minute looks exactly like a hung one. Unless it says something. progress in the call context sends one line per update:

tstools.ts
async execute({ id }, { signal, progress }) {
  for (let check = 1; ; check++) {
    const job = await checkJob(id, signal);
    if (job.done) return { content: [{ type: "text", text: `${id} is done` }], details: job };
    progress?.(`${id} still running, check ${check}`);
    await sleep(5_000, signal);
  }
}

Why the ?.? Because not every host has a place for it:

HostWhere the line goes
MCPnotifications/progress, only when the client sent a progressToken
PionUpdate with one text block, so the call keeps spinning
OMPSame, and the status line shows it next to the spinner
CLIOne stderr line, rewritten in place and wiped before the answer. On a terminal only
AI SDKNowhere. progress is undefined

The second argument is optional. Know how far along you are? Pass { progress, total }, like milliseconds against a timeout. MCP wants progress to grow with every notification. A value that goes back is dropped there, and a line without one counts one step up.

Every line goes through sanitizeLine. Once the call has answered, progress goes quiet. A forgotten timer can't talk over the result. And if a host chokes on a line, your call doesn't even notice.

Borrow what the host knows

Pi and OMP know who's logged in and which session is running. MCP doesn't even know there's a login. host in the call context is the host's own ctx. The Pi and OMP adapters hand it over untouched. The CLI brings its own little CliHost. Is --json watching? It says. Everywhere else it's undefined.

It's typed unknown, because the core imports neither host. So you narrow it yourself:

tstools.ts
interface HostContext {
  readonly modelRegistry?: unknown;
  readonly sessionManager?: { getSessionId(): string };
}

async execute({ query }, { signal, host }) {
  const { modelRegistry, sessionManager } = (host ?? {}) as HostContext;
  return await search(query, { signal, modelRegistry, session: sessionManager?.getSessionId() });
}

The shapes differ. Pi's modelRegistry has getProviderAuth, OMP keeps its logins under modelRegistry.authStorage. The tool still has to answer on MCP, though. Keep a path that works without them. A shim over pi.registerTool would get you the same ctx, sure. It's also exactly the wrapper this package is here to delete.

Keep the executor lazy

A Pi or OMP extension imports your tools at startup to register them. If the tools module imports your whole library, every agent start pays for it, even when nobody calls the tool. Load the heavy part on the first call:

tstools.ts
let operations: Promise<typeof import("./tool-operations.ts")> | undefined;

function loadOperations() {
  operations ??= import("./tool-operations.ts");
  return operations;
}

export const hashTool = defineTool({
  // …
  execute: async (params) => (await loadOperations()).hashCompute(params),
});

Registering the tools then costs the schema and nothing else.