# Building chat UI (/docs/building-chat-ui)



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 [#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 />` [#layer-1--chatbox-]

```tsx
// 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 [#layer-2--pleachreact-hooks--your-own-components]

The `@pleach/react` SKU ships the lower-level primitive hooks,
factored so you can build your own surface:

* **`useSessionRuntime`** — owns one `SessionRuntime` per 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 via `onEvent`.
* **`useEventLog`** — facade over a caller-owned `EventLogClient` for rendering audit rows.
* **`useInterruptUI`** — routes each pending interrupt to the matching handler; returns `renderActive` / `resolveInterrupt` / `cancelInterrupt`.
* **Correction dedup + stream lock** — `createCorrectionDedup` and `createStreamLock` handle the "user edits while streaming" race and serialize concurrent turns.
* **Vercel AI SDK adapter** — `useVercelAISDKCompat()` from `@pleach/react/adapters/vercel-ai-sdk` translates Pleach `StreamEvent`s into AI SDK's `UIMessage` taxonomy.

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](/docs/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).

```tsx
// 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 [#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 [#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.

```tsx
// 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 [#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).

```tsx
// 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 [#why-we-dont-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 [#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 [#where-to-go-next]

<Cards>
  <Card title="@pleach/react" href="/docs/react" description="The primitive-hook surface — useSessionRuntime, useSessionMessageStream, useEventLog, useInterruptUI." />

  <Card title="Getting started" href="/docs/getting-started" description="The npx pleach init wizard + the two-snippet hand-wire path." />

  <Card title="Recipes" href="/docs/recipes" description="End-to-end runnable patterns including simple-chatbot, observable-chatbot, rag-chatbot." />

  <Card title="Stream events" href="/docs/stream-events" description="The StreamEvent discriminated union you bind the UI against." />
</Cards>
