Commands
Typed thread-scoped operations called explicitly by trusted server code or opted-in clients.
A command is an app-defined server function registered on an agent. Application code calls it by name on a thread; the runtime validates the input, resolves the same scoped capabilities and files available to tools, and returns a typed response.
Commands are server-only by default. Add expose: "client" only when a browser or other ordinary PAI client should be allowed to invoke one.
Commands fill the seam between two surfaces that already exist:
- The ordinary client API (
thread.send,respondToAction, subscriptions) is browser-facing and intentionally narrow. runtime.admin(...).thread(threadId)is trusted server code with full mutation authority.
Apps regularly need a thread-scoped operation that doesn't fit either: reset a sandbox, list a workspace, push a system message, or trigger a run from a webhook. Commands reuse the agent's identity, capabilities, file provider, validation, and thread scope without adding a parallel service or route for each operation.
Define A Command
import { defineCommand } from "@pai/core";
import { z } from "zod";
const triggerRun = defineCommand({
expose: "client",
thread: "ensure",
input: z.object({
notification: z.string().trim().min(1).max(2000),
}),
run: async ({ input, thread }) => {
const result = await thread.trigger({
notification: input.notification,
});
return { runId: result.run?.runId ?? null };
},
});input is a Zod schema validated before execution. run receives parsed input and returns the typed response. The return type becomes the contract; there is no separate output schema.
Omit expose for server-only commands. Such commands are absent from the public manifest and inferred client contract, and guessed HTTP calls fail as unknown commands before input parsing or capability acquisition.
Commands require an existing thread by default. Set thread: "ensure" only for
first-touch entry points such as push-message or trigger operations that are
allowed to create the requested thread.
Register On An Agent
Register commands through defineAgent({ commands }). The key is the command name.
Names used by JavaScript object and Promise protocols, such as then,
toJSON, and constructor, are reserved because command facades are
property-based proxies. defineAgent() rejects these names during startup.
export const agent = defineAgent({
name: "trigger-push-message",
model,
instructions,
commands: {
pushMessage,
triggerRun,
},
});
export type AgentContract = InferAgentContract<typeof agent>;Call From Server Code
Trusted backend code can call every registered command through the normal admin thread handle:
const result = await runtime
.admin({ identity })
.thread(threadId)
.commands.rebuildIndex({ force: true });This is the default command surface. It is useful in routes, jobs, webhooks, and application services that already have a trusted PAI identity.
Opt In A Client
Commands with expose: "client" appear on client.thread(threadId).commands:
const result = await client
.thread(threadId)
.commands.triggerRun({ notification: "Build finished." });
result.runId; // string | null, inferred from the server `run` return typeWrong name, private command, wrong input shape, or wrong return-type access is a compile error. The same agent contract types thread.send and thread.commands.* from one source, with no client schema duplication.
What run Receives
type CommandExecutionContext<TInput, TCapabilities> = {
input: TInput;
thread: CommandThread;
agentId: string;
identity: {
identity: unknown;
clientData: unknown;
};
capabilities: CapabilityFacades<TCapabilities>;
files: ScopedFiles;
signal: AbortSignal;
};input— parsed output of the Zod schema. Validation already ran;runnever sees raw browser input.thread— trusted thread operations scoped to the threadId from the call. It matchesruntime.admin().thread()except that sibling command invocation is omitted because a command cannot infer its containing agent during definition.agentId— the agent executing this command: the id it was registered under increatePai, or the agent's ownnamewhen a runtime was built from a definition with no registry to have named it. A definition says nothing about who registers it, so a command shared by several agents can only learn this here.identity— user, scope, and client data resolved by the sameResolveOperationthat authenticatesthread.send. No separate auth wiring.capabilities— facades declared by the command'scapabilitiesmap and acquired under the same binding scope as tools.files— durable file reads and saves bound to the trusted scope, runtime-derived creator, and current thread association.signal— aborts when the client disconnects or the request is cancelled.
const resetWorkspace = defineCommand({
input: z.object({}),
capabilities: { sandbox },
run: async ({ capabilities }) => {
await capabilities.sandbox.reset();
return { status: "reset" as const };
},
});Update Terminal Run Metadata
A command is the normal way to validate a product action before changing server-written run metadata. Reuse the schema object registered on the agent:
export const reportRunMetadata = {
private: z.object({ evaluationJobId: z.string().optional() }),
public: z.object({ rating: z.enum(["up", "down"]).optional() }),
};
export const rateResponse = defineCommand({
expose: "client",
runMetadata: reportRunMetadata,
input: z.object({
runId: z.string(),
rating: z.enum(["up", "down"]),
}),
run: async ({ input, thread }) => {
return thread.runs.mergeMetadata(input.runId, {
rating: input.rating,
});
},
});
export const reportAgent = defineAgent({
// ...
runMetadata: reportRunMetadata,
commands: { rateResponse },
});Adding runMetadata gives the command body exact private and client-readable metadata
types. defineAgent() verifies that the command and agent reuse the same raw
schema object, so a mismatched reusable command fails during registration rather
than on its first request. Commands that do not access metadata can omit it.
mergePrivateMetadata() writes the server-only bag and mergeMetadata() writes
the client-readable bag. Both are shallow top-level merges, validate the
complete resulting object, and accept only terminal runs. A mergeMetadata()
write emits run.updated, so mounted clients receive the new complete metadata
through their ordinary thread watch.
When To Use A Command
Use a command when:
- The action operates on a specific thread.
- It should reuse the agent's scoped capabilities, files, identity, or trusted thread handle.
- Application code, rather than the model, decides when it runs.
Call the command through runtime.admin(...) by default. Add expose: "client" when the browser needs it. Use a separate route or service when:
- The action is not thread-scoped.
- The action streams transient output back to the caller (commands return a single typed value).
- The operation belongs to a reusable domain service independent of PAI identity and thread semantics.
Tools and commands solve different problems. Tools run mid-generation when the model decides to call them. Commands run when application code calls them: UI buttons, post-action effects, routes, jobs, and server hooks.
External webhooks and cron jobs still need their own ingress or scheduler. When
their work is thread-scoped, that handler can call a server-only command through
runtime.admin(...) rather than duplicating the operation in the route.
Validation Boundary
input is the only validation surface. The server parses the request body's
input field with the declared schema before run is invoked. Bad HTTP input
fails with a 400 before any side effects run.
The return value is not re-validated. The runtime trusts the server function's return type, and the active transport serializes it.
Wire Format
The HTTP transport maps client-exposed commands to
POST /threads/:threadId/commands/:commandName with body
{ input, clientData?, clientTools? }. Server-only commands have no HTTP
surface; invoke them through runtime.admin(...).
Other transports carry AgentClientTransport.executeCommand() through their
own paired wire contract. Shared CommandContract, CommandInput, and
CommandOutput types live in @pai/protocol.