PAIPAI

Serve Over HTTP

Mount a Persi PAI app on Express and use production providers.

createPai is the app. createPaiExpressReceiver is the HTTP door.

The first app already mounted an in-memory server. This page shows the production pieces Persi needs, using that small app as the example.

Mount the receiver

Add to the first-app server — do not replace the file:

app.use(
  "/api/pai",
  createPaiExpressReceiver({
    pai,
    resolveIdentity,
  }),
);

Each registered agent is served under its registry id, for example /api/pai/main/....

Workspace-scoped URL:

app.use(
  "/api/workspaces/:workspaceId/pai",
  createPaiExpressReceiver({ pai, resolveIdentity }),
);

Still resolve the workspace on the server. The path is a hint, not proof.

Hide agents from the browser

createPaiExpressReceiver({
  pai,
  resolveIdentity,
  access: {
    main: "full",
    explorer: "read",
  },
});

read lets the client watch a child thread. It cannot start that agent. The parent can still spawn it in-process — see Subagents.

Production providers

Pass storage, files, and realtime into createPai. The agent definition does not change.

server/pai.ts
import { createPai } from "@pai/core";
import { createMongoFileProvider } from "@pai/files-mongo";
import { createRedisThreadLiveProvider } from "@pai/realtime-redis";
import { createMongoThreadStore } from "@pai/storage-mongo";
import { assistantAgent } from "./assistant-agent";

const storage = createMongoThreadStore({
  // Replica set required (transactions). Example local URI:
  // mongodb://127.0.0.1:27017/pai?replicaSet=rs0
  url: process.env.MONGO_URL!,
});
await storage.initialize();

const files = createMongoFileProvider({
  url: process.env.MONGO_URL!,
});
await files.initialize();

const realtime = createRedisThreadLiveProvider({
  url: process.env.REDIS_URL!,
  prefix: "pai:production",
});
await realtime.connect();

export const pai = createPai({
  agents: { main: assistantAgent },
  scopeKey: (identity) => identity.workspaceId,
  userKey: (identity) => identity.userId,
  storage,
  files,
  realtime,
  live: { mode: "distributed" },
});
ProviderWhen you need it
StorageAlways, once you care about restart. Mongo is the production store.
FilesWhen users attach documents or images.
RealtimeWhen more than one Node process serves the same threads.
live.mode: "distributed"Requires Redis realtime so watchers on other processes wake up. createPai throws without a realtime provider.

Mongo thread storage uses transactions. Run Mongo as a replica set, even locally.

Call storage.initialize(), files.initialize(), and realtime.connect() at startup so connection errors fail the boot, not the first request. createPai does not connect Redis for you.

The Express receiver's close() calls pai.close() once, shutting thread storage, the live service, and telemetry. It does not close Redis, GridFS, or GKE. Keep a handle to the receiver and close those providers separately:

const receiver = createPaiExpressReceiver({ pai, resolveIdentity });
app.use("/api/pai", receiver);

process.on("SIGTERM", async () => {
  await receiver.close();
  await realtime.close();
  await files.close();
});

Safe errors

Clients receive a stable code, a safe message, and an optional diagnostic reference. The original exception stays on the server.

createPai({
  mapError: (error) => {
    if (error instanceof WorkspaceNotFound) {
      return { code: "workspace_not_found", message: "Workspace not found." };
    }
  },
});

createPaiExpressReceiver({
  pai,
  resolveIdentity,
  mapHttpError: mapError,
});

The original exception goes to onRuntimeError (onHttpError on the receiver). Without those hooks PAI reports it itself: a content-free record to a configured logger, or the raw event to the console. The exception can hold conversation data; see Logging for what is safe to log.

Show the safe message in the UI. Give support the PAI-… reference so they can find the server log. How the React chat displays this: Errors.

Live updates on one process use HTTP/SSE with no extra package. Across several Node processes, add Redis as above. If a network buffers SSE events, pair createPaiSocketIoServer() with createPaiSocketIoClient() so live events can use Socket.IO HTTP long-polling when WebSocket is unavailable. Keep the HTTP receiver mounted for commands, reads, and files. Persi uses this Socket.IO connection.

Queue-removal rollout

Older PAI browser bundles read queue state even when the application never used queueing. For the initial release that removes queueing, HTTP/SSE and Socket.IO snapshots therefore retain queue: { items: [], canRecall: false }, and HTTP send responses retain status: "started". These fields exist only at the network boundary; the current client API and runtime have no send queue.

Remove these temporary fields in the following release once older browser bundles are retired or required to refresh. Update the frontend and backend PAI packages together. Existing production thread, message, and direct-run records need no migration. Development records created through queueing are not supported.

Next

On this page