PAIPAI

defineTool()

Typed function the model can call on the server, or a client-routed declaration.

defineTool is a typed function the model can call. With execute, PAI runs it on the server. Without execute, the browser must fulfill it — see Client tools.

A tool has no ctx.identity. Caller facts go through ctx.context.

import { defineTool } from "@pai/core";
import { z } from "zod";

export const searchDocs = defineTool({
  id: "searchDocs",
  description: "Search workspace documentation",
  inputSchema: z.object({ query: z.string() }),
  outputSchema: z.object({
    hits: z.array(z.object({ title: z.string(), url: z.string() })),
  }),
  contextSchema: z.object({
    workspaceId: z.string(),
    docs: z.custom<DocsSearch>(),
  }),
  execute: async (ctx) => {
    const hits = await ctx.context.docs.search(ctx.input.query, {
      workspaceId: ctx.context.workspaceId,
      signal: ctx.signal,
    });
    return { hits };
  },
});

id is the stable wire name. The agent map key is what the model sees. Keep them the same unless you have a reason not to.

Import

import { defineTool } from "@pai/core";

Signature

function defineTool(config: {
  id: string;
  description: string | ((ctx) => string);
  inputSchema: z.ZodType;
  outputSchema?: z.ZodType;
  contextSchema?: z.ZodType;
  execute?: (ctx) => output | Promise<output>;
  suspend?: Record<string, { suspendSchema; resumeSchema }>;
  dataSchemas?: Record<string, z.ZodType>;
  capabilities?: CapabilityMap;
  sequential?: boolean | { failed?: (output) => boolean };
  tools?: ToolMap;
  toModelOutput?: (input) => ToolResultOutput;
}): ToolDefinition;

Parameters

config: ToolConfig

id: string

Non-empty, no surrounding whitespace. Stable across agents.

description: string | ((ctx) => string)

The model uses this to decide when to call the tool. The callback runs each model step with context. When the tool declares tools, it also receives tools: the description, inputSchema, and outputSchema of each enabled child, and direct, which is true when the step also offers the model that child as its own tool, under the same name and with the same id. In runtime.tools(...).list() direct is always false, since the listing's consumer chooses which tools its model sees.

inputSchema: z.ZodType

Parsed as ctx.input.

outputSchema?: z.ZodType

Validates the final result.

contextSchema?: z.ZodType

Types ctx.context. Filled from runtimeContext or mapContext.

execute?: (ctx: ToolExecutionContext) => output | Promise<output>

Omit for a client-routed tool. tools and capabilities require execute.

suspend?: Record<string, { suspendSchema, resumeSchema }>

Human-approval waits. Requires execute. See Human approval.

suspendSchema: z.ZodType

Payload the UI renders.

resumeSchema: z.ZodType

Payload action.submit must send.

dataSchemas?: Record<string, z.ZodType>

Named ToolData channels on ctx.data.

capabilities?: CapabilityMap

Bound at createPai / createAgentRuntime. Requires execute.

sequential?: boolean | { failed?: (output) => boolean }

Run each call alone, in the order the model wrote it. The runtime runs a step's calls in that order: consecutive calls to other tools run together, and a sequential call starts only after every call before it in the step has settled, so the calls after it see its effects. A call that waits, on a suspension or on its client, holds the runtime's calls after it until it settles. A client call itself is offered to its client when the step ends, whatever comes before it: ordering covers the calls the runtime runs. If a sequential call fails, the calls after it in that step that have not started are not run; each gets the tool error Not run: the earlier call to <tool> in this step failed. A tool error fails a call, a cancelled suspension included. A tool that reports some failures in its output, such as a status field or an exit code, passes { failed }: failed(output) returns true for such an output, which stays the call's result. It receives the output as stored, so undefined arrives as null and a resume can pass an output an earlier version recorded. Keep the check pure and cheap: it can run again as calls settle. If it throws, the call counts as failed and the error is recorded in diagnostics. Say in the tool's description which outputs count, since the model decides what to put in one step from it. The model step appends SEQUENTIAL TOOL: runs alone between parallel groups. to the tool's description; other tools' descriptions stay unchanged. When the step offers at least one sequential tool, PAI also adds one block of ordering and failure rules to its system instructions, after any existing instructions, whose messages and provider options are kept. The rules say that dependent sequential calls can share a step when all their inputs are known, and that a dependency on an unmarked tool needs a later step. Use sequential for tools whose effects later calls may read, such as writing a file another call runs. Requires execute: a client tool runs when its client handles it, so the runtime cannot order it. See Run in order.

tools?: ToolMap

Children this tool's execute calls through ctx.tools. Each value is a definition or a { tool, enabled, mapContext, onOutput } registration, as on defineAgent({ tools }). These entries are separate from the agent's registrations. Without contextSchema, this tool requires the combined context of children registered without mapContext, and registration callbacks receive that context. Children need execute and cannot declare suspend or tools. Existing dataSchemas are allowed; nested ToolData writes are ignored and reads return an empty list. Annotate a reusable map value with ToolMap; constrain a generic map parameter with ToolMapConstraint instead (see Forward a nested map). Test with createTestRuntime(), not runTool. codeModeTool() is built on this map. See Nested tools.

