Building chat UI
From <ChatBox /> for the MVP to the @pleach/react primitives for the middle ground to assistant-ui or CopilotKit for design-system-grade chat. Three layers, one substrate.
Pleach ships one styled chat surface (<ChatBox />) and a small
hook + facade surface (@pleach/react). Neither is a full chat
UI library — that's a deliberate scope decision. This page is the
honest map of "what to use when," including when to compose with
assistant-ui or CopilotKit instead of writing the
component yourself.
The three layers
| You want | Reach for | What you give up |
|---|---|---|
| MVP, ship today | <ChatBox /> from @pleach/core/quickstart | Per-message customization, multi-thread UI, generative-UI tool rendering |
| Production chat in your design system | @pleach/react hooks + your own components | Composer primitives, attachment dropzones, dictation, branched edit-mode |
| Multi-thread, attachments, generative UI, polished UX | @pleach/react runtime + assistant-ui or CopilotKit | An npm dependency on a UI library |
There is no shame in the third row. Mastra, OpenAI Agents SDK, Inngest AgentKit, and the LangGraph reference UIs all integrate with assistant-ui or CopilotKit for production chat. The underlying runtime is yours; the component family is theirs. Both sides win.
Layer 1 — <ChatBox />
// app/page.tsx
"use client";
import { ChatBox } from "@pleach/core/quickstart";
export default function Home() {
return <ChatBox apiUrl="/api/chat" />;
}Unstyled, semantic, ARIA-correct. Composed of plain HTML elements
with data-pleach-* selectors. Style with a stylesheet that
targets those selectors, or pass a classNames map. Internally
it composes useChat — you can replace any subset of the markup
without re-implementing the streaming.
Use when: the chat is one tab in a bigger app, your design system is light, you want to ship today.
Layer 2 — @pleach/react hooks + your own components
The @pleach/react SKU ships the lower-level primitive hooks,
factored so you can build your own surface:
useSessionRuntime— owns oneSessionRuntimeper mount behind a ref (construct-once, cleanup-on-unmount). No provider required.useSessionMessageStream— fires one turn at a time against the active runtime/session; you own the UI reduction viaonEvent.useEventLog— facade over a caller-ownedEventLogClientfor rendering audit rows.useInterruptUI— routes each pending interrupt to the matching handler; returnsrenderActive/resolveInterrupt/cancelInterrupt.- Correction dedup + stream lock —
createCorrectionDedupandcreateStreamLockhandle the "user edits while streaming" race and serialize concurrent turns. - Vercel AI SDK adapter —
useVercelAISDKCompat()from@pleach/react/adapters/vercel-ai-sdktranslates PleachStreamEvents into AI SDK'sUIMessagetaxonomy.
The batteries-included facade lives one layer up: HarnessProvider
and useHarness on @pleach/core/react, and the Pleach-native
useChat (returns { messages, status, submit }) on
@pleach/core/quickstart.
See React for the full surface.
Use when: you have a design system, you want full per-message control, the chat is the product (not one tab in something else).
// app/(chat)/page.tsx
"use client";
import { useState } from "react";
import { useSessionRuntime, useSessionMessageStream } from "@pleach/react";
import { createPleachRuntime } from "@pleach/core/runtime";
export default function ChatPage() {
const { runtime, sessionId } = useSessionRuntime({
buildRuntime: () => createPleachRuntime({ /* config */ }),
initSession: async (rt) => rt.sessions.create(),
});
const [messages, setMessages] = useState<Message[]>([]);
const { streamMessage, isStreaming } = useSessionMessageStream({
runtime,
sessionId,
onEvent: (e) => setMessages((prev) => reduceMessage(prev, e)),
});
return (
<main className="chat">
<MessageList messages={messages} />
<Composer
onSubmit={(text) => streamMessage({ content: text })}
disabled={isStreaming}
/>
</main>
);
}<MessageList /> and <Composer /> here are your components.
useSessionRuntime owns the runtime lifecycle behind a ref (no
provider), and useSessionMessageStream gives you the stream — the
rendering is yours, reduced from onEvent.
Layer 3 — compose with a UI library
When you need multi-thread sidebar, attachment dropzones,
dictation, branched edit mode, generative-UI per-tool rendering,
and a polished design out of the box, compose your SessionRuntime
with an external UI library.
Option A — assistant-ui
assistant-ui ships Radix-style headless primitives:
ThreadPrimitive.{Root,Viewport,Messages},
ComposerPrimitive.{Root,Input,Send,AttachmentDropzone,...},
MessagePrimitive.{Root,Parts}. Unopinionated on styling, very
opinionated on the chat state model. Same primitive contract
works against web, React Native, and Ink TUI.
// app/(chat)/page.tsx
"use client";
import { AssistantRuntimeProvider } from "@assistant-ui/react";
import { Thread } from "@/components/thread"; // assistant-ui-shaped
import { usePleachAssistantRuntime } from "@/lib/pleach-runtime-adapter";
export default function ChatPage() {
const runtime = usePleachAssistantRuntime({ api: "/api/chat" });
return (
<AssistantRuntimeProvider runtime={runtime}>
<Thread />
</AssistantRuntimeProvider>
);
}The adapter is ~30 LoC that maps Pleach's StreamEvent discriminated
union into assistant-ui's runtime contract. We don't ship the
adapter today — file an issue if you'd like one in @pleach/react.
Best for: product-shaped chat where you want compound-component primitives without coupling to a hosted vendor.
Option B — CopilotKit
CopilotKit is the embedded copilot pattern — sidebar / popup
that knows about your app state. Three packages:
@copilotkit/react-core + @copilotkit/react-ui +
@copilotkit/runtime. Hooks: useCopilotReadable (app state →
LLM context), useCopilotAction / useFrontendTool
(generative-UI tool rendering), useCopilotChat (programmatic
control).
// app/layout.tsx
"use client";
import { CopilotKit } from "@copilotkit/react-core";
import { CopilotSidebar } from "@copilotkit/react-ui";
export default function RootLayout({ children }) {
return (
<CopilotKit runtimeUrl="/api/copilotkit">
{children}
<CopilotSidebar />
</CopilotKit>
);
}You'd wire /api/copilotkit/route.ts to a CopilotRuntime whose
service adapter delegates to Pleach. As with assistant-ui, we
don't ship the adapter today; the integration point is the AI SDK
adapter (CopilotKit accepts AI-SDK-shaped streams; pleach's
useVercelAISDKCompat() from @pleach/react/adapters/vercel-ai-sdk
produces them).
Best for: the copilot is a feature of an existing app, not the app itself. Sidebar / popup UX. Generative-UI per-tool rendering.
Why we don't ship Layer 3 ourselves
A primitive component family is real work — assistant-ui has 25+
primitives and three years of refinement. Building a competing
family inside @pleach/react would either duplicate that effort
or ship a worse one. The pleach surface stays minimal so the
substrate (audit ledger, family-lock, replay determinism,
multi-tenant) gets the engineering investment instead.
If your project hits a wall where @pleach/react hooks aren't
enough but <ChatBox /> is too little, compose with assistant-ui
or CopilotKit. Both ecosystems are healthy, both work with the
pleach runtime through a thin adapter, and you keep the audit row
in your Postgres on every turn.
Decision
| If your situation is... | Layer | Stack |
|---|---|---|
| "I need a chat tab on my dashboard tonight" | 1 | <ChatBox /> |
| "Chat is in our design system, we have 5 message types" | 2 | @pleach/react hooks + your components |
| "Chat is the product. Multi-thread, attachments, polished UX" | 3 | @pleach/react runtime + assistant-ui |
| "Add a copilot to our existing SaaS, sidebar pattern, knows our app state" | 3 | @pleach/react runtime + CopilotKit |
| "We're building Cursor / Lovable / a coding agent" | 3 | @pleach/react runtime + assistant-ui + your file-tree / diff UI |
Where to go next
@pleach/react
The primitive-hook surface — useSessionRuntime, useSessionMessageStream, useEventLog, useInterruptUI.
Getting started
The npx pleach init wizard + the two-snippet hand-wire path.
Recipes
End-to-end runnable patterns including simple-chatbot, observable-chatbot, rag-chatbot.
Stream events
The StreamEvent discriminated union you bind the UI against.