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
afterToolResultturned into an error; - a failure
afterToolResultturned 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
signalfor cancellation. - Keep large generated resources in your application data model and return stable ids from tool output.
- PAI does not retry
executeautomatically. If a side effect can run before a suspend point, protect it with your own idempotency key or durable app state.