pleach
Build

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 wantReach forWhat you give up
MVP, ship today<ChatBox /> from @pleach/core/quickstartPer-message customization, multi-thread UI, generative-UI tool rendering
Production chat in your design system@pleach/react hooks + your own componentsComposer primitives, attachment dropzones, dictation, branched edit-mode
Multi-thread, attachments, generative UI, polished UX@pleach/react runtime + assistant-ui or CopilotKitAn 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 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 lockcreateCorrectionDedup and createStreamLock handle the "user edits while streaming" race and serialize concurrent turns.
  • Vercel AI SDK adapteruseVercelAISDKCompat() from @pleach/react/adapters/vercel-ai-sdk translates Pleach StreamEvents 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 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...LayerStack
"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

On this page