toModelOutput?: (input) => ToolResultOutput

Pure projection of the durable JSON result for the model. Re-run on replay.

Returns

A ToolDefinition.

Register on defineAgent({ tools }).

ToolExecutionContext

input: parsed inputSchema

context: parsed contextSchema or unknown

Not identity. See Tool context.

signal: AbortSignal

Abort when the user hits Stop.

thread: { threadId, runId, toolCallId, toolName }

agentId: string

createPai registry id.

entry: { type: "initial" } | { type: "resume", name, resume }

Initial call vs resume after ctx.suspend.

suspendHistory: unknown[]

Prior suspend payloads on this call.

data: { write, list }

Named channels from dataSchemas. Write with ctx.data.write("progress", value), not .set.

files: ScopedFiles

Scope-authorized files on this thread.

capabilities: CapabilityFacades

Bound at createPai / createAgentRuntime.

tools: ToolCallMap

One entry per child declared in tools, keyed the same way.

execute: (input, options?: { abortSignal?: AbortSignal }) => Promise<output>

Runs the child through PAI: input and output validation, context, capabilities, and registration callbacks. Shares this call's lifetime. Await every call before returning.

enabled: boolean

Resolved once before this call starts. When false, execute rejects with Tool is disabled.

inputSchema: FlexibleSchema

The child's input schema, shaped for AI SDK tools.

outputSchema?: FlexibleSchema

Present when the child declares outputSchema.

agents: AgentRunner

Run another registered agent.

fail: (message: string) => ToolFailResult

Planned stop. String, not { code, message }. Client sees { code: "tool_failed", message }.

suspend: (name, payload) => SuspendResult

Examples

Fail

if (!record) return ctx.fail(`No record ${ctx.input.id}.`);

Suspend and resume

if (ctx.entry.type === "initial") {
  return ctx.suspend("approve", {
    title: draft.title,
    summary: draft.summary,
  });
}
if (ctx.entry.type === "resume" && ctx.entry.name === "approve") {
  if (!ctx.entry.resume.approved) return { published: false };
  return { published: true };
}

Run in order

const runScript = defineTool({
  id: "run-script",
  description:
    "Run a script. A non-zero exit code counts as a failed call: the calls " +
    "after it in the same step are not run.",
  inputSchema: z.object({ path: z.string() }),
  outputSchema: z.object({ exitCode: z.number(), stdout: z.string() }),
  sequential: { failed: (output) => output.exitCode !== 0 },
  execute: async (ctx) => runScriptFile(ctx.input.path),
});

ToolData

await ctx.data.write("progress", { label: "Reading", percent: 10 });

Nested tools

export const totalSpend = defineTool({
  id: "totalSpend",
  description: "Total paid invoice spend, in cents, across months.",
  inputSchema: z.object({ months: z.array(z.string()) }),
  outputSchema: z.object({ totalCents: z.number().int() }),
  tools: { listInvoices },
  execute: async (ctx) => {
    if (!ctx.tools.listInvoices.enabled) {
      return ctx.fail("Invoices are not available.");
    }
    const months = await Promise.all(
      ctx.input.months.map((month) =>
        ctx.tools.listInvoices.execute({ month }),
      ),
    );
    const totalCents = months
      .flat()
      .filter((invoice) => invoice.status === "paid")
      .reduce((sum, invoice) => sum + invoice.amountCents, 0);
    return { totalCents };
  },
});

execute input and output are typed from listInvoices. Child calls do not appear on the thread. A child registered without mapContext makes totalSpend require its context too. To let the model write this kind of composition itself, see Code mode.

Forward a nested map

A factory that takes its caller's map constrains the type parameter with ToolMapConstraint and takes the map through ToolConfig, so registrations stay typed. This is how codeModeTool is built.

import { defineTool, type ToolConfig, type ToolMapConstraint } from "@pai/core";
import { z } from "zod";

export function countingTool<
  const TTools extends ToolMapConstraint = {},
  const TContext extends z.ZodType | undefined = undefined,
>(
  options: Pick<
    ToolConfig<z.ZodObject<{}>, undefined, TContext, undefined, undefined, {}, TTools>,
    "tools" | "contextSchema"
  >,
) {
  return defineTool({
    id: "countingTool",
    description: "Count the enabled children.",
    inputSchema: z.object({}),
    tools: options.tools,
    contextSchema: options.contextSchema,
    execute: (ctx) =>
      Object.values(ctx.tools).filter((child) => child.enabled).length,
  });
}

ToolMap does not work as this constraint: inference records a registration before its callbacks are typed, and those registrations do not satisfy it.

Learn more

On this page