Generative UI
Let a tool stream a typed UI component — a chart, a card, a form — into the reply instead of (or alongside) plain text: ctx.emitUi, the component catalog and its genui tools, the ui stream event, and rendering in React or any other client.
A tool does not have to answer only in prose. It can push a typed UI component frame into the run's live stream — a weather card, a chart, a confirmation form — and the frontend renders your real React component in the transcript, right where the text would have gone. A client that cannot draw it declares so, and receives the component's text instead — see One output, many surfaces.
Drawing the same components through CopilotKit or OpenUI instead of the native client is compared, row by row, in Generative UI: native vs CopilotKit vs OpenUI. The same UI can also ship as A2UI surfaces, to any A2UI renderer.
For result-driven UI, use the tool’s optional present hook. See Component registries and server rendering for functional/class tools, custom components, shared React renderers, and WhatsApp image/PDF delivery.
What it is (and when to reach for it)
The model streams text. Sometimes text is the wrong medium: "your glucose is trending up" is far weaker than an actual line chart, and "here's the weather" begs for a card with an icon and a temperature. Generative UI lets a tool emit a structured component reference — a name plus a JSON data payload — that the client maps to a component in its own registry and paints inline.
The split is deliberate:
- The server decides what to show and with what data — the tool computes the payload and emits
('WeatherCard', { city, tempC, condition }). It never ships markup, HTML, or JSX over the wire. - The client decides how to render it — it owns the
WeatherCardReact component and the styling. The server'snameis just a key into that registry.
That keeps the trust boundary clean (no server-authored HTML executing in the browser) and lets the same run drive a rich web UI, a terminal renderer, or a mobile app from one stream.
There is one opt-in exception: the sandbox, a catalog component whose props are HTML, CSS and JavaScript the model writes for a one-off interactive answer no catalog component fits (a calculator, a small simulation). It is offered to the model as a last resort, and the browser runs it in an isolated iframe under a strict CSP. Off unless you turn it on.
Emit alongside text, not only instead of it
A push doesn't replace the tool's return value or the model's prose. A tool can emit a component and return a text result to the model (so the model can keep reasoning about it) and the model can keep streaming text after. Component and text frames interleave in emission order — see Interleaving.
Use it when the answer is inherently visual or interactive: metrics/charts, maps, media galleries, structured records (an order, an invoice), or a small inline form. Reach for plain text (or a plain tool return) when the answer is prose or a value the model needs to reason over.
The whole path, end to end
A tool pushes a component — ctx.emitUi(component, props) during execute, its present hook after it, or the ui__render tool (or a ui__show_* one) the catalog registered. With a catalog configured the push is validated and negotiated against the client's UI capabilities first; a drawable one is written as a { t: 'component', name, data, id, toolCallId, … } frame to the run's token stream at the current position.
The sink carries the typed frame. Text and component frames travel in the same token stream — the in-process sink buffers them for reconnect, the Redis and Lucid sinks share them across replicas. Frame-for-frame identical either way.
The provider serializes each frame to SSE in the agent stream protocol: a text frame becomes {"kind":"text","text":...}; a component frame becomes a ui event carrying { id, component, props, version?, fallbackText?, toolCallId? }. Over AG-UI it is the agora.ui custom event.
The client switches on the event kind — text events append text; ui events look up component in a component registry and render it with props.
Server side
ctx.emitUi(component, props, options?)
The push to reach for. Every tool's context carries it — always present, a no-op outside a conversation (an MCP call), so a tool never branches on where it runs:
defineTool(
{ name: 'orders', kind: 'read', description: 'List recent orders', input: z.object({}) },
async (_input, ctx) => {
const columns = [{ key: 'id', label: 'Order' }, { key: 'status', label: 'Status' }]
const { id } = await ctx.emitUi('DataTable', { columns, rows: [] }) // a placeholder
const orders = await Order.query().select('id', 'status').limit(20)
const rows = orders.map((order) => ({ id: order.id, status: order.status }))
await ctx.emitUi('DataTable', { columns, rows }, { id }) // same id → replaced in place
return { count: rows.length } // what the model reads
},
)- It streams at once as a
uievent ({ kind: 'ui', id, component, props, version?, fallbackText?, toolCallId }). iddefaults to<toolCallId>:ui:<n>; pushing anidagain replaces that component rather than adding one.propsmust be a JSON object (it is snapshotted at the push).versionnames the props' schema version, so a client can keep rendering components an older server persisted.fallbackTextis the text a client shows when it cannot draw the component.- With
genuiconfigured, the push is checked against the catalog resolved for the request: the component must be defined there, a givenversionmust be the definition's, and the props must pass its schema — otherwiseemitUithrows. The server then stamps the definition'sversionand generatesfallbackTextfrom it (anyfallbackTextoption is replaced). A component the client did not declare in its UI capabilities is streamed and persisted as text instead, andemitUistill resolves with itsid. - A placeholder first, then the data under the same
id, is how a tool shows a skeleton while it reads — with the empty and error ends, and when not to send one, in Loading states and skeletons. - Once the step's tools have settled, every component they pushed is persisted on the assistant message (
StoredMessage.ui, after what the model turn itself streamed), soGET /agent/threads/:idreturns what the live stream showed. The pushes ride the tool step's journaled result, so a durable replay neither re-streams nor re-persists them. A customAgentStoreopts in withsetMessageUi(optional).
A component catalog — @adonis-agora/agent/genui
Instead of writing a tool per component, declare the components once and let the model push them. Agora owns the isomorphic, framework-free catalog in @adonis-agora/agent/genui, so the same definition file serves the server and the browser's GenuiProvider from @adonis-agora/agent/react/genui. No Aviary package is required:
import { defineCatalog, defineComponent } from '@adonis-agora/agent/genui'
import { BUILTIN_COMPONENTS, LAYOUT_COMPONENTS } from '@adonis-agora/agent/genui/builtins'
import { z } from 'zod'
export const catalog = defineCatalog([
...BUILTIN_COMPONENTS, // DataTable, Chart, KpiCards, …
...LAYOUT_COMPONENTS, // Stack, Card, Heading, Text, …
defineComponent({
name: 'OrderCard',
title: 'Order card',
description: 'One order at a glance.',
props: z.object({ id: z.number(), status: z.string() }),
}),
])import { genui } from '@adonis-agora/agent/genui'
import { catalog } from '#genui/catalog'
export default defineConfig({
model,
genui: genui({ catalog }),
})BUILTIN_COMPONENTS are ready-made definitions (no visuals) for a table, a chart, KPI cards, sources, a checklist, a timeline, code, a diff and a callout; LAYOUT_COMPONENTS adds Stack, Card, Heading, Text, Badge, Link and Image for tree mode. Each is listed with its props, and how to pick or override them, in Builtin components.
That registers one tool, ui__render, whose input is a component tree composed from the catalog — { type, props, children? }, nested through the components that take children (Stack, Card):
{ "type": "Card", "props": { "title": "Orders" }, "children": [
{ "type": "KpiCards", "props": { "items": [{ "label": "Open", "value": 12 }] } },
{ "type": "OrderCard", "props": { "id": 7, "status": "shipped" } }
] }A tree of a single component is just that component — { "type": "OrderCard", "props": { … } } — and is pushed exactly as a per-component tool would push it, so one tool covers both a lone table and a dashboard. Each call is validated against the catalog node by node (a refused call reaches the model as a tool error it can fix) and pushed through ctx.emitUi; a composed tree streams as one genui:tree component that your client draws node by node. Component names are letters and digits starting with a letter (/^[A-Za-z][A-Za-z0-9]{0,63}$/); a definition marked internal: true is never offered to the model.
The tool's input schema is exact: below the root, every node is a union by type whose variants carry their component's own props schema, and only components that take children have children (recursive through $defs / $ref). The root itself is a plain object listing every type and every props schema, because OpenAI and Anthropic both refuse a union at the top level of a tool's parameters. A component whose props schema is not self-contained (a recursive Zod schema carries its own references) is described as a plain object. A provider that refuses $ref in tool parameters at all takes treeSchema: 'loose' — one generic node shape, props described in the tool's text only. Whichever schema the model sees, the server validates every call.
When the model gets the shape wrong. Where what it meant is unambiguous, the server takes it as meant rather than refusing the call (validateTree): a tree, children or props sent as a JSON string (with a stray trailing }, or > written for a key's :); an element that left out its envelope — { "props": { "html": … } } or the bare props { "html": …, "css": … } — when exactly ONE component's props schema fits those keys (every key one of its properties, every required one present); props written beside the type; a whole element wrapped in one more key ({ "props": { "type": …, "props": … } }, { "root": "{…}" }), or the component named under component / root. Anything else is refused with a message naming the shape. A client that draws a call from its arguments (CopilotKit renders tool calls; an OpenUI artifact renderer reads them) applies the same reading with normalizeTreeInput(catalog, args) from @adonis-agora/agent/genui, so it draws what the server accepted. The tool's description also shows the envelope with an example built from your catalog.
A component with a tool of its own. componentTools: ['Sandbox'] keeps tree mode and ALSO registers a flat tool for each named standalone component — ui__sandbox, whose input IS the sandbox's props (prefix: componentToolPrefix, default ui__). A big component a model writes on its own has no envelope to leave out there; it streams as it does in a tree, may still be nested in a ui__render tree, and the tree tool's description points the model at it. With per-channel genui it is offered on the channels that run in tree mode.
Per-component mode. mode: 'per-component' registers a ui__show_<snake_case name> tool per model-facing standalone component instead (OrderCard → ui__show_order_card), each taking that component's props directly. Reach for it with small models that compose trees poorly, when each tool should carry its exact schema on its own, or when the catalog only has standalone components to show one at a time. Layouts get no tool there (they could only push an empty box).
Options, all optional:
| Option | Meaning |
|---|---|
mode | 'tree' (default) — one ui__render tool taking a nested { type, props, children } tree, pushed as one genui:tree component (or as the component itself for a single node) — or 'per-component', one ui__show_* tool per component. |
treeSchema | 'strict' (default) or 'loose': how exactly ui__render's input schema describes the nodes (see above). |
showTool | true (or a tool name) adds a generic ui__show taking { component, props } — how a model reaches components that exist only in a per-request catalog. |
terminal | A successful push ends the turn. |
roles | Roles allowed to call the tools; omitted → defaultRoles. |
ability | The authorization ability the UI tools declare, for an ability-aware authorizer such as authzToolAuthorizer (which denies a tool with none, so without it the UI tools are never offered). A string for every tool; a map by tool name ({ ui__render: 'genui.render', ui__sandbox: 'genui.sandbox' }, where a tool left out declares none); or ({ name, component }) => ability | undefined. The authorizer still decides; nothing is allowed by default. |
presentation | (component) => ToolPresentation | undefined, overriding the generated chat copy per tool. |
namePrefix | Prefix of the per-component tools (mode: 'per-component'). Default ui__show_. |
componentTools, componentToolPrefix | Tree mode: standalone components that also get a flat tool of their own (['Sandbox'] → ui__sandbox). See above. |
treeToolName, treeInstructions, treeLimits | The tree tool's name (default ui__render), extra description, and size limits. |
streaming | Tree mode: 'complete' (default) draws the tree once the call has run; 'partial' draws it while the model writes it. See Drawing a tree while the model writes it. |
streamingThrottleMs | Least time between two partial tree frames of one call. Default 100. |
showInstructions | Extra description for the show tool. |
sandbox | true, or defineSandbox options ({ policy, instructions }), adds the sandbox component — model-written HTML, CSS and JS in an isolated frame, described as a last resort. Off by default. |
When a turn carries UI capabilities, each tool describes only what the client can draw, and a component's tool is hidden from the model when it cannot. The catalog is bound in the container — @inject() constructor(private genui: AgentGenui) → this.genui.catalog.
Per request. A tenant with its own components, or its own version of one, is a resolver:
import { GenuiCatalogResolver, type GenuiCatalogScope } from '@adonis-agora/agent/genui'
export class TenantCatalogs extends GenuiCatalogResolver {
async resolve({ actor }: GenuiCatalogScope) {
return catalog.extend(await Tenant.componentsFor(actor.tenantRef))
}
}
// config/agent.ts
genui: genui({ catalog, resolver: TenantCatalogs, showTool: true })A resolver class is built through the container (so it can @inject()); an instance or a plain (scope) => catalog works too. Calls validate against the resolved catalog, and each turn's tool descriptions come from it — through ToolHandler.describe, a hook any tool can implement to vary what the model is told per actor, thread or agent.
Drawing a tree while the model writes it
A model-composed layout is one ui__render call, and its arguments stream for as long as the model writes them — seconds, for a dashboard. By default nothing is drawn until the call has run; then the validated tree appears whole. With streaming: 'partial' the layout fills in as it is written:
genui: genui({ catalog, streaming: 'partial' }) // tree mode, the defaultThe server reads the streaming arguments (tool-input-delta) as far as they go and pushes the tree so far as ui frames marked partial: true, under the id the final push uses (<toolCallId>:ui:0). Each one replaces the last in place; the call's own push, validated as always, replaces the last preview. What a partial frame is, and is not:
- Not validated. A preview's props are whatever the model has written so far. Only the final tree goes through the catalog, and an invalid one is refused to the model as before (it retries under a new call). A preview the call does not replace — the tree was invalid, or degrades to text for this client — is withdrawn with a partial frame whose
propsare{}, before the call's outcome. - Not persisted. The message keeps the final tree only; a reloaded thread draws it. A re-attach (
GET /agent/chat/:runId/stream?after=<seq>) replays the previews still buffered and ends on the final frame. - Not for text. A preview carries no
fallbackText, a text channel skips it, and a preview only ever holds components the client declared in its UI capabilities: once the tree reaches a node this client cannot draw, its previews stop, and the final tree, sent to it as text, withdraws them. - Throttled. At most one preview every
streamingThrottleMs(100 ms) per call, and only when it changed, so a long tree costs the stream (and the Redis or SQL sink buffering it) a bounded number of snapshots.
A node appears once its type has arrived and names a component this client draws. Every node of a partial tree carries its position as id (root, root.0, root.1.2, the same rule that names the final tree's nodes, so a renderer keyed on it never remounts), and incomplete: true while the model is still writing it:
{ "kind": "ui", "id": "c1:ui:0", "component": "genui:tree", "partial": true, "version": 1,
"props": { "root": { "id": "root", "type": "Card", "props": { "title": "Sales" }, "incomplete": true,
"children": [{ "id": "root.0", "type": "KpiCards", "props": { "items": [{ "label": "Rev" }] }, "incomplete": true }] } } }Per component. A component that cannot draw half its data opts out on its definition:
defineComponent({ ...Chart, streaming: 'complete' })While the model writes it, such a node is sent as a placeholder, { id, type, props: {}, incomplete: true, held: true }, without its children; it is drawn once its whole subtree has arrived and its props pass its schema. streaming: 'partial' on a definition does the opposite — previews a component in a tree that defaults to complete — and a complete layout above it is drawn (incomplete, its children so far) once its own props are whole, instead of holding it back. The default stays complete because a renderer written for validated props would be handed half-written ones: opting in is saying your renderers cope (see In React).
How renderers draw skeletons for incomplete and held nodes is in Loading states and skeletons.
Previews need a model stream that carries the call's arguments as they are written. aiSdkModel does; the OpenCode engine does not (its server sends a tool call's input whole), so there a tree appears once, when the call completes.
The mechanism is a hook any tool can implement: ToolHandler.previewInput(scope) answers how to preview a streamed call (render(partialInput) → a frame, undefined for nothing new, null to withdraw), and the loop does the parsing (parsePartialJson, exported), the throttling and the withdrawal.
Terminal tools
A tool whose effect IS the answer — it pushed the UI the user reads — can end the turn: terminal: true on defineTool / @AiTool / static tool. Once such a call succeeds no further model call narrates it; a failed one does not end the turn, so the model can recover.
defineTool({ name: 'showChart', kind: 'read', description: '…', input, terminal: true }, async (input, ctx) =>
ctx.emitUi('Chart', await chartFor(input)),
)ctx.emitComponent(name, data) below is the older form. It still works — and now goes through the same path, so its components are validated, negotiated and persisted too.
Money and units in what a tool returns
A tool's output is what the model reads, and a model handed totalCents: 12999 sometimes says "$12,999". Return the amount already formatted for people, beside the raw value a component takes — { total: '$129.99', totalCents: 12999 } — and push the raw value to the component (its props are typed). The same goes for any unit a model could misread (bytes, basis points, milliseconds): format it in the data, not in a prompt line the model may skip. The frontends example's list_orders does exactly that.
ctx.emitComponent(name, data)
Every tool handler's second argument is an AiToolCtx. Under the agent loop it also carries the older push:
emitComponent?(name: string, data: unknown): void | Promise<void>name— the component name, a key the client resolves to a component. With a catalog configured it must be a catalog component, so use the same identifier rules ('WeatherCard','TrendChart').data— the props. A JSON object is pushed as is; any other value is wrapped as{ value: data }. Keep it serializable: no class instances, no functions, no circular references.
It is ctx.emitUi(name, data) without options and without the returned id.
Call it through the optional chain
emitComponent is optional on AiToolCtx — the agent loop wires it, but other contexts (an MCP call, a test, a direct registry.invoke) omit it. Call it as ctx.emitComponent?.(...), or use ctx.emitUi, which is always present and does nothing where there is no conversation.
Here is a ReadTool that emits a weather card and also returns a text summary to the model — so the model can narrate around the card:
import { ReadTool } from '@adonis-agora/agent'
import type { AiToolCtx } from '@adonis-agora/agent'
import { z } from 'zod'
import WeatherService from '#services/weather_service'
import { inject } from '@adonisjs/core'
const input = z.object({ city: z.string() })
type Input = z.infer<typeof input>
interface WeatherCard {
city: string
tempC: number
condition: 'sunny' | 'cloudy' | 'rain'
humidity: number
}
@inject()
export default class GetWeather extends ReadTool<Input, { tempC: number }> {
constructor(private readonly weather: WeatherService) {
super()
}
static tool = {
name: 'get_weather',
description: 'Current weather for a city. Renders a weather card.',
input,
}
async execute({ city }: Input, ctx: AiToolCtx) {
const w = await this.weather.current(city)
// 1) Push the UI component into the stream at this position.
const card: WeatherCard = {
city,
tempC: w.tempC,
condition: w.condition,
humidity: w.humidity,
}
await ctx.emitComponent?.('WeatherCard', card)
// 2) Still return a text result so the model can reason about / narrate it.
return { tempC: w.tempC }
}
}Works from any tool shape
emitUi and emitComponent live on the ctx, not on a base class — so they work identically from a @AiTool class, a BaseTool/ReadTool/ActionTool subclass, or a defineTool function. An action tool's execute runs only after approval, so what it pushes arrives with the result; the approval card itself is worded by presentation.confirm or a preflight confirmation (Action preflight).
The typed stream frame
The transport is a discriminated union — the "data plane" that carries live output decoupled from the durable control plane:
// from @adonis-agora/agent — the two arms generative UI uses
export type StreamFrame =
| { t: 'text'; v: string }
| {
t: 'component'
name: string
data: unknown
id?: string // `<toolCallId>:ui:<n>` unless the push named one
toolCallId?: string
version?: number
fallbackText?: string
componentVersions?: Record<string, number> // per node type, on a `genui:tree` push
partial?: true // a preview of a tree the model is still writing — never persisted
}
| { t: 'event'; event: AgentStreamEvent } // …and the error, elicitation and approval framesA push is written as { t: 'component', name: 'WeatherCard', data: card, id, toolCallId, … } to the run's SinkWriter. Text tokens from the model — and the text of a component the client could not draw — are the { t: 'text'; v } arm. Both flow through the same TokenStreamSink — so component frames get the same buffering/replay guarantees as text: a late subscriber that re-attaches replays every component frame emitted so far, in order, before following live.
The ui stream event
AgentSseEncoder (exported) writes each frame as the AgentStreamEvents it stands for. A component frame becomes a ui event carrying its version, fallbackText, componentVersions and toolCallId when set; one that carries no data object is wrapped as { value: data }, and one buffered without an id is numbered by its position (ui:<n>) so a replay replaces rather than duplicates it.
So a run that emits a weather card mid-sentence produces this on the wire (POST /agent/chat or GET /agent/chat/:runId/stream):
event: meta
data: {"runId":"7f3…","threadId":"a12…"}
id: 1
data: {"kind":"text","text":"The weather in "}
id: 2
data: {"kind":"text","text":"Lisbon:"}
id: 3
data: {"kind":"ui","id":"c1:ui:0","component":"WeatherCard","props":{"city":"Lisbon","tempC":21,"condition":"sunny","humidity":48},"version":1,"fallbackText":"*Lisbon* 21°C, sunny","toolCallId":"c1"}
id: 4
data: {"kind":"text","text":" Looks like a nice day."}
event: done
data: {}The key facts a client relies on:
- Every frame is an
AgentStreamEventdispatched onkind—text,ui, and the rest of the shared vocabulary. A reader skips kinds it does not know. - A
uievent carries{ id, component, props }, plusversionandfallbackTextwhen a catalog is configured; a seconduiwith the sameidreplaces the first in place. partial: truemarks a preview (streaming trees): draw it, never keep it. One with emptypropswithdraws it. AG-UI carries the same frames as repeatedagora.uievents with the sameid.- Order is preserved — the event lands exactly where
emitUi/emitComponentwas called relative to the surrounding text.
Client side
The server never sends markup, so you own every pixel. You supply one thing — a registry mapping each component name to the React component that renders its props.
In React
@adonis-agora/agent/react/genui draws pushed components with your renderers. Hand the registry to the provider once; every ui block of the transcript is then one <GenerativeUI>:
import { AgentProvider, useAgentChat } from '@adonis-agora/agent/react'
import { GenerativeUI, type GenuiRegistry } from '@adonis-agora/agent/react/genui'
import { WeatherCard } from '~/components/weather_card'
import { TrendChart } from '~/components/trend_chart'
import { catalog } from '#genui/catalog' // optional: the file the server uses — props are validated before drawing
const registry: GenuiRegistry = { WeatherCard, TrendChart }
export default function ChatPage() {
return (
<AgentProvider genui={{ registry, catalog }}>
<Chat />
</AgentProvider>
)
}
function Chat() {
const { transcript } = useAgentChat()
return transcript.items.map((item) => (
<article key={item.id} data-role={item.role}>
{item.blocks.map((block) => {
if (block.kind === 'text') return <p key={block.key}>{block.text}</p>
if (block.kind === 'ui') return <GenerativeUI key={block.key} part={block} />
return null
})}
</article>
))
}Streaming trees. A partial tree frame renders node by node like a final one, keyed by node position, so nodes stay mounted while they fill in and when the final frame replaces the preview. A node the model is still writing is not validated against the catalog (its props are half there); its renderer can ask with useGenuiNode(), which answers { id, type, incomplete, held } inside a tree and null outside one:
import { useGenuiNode } from '@adonis-agora/agent/react/genui'
function KpiCards({ items = [] }: { items?: Kpi[] }) {
const node = useGenuiNode()
if (node?.incomplete && items.length === 0) return <KpiSkeleton />
return <Kpis items={items.filter((item) => item?.label)} />
}Props of an incomplete node can be missing or half-built (an array still growing, its last item partial), so a renderer opted into previews defaults what it reads. A held node (streaming: 'complete') never reaches its renderer: <GenerativeUI> draws placeholder instead — a node, or (node) => ReactNode — defaulting to loading, and settable once on <GenuiProvider>. A withdrawn preview draws nothing, and the transcript drops a preview whose call settled without replacing it. incomplete only ever means "the model has not finished writing this node"; it is not a loading state for data.
Only the nodes that changed render again. The chat reaches React as a fresh copy on every update (each token, each partial frame), so the transcript and the tree renderer share structure with the previous frame: a node, a pushed component or a props object equal to the one before keeps that object, and its renderer does not run. A node that grew, or whose incomplete or held flag changed, is a new object, and so are its ancestors. A heavy renderer (a chart that measures itself, a table) therefore needs no memo of its own to keep up with a fast stream. Inline fallback, loading, placeholder and onError values are read when a node renders, so passing new ones does not re-render the tree either.
A component the registry does not know, props the catalog refuses, or a renderer that throws renders your fallback — or, without one, the item's fallbackText (nothing when it has none). A name is a lookup, never something to evaluate. A repeated id replaces the component in place, and a reloaded thread draws the same components from the message's persisted ui list. To tell the server what this registry can draw, pass uiCapabilities to <AgentProvider> — see One output, many surfaces. See React for the rest of the hook.
A refused call the model fixed. When a ui__render (or any tool) call fails and the model's next tool call is the same tool and does not fail, the transcript leaves the failed one out (retriedCallIds) — while the retry runs and once it succeeds — so the person sees the view, not the refusal before it. A failure the model moved on from, or whose retry failed too, stays. useChatTranscript({ hideRetriedFailures: false }) (or buildTranscriptBlocks's option) keeps every attempt, for an operator's view.
Without React
The framework-free client decodes the same frames into parts. foldPart is a pure reducer over them:
import { createAgentChatClient } from '@adonis-agora/agent/client'
import type { ChatPart } from '@adonis-agora/agent/client'
const client = createAgentChatClient({ basePath: '/agent' })
await client.send({
body: { message: 'How is my glucose trending?' },
onParts: (parts: ChatPart[]) => paint(parts),
})
function paint(parts: ChatPart[]) {
for (const part of parts) {
if (part.type === 'text') appendText(part.text)
else mountComponent(part.name, part.data, part.fallbackText)
}
}onParts fires on every frame with the message rebuilt from scratch, so paint can be a plain re-render rather than a diff. A component part with partial: true is a preview of a tree the model is still writing: foldPart replaces it in place, removes it when it is withdrawn, and the client drops any left standing when the stream ends (settleParts). A re-attach after a dropped connection replays the run from its first frame — including every component frame — so the rebuilt transcript is always complete.
Actions back to the agent
A drawn component can hand something back to the conversation — a button in your OrderCard, a sandbox's agent.send, an A2UI button. Each is a UiAction ({ source, name, context, text? }), and each becomes the same thing: the next user message, whose text (uiActionText) says what was done and carries the values as JSON. In React, give the tree a handler — usually the chat's sendUiAction — and read it in your components with useGenuiAction():
import { useAgentChat } from '@adonis-agora/agent/react'
import { GenuiActionProvider, useGenuiAction } from '@adonis-agora/agent/react/genui'
const chat = useAgentChat()
<GenuiActionProvider onAction={chat.sendUiAction}>{/* the transcript */}</GenuiActionProvider>
function RefundButton({ orderId }: { orderId: number }) {
const act = useGenuiAction()
return <button onClick={() => act?.({ source: 'component', name: 'refund', context: { orderId }, text: `Refund #${orderId}` })}>Refund</button>
}<GenuiProvider onAction> does the same for its whole tree. An action is only a user message: the model decides what to do with it, and any action tool it calls still asks for approval. Over AG-UI, send it as forwardedProps.uiAction (AG-UI). The transcript draws such a message as a chip — the user's sentence and a few values — not as its JSON (what the user sees).
Over A2UI
The same ui frames can be served as A2UI v0.9 surfaces — a2uiAdapter() for a JSON Lines route, or agUiAdapter({ a2ui: true }) inside the AG-UI stream — so any A2UI renderer (@a2ui/react, Lit, Angular, Flutter) draws them. The builtins map onto A2UI's basic catalog; your components take a mapping function, or travel as custom components of your own A2UI catalog.
Interleaving with text
Component and text frames land in emission order. A tool that emits a card between two sentences streams text → component → text, and the sink preserves that exact ordering through buffering, replay, and the Redis multi-replica transport. A quick end-to-end shape:
// inside a tool's execute:
// (the model has already streamed "olha ")
await ctx.emitUi('TrendChart', { series, unit: 'mg/dL' })
// (the model then streams "tá subindo")yields, frame for frame:
data: {"kind":"text","text":"olha "}
data: {"kind":"ui","id":"c1:ui:0","component":"TrendChart","props":{"series":[...],"unit":"mg/dL"},"toolCallId":"c1"}
data: {"kind":"text","text":"tá subindo"}The client appends "olha ", drops the chart component into the transcript, then appends "tá subindo" after it — a chart embedded mid-sentence, exactly where the tool put it.
A second example — a chart
Server: a tool that computes a series and emits a TrendChart component (payload only — the client owns the SVG/canvas).
import { ReadTool } from '@adonis-agora/agent'
import type { AiToolCtx } from '@adonis-agora/agent'
import { z } from 'zod'
const input = z.object({ metric: z.enum(['glucose', 'weight']), days: z.number().int().default(30) })
type Input = z.infer<typeof input>
export default class GlucoseTrend extends ReadTool<Input, { points: number }> {
static tool = {
name: 'glucose_trend',
description: 'Plot a health metric over time as a chart.',
input,
}
async execute({ metric, days }: Input, ctx: AiToolCtx) {
const series = await readSeries(ctx.actor.id, metric, days) // [{ t, v }, …]
await ctx.emitUi('TrendChart', {
series,
unit: metric === 'glucose' ? 'mg/dL' : 'kg',
label: metric,
})
// Return a compact summary the model can talk about.
const latest = series.at(-1)?.v
return { points: series.length, latest }
}
}Client: the TrendChart entry in the registry renders your real chart component with the emitted series/unit — nothing about the chart's implementation crosses the wire, only its data.
Testing it
A push writes to the same sink you already assert against in tool/loop tests. Emit from a fake tool and drain the sink — component frames come out typed and in order:
const sink = new InProcessTokenStreamSink()
const w = await sink.open('r1')
await w.write({ t: 'text', v: 'olha ' })
await w.write({ t: 'component', name: 'TrendChart', data: { unit: 'mg/dL' } })
await w.write({ t: 'text', v: 'tá subindo' })
await w.end()
const frames = []
for await (const f of sink.subscribe('r1')) frames.push(f)
// → [ {t:'text',v:'olha '}, {t:'component',name:'TrendChart',data:{unit:'mg/dL'}}, {t:'text',v:'tá subindo'} ]And at the wire level, frameToEvents(frame, position) is a pure function — assert the exact events without booting HTTP:
frameToEvents({ t: 'component', name: 'card', data: { a: 1 } }, 0)
// → [{ kind: 'ui', id: 'ui:0', component: 'card', props: { a: 1 } }]See also
- React — the provider and
useAgentChat, whose transcript carries these components asuiblocks. - Browser client — the framework-free SSE client that decodes the same frames.
- Loading states and skeletons — tool-driven skeletons, progressive trees and skeleton renderers.
- Tools — how to author the tool that calls
emitUi, and the fullAiToolCtx. - Builtin components —
BUILTIN_COMPONENTSandLAYOUT_COMPONENTS, their props and text fallbacks. - Sandboxed generated UI — the opt-in, last-resort component the model writes in HTML, CSS and JS, and its security model.
- A2UI — the same components as A2UI v0.9 surfaces, over JSON Lines or AG-UI.
- Generative UI: native vs CopilotKit vs OpenUI — the same components through three frontends, verified.
- One output, many surfaces — UI capabilities, text fallbacks, and what AG-UI, text channels and MCP receive.
- Component registries and server rendering —
present, custom components, and image/PDF export. - Streaming & HTTP — the SSE envelope, the routes that carry component frames, and re-attaching to a live run.
- Redis streaming — the multi-replica sink that replays component frames across pods.
RAG media ingestion
Auto-index uploaded files into the agent's RAG store — mediaRagIngestion bridges an @adonis-agora/media upload.complete event through text extraction, chunking, and embedding, tagged per tenant/owner.
Loading states and skeletons
Show the shape of a component before its data or its props exist: a tool-driven skeleton replaced in place by the same ctx.emitUi id, and progressive ui__render trees whose renderers draw skeletons through useGenuiNode().