Agora

One output, many surfaces

How one tool result reaches a React chat, an AG-UI client, a text-only channel such as WhatsApp, and an MCP client — what lives on the AdonisJS backend and what in the Inertia React frontend, UI capability negotiation, text fallbacks, and what AG-UI carries compared with the native stream.

A tool answers once. The same run can be watched by a React chat that draws components, an AG-UI client, a webhook that relays text to WhatsApp, or nothing at all (an MCP call). This page follows one result through each of them.

The pieces involved are documented on their own pages: Generative UI (pushing components), Component registries and server rendering (present, custom components, image/PDF export), and the transports (Streaming & HTTP, AG-UI, MCP).

Backend and frontend

Every piece below runs on one of two sides. Each code block on this page is labelled with its side and the file it belongs in.

SideRuns inFiles (in a standard Adonis + Inertia app)Imports from
Backendthe AdonisJS processadonisrc.ts (the provider), config/agent.ts, app/genui/*.ts (component definitions, the catalog), app/agent_tools/*.ts (tools), start/routes.ts (the page route), app/controllers/*.ts (webhooks of your own)@adonis-agora/agent, @adonis-agora/agent/genui, @adonis-agora/agent/genui/builtins, @adonis-agora/agent/ag-ui
Frontendthe browser, bundled by Viteinertia/pages/*.tsx (the page that mounts the chat), inertia/components/**/*.tsx (the React renderers of your components, the chat UI), inertia/layouts/*.tsx@adonis-agora/agent/react, @adonis-agora/agent/react/genui

The backend owns everything the model and the database touch: which components exist (the catalog), what each tool returns, validation, negotiation, the text fallback and the HTTP routes. The frontend only draws: it maps a component name to a React component, says which of them it can draw, and renders the stream. Nothing reaches the browser through Inertia page props; the chat talks to the agent's own routes over fetch.

One file may be read by both sides

app/genui/catalog.ts lives with the backend, but it is plain data: @adonis-agora/agent/genui imports nothing server-only, so the browser bundle may import the same file to validate props before drawing. That import is optional. Keep anything server-only (models, services, #config/*) out of that file and of the definitions it imports.

End to end: an Inertia chat that draws OrderSummary

One component, one tool, one chat page. The backend half is the same for every surface on this page; only the frontend half is specific to the React chat.

Backend — AdonisJS

1. The provider. node ace add @adonis-agora/agent registers @adonis-agora/agent/agent_provider in adonisrc.ts and publishes config/agent.ts (Getting started). At boot the provider mounts the agent's HTTP routes under /agent (the path in config/agent.ts). You write no controller and no route for the chat itself.

2. The component definition — the contract every surface reads:

FieldRead by
name, versionthe frontend registry that draws it, and capability negotiation
props (Standard Schema or JSON Schema)the backend, which validates every push; the frontend, which can validate before drawing
descriptionthe model, when the genui tools offer the component
fallbackText(props)every surface that cannot draw it
app/genui/order_summary.ts (backend)
import { createComponent } from '@adonis-agora/agent/genui'
import { z } from 'zod'

export const orderSummary = createComponent({
  name: 'OrderSummary',
  title: 'Order summary',
  description: 'One order: status and total.',
  version: 1,
  props: z.object({ id: z.string(), status: z.string(), totalCents: z.number().int() }),
  fallbackText: ({ id, status, totalCents }) =>
    `*Order ${id}* — ${status}, ${(totalCents / 100).toFixed(2)}`,
})

3. The catalog, and genui in the config:

app/genui/catalog.ts (backend; the frontend may import it too)
import { defineCatalog } from '@adonis-agora/agent/genui'
import { BUILTIN_COMPONENTS } from '@adonis-agora/agent/genui/builtins'
import { orderSummary } from './order_summary.js'

export const catalog = defineCatalog([...BUILTIN_COMPONENTS, orderSummary.definition])
config/agent.ts (backend)
import { defineConfig, AuthActorResolver } from '@adonis-agora/agent'
import { genui } from '@adonis-agora/agent/genui'
import { catalog } from '#genui/catalog'

export default defineConfig({
  model,                                   // see Getting started
  actorResolver: new AuthActorResolver(),  // who is calling: reads ctx.auth.user
  genui: genui({ catalog }),
  // path: 'agent',                        // the default: routes under /agent
})

#genui/* is not one of Adonis's default import aliases: add "#genui/*": "./app/genui/*.js" to imports in package.json, or import the file by relative path.

4. The tool returns domain data and presents it with the component:

app/agent_tools/order_status.ts (backend)
import { defineTool } from '@adonis-agora/agent'
import { z } from 'zod'
import { orderSummary } from '#genui/order_summary'

export const orderStatus = defineTool({
  name: 'orderStatus',
  description: 'Look up one of the customer’s orders',
  input: z.object({ orderId: z.string() }),
  execute: async ({ orderId }, ctx) => Order.findForCustomer(orderId, ctx.actor.id),
  present: (order) =>
    orderSummary({ id: order.id, status: order.status, totalCents: order.totalCents }),
})

The model reads execute's return value on every surface. present (or a direct ctx.emitUi) is the only part whose delivery differs.

5. The page route. Only the page is yours to route; it renders the Inertia page and passes no agent data as props:

start/routes.ts (backend)
import router from '@adonisjs/core/services/router'
import { middleware } from '#start/kernel'

router.on('/chat').renderInertia('chat').use(middleware.auth())

middleware.auth() protects the page, not the /agent/* routes: those are mounted by the provider and identify the caller through actorResolver. AuthActorResolver reads ctx.auth.user, so that user has to be populated on those requests too (for example by a server middleware that calls ctx.auth.check()), or write an ActorResolver of your own (Authorization).

Frontend — Inertia + React

6. The renderer — an ordinary React component whose props are the definition's props:

inertia/components/genui/order_summary.tsx (frontend)
export function OrderSummary({ id, status, totalCents }: { id: string; status: string; totalCents: number }) {
  return (
    <section>
      <h3>Order {id}</h3>
      <p>{status} — {(totalCents / 100).toFixed(2)}</p>
    </section>
  )
}

7. The page mounts the connection, hands it the registry, says what it can draw, and renders each ui block:

inertia/pages/chat.tsx (frontend)
import { AgentProvider, useAgentChat } from '@adonis-agora/agent/react'
import { GenerativeUI, type GenuiRegistry } from '@adonis-agora/agent/react/genui'
import { OrderSummary } from '~/components/genui/order_summary'
import { catalog } from '../../app/genui/catalog' // optional: the backend's catalog, to validate props before drawing

// Keys are the catalog's component names.
const registry: GenuiRegistry = { OrderSummary }

export default function ChatPage() {
  return (
    <AgentProvider
      genui={{ registry, catalog }}
      uiCapabilities={{ components: [{ name: 'OrderSummary', version: 1 }] }}
    >
      <Chat />
    </AgentProvider>
  )
}

function Chat() {
  const { transcript, composer } = useAgentChat()
  return (
    <main>
      {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>
      ))}
      <form onSubmit={(event) => { event.preventDefault(); void composer.submit() }}>
        <input value={composer.text} onChange={(event) => composer.setText(event.target.value)} />
        <button type="submit" disabled={!composer.canSend}>Send</button>
      </form>
    </main>
  )
}

Nothing changes in inertia/app.tsx: <AgentProvider> is an ordinary component. Put it in the page, or in a layout when several pages show a chat. The rest of the hook (history, queue, approvals, attachments) is on React.

With this page, OrderSummary is drawn; any other catalog component a tool presents (a builtin DataTable, say) arrives as its text, and the genui tools offer the model only OrderSummary, because the page declared nothing else.

How the two halves connect

Backend sideFrontend side
Base URLthe provider mounts POST /agent/chat, GET /agent/chat/:runId/stream, GET /agent/threads/:id, … (all routes) under path in config/agent.ts (default agent)<AgentProvider> calls the same origin under /agent; pass path when the config sets another, baseUrl for an API on another origin
IdentityactorResolver turns the request into an actor; every thread and run is owner-scoped to itthe session cookie rides every request (credentials: 'same-origin')
CSRF@adonisjs/shield checks every POST<AgentProvider> sends the token per request: the XSRF-TOKEN cookie as X-XSRF-TOKEN (shield's enableXsrfCookie: true, the Inertia default), else <meta name="csrf-token"> as X-CSRF-TOKEN
What can be drawnintersects uiCapabilities with the catalog, validates, sends a ui frame or textuiCapabilities on the provider (or useAgentChat({ uiCapabilities })) rides in the body of every POST /agent/chat
Component identityname and version of the definitionthe registry key, and the { name, version } declared in uiCapabilities — all three must match
Historymessages are persisted with their ui listuseAgentChat({ threadId }) loads GET /agent/threads/:id and draws the same components (or texts) again
Inertia page propsnone needednone read; the chat does not go through Inertia visits

Mount the provider

A hook used outside <AgentProvider> falls back to a bare client that sends no CSRF token, and shield answers every POST from it with 403.

UI capabilities

A client says which components it can draw with uiCapabilities. It is written by the frontend (or by the backend code that speaks for a channel, such as a webhook) and applied by the backend:

uiCapabilities (sent by the client)
{ components: [{ name: 'OrderSummary', version: 1 }, { name: 'DataTable', version: 1 }] }
ValueMeaning
omittedNo negotiation. Every catalog component is drawable (the behaviour before negotiation existed).
{ components: [] }Text only. Nothing is drawn; every push becomes its text.
a listOnly components whose name and version match exactly (a definition without version is version 1).

Each entry is { name, version }: name matches /^[A-Za-z][A-Za-z0-9]{0,63}$/, version is a positive integer, names are unique, and no other key is allowed. Anything else is refused: validateUiCapabilities (exported) throws a TypeError, and the AG-UI route answers 400.

Capabilities only narrow. The server intersects them with the catalog the actor is authorized for; a client cannot add a component, change a schema, or reach a tool by declaring something. They travel with the turn:

WhereHowSide
POST <path>/chatuiCapabilities in the bodyfrontend: <AgentProvider uiCapabilities> / useAgentChat({ uiCapabilities })
POST <path>/ag-uiforwardedProps.uiCapabilitiesfrontend: any AG-UI client, or agUiBackend()
AgentService.send / chatChatParams.uiCapabilitiesbackend: your controller, job or webhook
a queued messagestored with it, so the turn it starts later negotiates the same waybackend
an independent action proposalstored in its execution context, so the deferred execution renders for the channel that askedbackend
a PromptBuilderPromptContext.uiCapabilitiesbackend
a toolctx.uiCapabilitiesbackend

What the server does with a push (backend)

All of this happens in the AdonisJS process; the frontend receives only the result.

With genui configured, every component a tool pushes — through present, ctx.emitUi, ctx.emitComponent, ui__render or a ui__show_* tool — is checked against the catalog resolved for that request before anything is streamed:

  1. Validation. An unknown component name, a version option that differs from the definition's, or props that fail the schema throw. From execute that fails the tool call like any other error; from present it is a presentation failure, and the successful result stands.
  2. Negotiation. A drawable component is streamed as a ui event carrying fallbackText, generated on the server from the definition. One the client did not declare is not streamed as a component at all: its text is.
  3. The text path. That text is streamed as ordinary text, persisted as an assistant message in the thread, and added to what the model reads for the rest of the turn, so its next sentence follows on from what the user actually saw.

The model is also offered only what the client can draw: ui__render (and ui__show) describe only the drawable part of the catalog — ui__render's schema lists only the drawable components — and are hidden when none is; in per-component mode each ui__show_<component> tool is hidden for an undeclared component.

Capabilities without a catalog

Without genui in config/agent.ts, pushes are not validated against a catalog. A turn that carries uiCapabilities still negotiates: a component the client declared is drawn, any other one becomes the fallbackText given to the push — and nothing at all when the push gave none. Configure genui to get validation and a generated fallbackText; once a catalog is configured, a name it does not define is refused even for a client that sent no capabilities.

How a component becomes text (backend)

The text is produced on the server and sent with the component, so a frontend never computes it: <GenerativeUI> shows the fallbackText it was given.

componentToText(catalog, component, props) (exported from @adonis-agora/agent/genui) is the rule every surface shares:

  • the definition's fallbackText(props), when it returns a non-empty string;
  • otherwise — no fallbackText, an empty result, or a throw — the props as a JSON code block, headed by *title* when the props have a title;
  • a composed tree (ui__render) renders node by node, depth first. A layout node that takes children and has no fallbackText contributes only its children's text.

The text is Slack-mrkdwn compatible (*bold*, fenced blocks), which WhatsApp also reads. The built-in DataTable fallback is a fixed-width table of at most 15 rows that ends with … N more rows; when every row matters on a text channel, send an image or PDF export instead.

Surface by surface

Web — React over the native stream

Backend: nothing beyond the end-to-end setup; the provider's /agent routes are the native stream. Frontend: declare what the registry draws, once, on the provider (or per chat with useAgentChat({ uiCapabilities }), which wins), as inertia/pages/chat.tsx does above.

The provider does not derive capabilities from the registry: pass them, or omit them to keep every catalog component drawable. Each ui block renders through <GenerativeUI>. When a component cannot be drawn there — unknown to the registry, props that fail the client catalog, a renderer that throws, or a stored tree whose component versions no longer match — and no fallback was given, it renders the item's fallbackText. A reloaded thread draws the same components (or texts) from the message's persisted ui list.

Web — any AG-UI client

Backend. Add the adapter next to the native routes; it mounts POST /agent/ag-ui:

config/agent.ts (backend)
import { agUiAdapter } from '@adonis-agora/agent/ag-ui'

export default defineConfig({
  // …
  adapters: [agUiAdapter()],
})

The route reads forwardedProps.uiCapabilities (a malformed value is a 400) and negotiates exactly as above. A drawable component arrives as the CUSTOM event agora.ui with { id, component, props, version?, fallbackText?, componentVersions?, toolCallId? } — so an AG-UI client that cannot draw it after all can still show the text; an undeclared one arrives as text, inside the run's TEXT_MESSAGE_* events.

Frontend. Any AG-UI client posts RunAgentInput to that URL with uiCapabilities in forwardedProps. The React chat above switches its turns to AG-UI with one prop; agUiBackend() forwards the chat's uiCapabilities, the session cookie and the CSRF header for you:

inertia/pages/chat.tsx (frontend)
import { AgentProvider, agUiBackend } from '@adonis-agora/agent/react'

<AgentProvider
  backend={agUiBackend()}
  genui={{ registry, catalog }}
  uiCapabilities={{ components: [{ name: 'OrderSummary', version: 1 }] }}
>
  <Chat />
</AgentProvider>

agUiBackend() sends only the chat turn over AG-UI; threads, the queue, decisions, uploads, cancel and re-attach stay on the native REST routes. See AG-UI.

What AG-UI costs you

AG-UI is a second door onto the same runs, not a richer one. The server-side behaviour — tools, authorization, quota, durable runs, memory — is identical; what differs is what the wire can carry and what a request can ask for. The native stream is POST /agent/chat plus GET /agent/chat/:runId/stream; AG-UI is POST /agent/ag-ui. "React" below means useAgentChat over agUiBackend(), which keeps the REST routes for everything but the turn.

CapabilityNative stream + React clientAG-UI
Text streamingtext framesSupported. TEXT_MESSAGE_START / CONTENT / END, one message per stretch of text
Reasoningreasoning framesSupported. REASONING_START, one reasoning message, REASONING_END
Tool calls and resultstool-input-* with toolKind (read / action) and parentId; tool-output, -error, -deniedPartial. TOOL_CALL_START / ARGS / END and TOOL_CALL_RESULT (a failure or a refusal marked in metadata['agora.outcome']). TOOL_CALL_START carries the kind as metadata['agora.toolKind'], which React keeps; parentId is not carried, so nested calls arrive flat
Generative UI and fallbackTextui frames, drawn by <GenerativeUI>; a streaming tree as partial frames under one idPartial. The CUSTOM event agora.ui, with fallbackText; a streaming tree's previews are repeated agora.ui events with the same id and partial: true, which agUiBackend() draws like the native ones. A generic AG-UI client ignores them unless it knows the name. Negotiation through forwardedProps.uiCapabilities works the same
pageContext, agent, persona, modelbody fields of POST /chatSupported through forwardedProps (pageContext, agent, persona, model, uiCapabilities); AG-UI context is added to the page context as agUiContext
Approvals (action tools)the run stays open; approval-requested / -settled frames; decided through POST /agent/tool-call/approve / reject while the stream continuesPartial. The AG-UI run ends with an Interrupt (reason: 'tool_approval') on RUN_FINISHED; a generic client decides with a new request carrying resume, which re-attaches to the parked run. agora.approval-requested / -settled are sent as CUSTOM events. React shows the normal approval card, decides through the native routes and re-attaches to the run, so the rest streams into the same message
Questions (elicitation)elicitation block with answer.run() / skip.run()Partial. An Interrupt (reason: 'input_required') carrying a JSON Schema of the answers, answered through resume by a generic client. React reads the agora.elicitation event, shows the normal question block, answers through the native routes and re-attaches to the run
Independent action proposalsproposal routes; a text decision answers 200 JSONSupported. A proposal's interrupt id names it and resume decides it through the proposal service; a text decision answers with the CUSTOM event agora.action-proposal-decision and the confirmation as text, which React turns into the same data-proposal-decision part as the native path
Message queuea send on a busy thread is queued (202); mode: 'queue' | 'interrupt'Not available on POST /ag-ui: a send while a turn runs is refused with 409. The queue's snapshots are sent as agora.queue. React queues through the REST route (enqueueMessage), so chat.queue keeps working
Regenerateregenerate: true on POST /chat replaces the last answerSupported through forwardedProps.regenerate: true (an existing thread is required); agUiBackend() sends it
Edit and forkPOST /agent/threads/:id/fork-from/:messageId, truncateNot part of AG-UI. React still forks through the REST routes
Transient threadstransient: true on POST /chatNot available. A thread named by AG-UI's threadId that does not exist is created as a regular thread
Attachmentsuploaded to POST /agent/attachments, sent as { mediaId } referencesSupported. Inline media parts (source.type: 'data') are staged; a file source with provider: 'agora' names an upload, checked against the caller like /chat (another actor's upload is a 403); agUiBackend() sends the composer's uploads that way. A url source is dropped with an agora.warning
Re-attach after a dropped connectionGET /agent/chat/:runId/stream?after=<seq> replays and followsPartial. There is no AG-UI route to re-attach, but every SSE event's id: is the run's own sequence number, so the native route continues exactly after the last event received (run id from agora.run). React does exactly that
CancelPOST /agent/chat/:runId/cancelPartial. AG-UI has no cancel request: use the native route with the id from agora.run. A cancelled run ends with RUN_FINISHED outcome: { type: 'cancelled' }
Durable runs, multiple replicasre-attach from any replicaSupported. A parked run waits durably; the interrupt id carries everything a resume needs, so any replica sharing the token sink serves it
Memoryapplied on the server; GET /agent/memoriesSupported on the server (same run). Listing and deleting memories stays on the REST routes
Tool presentation (elsewhere, result views)GET /agent/tools, read by useToolCatalog()Not part of AG-UI. React still reads it over REST
Receipts and other datatool results (an action-proposal receipt, a background-delegation receipt) are tool-output; frames of a kind the client does not know become data-<kind> partsPartial. Receipts arrive as TOOL_CALL_RESULT content (JSON). A frame kind the encoder does not know is dropped
Title, usage, costtitle; step-finish with usage, costUsd, reasoningMsSupported as agora.title, agora.step-usage (with the model), and per-model RUN_FINISHED.usage. React keeps the title and each step's usage, cost and model
Background (detached) runsreceipt + a later message in the threadSame on the server; the later message is read from the thread over REST, as on the native path
Frontend tools, AG-UI state—Not supported. tools is ignored with a warning; state is ignored and no state events are sent

Choose AG-UI when the client is someone else's (CopilotKit, @ag-ui/client, a Python or .NET agent UI). For your own React app, the native stream carries more and needs no adapter. What that means for generative UI in two such clients — CopilotKit and OpenUI — is measured in Generative UI: native vs CopilotKit vs OpenUI.

Text-only channels — WhatsApp and similar

Backend only. There is no frontend of yours: the channel's app is the client. @adonis-agora/agent/channels turns a provider's webhook into a turn: it resolves the sender to an actor and the conversation to a thread, sends the message with text-only capabilities (uiCapabilities: { components: [] }), and relays the reply — the model's prose and, where present pushed OrderSummary, its fallbackText, in the order they were produced — converted to the channel's markdown and split at its length limit:

start/routes.ts (backend)
import router from '@adonisjs/core/services/router'
import { channels, evolutionApi } from '@adonis-agora/agent/channels'
import env from '#start/env'

router.post(
  '/webhooks/whatsapp',
  channels.handle(
    evolutionApi({
      url: env.get('EVOLUTION_URL'),
      instance: env.get('EVOLUTION_INSTANCE'),
      apiKey: env.get('EVOLUTION_API_KEY'),
      webhookToken: env.get('EVOLUTION_WEBHOOK_TOKEN'),
    }),
    {
      actor: (message) => whatsappActor(message.from), // your identity mapping
      thread: (actor, message) => whatsappThread(actor, message.conversation), // null → new thread
      onThreadCreated: (threadId, actor, message) => saveWhatsappThread(threadId, actor, message),
    },
  ),
)

It verifies the webhook, deduplicates provider retries, answers 200 before the turn runs, and puts pending proposals to the person as Confirm/Cancel buttons where the channel has them. WhatsApp Cloud API and Telegram adapters, the capability table and custom adapters are on Text channels. pageContext (default { kind: '<channel>' }) is free-form: tools and prompt builders read ctx.pageContext.kind to tailor wording; nothing in the library interprets it.

To send a table or chart as an image instead, keep uiCapabilities listing it, collect the t: 'component' frames as well, and route them through the server renderer and your WhatsApp media upload. The library never sends attachments itself.

In the default blocking mode an action call keeps that stream open until someone decides it, so a channel without approval buttons wants independent approvals. Use send, not chat, there: send recognizes text decisions and returns proposalDecision without starting a turn.

MCP

Backend only. The MCP server exposes tools, not conversations. tools/call answers the tool's return value as one text content block (a string as is, anything else as JSON). The call's emitUi accepts pushes and discards them, and present runs against the same no-op, so no component and no fallback text reach an MCP client. Return what an MCP client should read from execute.

Outcomes that arrive later

This happens on the backend (a worker), after the turn; each surface picks the result up from the thread.

With independent approvals, an approved action runs after its turn has ended. The worker executes it with the capabilities and page context recorded when the proposal was made, so its present output is negotiated for the channel that asked. The outcome is admitted into the thread as an assistant message — the result summary followed by the text of any component the client could not draw — with the drawable components on its ui list. A channel served by channels.handle relays it to the conversation with actionProposalWorker: { onSettled: channels.onSettled }; any other client reads the thread (or GET <path>/threads/:id/action-proposals) and relays the new message itself.

On this page

Partner

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

Become a partner →