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, via ctx.emitComponent, the text|component stream frame, and the component SSE frame.
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. Text-only clients are untouched: the wire stays byte-for-byte backward compatible.
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
('weather_card', { city, tempC, condition }). It never ships markup, HTML, or JSX over the wire. - The client decides how to render it — it owns the
weather_cardReact 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.
Emit alongside text, not only instead of it
emitComponent 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 calls ctx.emitComponent(name, data) during execute. This writes a { t: 'component', name, data } frame to the run's token stream at the current position.
The sink carries the typed frame. The token stream is a union of { t: 'text' } and { t: 'component' } frames — the in-process sink buffers them for reconnect, the Redis sink shares them across replicas. Frame-for-frame identical either way.
The provider serializes each frame to SSE. A text frame stays the legacy data: {"delta":...}; a component frame becomes an event: component frame carrying { name, data }.
The client switches on the event type — plain data frames append text; component frames look up name in a component registry and render it with data.
Server side
ctx.emitComponent(name, data)
Every tool handler's second argument is an AiToolCtx. Under the agent loop it carries an emitComponent method:
interface AiToolCtx {
actor: Actor
threadId: string
runId: string
requestId: string
persona?: Persona
pageContext?: PageContext
host?: unknown
/**
* Push a UI component onto the run's stream at the current position (between the
* text tokens already emitted and the ones that follow). Optional: only the
* agent-loop assembly provides it — other ctx constructors may omit it.
*/
emitComponent?(name: string, data: unknown): void | Promise<void>
}The signature is exactly emitComponent?(name: string, data: unknown): void | Promise<void>:
name— a string key the client resolves to a component. Choose stable names your frontend registry knows ('weather_card','trend_chart'). It has no server-side meaning beyond being echoed on the wire.data— an arbitrary payload, sent as JSON. Keep it serializable: no class instances, no functions, no circular references.
Call it through the optional chain
emitComponent is optional on AiToolCtx — the agent loop wires it, but some ctx constructors (tests, the container-less registration path) omit it. Always call it as ctx.emitComponent?.(...) so a tool stays runnable in a context that doesn't provide a stream. A tool that hard-requires generative UI should still degrade to its text return when the method is absent.
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?.('weather_card', card)
// 2) Still return a text result so the model can reason about / narrate it.
return { tempC: w.tempC }
}
}Works from any tool shape
emitComponent lives on the ctx, not on a base class — so it works identically from a @AiTool class, a BaseTool/ReadTool/ActionTool subclass, or a defineTool function. An action tool can emit a component before its side effect (e.g. render a confirmation card, then wait for HITL approval).
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
export type StreamFrame =
| { t: 'text'; v: string }
| { t: 'component'; name: string; data: unknown }ctx.emitComponent('weather_card', card) is sugar for writing { t: 'component', name: 'weather_card', data: card } to the run's SinkWriter. Text tokens from the model 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 event: component SSE frame
The exported frameToSse helper maps each frame to its wire envelope:
export function frameToSse(frame: StreamFrame): string {
if (frame.t === 'component') {
return `event: component\ndata: ${JSON.stringify({ name: frame.name, data: frame.data })}\n\n`
}
return `data: ${JSON.stringify({ delta: frame.v })}\n\n`
}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…"}
data: {"delta":"The weather in "}
data: {"delta":"Lisbon:"}
event: component
data: {"name":"weather_card","data":{"city":"Lisbon","tempC":21,"condition":"sunny","humidity":48}}
data: {"delta":" Looks like a nice day."}
event: done
data: {}The key facts a client relies on:
- Text frames are unchanged — a plain
data:frame with{"delta": "..."}, byte-for-byte the legacy envelope. Any text-only consumer keeps working with zero changes. - A component frame is a named SSE event —
event: component, whosedata:is{ "name": string, "data": <your payload> }. - Order is preserved — the frame lands exactly where
emitComponentwas called relative to the surrounding text.
Backward compatible by construction
Because the only new thing on the wire is a named event (event: component), a client that only reads the default (unnamed) message event — i.e. reads text deltas and ignores the rest — behaves exactly as before. Generative UI is purely additive.
Client side
The package ships the consumer: @adonis-agora/agent/client decodes component frames out of the box, and @adonis-agora/agent/react folds them into message parts. You supply one thing — a registry mapping each name to the React component that renders its data.
1. A component registry
The server never sends markup, so you own every pixel here.
import type { ChatPart } from '@adonis-agora/agent/client'
import { WeatherCard } from './components/WeatherCard'
import { TrendChart } from './components/TrendChart'
const REGISTRY: Record<string, React.ComponentType<{ data: any }>> = {
weather_card: ({ data }) => <WeatherCard {...data} />,
trend_chart: ({ data }) => <TrendChart series={data.series} unit={data.unit} />,
}
export function renderPart(part: ChatPart, key: number) {
if (part.type === 'text') return <p key={key}>{part.text}</p>
const Component = REGISTRY[part.name]
// Unknown name → render nothing (or a fallback). Never eval a server-supplied string.
return Component ? <Component key={key} data={part.data} /> : null
}2. Render the parts
useAgentChat gives you a transcript whose assistant messages are already ChatPart[] — text and component parts, in emission order:
import { useAgentChat } from '@adonis-agora/agent/react'
import { renderPart } from './registry'
export function Chat() {
const { messages, status, send } = useAgentChat({ basePath: '/agent' })
return (
<div>
{messages.map((message) => (
<article key={message.id} data-role={message.role}>
{message.parts.map(renderPart)}
</article>
))}
</div>
)
}That is the whole client integration. The SSE parsing, the component event decoding, the folding of consecutive text deltas into one part, and the re-attach on a dropped connection are all handled — see Browser client & React.
Without React
The same decoding is available framework-free. foldPart is a pure reducer over the decoded frames:
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)
}
}onParts fires on every frame with the message rebuilt from scratch, so paint can be a plain re-render rather than a diff. 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.
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.emitComponent?.('trend_chart', { series, unit: 'mg/dL' })
// (the model then streams "tá subindo")yields, frame for frame:
data: {"delta":"olha "}
event: component
data: {"name":"trend_chart","data":{"series":[...],"unit":"mg/dL"}}
data: {"delta":"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 trend_chart 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.emitComponent?.('trend_chart', {
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 trend_chart 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
emitComponent 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: 'trend_chart', 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:'trend_chart',data:{unit:'mg/dL'}}, {t:'text',v:'tá subindo'} ]And at the wire level, frameToSse({ t: 'component', name, data }) is a pure function — assert the exact envelope without booting HTTP:
frameToSse({ t: 'component', name: 'card', data: { a: 1 } })
// → 'event: component\ndata: {"name":"card","data":{"a":1}}\n\n'See also
- Browser client & React — the shipped SSE client and the
useAgentChathook that decode these frames. - Tools — how to author the tool that calls
emitComponent, and the fullAiToolCtx. - 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.
Authorization
The fail-closed governance model — the DefaultToolAuthorizer (ADMIN-only, role intersection), the offered-and-invoke double check, and the ActorResolver identity seam that never fabricates a caller.