PAIPAI

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:

  1. Define an agent with defineAgent
  2. Turn it into an app with createPai
  3. Mount it on Express
  4. 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.ts

In-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:

.npmrc
@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=true

Then authenticate with Google Cloud:

npx google-artifactregistry-auth@3.5.0 --credential-config=.npmrc
export OPENAI_API_KEY=...

Install

package.json
{
  "name": "assistant",
  "private": true,
  "type": "module",
  "scripts": {
    "dev:server": "tsx server/http-server.ts",
    "send": "tsx scripts/send.ts"
  }
}
tsconfig.json
{
  "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/cors

Express 5 is required so the receiver types match.

1. Define the agent

server/assistant-agent.ts
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

server/http-server.ts
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:server

The 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

scripts/send.ts
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 send

What you just built

send.ts  →  HTTP  →  Express resolveIdentity  →  agent  →  thread

The same HTTP API is what React will use next.

Next

Put a chat UI on this.

On this page