First App
Define an agent, serve it over Express, and send a message.
In this tutorial you will build the smallest useful PAI app: one agent, one Express server, one send. This isolated example teaches the contracts used by Persi without requiring the Persuit codebase.
You will:
- Define an agent with
defineAgent - Turn it into an app with
createPai - Mount it on Express
- Send a message from a Node script
Create an empty folder. You will add:
package.json
tsconfig.json
server/assistant-agent.ts
server/http-server.ts
scripts/send.tsIn-memory storage is the default. Restarting the server forgets every thread. That is fine for this page.
Prerequisites
- Node.js 22+ and pnpm
- An OpenAI API key (any AI SDK model works)
- Access to the npm registry that publishes
@pai/*(not the public npm registry)
Create .npmrc in the project. Replace the GCP project and Artifact Registry repo with yours:
@pai:registry=https://us-central1-npm.pkg.dev/my-gcp-project/my-npm-repo/
//us-central1-npm.pkg.dev/my-gcp-project/my-npm-repo/:always-auth=trueThen authenticate with Google Cloud:
npx google-artifactregistry-auth@3.5.0 --credential-config=.npmrcexport OPENAI_API_KEY=...Install
{
"name": "assistant",
"private": true,
"type": "module",
"scripts": {
"dev:server": "tsx server/http-server.ts",
"send": "tsx scripts/send.ts"
}
}{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"skipLibCheck": true,
"noEmit": true
},
"include": ["server", "scripts"]
}pnpm add @pai/core @pai/express @pai/client-http express@^5.1.0 cors ai@^7 @ai-sdk/openai zod@^4.1.8
pnpm add -D tsx @types/express@^5 @types/corsExpress 5 is required so the receiver types match.
1. Define the agent
import { openai } from "@ai-sdk/openai";
import { defineAgent } from "@pai/core";
import { z } from "zod";
export const assistantAgent = defineAgent({
name: "assistant",
model: openai("gpt-4.1-mini"),
identity: z.object({
userId: z.string(),
workspaceId: z.string(),
}),
instructions: "Be concise. You help with this workspace only.",
});identity is a Zod schema. Everything PAI stores for this agent is keyed from it. Start with userId and workspaceId for this example; Persi carries the product identity fields it needs.
model is an AI SDK language model. Swap openai(...) for any other provider without changing PAI.
2. Create the app and serve it
import cors from "cors";
import express from "express";
import { createPai, type InferPaiContract } from "@pai/core";
import { createPaiExpressReceiver } from "@pai/express";
import { assistantAgent } from "./assistant-agent";
export const pai = createPai({
agents: { main: assistantAgent },
scopeKey: (identity) => identity.workspaceId,
userKey: (identity) => identity.userId,
});
export type AssistantPai = InferPaiContract<typeof pai>;
const app = express();
app.use("/api", cors({ origin: "http://localhost:5173" }));
app.use(
"/api/pai",
createPaiExpressReceiver({
pai,
resolveIdentity: ({ request }) => ({
identity: {
userId: request.headers.get("x-user-id") ?? "dev-user",
workspaceId: request.headers.get("x-workspace-id") ?? "dev",
},
}),
}),
);
app.listen(3001);Two decisions live here:
scopeKey— the tenancy key. Threads in workspace A are invisible to workspace B.resolveIdentity— runs on every request. In development a header is fine. In production, read the session or JWT here. Never trust a workspace id from the client body.
Export AssistantPai. The React app will import that type.
pnpm dev:serverThe server is up at http://localhost:3001/api/pai. Storage is in-memory until you pass a store — see Serve over HTTP.
3. Send a message
import { createPaiHttpClient } from "@pai/client-http";
import type { AssistantPai } from "../server/http-server";
const pai = createPaiHttpClient<AssistantPai>({
url: "http://localhost:3001/api/pai",
headers: {
"x-user-id": "dev-user",
"x-workspace-id": "dev",
},
});
const assistant = pai.agent("main");
const thread = assistant.thread(assistant.newThreadId());
const run = await thread.send("Say hello in one sentence.");
const state = await run.waitUntilIdle();
console.log(state.messages);pai.agent("main") matches the key in createPai({ agents }).
newThreadId() is just an id. The first send creates the stored thread.
Leave pnpm dev:server running. In a second terminal, from the same folder:
pnpm sendWhat you just built
send.ts → HTTP → Express resolveIdentity → agent → threadThe same HTTP API is what React will use next.