Tools And Pending Actions
The @pai/core backend tool and pending action API.
Package: @pai/core
defineTool
function defineTool<TConfig extends ToolConfig>(
config: TConfig,
): ToolDefinition<InferToolContract<TConfig>, InferToolContext<TConfig>>;defineTool() creates a tool definition. A tool can execute in the backend, suspend and resume, or omit execute so an external client submits the output later.
Named suspend points require a backend execute function because only that
function can enter them. A tool without execute exposes one implicit output
action instead, using the tool input as its pending input and accepting the tool
output when submitted.
When the config declares capabilities, the declared map is carried in the definition's third type parameter, so ctx.capabilities and the runtime binding slot are typed from the declaration. Declaring capabilities without execute is a construction-time error.
Tool definition ids
Every app-defined tool requires a non-empty literal id. The id is the stable,
globally unique identity of one wire spec: its input, output, tool data, suspend
points, and execution mode. It does not have to match an agent's registration
key.
The value must be one concrete string literal with no leading or trailing
whitespace. Keep an app-owned id stable in source and namespace it to the
application or domain, for example "crm.lookup-customer.v1". A reusable
package should use a namespace it owns, such as
"acme.pai-crm.lookup-customer.v1", so its definitions do not collide with an
application's ids or definitions from another package. The punctuation is a
convention rather than a parser requirement; stability and uniqueness are the
contract.
Pass the tool config inline so TypeScript preserves the id literal. If you
extract the config, preserve the property with
id: "crm.lookup-customer.v1" as const; annotating the object as ToolConfig
or using satisfies ToolConfig without as const widens the property and fails
the literal-id check.
Reuse the same id when multiple agents register the same definition under
different names, or when separate implementations intentionally implement the
same wire spec. This lets generated clients share one renderer and one spec
type across those registrations. Two incompatible specs must never share an
id. When changing a schema incompatibly while old persisted conversations or
generated clients can still coexist, assign a new or versioned id, such as
"crm.lookup-customer.v2".
Persisted tool-call compatibility
Treat each tool registration name as a durable version identifier. A terminal PAI-managed static call may load unchanged after that name is removed, but an unfinished removed call requires migration because no current tool can finish it. Do not reuse a name for an incompatible input, output, ToolData, suspension, or execution contract while older calls may remain; use a new versioned name, or migrate the affected stored messages first.
Removed provider tools remain unsupported because their current provider schema and conversion authority are required to read retained calls. PAI does not yet provide migration utilities, so applications must perform any required rewrite at their storage boundary.
No historical part type or marker is added. The current contract's static-tool
union cannot enumerate names that are no longer registered, so consumers that
preserve those parts must inspect the runtime discriminant defensively. Use
getToolName(part) rather than assuming a toolName field; migrate removed
names before relying on exhaustive current-contract typing. This compatibility
is applied while reading existing messages and does not change the stored
message or database schema.
ToolConfig
import type { JsonSchemaToolSchema, ToolDescription, ToolMap, ToolSchema } from "@pai/core";
import type { Tool } from "ai";
import type { z } from "zod";
type ToolConfig<TContext = unknown, TTools extends ToolMap = {}> = {
/** Stable identity for this tool definition, independent of registration names. */
id: string;
/** Static text or a synchronous description prepared for each model step. */
description: ToolDescription<TContext, TTools>;
/** Schema for the model-supplied tool input. */
inputSchema: ToolSchema;
/** Optional schema for validating the final tool output. */
outputSchema?: ToolSchema;
/** AI SDK conversion of a successful result into model-facing text or media. */
toModelOutput?: Tool["toModelOutput"];
/** Optional Zod schema for tool-specific runtime context. */
contextSchema?: z.ZodType;
/** Optional named wait points that can pause and later resume this tool. */
suspend?: SuspendMap;
/** Typed tool-scoped data records for progress and rich renderer state. */
dataSchemas?: Record<string, ToolSchema>;
/** Capability requirements bound at `createAgentRuntime`. Requires `execute`. */
capabilities?: CapabilityMap;
/** Explicit child tool registrations, available through ctx.tools. */
tools?: ToolMap;
/** Backend implementation. Omit this when an external client supplies output. */
execute?: ToolExecute;
};
type SuspendMap = Record<
string,
{
/** Data persisted and exposed to clients while the tool is waiting. */
suspendSchema: z.ZodType;
/** Data required from the client or external system to resume the tool. */
resumeSchema: z.ZodType;
}
>;The inputSchema, outputSchema, and contextSchema names align with the AI
SDK's schema declarations. dataSchemas is a PAI extension for named,
tool-scoped progress and renderer data; execution values keep the concise
ctx.input, ctx.context, and ctx.data names.
contextSchema currently supplies static inference for mapped tool context;
the production runtime passes that value through without parsing it.
outputSchema validates results at PAI's durable execution boundary but is not
forwarded to the model-facing AI SDK tool. Those behaviors are independent of
the declaration-key names.
Dynamic descriptions
Like the AI SDK, description accepts a string or a synchronous callback.
The callback receives the tool's mapped context, inferred from its definition:
import { defineTool } from "@pai/core";
import { z } from "zod";
const formatPrice = defineTool({
id: "example.format-price",
contextSchema: z.object({ currency: z.enum(["USD", "EUR"]) }),
inputSchema: z.object({ amountCents: z.number().int() }),
outputSchema: z.string(),
description: ({ context }) =>
`Format a price in ${context.currency}. Supply the amount in cents.`,
execute: ({ input, context }) =>
new Intl.NumberFormat("en", {
style: "currency",
currency: context.currency,
}).format(input.amountCents / 100),
});The enclosing agent must supply this context, or use the tool registration's
mapContext to project its runtime context. TContext in the type overview is
inferred; consumers do not supply it to defineTool.
Before each model step, PAI resolves the available tool's mapped context and then its description. Preparation does not acquire capabilities or execute the tool. Keep description and context-mapping callbacks free of side effects: they can run repeatedly, and invocation can map context again. The description callback is synchronous even when context mapping is asynchronous.
Static manifests have no runtime context, so a callback-backed description is represented there as an empty string. The model receives the resolved text. Preparation does not change shared definitions or retained messages, and no new persisted fields are added. A description guides the model; input validation and tool authorization still apply separately.
Child tool descriptions
A tool that declares nested tools also receives a readonly
tools map in its description callback. It contains metadata for its own enabled
children:
const total = defineTool({
id: "example.total",
contextSchema: z.object({ currency: z.string(), canRead: z.boolean() }),
inputSchema: z.object({}),
tools: {
report: {
tool: report,
enabled: ({ runtimeContext }) => runtimeContext.canRead,
},
},
description: ({ context, tools }) => tools.report
? `Summarize the available report in ${context.currency}. ${tools.report.description}`
: "Report access is currently unavailable.",
execute: async ({ tools, fail }) => tools.report.enabled
? tools.report.execute({ region: "north" })
: fail("Report access is currently unavailable."),
});TContext and TTools in the type overview are inferred from the definition;
consumers do not supply them. Each metadata entry has a resolved description,
an SDK inputSchema, and an optional outputSchema. Disabled entries are
absent. This is a description of declared children, not the agent's registry
or a set of execution handles; typed calls remain on execute's ctx.tools.
Before each model step, PAI prepares enabled parents with dynamic descriptions. It maps the parent's context, resolves child availability, then resolves enabled child descriptions before the parent description. An enabled child's context is mapped only when its description is dynamic; static child descriptions skip mapping. Static or disabled parents skip child description preparation entirely. This phase does not acquire capabilities or execute tools. Keep description, availability, and context mapping callbacks free of side effects because they can run repeatedly. Before each parent invocation, PAI resolves child availability separately for the execution map. That value remains fixed throughout the parent invocation.
Model-facing tool output
toModelOutput uses the AI SDK's callback signature. Its input and output
are inferred from the tool's schemas, and it also receives toolCallId. Return
native SDK text, JSON or content blocks synchronously or asynchronously.
import { readFile } from "node:fs/promises";
import { defineTool } from "@pai/core";
import { z } from "zod";
const showChart = defineTool({
id: "reports.show-chart.v1",
description: "Inspect the report's chart.",
inputSchema: z.object({}),
outputSchema: z.object({
mediaType: z.literal("image/png"),
data: z.string(),
}),
execute: async () => ({
mediaType: "image/png" as const,
data: (await readFile(new URL("./chart.png", import.meta.url))).toString("base64"),
}),
toModelOutput: ({ output }) => ({
type: "content",
value: [{
type: "file",
mediaType: output.mediaType,
data: { type: "data", data: output.data },
}],
}),
});The stored tool result and the client-visible output remain the original JSON object. Only the model-facing projection changes. Without the callback, PAI uses the SDK's default conversion. Error and cancellation results bypass the success callback.
Keep the converter deterministic and free of side effects. It runs again when history is prepared for later model calls, including after a runtime restart. Snapshot media in the tool result; do not reread temporary workspace paths or publish files inside the converter. Bound image sizes before returning them: base64 adds storage overhead, and retained images can be sent on later turns. Media support depends on the selected model and provider.
A tool with a converter must return a JSON value. Use explicit null when
there is no value; an output schema that produces undefined is rejected for
these tools because PAI stores ordinary void results as null, which would
change the converter's typed input.
Disabling a registered tool prevents new calls but retains its converter for historical results. Keep that definition registered while its converted history must remain readable. Removing it also removes its model-output conversion; the remaining stored JSON cannot reconstruct the callback.
When adding or changing a converter, support the output shapes already stored under that registration name, or version the name and migrate history as needed. Converter errors fail model preparation; PAI does not silently fall back to raw JSON, which could expose data the converter deliberately omits.
Execution and suspension
type ToolExecute<
TContract extends ToolContract = ToolContract,
TToolContext = unknown,
TCapabilities extends CapabilityMap = {},
> = (
ctx: ToolExecutionContext<TContract, TToolContext, TCapabilities>,
) =>
| ToolOutput<TContract>
| ToolSuspendResult<TContract, SuspendName<TContract>>
| ToolFailResult
| Promise<
| ToolOutput<TContract>
| ToolSuspendResult<TContract, SuspendName<TContract>>
| ToolFailResult
>;suspend is a map of named wait points. Each wait point pairs the data exposed while suspended with the data required to resume.
const requestApproval = defineTool({
id: "requestApproval",
description: "Request approval before sending",
inputSchema: input,
outputSchema: output,
suspend: {
approval: {
suspendSchema: approvalSuspendSchema,
resumeSchema: approvalResumeSchema,
},
},
execute: async (ctx) => {
if (ctx.entry.type === "initial") {
return ctx.suspend("approval", {
prompt: ctx.input.summary,
});
}
if (ctx.entry.type === "resume" && ctx.entry.name === "approval") {
return ctx.entry.resume;
}
throw new Error("Unsupported tool entry");
},
});ToolDefinition
import type { z } from "zod";
type ToolContract = {
/** Stable wire-spec identity; absent on provider-executed tools. */
id?: string;
/** Public tool name. */
name: string;
/** Whether the definition executes in the PAI backend. */
hasExecute?: boolean;
/** Parsed output type of the tool input Zod schema. */
input: unknown;
/** Parsed output type of the tool output Zod schema. */
output: unknown;
/** Tool-scoped data contracts keyed by data name. */
data: Record<string, unknown>;
/** Suspend/resume contracts keyed by suspend point name. */
suspend: Record<
string,
{
/** Parsed output type of `suspendSchema`. */
input: unknown;
/** Parsed output type of `resumeSchema`. */
resume: unknown;
}
>;
};
type ToolDefinition<
TContract extends ToolContract = ToolContract,
TContext = unknown,
TCapabilities extends CapabilityMap = {},
TTools extends ToolMap = {},
TInputSchema extends ToolSchema = ToolSchema,
> = {
/** Distinguishes backend tools from client-executed tools. */
readonly kind: "tool";
/** Stable wire-spec identity, preserved as a string literal. */
readonly id: TContract["id"];
/** Type-level public contract inferred from the tool config. */
readonly contract: TContract;
/** Type-only server context inferred from `contextSchema`. Not serialized. */
readonly context: TContext;
/** Definition schemas and metadata. The executable callback lives only on `execute`. */
readonly config: Omit<
ToolConfig<TInputSchema>,
"capabilities" | "execute" | "id" | "tools"
>;
/** Capability tokens this tool declares, keyed by the tool-local ctx name. Defaults to `{}`. */
readonly capabilities: TCapabilities;
/** Tool-local backend invocation authority, independent of agent registration. */
readonly tools?: ToolMapRegistrations<TContext, TTools>;
/** Backend implementation from the config, when present. */
readonly execute?: ToolExecute<TContract, TContext, TCapabilities, TTools>;
};
type InferToolContract<TConfig extends ToolConfig> = {
/** Literal wire-spec identity supplied by the definition. */
id: TConfig["id"];
/** Public name is supplied by the agent's registration key. */
name: string;
/** Literal execution mode; false means an external client supplies output. */
hasExecute: TConfig["execute"] extends ToolExecute<any, any, any>
? true
: false;
/** Parsed output of the tool input schema. */
input: ParsedToolSchema<TConfig["inputSchema"]>;
/** Parsed output of the optional tool output schema. */
output: TConfig["outputSchema"] extends ToolSchema
? ParsedToolSchema<TConfig["outputSchema"]>
: unknown;
/** Data contracts inferred from `dataSchemas`. */
data: InferToolDataContracts<TConfig["dataSchemas"]>;
/** Backend suspend contracts; client-executed tools always infer an empty map. */
suspend: TConfig["execute"] extends ToolExecute<any, any, any>
? InferSuspendContracts<TConfig["suspend"]>
: {};
};
type InferToolDataContracts<TData> =
TData extends Record<string, ToolSchema>
? { [TName in keyof TData & string]: ParsedToolSchema<TData[TName]> }
: {};
type ParsedToolSchema<TSchema> =
TSchema extends z.ZodType
? z.output<TSchema>
: TSchema extends JsonSchemaToolSchema<infer TOutput>
? TOutput
: unknown;
type InferToolContext<TConfig extends ToolConfig> =
TConfig["contextSchema"] extends z.ZodType
? z.output<TConfig["contextSchema"]>
: unknown;
type InferSuspendContracts<TSuspend> =
TSuspend extends SuspendMap
? {
[TName in keyof TSuspend & string]: {
/** Parsed output of this wait point's `suspendSchema`. */
input: z.output<TSuspend[TName]["suspendSchema"]>;
/** Parsed output of this wait point's `resumeSchema`. */
resume: z.output<TSuspend[TName]["resumeSchema"]>;
};
}
: {};Tool definitions are safe to include in defineAgent({ tools }). Tool implementations run only inside the backend runtime.
The tool context type is server-only. It is used to type-check agent registration and tool execution, but it is not included in the public agent contract, manifest, protocol DTOs, or client bundles.
If execute is omitted, a model call to the tool creates pending work. The output schema becomes the submitted payload for that pending action. Invalid submissions are rejected and leave the tool waiting.
const runBrowserCheck = defineTool({
id: "runBrowserCheck",
description: "Ask an external browser worker to inspect a page",
inputSchema: input,
outputSchema: output,
});Tool Execution Context
type ToolExecutionContext<
TContract extends ToolContract = ToolContract,
TToolContext = unknown,
TCapabilities extends CapabilityMap = {},
TTools extends ToolMap = {},
> = {
/** Parsed tool input supplied by the model. */
input: TContract["input"];
/** Tool-specific context, either agent runtimeContext directly or a mapped value. */
context: TToolContext;
/** The agent executing this call: its registry id, or the agent's own name. */
agentId: string;
/** Run another registered agent as part of this run. */
agents: AgentRunner;
/** The call's position; use a provider-namespaced resource key containing trusted scope and thread identity. */
thread: { threadId: ThreadId; runId: RunId; toolCallId: string; toolName: string };
/** Runtime-bound, typed capability facades, keyed by the declared ctx name. */
capabilities: CapabilityFacades<TCapabilities>;
/** Typed calls to the tool's own declared children. */
tools: ToolCallMap<TTools>;
/** Aborts when the run, request, or server shutdown is cancelled. */
signal: AbortSignal;
/** Whether this execution is the initial call or a resume entry. */
entry: ToolEntry<TContract>;
/** Durable suspend records for this tool call. */
suspendHistory: unknown[];
/** Typed tool-scoped progress/state data visible to clients and renderers. */
data: ToolDataWriter<TContract>;
/** Trusted file operations bound to this call's scope, creator, and thread. */
files: ScopedFiles;
/** Pause this tool at a named wait point and expose typed input to clients. */
suspend<TName extends SuspendName<TContract>>(
name: TName,
input: TContract["suspend"][TName]["input"],
): ToolSuspendResult<TContract, TName>;
/** Resolve the tool as a deliberate, model-visible failure. */
fail(failure: ToolFailure): ToolFailResult;
/** Trigger follow-up generation on this thread. */
trigger(input: ToolTriggerInput): Promise<unknown>;
};ctx.input is the parsed tool-call input, including schema defaults and transformations. ctx.context is the context this tool declared it needs. ctx.capabilities holds the runtime-bound facades for the capabilities this tool declared (see Capabilities). ctx.agentId names the agent executing the call: the id it was registered under in createPai, or the agent's own name when a runtime was built from a definition with no registry to have named it. A definition says nothing about who registers it, so a tool shared by several agents can only learn this here. ctx.thread locates the call in the conversation; runId/toolCallId make good idempotency keys. A threadId can repeat in different scopes, so resource keys must combine ctx.thread.threadId with a provider-namespaced canonical identifier derived from trusted tenancy. Capability-bound resources apply that policy automatically.
A tool has no ambient access to its caller. There is no ctx.identity: everything a tool knows about the request comes from ctx.context, the context it declared it needs. If the agent runtimeContext already has that shape, the tool can be registered directly; if it is broader or different, register the tool with mapContext at the agent boundary. mapContext receives the agent runtimeContext along with the trusted identity and untrusted clientData of the request, so a tool that needs a tenant id gets one — typed, and declared in the tool's own contextSchema rather than cast out of an opaque value.
type CustomerStore = {
read(customerId: string): Promise<{ name: string }>;
};
const lookupCustomer = defineTool({
id: "lookupCustomer",
description: "Read customer records",
inputSchema: z.object({ customerId: z.string() }),
outputSchema: z.object({ name: z.string() }),
contextSchema: z.object({
customerStore: z.custom<CustomerStore>(),
}),
execute: async (ctx) => {
return ctx.context.customerStore.read(ctx.input.customerId);
},
});
const agent = defineAgent({
runtimeContext: async ({ scope }) => ({
workspace: await loadWorkspace(identity.workspaceId),
stores: await loadStores(identity.workspaceId),
}),
tools: {
lookupCustomer: {
tool: lookupCustomer,
mapContext: ({ runtimeContext }) => ({
customerStore: runtimeContext.stores.customers,
}),
},
},
});Registering a context tool without a compatible agent runtime context is an error. Static tool maps should fail in TypeScript. Dynamic tool providers should fail during tool resolution before the model sees the tool.
Nested Tool Calls
A backend tool can declare its own tools map and call those tools without
creating additional conversation parts. This is the core boundary used by
@pai/code-mode; it also supports ordinary typed
TypeScript composition.
const total = defineTool({
id: "example.total",
description: "Calculate a total from regional reports.",
inputSchema: z.object({ regions: z.array(z.string()) }),
outputSchema: z.number(),
tools: { report }, // An existing defineTool definition.
execute: async ({ input, tools }) => {
const reports = await Promise.all(
input.regions.map(region => tools.report.execute({ region })),
);
return reports.reduce((sum, result) => sum + result.amount, 0);
},
});Each entry is a bound, SDK-shaped tool with a readonly enabled boolean,
inputSchema, optional outputSchema, and a required execute. Its schemas
are derived from the original definition; consumers do not redeclare or convert
them. The whole ctx.tools map can be passed directly to an AI SDK API accepting a ToolSet,
including the code-mode evaluator.
execute accepts the schema's raw input and returns the validated output.
For example, a child with z.string().transform(Number) accepts a string from
its caller and receives a number in its own ctx.input. Optional fields and
defaults retain their input types. Existing ToolInputOf describes the parsed
input seen by tool implementations and lifecycle observers.
Calls accept an optional second argument { abortSignal } that can shorten
that child's lifetime without cancelling its siblings. PAI owns identity,
context, and call identifiers; SDK execution options cannot replace them.
Await child calls before returning; the enclosing invocation closes their
handles when it finishes.
Child registrations accept the same tool, mapContext, enabled, and
onOutput shape as agent registrations. Without contextSchema, the enclosing
tool requires the intersection of its child contexts. With contextSchema,
that schema declares the enclosing context, and incompatible children require
a mapper. Registration callbacks receive that enclosing tool context as
runtimeContext; agent lifecycle hooks retain the full agent runtime context.
Children default to enabled. An explicit enabled boolean or callback applies
only to that registration; there is no automatic synchronization with a direct
agent registration of the same definition.
Before calling the parent's execute, PAI resolves every child's registration
once. The resulting enabled value stays fixed for that parent invocation, so
ordinary TypeScript code can branch on it:
if (!ctx.tools.report.enabled) {
return ctx.fail("Report access is currently unavailable.");
}
return ctx.tools.report.execute({ region: "north" });The execute method always exists and enforces that same value. It does not
reevaluate enabled for each child call, even if the callback's external state
changes while the parent is running. The next parent invocation resolves a
fresh value. A resolution failure or cancellation prevents the parent
implementation from starting; it is not represented as enabled: false.
Enabled calls still perform context mapping, input validation, lifecycle
policy, capability acquisition, and execution when actually called. These can
fail or be cancelled independently. Disabled children do not map context or
acquire capabilities.
Description metadata contains only enabled children. Passing that whole map to
an SDK description builder requires no per-key checks; check for presence when
reading a particular optional entry. This metadata is resolved separately for
each model step and does not supply execution handles or determine the next
invocation's availability. Execution descriptors exist for every declared child,
including disabled children.
The bound schemas are references without a second validator: validation remains
inside PAI's bound execute, including when called by an SDK evaluator.
Children retain input/output validation, availability and lifecycle policy,
scoped capabilities, cancellation, and output observers. Their capability
requirements participate in runtime binding validation. Output observers see
the settled output delivered to the calling program. Unexpected nested
exceptions use a generic message unless the application supplies a deliberate
mapError projection. A failed call rejects with ToolInvocationError.
Lifecycle-hook failures observed while the parent call is active remain fatal,
even if the calling program catches the child's rejection.
Only ordinary backend children are supported. Client execution, suspension, child ToolData, and further nesting are rejected at construction and checked again by the runtime. The map is an explicit allowlist; other agent tools are not available automatically. Its registrations own their own policy even when the same definition is also registered directly on an agent.
The outer call and its result are durable. Child inputs/results are not
individual conversation parts, and child toModelOutput does not run. This
does not checkpoint or roll back external side effects; retry-safe writes
remain an application responsibility. Use createTestRuntime with a scripted
model to test these runtime guarantees.
ToolFailure
ctx.fail() declares a failure the tool decided on. Its message reaches the model verbatim. Direct tool execution also preserves a thrown exception's message by default; use the application's mapError to control that projection. Nested calls use a generic message for unexpected exceptions unless mapError supplies an explicit message.
type ToolFailure = string;A failure is a sentence and nothing else. There is no code and no retryable: the native output-error part the model reads carries only errorText, so anything else would be a field no client could observe.
execute: async (ctx) => {
const brief = await ctx.context.repository.getJobBrief(ctx.input.jobBriefId);
if (!brief) {
return ctx.fail(
`No job brief ${ctx.input.jobBriefId}. List them with searchJobBriefs first.`,
);
}
return { brief };
};message is the model's whole account of what happened, so naming the alternatives — or the next call to make — is what turns a dead end into a recovery.
Three outcomes are worth telling apart deliberately:
- An expected outcome is typed output, not a failure. A declined approval, a refused widget, an ambiguous result — if the tool knows about it, put it in a discriminated union on the output schema. Reporting it as a failure tells the model the call could not be evaluated, which is the opposite of what happened.
ctx.fail(sentence)is a deliberate, model-visible refusal the tool chose.throwis for the exceptional. A caught exception belongs in athrowand the app'smapError, not inctx.fail.
Cancellation is not in that list, because it is not a tool-authored outcome. A run is stopped from outside and the tool observes it through ctx.signal; the runtime records the abandoned call itself, and a client reads it as part.cancelled.
toolFailureDetails(failure) normalises a ToolFailure into the { code: "tool_failed", message } record a client and the model receive. The runtime, the harness, and runTool all use it, so an app rendering or logging a failure can agree with them rather than reimplementing the defaults.
Capabilities
Tools declare runtime-bound services as capability tokens; the runtime acquires the facades before execute runs and places them at ctx.capabilities, keyed by the declared ctx name:
import { sandbox } from "@pai/sandbox";
const tool = defineTool({
id: "sandboxCommand",
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 map key is the tool-local ctx name and is aliasable ({ box: sandbox } reads as ctx.capabilities.box); the capability's id is the binding key at the runtime. capabilities requires execute — declaring capabilities on an external-output tool throws at defineTool.
capabilities and tool context are orthogonal. contextSchema types app
data supplied from agent runtimeContext/mapContext and exposed as ctx.context;
capabilities supplies runtime-bound services with lifecycle. Bindings are
provided at
createAgentRuntime({ capabilities }).
The full declaration and binding API is in
Capabilities, and the guide is
Capabilities.
Tool Data And Metadata
Use dataSchemas for tool-scoped renderer state such as progress, subagent activity, search results, or deployment steps.
const deployService = defineTool({
id: "deployService",
description: "Deploy a service",
inputSchema: input,
outputSchema: output,
dataSchemas: {
progress: z.object({
step: z.string(),
status: z.enum(["running", "done"]),
}),
},
execute: async (ctx) => {
await ctx.data.write("progress", {
step: "build",
status: "running",
}, { id: "build" });
return { deployed: true };
},
});type ToolDataWriter<TContract extends ToolContract = ToolContract> = {
/** Write or replace one data item. Same id means update-in-place in projections. */
write<TName extends keyof TContract["data"] & string>(
name: TName,
value: TContract["data"][TName],
options?: { id?: string; transient?: boolean },
): Promise<void>;
/** Read the latest data items already written by this tool call. */
list<TName extends keyof TContract["data"] & string>(
name: TName,
): Promise<Array<{
id: string;
value: TContract["data"][TName];
updatedAt: string;
}>>;
};Tool data is attached to the current native tool part. It is included in
snapshots, survives refresh, and is grouped by channel on the projected
PaiStaticToolPart or PaiDynamicToolPart:
type PaiToolDataItem<TValue = JsonValue> = {
id: string;
value: TValue;
updatedAt: string;
};
type PaiToolDataView<
TAgent extends AgentContract,
TName extends ToolName<TAgent> & string,
> = {
readonly [TChannel in keyof ToolData<TAgent, TName> &
string]?: readonly PaiToolDataItem<
ToolData<TAgent, TName>[TChannel]
>[];
};
const latest = part.data.progress?.at(-1);When present, an output schema parses the tool result; ToolData uses its channel
schema. PAI then normalizes output and ToolData to its lossless JSON shape before
recording or emitting them. Properties whose value is undefined are omitted
from objects, matching JSON object serialization. undefined array entries and
other values that cannot round-trip through JSON losslessly are rejected. A
top-level undefined tool output remains successful: it is omitted from durable
state and represented to the model as JSON null.
The default is durable. Use transient: true only for cosmetic live progress that does not need to survive refresh, reconnect, or polling. Transient data can appear in live watch/request-stream updates, but it is omitted from snapshots and storage.
Tool data is not model-visible by default. If the model needs a value, return it as tool output, put it in a suspend payload, or append a message through a trusted runtime API.
Tools cannot write message metadata. messageMetadata is declared by the agent
and describes the agent's own state; a tool is a reusable definition that does
not know which agent it is registered on, so it has no typed shape to write. Use
ctx.data for tool-scoped output, tool output for values the model needs, or a
trusted runtime API from a lifecycle hook when conversation-level state should
change.
Files And Triggering
Tools can persist generated files through ctx.files.save(). The runtime injects
the trusted scope, runtime-derived creator key, current thread association, and
run cancellation. Tool input therefore cannot select another tenant, forge
creator provenance, or claim a different conversation. Providers authorize by
scopeKey by default; userKey records the creating actor and threadId
records the creation-origin thread rather than every later transcript
reference, unless the provider adds a stricter read gate:
const report = await ctx.files.save({
body: new TextEncoder().encode("category,count\nopen,3\n"),
mediaType: "text/csv",
filename: "report.csv",
});
return { fileId: report.fileId, filename: report.filename };type ScopedFiles = {
save(
input: Pick<
SaveFileInput,
"body" | "mediaType" | "filename" | "metadata"
>,
): Promise<UploadedFile>;
read(input: { fileId: string }): Promise<StoredFileBody>;
};Return the resulting fileId in typed tool output when the client should
render a preview or download action. ctx.files.read({ fileId }) resolves an
already-authorized durable file under the current runtime scope; applications
still decide which file ids enter tool input. Provider authorization is
scope-based by default, while the current user and thread are recorded on newly
saved files as creation provenance.
Background tools can wake the agent after external work completes:
await ctx.trigger({
notification: "Background subagent completed.",
data: { jobId, result },
hidden: true,
});ctx.trigger() appends a server notification and asks the runtime to continue
generation. Its current public result is opaque; observe the resulting work
through thread state rather than depending on a command return shape. It does
not require a connected client. Clients receive the change through
thread.refresh(), polling, or thread.watch().
ToolEntry
type ToolEntry<TContract extends ToolContract = ToolContract> =
| {
/** First execution for this tool call. */
type: "initial";
}
| {
[TName in keyof TContract["suspend"] & string]: {
/** Re-entry after a pending action was submitted. */
type: "resume";
/** Suspend point name being resumed. */
name: TName;
/** Parsed resume payload from that suspend point's `resumeSchema`. */
resume: TContract["suspend"][TName]["resume"];
/** Action reference that triggered the resume. */
action: PendingActionRef;
};
}[keyof TContract["suspend"] & string];ctx.entry tells the tool why execute is running. Resume entries are produced when a pending action is submitted.
Suspension History
ctx.suspendHistory contains the durable suspension episodes for the current
tool call. Its public type is deliberately unknown[]: the stored callback
representation is not an application contract. Validate it before using
history as application policy.
Client message projection uses native-first vocabulary instead. Each projected
tool part exposes all episodes on part.suspensions, where the discriminant is
state, and the current pending episode on part.pendingSuspension:
type PaiSuspensionView<
TAgent extends AgentContract,
TName extends ToolName<TAgent> & string,
> = {
[TSuspendName in keyof ToolSuspend<TAgent, TName> & string]: {
actionId: string;
name: TSuspendName;
input: ToolSuspend<TAgent, TName>[TSuspendName]["input"];
suspendedAt: string;
} & PaiSuspensionState<
ToolSuspend<TAgent, TName>[TSuspendName]["resume"]
>;
}[keyof ToolSuspend<TAgent, TName> & string];
type PaiSuspensionState<TResume> =
| { state: "pending" }
| ({ state: "submitted"; resolvedAt: string } &
(undefined extends TResume
? { resume?: TResume }
: { resume: TResume }))
| { state: "cancelled"; resolvedAt: string }
| { state: "failed"; resolvedAt: string };part.pendingSuspension is the pending arm of this union or null. Dynamic
tools expose the contract-erased PaiUntypedSuspensionView equivalent.
The native AI SDK part.state remains unchanged while a named PAI suspension
is pending. UI layers derive "waiting" as presentation state from this
sidecar and normalized run control state.
Frontend-defined client tools are a client API. React apps define stable ones with provider/session clientTools config and component-scoped ones with useClientTool(); low-level clients can pass serialized client-tool snapshots.
Native Client Tool State
Application clients render the AI SDK's native state-discriminated tool parts.
A registered tool named requestApproval appears as
type: "tool-requestApproval"; untyped provider or request-time tools use
type: "dynamic-tool" and carry toolName separately.
switch (part.state) {
case "input-streaming":
case "input-available":
case "approval-requested":
case "approval-responded":
case "output-available":
case "output-error":
case "output-denied":
break;
}Inputs, outputs, errorText, approval facts, and provider metadata stay on the
native state arm that owns them. PAI does not serialize additional running,
waiting, or cancelled arms. React derives those presentation values as
a renderer's sidecars from the native part, the owning run, and command
sidecars.
PendingActionView
PendingActionView is a client command sidecar from @pai/client; it is not a
field on a serializable message or tool part.
type PendingActionView<TContract extends AgentContract = AgentContract> = {
/** Fully qualified name, such as `requestApproval.approval`. */
name: string;
input: unknown;
origin: PendingActionOrigin<TContract>;
ref: PendingActionRef<TContract>;
submit(resume: unknown): Promise<ActionResult<TContract>>;
cancel(input?: { reason?: string }): Promise<ActionResult<TContract>>;
fail(error: unknown): Promise<ActionResult<TContract>>;
};Resolve it from the exact public message and native tool part:
for (const message of thread.getState().messages) {
for (const part of message.parts) {
if (!isPaiToolPart(part)) continue;
const action = thread.getPendingAction(message, part);
if (action?.name === "requestApproval.approval") {
await action.submit({ approved: true });
}
}
}The low-level client keeps action payloads unknown. @pai/react re-narrows
the renderer action's input and submit() payload from the tool contract.
PendingActionOrigin
type PendingActionOrigin<TContract extends AgentContract = AgentContract> = {
kind: "tool";
threadId: ThreadId;
toolName:
| ToolName<TContract>
| (string & Record<never, never>);
suspendName?: string;
messageId: MessageId;
toolCallId: ToolCallId;
runId: RunId;
};
type PendingActionRef<TContract extends AgentContract = AgentContract> =
PendingActionOrigin<TContract> & {
/** Suspension data identity, or toolCallId for client/manual output. */
actionId: string;
name: string;
};The origin ties the sidecar to the open model message and native tool call that
created it. For an external-output client tool, actionId is its
toolCallId; a named suspension uses the stable identity of its retained
suspension data.