assistant-ui
Drop in a working chat, or build your own thread on the same runtime.
assistant-ui over a Subako session: one hook that turns the session into an assistant-ui runtime, and one component that renders a working chat.
Requires React 19, @assistant-ui/react 0.15 with @assistant-ui/react-markdown 0.14.
pnpm add @subako-ai/assistant-ui @subako-ai/react @subako-ai/sdk @assistant-ui/react @assistant-ui/react-markdown
The whole chat
The provider and the session are @subako-ai/react’s; this package draws them.
import { SubakoChat } from "@subako-ai/assistant-ui";
import { SubakoProvider, useSession } from "@subako-ai/react";
function Assistant({ sessionId }: { sessionId: string }) {
// The app opens the session and hands it down, so the chat opens no second connection.
return <SubakoChat session={useSession(sessionId)} />;
}
export function App({ sessionId }: { sessionId: string }) {
return (
<SubakoProvider client={subako}>
<Assistant sessionId={sessionId} />
</SubakoProvider>
);
}
That is the transcript, the tool calls with their results, the approval prompt, a composer, and a stop button while a run is under way. The assistant’s text is drawn as markdown, tables and all, with links that open in a new tab; what the person typed is shown as typed.
The session is the one the app opened, so the tools the app declares and the thread share one connection. useSession hands back null until its effect has connected, which the chat renders as an empty thread with a disabled composer.
It brings its own small stylesheet, so it renders acceptably with nothing else installed — no CSS framework, no shadcn registry, no @assistant-ui/styles.
Styling
className lands on the root element, and these class names are stable:
| Class | What it is |
|---|---|
.subako-chat |
The root. |
.subako-viewport |
The scrolling message list. |
.subako-message, -user, -assistant |
One bubble. |
.subako-markdown |
The assistant’s body, as markdown. |
.subako-thinking |
A thinking block. |
.subako-tool |
One tool call, folded to its name and status. |
.subako-tool-args, .subako-tool-result |
The input and the output behind the fold. |
.subako-approval |
The allow / deny prompt. |
.subako-composer, .subako-input |
The composer. |
Every rule of the stylesheet sits in a cascade layer named subako. A rule of the app’s own that names one of those classes wins over it, at any specificity and from anywhere in the page:
.subako-user {
border-radius: 4px;
}
A rule that names no class — a reset’s * and button, the app’s own pre — does not reach the chat’s own elements. Each carries a guard, in no layer, that turns unlayered element rules away and takes the layered ones instead, so Tailwind v3’s preflight, a normalize.css, and the app’s global button all leave the chat as it is.
An app whose CSS is layered itself — Tailwind v4 is one — says where subako goes by naming it in its layer order, ahead of anything else that declares a layer:
@layer theme, base, subako, components, utilities;
@import "tailwindcss";
After base, so preflight does not strip the chat’s own buttons; before components and utilities, so an override the app writes inside a layer of its own wins. Left unnamed, subako lands last and the chat’s own rules beat the app’s layered ones.
Colors
Every surface names its own color and its own background, so the chat is legible on a page of any color instead of inheriting half of one. Eight custom properties drive it, light by default and dark under prefers-color-scheme: dark. Set them on .subako-chat itself — a value set above it is shadowed by the chat’s own defaults — which is how an app whose dark mode is a class or a data-theme keeps the chat in step:
.subako-chat {
--subako-bg: var(--card);
--subako-fg: var(--ink);
--subako-muted: var(--dim);
--subako-accent: var(--accent);
--subako-accent-fg: #fff;
--subako-border: var(--line);
--subako-surface: var(--bg);
--subako-error: var(--danger);
color-scheme: inherit;
}
--subako-bg is the panel, the composer and its controls; --subako-surface is the raised one — the assistant bubble, the tool call’s arguments and result, the approval bar; --subako-accent with --subako-accent-fg is the user bubble; --subako-muted is the reasoning block and the placeholder; --subako-error is a failed tool result.
A page with a Content Security Policy
The stylesheet is an inline <style>, so a page whose style-src-elem names a nonce drops it and the thread renders unstyled. Hand the chat the same nonce the page puts on its own tags:
<SubakoChat session={session} nonce={cspNonce} />
A page with no such policy needs nothing: left out, the attribute is not written at all.
useSubakoRuntime
For a thread you build yourself:
const session = useSession(sessionId);
const runtime = useSubakoRuntime(session);
useExternalStoreRuntime over the session. The log stays the only store: the runtime keeps no messages of its own, so a send that fails leaves the thread as the server has it. A null session is a thread with no messages and a composer that cannot be typed into.
The runtime subscribes to the session itself, so the thread follows the log wherever it is mounted. That matters because useSession only acquires: hand the session to a <SubakoChat> sitting in some provider’s children and its props never change, so React would never redraw it. Nothing above the chat has to re-render for a new message to appear.
| assistant-ui | Subako |
|---|---|
messages |
state.transcript, through convertMessage |
isRunning |
state.isRunning |
onNew |
send(text), over the text parts of the appended message |
onCancel |
cancel() |
onRespondToToolApproval |
resolveApproval(callId, approved ? "allow" : "deny", reason) |
The argument is anything carrying those members, or null.
convertMessage is the pure half, exported so an app can convert a transcript without a runtime.