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:
- Scaffold the UI
- Create a typed HTTP client
- 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.8CORS 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:
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
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.
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
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>
);
}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 devOpen 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
- Add a tool the model can call
- Put a real user on the request
- Go deeper on the React API