PAIPAI

Backend Tools

Define tools that run inside the backend runtime.

Use defineTool() for server-side tools.

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

export const createTask = defineTool({
  id: "createTask",
  description: "Create a workspace task",

  inputSchema: z.object({
    title: z.string(),
    description: z.string().optional(),
  }),

  outputSchema: z.object({
    taskId: z.string(),
  }),

  execute: async (ctx) => {
    const task = await ctx.context.tasks.create(
      {
        workspaceId: ctx.context.workspace.id,
        title: ctx.input.title,
        description: ctx.input.description,
      },
      { signal: ctx.signal },
    );

    return { taskId: task.id };
  },
});

Add tools to the agent:

export const agent = defineAgent({
  tools: {
    createTask,
  },
});

Tool input and output schemas become part of the inferred contract and manifest. The required id is the definition's stable wire-spec identity; it is independent of the createTask registration key. Reuse an id for aliases or separate implementations of the same spec. Give incompatible schemas distinct ids, and use a new or versioned id when old persisted conversations can coexist with an incompatible revision.

Use toModelOutput when the model needs a different representation of a successful result. For example, a tool can store a JSON object containing a chart's base64 data, then project it into a native image block so the model can inspect the chart. The callback uses the AI SDK's signature and leaves the stored result and renderer output intact.

Conditional Availability

Register a tool with enabled when it should only be available in some runtime contexts:

export const agent = defineAgent({
  runtimeContext: async ({ identity }) => ({
    permissions: await loadPermissions(identity.userId, identity.workspaceId),
  }),
  tools: {
    deleteAccount: {
      tool: deleteAccount,
      enabled: ({ runtimeContext }) =>
        runtimeContext.permissions.canDeleteAccounts,
    },
  },
});

enabled accepts a boolean or a sync/async resolver. The resolver receives the same runtimeContext, identity, clientData, threadId, and signal as other agent behavior resolvers. PAI calls it before each model step and whenever it rechecks tool availability for a pending-action response, ToolData write, or resume execution. A resolver can therefore run multiple times for one run or thread; keep it side-effect-free and idempotent, and honor signal during async work. A disabled tool is omitted from the model request and cannot execute, but it remains in the agent's static contract and manifest.

If a tool becomes disabled while one of its actions is pending, successful submission and resumed execution are rejected. Cancellation and failure remain available so clients can close obsolete pending work without re-enabling the tool.

Use this for coarse tool availability. The tool's execute function must still authorize the specific resource and operation from trusted identity and scope; clientData is not an authorization source.

Tool Context

A tool can declare the context it needs. This keeps reusable tools small and prevents them from depending on the whole agent runtime context.

type TaskStore = {
  create(input: { workspaceId: string; title: string }): Promise<{ taskId: string }>;
};

export const createTask = defineTool({
  id: "createTask",
  description: "Create a workspace task",
  inputSchema: createTaskInput,
  outputSchema: createTaskOutput,
  contextSchema: z.object({
    workspaceId: z.string(),
    tasks: z.custom<TaskStore>(),
  }),
  execute: async (ctx) => {
    return ctx.context.tasks.create({
      workspaceId: ctx.context.workspaceId,
      title: ctx.input.title,
    });
  },
});

If the agent runtimeContext already satisfies the tool context, register the tool directly. If not, map the agent runtime context to the tool context:

export const agent = defineAgent({
  runtimeContext: async ({ scope }) => ({
    workspace: await loadWorkspace(identity.workspaceId),
    stores: await loadStores(identity.workspaceId),
  }),
  tools: {
    createTask: {
      tool: createTask,
      mapContext: ({ runtimeContext }) => ({
        workspaceId: runtimeContext.workspace.id,
        tasks: runtimeContext.stores.tasks,
      }),
    },
  },
});

Direct registration is allowed when the agent runtime context is assignable to the tool's declared context. Otherwise TypeScript requires a configured registration with mapContext, so an incompatible agent runtime context cannot reach execute accidentally.

Tool context is backend-only. React renderers read public tool state such as input, output, suspend payloads, and tool data.

Observing Tool Output

A tool is portable across agents, so a consequence only one agent cares about does not belong in the tool's own context. Register onOutput beside the tool to keep agent state from its results:

export const agent = defineAgent({
  runtimeContext: async ({ identity }) => ({
    recents: await loadRecentDocuments(identity.userId),
  }),
  tools: {
    openDocument: {
      tool: openDocument,
      onOutput: async ({ runtimeContext, input, output }) => {
        await runtimeContext.recents.record(input.docId, output.revision);
      },
    },
  },
});

onOutput receives the same runtimeContext, identity, clientData, threadId, and signal as enabled and mapContext, plus the call itself: input and output typed from this tool's own schemas, tool.name as this agent registered it, and call.toolCallId.

lifecycle.afterToolResult also sees every tool result, but it sees them as name: string with output: unknown, so an agent using it to keep state from one tool has to match the name and cast. Prefer onOutput when you know which tool you mean; reach for the lifecycle hook when the behavior is agent-wide, or when you need to observe failures as well.

It runs once per call, on the settled, validated output — after afterToolResult has had its say — so it sees exactly what the next model step will:

  • a suspended call runs it on the resumed result, not at the approval;
  • a client-executed tool runs it on the submitted output, once validated;
  • a skipped call runs it on the synthetic output the model is given;
  • a failed call never reaches it, and neither does one afterToolResult turned into an error;
  • a failure afterToolResult turned into an output does reach it.

It is purely an observer: the return value is ignored and the result cannot be rewritten. Throwing fails the run, with the error reported to onRuntimeError and redacted from the client. PAI awaits it before the output reaches the model, so slow work here delays the run — and its side effects are not transactional with the tool result, which is persisted after it returns.

Capabilities

For stateful, lifecycle-bound resources — a sandbox per conversation, a browser per user — declare a capability instead of threading a service through context. The runtime provides a typed facade at ctx.capabilities.<name> and owns provisioning and cleanup:

import { sandbox } from "@pai/sandbox";

export const runScript = defineTool({
  id: "runScript",
  description: "Run a command in this conversation's sandbox.",
  inputSchema: z.object({ command: z.string() }),
  capabilities: { sandbox },
  execute: (ctx) =>
    ctx.capabilities.sandbox.runCommand({ command: ctx.input.command }),
});

The binding is supplied at createAgentRuntime({ capabilities }). See Capabilities for the mental model and when plain context is the better fit.

Guidelines

  • Make tool effects idempotent where possible.
  • Use signal for cancellation.
  • Keep large generated resources in your application data model and return stable ids from tool output.
  • PAI does not retry execute automatically. If a side effect can run before a suspend point, protect it with your own idempotency key or durable app state.

On this page