PAIPAI

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.

On this page