Agora
Generative UI

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 WeatherCard React component and the styling. The server's name is 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 ui event ({ kind: 'ui', id, component, props, version?, fallbackText?, toolCallId }).
  • id defaults to <toolCallId>:ui:<n>; pushing an id again replaces that component rather than adding one. props must be a JSON object (it is snapshotted at the push). version names the props' schema version, so a client can keep rendering components an older server persisted. fallbackText is the text a client shows when it cannot draw the component.
  • With genui configured, the push is checked against the catalog resolved for the request: the component must be defined there, a given version must be the definition's, and the props must pass its schema — otherwise emitUi throws. The server then stamps the definition's version and generates fallbackText from it (any fallbackText option is replaced). A component the client did not declare in its UI capabilities is streamed and persisted as text instead, and emitUi still resolves with its id.
  • 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), so GET /agent/threads/:id returns 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 custom AgentStore opts in with setMessageUi (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:

app/genui/catalog.ts
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() }),
  }),
])
config/agent.ts
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:

OptionMeaning
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).
showTooltrue (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.
terminalA successful push ends the turn.
rolesRoles allowed to call the tools; omitted → defaultRoles.
abilityThe 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.
namePrefixPrefix of the per-component tools (mode: 'per-component'). Default ui__show_.
componentTools, componentToolPrefixTree mode: standalone components that also get a flat tool of their own (['Sandbox'] → ui__sandbox). See above.
treeToolName, treeInstructions, treeLimitsThe tree tool's name (default ui__render), extra description, and size limits.
streamingTree 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.
streamingThrottleMsLeast time between two partial tree frames of one call. Default 100.
showInstructionsExtra description for the show tool.
sandboxtrue, 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:

config/agent.ts
genui: genui({ catalog, streaming: 'partial' }) // tree mode, the default

The 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 props are {}, 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:

app/agent_tools/get_weather.ts
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 frames

A 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 AgentStreamEvent dispatched on kind — text, ui, and the rest of the shared vocabulary. A reader skips kinds it does not know.
  • A ui event carries { id, component, props }, plus version and fallbackText when a catalog is configured; a second ui with the same id replaces the first in place.
  • partial: true marks a preview (streaming trees): draw it, never keep it. One with empty props withdraws it. AG-UI carries the same frames as repeated agora.ui events with the same id.
  • Order is preserved — the event lands exactly where emitUi/emitComponent was 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>:

inertia/pages/chat.tsx
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).

app/agent_tools/glucose_trend.ts
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

On this page

Partner

Your product as the official integration for Agent, right here.

Become a partner →