@adambossy/agent-ui
v0.6.1
Published
React components and a live-component runtime for building agent chat UIs.
Readme
@adambossy/agent-ui
React components and a live-component runtime for building agent chat UIs: streaming messages, reasoning, tool-call rendering, subagent expansion, and a side-panel "live document" system driven by server-streamed ops.
Backend-agnostic. The library renders UI and manages client state; you supply the transport (e.g. the Vercel AI SDK's
useChat) and wire its data parts to the stores. The playground is a complete reference integration.
Install
The package is currently private. Within this monorepo it resolves through the
workspace; external consumers can use a git dependency or npm pack.
import {
Message,
Composer,
LiveDocsPanel,
useLiveDocStore,
useLiveUIStore,
useSubagentStore,
registerLiveComponent,
registerToolRenderer,
} from "@adambossy/agent-ui";
import type { UIMessage, LiveOpEvent } from "@adambossy/agent-ui";Styles (Tailwind v4)
The library is styled with Tailwind v4 utilities plus a small set of design tokens and custom classes. Consumers process the shipped stylesheet through their own Tailwind build:
@import "tailwindcss";
@import "@adambossy/agent-ui/styles.css";
/* Let Tailwind generate the utility classes the components use: */
@source "../node_modules/@adambossy/agent-ui/dist";Public API
- Components —
Message,Composer,Markdown,Reasoning - Tool renderers —
registerToolRenderer,resolveToolRenderer,markAsSubagent,isSubagentTool,isNonUITool,DefaultTool - Run status —
registerToolStatusText,statusTextFor,humanizeToolName,configureRunStatus,getRunStatusConfig,deriveRunStatus - Tool display modes —
ToolDisplayProvider,useToolDisplay,ToolDisplayMode,registerToolSummaryText,summaryTextFor,ToolSummaryTextFn,deriveTurnSummary,TurnSummaryEntry - Subagent store —
useSubagentStore,useSubagent - Live-component runtime —
registerLiveComponent,LiveComponentHost,LiveDocsPanel,useLiveDocStore,useLiveUIStore,LiveOpClient,zodCodec, and the live-op protocol types - Types —
UIMessage,UIMessagePart*,LiveOpEvent,LiveComponentManifest,LiveRendererProps,SubagentEvent, …
Anything not re-exported from the package root is internal and may change without notice.
Run status
Tools without a registered renderer are ephemeral by default: instead of a
persistent JSON card, Message shows a shimmering in-run status line
("Running webSearch…") that disappears once the run moves past that tool call.
The status line also covers approval-requested ("Waiting for approval:
webSearch…"), so a tool call stalled on human approval doesn't vanish from the
run. It does not cover output-error or output-denied — a failed or
denied ephemeral tool call renders nothing at all once it leaves the active
states above. If a tool's failures need to stay visible to the user, register
DefaultTool for it (see below) instead of leaving it ephemeral.
The line is pinned to the top of the turn — it renders as the first child of the assistant message, above any widgets, streamed text, and the summary-mode receipt, in both display modes. That keeps current activity in a fixed spot instead of trailing behind whatever has already rendered, and lets it reappear mid-turn (e.g. the model starts reasoning again after already producing some text) without disturbing anything above it.
Reasoning parts are not rendered inline. While a reasoning part streams,
the pinned line reads "Thinking…" — the same placeholder shown before any
content exists — instead of an inline <Reasoning> block. In "summary"
display mode, finished reasoning is folded into the TurnSummary receipt,
which shares the pinned top slot with the status line (see below); in
"ephemeral" mode it leaves zero residue once it completes, matching how
ephemeral tool calls behave. The Reasoning component itself still ships
and is exported for consumers who want to compose it manually.
Migrating from ≤0.2: previously every unregistered tool rendered a collapsible JSON card. As of the ephemeral-by-default change, an unregistered tool renders only a transient shimmer line and leaves no card once it completes. To restore the old per-tool behavior — including visibility into
output-error/output-denied— callregisterToolRenderer(name, DefaultTool)for that tool name.
Register DefaultTool for a tool name to opt back into the old collapsible
JSON card:
import { registerToolRenderer, DefaultTool } from "@adambossy/agent-ui";
registerToolRenderer("debugTool", DefaultTool); // opt back into the JSON cardCustomize the shimmer text with registerToolStatusText, which is called
with the live tool part so it can refine its wording as streamed input
fields arrive (part.input is typed unknown, so narrow or cast it):
import { registerToolStatusText, configureRunStatus } from "@adambossy/agent-ui";
registerToolStatusText("webSearch", (part) => {
const query = (part.input as { query?: string } | undefined)?.query;
return query ? `Searching for ${query}` : "Searching the web";
});
configureRunStatus({ expandableParallel: true }); // stack parallel tool calls into separate linesconfigureRunStatus is a setup-time global, not a per-render prop: call it
once during app initialization (alongside your registerToolRenderer /
registerToolStatusText calls). It is not reactive: a mid-session call
takes effect only at each status line's next re-render (frequent while
streaming, but not forced by the call itself).
Composer takes the same status ("ready" | "submitted" | "streaming" |
"error") your transport already tracks (e.g. the AI SDK's useChat), plus
onStop: while status is "submitted" or "streaming" the send button
swaps to a stop button that calls onStop.
<Composer status={status} onStop={stop} onSend={sendMessage} />Display modes
The run-status line above is one of two per-conversation display modes
for non-UI tool activity (unregistered tools, and subagent-marked tools —
see isNonUITool below), selected by mounting a ToolDisplayProvider
around a conversation's transcript:
"ephemeral"(default) — the behavior described above: a shimmering status line while a non-UI tool runs, and nothing once it settles. This is the behavior you get with no provider mounted at all."summary"— finished non-UI tool calls, plus finished reasoning, additionally collect into a past-tenseTurnSummaryreceipt — a single collapsed line ("Thought, Searched the web, Read your calendar") that expands into per-entry detail: tool entries via the sameDefaultTool/SubagentToolcards, sooutput-errorandoutput-denied— invisible in ephemeral mode — are visible here; reasoning entries via a compact "Thought" block showing the reasoning text. The receipt shares the pinned top slot with the run-status line rather than trailing the turn: the line wins while there's active work, and the receipt takes the slot back once it yields (prose streaming, or the turn has ended).
deriveTurnSummary(message, isNonUI) returns TurnSummaryEntry[], a
discriminated union of { kind: "tool"; part } and
{ kind: "reasoning"; part } entries in message order — a reasoning entry
appears once its part is finished (state !== "streaming") and non-empty; an
actively streaming reasoning part stays covered by the pinned run-status line
instead (see above), so a part is never represented in both places.
Message reads the mode via useToolDisplay() internally, so nothing
outside the provider is affected — mount one per conversation (or per tab)
to let each run its own mode independently:
import { useState } from "react";
import {
ToolDisplayProvider,
Message,
type ToolDisplayMode,
type UIMessage,
} from "@adambossy/agent-ui";
function Conversation({ messages }: { messages: UIMessage[] }) {
const [mode, setMode] = useState<ToolDisplayMode>("ephemeral");
return (
<ToolDisplayProvider mode={mode}>
{messages.map((m) => (
<Message key={m.id} message={m} />
))}
</ToolDisplayProvider>
);
}Give a summarized tool call its own past-tense wording with
registerToolSummaryText — the past-tense counterpart to
registerToolStatusText above. It only runs once a call has reached a
terminal state, so part.input/part.output are fully settled (still typed
unknown, so narrow or cast it):
import { registerToolSummaryText } from "@adambossy/agent-ui";
registerToolSummaryText("webSearch", (part) => {
const query = (part.input as { query?: string } | undefined)?.query;
return query ? `Searched for ${query}` : "Searched the web";
});Tools with no registered summary text fall back to `Ran
${humanizeToolName(toolName)}`.
Subagents: a subagent call (markAsSubagent) now counts as a non-UI
tool (see isNonUITool below), which changes how it renders. Previously a
subagent always drew a persistent top-level SubagentTool card. Now, like
any other non-UI tool, it appears only in the run-status line while
running; once it finishes, in "ephemeral" mode it leaves no residue, and
in "summary" mode it becomes an entry in the TurnSummary receipt
instead of a standing top-level card. Expanding that entry renders the same
SubagentTool component — the nested transcript inside it (the subagent's
own reasoning, text, and tool parts) is unaffected by any of this.
isNonUITool: the predicate that decides which tools skip inline
rendering covers unregistered tools and subagent-marked tools (an earlier
revision called this isEphemeralTool; that name never shipped in a
release).
Build
npm run build # tsup -> dist/{index.js, index.d.ts, styles.css}
npm run typecheck