Product Events
Run host application work beside PAI and persist the final result into a thread.
Some product workflows are not agent runs. A terminal command, desktop action, build job, or file import does work outside PAI and then persists a final record into a thread.
The default path is a command. For workflows that don't fit a command shape — streaming output back to the caller, callers that aren't typed PAI clients, actions not scoped to a thread — drop down to runtime.admin(...).
Default: Define A Command
Register a command on the agent. The client receives a typed call site, the server validates input, and run gets a trusted thread handle scoped to the call.
// server/agent.ts
import { defineAgent, defineCommand } from "@pai/core";
import { z } from "zod";
const recordShellResult = defineCommand({
expose: "client",
input: z.object({
exitCode: z.number().int(),
summary: z.string().min(1).max(2000),
}),
run: async ({ input, thread }) => {
await thread.messages.append({
role: "assistant",
visibility: { transcript: true, context: true },
parts: [{ type: "text", text: input.summary }],
metadata: {
productCommand: "shell",
exitCode: input.exitCode,
},
});
},
});
export const agent = defineAgent({
// ...
commands: { recordShellResult },
});// web/run-shell.ts
const result = await runShellCommand({ command });
await client.thread(threadId).commands.recordShellResult({
exitCode: result.exitCode,
summary: `Command finished with exit code ${result.exitCode}.`,
});Use thread.messages.append() when you only need to record what happened. Use thread.trigger() when the product event should ask the model to continue from a notification.
Fallback: runtime.admin(...)
Use the admin client when the product action doesn't fit a command:
- The route needs to stream transient output back to the caller while it runs.
- The caller is not a typed PAI client — an external webhook, a Stripe event handler, a CI job hitting your server directly.
- The action is not scoped to a single thread.
runtime.client(...).thread(threadId) returns the ordinary Thread API used by remote clients. runtime.admin(...).thread(threadId) returns a RuntimeThread: the same thread concept with trusted server-only helpers for direct message mutation, lock transactions, and backend triggers.
// server/product-routes.ts
app.post("/workspaces/:workspaceId/threads/:threadId/shell", async (req, res) => {
const user = await requireUser(req);
const workspace = await requireWorkspaceMember(req, user);
const threadId = req.params.threadId;
const result = await runShellCommand({
command: req.body.command,
onStdout: (chunk) => res.write(chunk),
});
const admin = runtime.admin({
identity: { userId: user.id, workspaceId: workspace.id },
});
await admin.withThreadLock(threadId, async (tx) => {
await tx.messages.append({
role: "assistant",
visibility: { transcript: true, context: true },
parts: [
{
type: "text",
text: `Command finished with exit code ${result.exitCode}.`,
},
],
metadata: {
productCommand: "shell",
exitCode: result.exitCode,
},
});
});
res.end();
});The streaming response (onStdout) is the reason this route exists rather than a command. The durable persistence still goes through the runtime so revisions, realtime notifications, and visibility rules stay consistent.