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.
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" },
});| Provider | When you need it |
|---|---|
| Storage | Always, once you care about restart. Mongo is the production store. |
| Files | When users attach documents or images. |
| Realtime | When 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.