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.
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.
| Field | Meaning |
|---|---|
ctx.input | Parsed inputSchema |
ctx.context | The tool's context (runtimeContext, or mapContext) |
ctx.signal | Abort when the user hits Stop |
ctx.thread | threadId, runId, toolCallId, toolName |
ctx.agentId | The createPai registry id running this call |
ctx.entry | { type: "initial" } or { type: "resume", name, resume } after ctx.suspend |
ctx.fail | Planned stop — Failures |
ctx.suspend | Wait for a person — Human approval |
ctx.suspendHistory | Prior suspend payloads on this call |
ctx.data | write / list — Tool data |
ctx.files | Scope-authorized files on this thread — Files |
ctx.capabilities | Bound facades (sandbox, …) — Sandbox |
ctx.tools | Nested child tools declared on this definition — nested tools |
ctx.agents | Run 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
- Tool context —
contextSchema,mapContext, identity - Failures —
ctx.failvs throw vs cancel - Tool data — typed progress channels
- Code mode — the model writes one script over several tools
- Wait for a person: Human approval
- Run in the browser: Client tools