Agora

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_card 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.

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:

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?.('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 eventevent: component, whose data: is { "name": string, "data": <your payload> }.
  • Order is preserved — the frame lands exactly where emitComponent was 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.

app/registry.tsx
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:

app/Chat.tsx
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).

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.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 useAgentChat hook that decode these frames.
  • Tools — how to author the tool that calls emitComponent, and the full AiToolCtx.
  • 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.

On this page