PAIPAI

Tools

Give the model functions that run on your server.

A tool is a typed function the model can call. PAI runs it on the server, stores the result on the thread, and shows it in the UI.

Use a tool when the model needs something it cannot guess: search your docs, write a row, call an internal API.

A tool has no ctx.identity. Everything it knows about the caller comes from ctx.context — usually filled from the agent's runtimeContext.

server/tools/search-docs.ts
import { defineTool } from "@pai/core";
import { z } from "zod";

type DocsSearch = {
  search: (
    query: string,
    input: { workspaceId: string; signal: AbortSignal },
  ) => Promise<Array<{ title: string; url: string }>>;
};

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 };
  },
});

Put the workspace and your services on runtimeContext, then register the tool:

Add runtimeContext and tools to the first-app agent — do not replace the file:

export const assistantAgent = defineAgent({
  name: "assistant",
  model: openai("gpt-4.1-mini"),
  identity: z.object({
    userId: z.string(),
    workspaceId: z.string(),
  }),
  runtimeContext: async ({ identity }) => ({
    workspaceId: identity.workspaceId,
    docs: createDocsClient(),
    permissions: await loadPermissions(identity),
  }),
  instructions: "Help with this workspace. Prefer tools over guessing.",
  tools: { searchDocs },
});

If the tool's context is the same shape as runtimeContext, registration is just { searchDocs }. If it is narrower, project it with mapContext — see Tool context.

The registration key (searchDocs) is what the model sees. The id is the stable wire name. Keep them the same unless you have a reason not to.

What execute receives

Every field on ctx. How to use fail / suspend / data lives on the linked pages.

FieldMeaning
ctx.inputParsed inputSchema
ctx.contextThe tool's context (runtimeContext, or mapContext)
ctx.signalAbort when the user hits Stop
ctx.threadthreadId, runId, toolCallId, toolName
ctx.agentIdThe createPai registry id running this call
ctx.entry{ type: "initial" } or { type: "resume", name, resume } after ctx.suspend
ctx.failPlanned stop — Failures
ctx.suspendWait for a person — Human approval
ctx.suspendHistoryPrior suspend payloads on this call
ctx.datawrite / list — Tool data
ctx.filesScope-authorized files on this thread — Files
ctx.capabilitiesBound facades (sandbox, …) — Sandbox
ctx.toolsNested child tools declared on this definition — nested tools
ctx.agentsRun another registered agent — Subagents

Honor ctx.signal. A tool that ignores it will keep running after the user cancelled.

Write a clear description. The model uses it to decide when to call the tool.

Hide a tool for some users

defineAgent({
  tools: {
    searchDocs,
    deleteRecord: {
      tool: deleteRecord,
      enabled: ({ runtimeContext }) => runtimeContext.permissions.canDelete,
    },
  },
});

When enabled is false, the model does not see the tool.

Render tools in React

{isPaiToolPart(part) ? (
  <Assistant.Tool message={message} part={part} />
) : null}

Custom cards: Tool UI.

Go deeper

On this page