PAIPAI

React Chat

Render a live chat UI on the agent you just served.

The server from First app already speaks HTTP. In this tutorial you add a Vite React app in web/ next to server/.

You will:

  1. Scaffold the UI
  2. Create a typed HTTP client
  3. Render a chat page with a thread in the URL

Keep the Express server running (pnpm dev:server). Run the UI in a second terminal.

Until you add Mongo, restarting the server forgets the thread.

Scaffold

From the same folder as server/:

Stay in the folder that contains server/ (do not cd web for the rest of this page):

pnpm create vite@latest web -- --template react-ts
pnpm --dir web add @pai/react @pai/client-http zod@^4.1.8

CORS on the server already allows http://localhost:5173.

Share the type from the server. Do not import server runtime into the browser. Point Vite at ../server so import type resolves:

web/vite.config.ts
import path from "node:path";
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";

export default defineConfig({
  plugins: [react()],
  resolve: {
    alias: { "@server": path.resolve(import.meta.dirname, "../server") },
  },
  server: { port: 5173 },
});

In web/tsconfig.app.json (or web/tsconfig.json), keep the Vite template and add these two keys under the existing compilerOptions. Do not replace the file — you still need jsx, lib, and module.

"compilerOptions": {
  "baseUrl": ".",
  "paths": { "@server/*": ["../server/*"] }
}

If the frontend cannot type-import from the server package, use codegen instead of this alias.

Typed bindings

web/src/pai.ts
import { createPaiHttpClient } from "@pai/client-http";
import { createPaiReact } from "@pai/react";
import type { AssistantPai } from "@server/http-server";

export const paiClient = createPaiHttpClient<AssistantPai>({
  url: "http://localhost:3001/api/pai",
  headers: () => ({
    "x-user-id": "dev-user",
    "x-workspace-id": "dev",
  }),
});

export const Pai = createPaiReact<AssistantPai>();
export const Assistant = Pai.agent("main");

In Persi, authentication comes from the Persuit session. These example headers stand in for that boundary.

Chat surface

Split the transcript from the composer. useChat() re-renders when the thread moves. useComposer() re-renders on every keystroke.

web/src/Chat.tsx
import { isPaiToolPart } from "@pai/react";
import { Assistant } from "./pai";

export function Chat() {
  const chat = Assistant.useChat();
  const composer = Assistant.useComposer();

  if (chat.loading) return <p>Loading…</p>;

  return (
    <section>
      {chat.messages.map((message) => (
        <article key={message.id} data-role={message.role}>
          {message.parts.map((part, i) => {
            if (part.type === "text") return <p key={i}>{part.text}</p>;
            if (isPaiToolPart(part)) {
              return (
                <Assistant.Tool
                  key={part.toolCallId}
                  message={message}
                  part={part}
                />
              );
            }
            return null;
          })}
        </article>
      ))}

      <form
        onSubmit={(event) => {
          event.preventDefault();
          void composer.submit();
        }}
      >
        <textarea
          value={composer.text}
          onChange={(event) => composer.setText(event.target.value)}
        />
        <button
          type="submit"
          disabled={!composer.canSubmit || chat.isRunning}
        >
          Send
        </button>
      </form>

      {chat.isRunning ? (
        <button type="button" onClick={() => void chat.stop()}>
          Stop
        </button>
      ) : null}
    </section>
  );
}

Render parts, not a single content string.

Page and thread list

web/src/Sidebar.tsx
import { Assistant } from "./pai";

export function Sidebar() {
  const threads = Assistant.useThreads();
  const client = Assistant.useClient();

  return (
    <ul>
      {threads.items.map((thread) => (
        <li key={thread.threadId}>
          <a href={`/chat/${thread.threadId}`}>
            {thread.title ?? "Untitled"}
          </a>
        </li>
      ))}
      <li>
        <button
          type="button"
          onClick={() => {
            window.location.assign(`/chat/${client.newThreadId()}`);
          }}
        >
          New chat
        </button>
      </li>
    </ul>
  );
}
web/src/App.tsx
import { useEffect, useState } from "react";
import { Assistant, Pai, paiClient } from "./pai";
import { Chat } from "./Chat";
import { Sidebar } from "./Sidebar";

export default function App() {
  const [threadId] = useState(() => {
    const match = window.location.pathname.match(/^\/chat\/([^/]+)$/);
    return match?.[1] ?? paiClient.agent("main").newThreadId();
  });

  useEffect(() => {
    if (window.location.pathname !== `/chat/${threadId}`) {
      window.history.replaceState(null, "", `/chat/${threadId}`);
    }
  }, [threadId]);

  return (
    <Pai.Provider client={paiClient}>
      <Sidebar />
      <Assistant.ThreadProvider threadId={threadId}>
        <Chat />
      </Assistant.ThreadProvider>
    </Pai.Provider>
  );
}

Vite already mounts the default App from web/src/main.tsx. Keep that file. Then:

pnpm --dir web dev

Open http://localhost:5173. You should land on /chat/<id> and be able to send a message. New threads are just ids. The first send creates the stored thread.

Next

On this page