npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@subako-ai/assistant-ui

v0.1.3

Published

assistant-ui runtime and chat for Subako sessions

Readme

@subako-ai/assistant-ui

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 with no setup at all.

Requires React 19, @assistant-ui/react 0.15 with @assistant-ui/react-markdown 0.14, and Node 22 or newer for the build. The package is ESM only.

pnpm add @subako-ai/assistant-ui @subako-ai/react @subako-ai/sdk @assistant-ui/react @assistant-ui/react-markdown

Getting started

The provider and the session are @subako-ai/react's; this package is what draws them.

import { SubakoSessionClient } from "@subako-ai/sdk";
import { SubakoProvider, useSession } from "@subako-ai/react";
import { SubakoChat } from "@subako-ai/assistant-ui";

const subako = new SubakoSessionClient({
	getToken: (sessionId) => fetch(`/api/subako/token/${sessionId}`).then((r) => r.text()),
});

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 whole chat: 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 — useSession hands back null until its effect has connected, which the chat renders as an empty thread with a disabled composer — so the tools the app declares and the thread share one connection. It brings its own small stylesheet, so it renders acceptably with nothing else installed — no CSS framework, no shadcn registry, no @assistant-ui/styles. className lands on the root element for an app that wants to style it, and the class names below 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. |

Overriding the styles

Every rule of the stylesheet sits in a cascade layer named subako. A rule of the app's own that names one of the classes above 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 of them 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. The one rule of a reset's that still reaches a button is [type="button"], which names an attribute; it takes the background only, and the buttons are the color of the bar they sit on. A tool UI the app draws itself is not guarded: that is the app's own markup.

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. After base, so preflight does not strip the chat's own buttons; before components and utilities, so a class the app puts on the root, and an override the app writes inside a layer of its own, both win:

@layer theme, base, subako, components, utilities;
@import "tailwindcss";

Left unnamed, subako lands last and the chat's own rules beat the app's layered ones.

A page with a Content Security Policy

The stylesheet above 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.

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. They come from eight custom properties, light by default and dark under prefers-color-scheme: dark; the root also carries color-scheme: light dark so form controls follow. Set the properties on .subako-chat itself to theme the chat with the app's own palette — 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, rather than the OS setting, 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.

useSubakoRuntime

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 — the one useSession has yet to open — is a thread with no messages and a composer that cannot be typed into.

The runtime subscribes to the session itself, with @subako-ai/react's useSessionState, 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, which is what useSession hands back.

convertMessage

The pure half, exported so an app can convert a transcript without a runtime:

| Log | assistant-ui | | ------------------ | ----------------------------------------------------------- | | text block | a text part | | thinking block | a reasoning part | | tool_call block | a tool-call part, with the joined tool_result as its text | | a pending approval | an approval with no decision, which is what shows a prompt | | a settled approval | the same approval, carrying the decision | | the event's seq | the message id |

The conversion is cached on the identity of the message object, not on the id, and the connection's transcript folds a new event into the messages it already made — replacing only the ones that event touched — so every other message is converted once and stays converted. A user message carries where it came from as metadata.custom.source.

Your own thread

<SubakoChat> exists for the first ten minutes. A real app keeps the runtime and builds the thread from assistant-ui's primitives, or from the components its shadcn registry installs:

function Chat({ sessionId }: { sessionId: string }) {
	const session = useSession(sessionId);
	const runtime = useSubakoRuntime(session);

	return (
		<AssistantRuntimeProvider runtime={runtime}>
			<ThreadPrimitive.Root>
				<ThreadPrimitive.Viewport>
					<ThreadPrimitive.Messages components={{ UserMessage, AssistantMessage }} />
				</ThreadPrimitive.Viewport>
				<ComposerPrimitive.Root>
					<ComposerPrimitive.Input />
					<ComposerPrimitive.Send>Send</ComposerPrimitive.Send>
				</ComposerPrimitive.Root>
			</ThreadPrimitive.Root>
		</AssistantRuntimeProvider>
	);
}

The approval prompt is a tool-call part with an approval that carries no decision; answer it with the part's own respondToApproval({ approved }), which is what reaches resolveApproval. The stop button is <ComposerPrimitive.Cancel>, which the runtime wires to cancel().

Tool UI

A tool call renders as a row folded to its name and its status — running, done, failed, needs approval, denied. Opening the row shows the input and the output, laid out as JSON when they are JSON. The approval prompt sits outside the fold, so a closed row still asks. To draw one of your tools yourself, register a tool UI the ordinary assistant-ui way; it wins over the default row. <SubakoChat> renders its children inside the runtime, which is where the registration has to happen:

import { makeAssistantToolUI } from "@assistant-ui/react";

const FillForm = makeAssistantToolUI<{ email?: string }, string>({
	toolName: "client__chat__fill_contact_form",
	render: ({ args, result }) => (result === undefined ? <p>Filling…</p> : <p>Filled {args.email}</p>),
});

<SubakoChat session={session}>
	<FillForm />
</SubakoChat>;

The name is what the model sees the tool under, which for a client tool is client__<client name>__<tool>.

components

The same renderers go on <SubakoChat> as components, which takes what MessagePrimitive.Parts takes: tools.by_name for the tools named, tools.Fallback for every other one in place of the chat's own row, and Text for the body in place of the chat's own markdown — for a renderer with a syntax highlighter, say, built on MarkdownTextPrimitive the way the chat's is. The chat's body, its reasoning block and its tool row stay wherever nothing is named.

function FilledForm({ args, result }: ToolCallMessagePartProps) {
	return result === undefined ? <p>Filling…</p> : <p>Filled {String(args.email)}</p>;
}

<SubakoChat session={session} components={{ tools: { by_name: { client__chat__fill_contact_form: FilledForm } } }} />;

SubakoToolApproval

A tool that needs approval has to ask for it from whatever draws it: a row of the app's own gets approval and respondToApproval like any tool-call part, and the run waits until something answers. SubakoToolApproval is the chat's own prompt, for dropping in — Allow and Deny while the server waits on the person, nothing once there is an answer or the run was canceled:

import { SubakoToolApproval } from "@subako-ai/assistant-ui";

function Terse(props: ToolCallMessagePartProps) {
	return (
		<div>
			{props.toolName}
			<SubakoToolApproval {...props} />
		</div>
	);
}

<SubakoChat session={session} components={{ tools: { Fallback: Terse } }} />;

What it does not do

No thread list, no branching, no editing, no attachments, no speech: the session is one conversation, its log is append-only, and the runtime declares only what the session can actually do.