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
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
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 }, 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.

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.