Aviary
Guides

One answer, every surface

How one tool result reaches the web, AG-UI clients, text-only channels such as WhatsApp, and MCP — what lives in the NestJS backend and what in the React frontend, UI capabilities negotiation, text fallbacks, and what AG-UI carries compared with the native stream.

The same agent answers a React app, an AG-UI client, a WhatsApp bot and an MCP client. You write a tool once. What each surface gets depends on what it can draw, and the server decides that per request.

tool execute ──► domain output ──────────────────────────────► the model, MCP callers
     │
     └─ present / ctx.emitUi ──► component { name, props, version }
                                   │  validated against the server catalog
                                   │  intersected with the client's uiCapabilities
                                   ├─ supported   ──► `ui` frame (+ fallbackText) ──► React, AG-UI `agora.ui`
                                   └─ unsupported ──► `text` frame (the component's text) ──► any channel

A tool result has three layers, and each surface takes the ones it can use:

LayerWho owns itWhere it goes
Domain output, what execute returnsthe toolAlways to the model. It is the whole answer over MCP and registry.invoke.
Component, { component, props, version } from present or ctx.emitUithe server catalogA ui frame, persisted on the message, for clients that declared the component.
Text, the definition's fallbackTextthe server catalogInstead of the component on a client that cannot draw it, and kept next to it in history.

Backend and frontend

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

SideRuns inFiles (a Nest API in api/, a React app in web/)Imports from
Backendthe NestJS processsrc/app.module.ts (AgentModule.forRoot, AgentGenuiModule, adapters), src/genui/*.ts (component definitions, the catalog), *.tool.ts (@AiTool providers), your guards and actorResolver, services of your own that call AgentService (webhooks, jobs)@dudousxd/nestjs-agent, @dudousxd/nestjs-agent/genui, @dudousxd/nestjs-agent-core/genui, @dudousxd/nestjs-agent-core/genui/builtins (@dudousxd/nestjs-agent-core/ag-ui only for a route of your own)
Frontendthe browserthe page that mounts the chat, the React renderers of your components, <AgentProvider>@dudousxd/nestjs-agent-react, @dudousxd/nestjs-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. AgentModule mounts the routes itself; you write no controller for the chat. The frontend only draws: it maps a component name to a React component, says which of them it can draw, and renders the stream it reads from those routes.

One file may be read by both sides

src/genui/catalog.ts lives with the backend, but it is plain data: @dudousxd/nestjs-agent-core/genui imports nothing server-only, so the React app may import the same file to validate props before drawing. That import is optional. Keep anything server-only (repositories, services, config) out of the catalog and the definitions it imports. With the API and the app in two repositories, publish the file as a small package.

End to end: a React 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 — NestJS

1. 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
Backend — api/src/genui/order-summary.ts
import { createComponent } from '@dudousxd/nestjs-agent-core/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)}`,
});

2. The catalog:

Backend — api/src/genui/catalog.ts (the frontend may import it too)
import { defineCatalog } from '@dudousxd/nestjs-agent-core/genui';
import { BUILTIN_COMPONENTS } from '@dudousxd/nestjs-agent-core/genui/builtins';
import { orderSummary } from './order-summary';

export const catalog = defineCatalog([...BUILTIN_COMPONENTS, orderSummary.definition]);

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

Backend — api/src/orders/order-status.tool.ts
import { AiTool, type AiToolCtx } from '@dudousxd/nestjs-agent';
import { z } from 'zod';
import { orderSummary } from '../genui/order-summary';
import { type Order, OrdersService } from './orders.service';

@AiTool({
  description: 'Look up one of the customer’s orders.',
  input: z.object({ orderId: z.string() }),
})
export class OrderStatusTool { // name: orderStatus, kind: read
  constructor(private readonly orders: OrdersService) {}

  execute({ orderId }: { orderId: string }, ctx: AiToolCtx) {
    return this.orders.findForCustomer(orderId, ctx.actor.id);
  }

  present(order: Order) {
    return 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.

4. The module. AgentModule mounts the agent's routes under path (default agent), resolves the caller with actorResolver, and puts every route behind guards. AgentGenuiModule gives the server its catalog. The tool is an ordinary provider:

Backend — api/src/app.module.ts
import { Module } from '@nestjs/common';
import { AgentModule, requestUserActorResolver } from '@dudousxd/nestjs-agent';
import { AgentGenuiModule } from '@dudousxd/nestjs-agent/genui';
import { catalog } from './genui/catalog';
import { OrderStatusTool } from './orders/order-status.tool';
import { OrdersService } from './orders/orders.service';
import { SessionGuard } from './auth/session.guard';

@Module({
  imports: [
    AgentModule.forRoot({
      model,                                     // see Getting started
      actorResolver: requestUserActorResolver(), // who is calling: reads req.user
      guards: [SessionGuard],                    // your guard, on every agent route
      // path: 'agent',                          // the default: routes under /agent
    }),
    AgentGenuiModule.forRoot({ catalog }),
  ],
  providers: [OrdersService, OrderStatusTool],
})
export class AppModule {}

A guard that sets req.user is enough for requestUserActorResolver(); without actorResolver the routes are anonymous, one identity per browser. See Identity & Authorization.

Frontend — React

5. The renderer, an ordinary React component whose props are the definition's props:

Frontend — web/src/genui/order-summary.tsx
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>
  );
}

6. The page mounts the connection, hands it the registry, and says what it can draw:

Frontend — web/src/chat-page.tsx
import { AgentProvider, ChatInput, MessageList, useAgentChat } from '@dudousxd/nestjs-agent-react';
import type { GenuiRegistry } from '@dudousxd/nestjs-agent-react/genui';
import { catalog } from '../../api/src/genui/catalog'; // optional: validate props before drawing
import { OrderSummary } from './genui/order-summary';

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

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

function Chat() {
  const chat = useAgentChat();
  return (
    <>
      <MessageList messages={chat.messages} status={chat.status} />
      <ChatInput onSubmit={(text) => chat.sendMessage({ text })} />
    </>
  );
}

MessageList draws every pushed component inside the provider. For your own markup, render each ui block with <GenerativeUI part={block} /> (see Frontend).

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 URLAgentModule mounts POST /agent/chat, GET /agent/chat/:runId/stream, GET /agent/threads/:id, … (all routes) under path (default agent), after any global prefix<AgentProvider> calls the same origin under /agent. Pass path for another prefix (global prefix included), baseUrl for an API on another origin
IdentityactorResolver turns the request into an actor, behind guards; every thread, run and attachment is owner-scoped to itthe session cookie rides every request (credentials="include" across origins), or getHeaders sends a bearer token
CSRFthe library checks none itself: whatever CSRF middleware or guard your app applies covers the agent's routes toogetHeaders sends the token per request, for example the XSRF-TOKEN cookie as X-XSRF-TOKEN (see Cookie sessions and CSRF)
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 list (fallbackText, version, componentVersions)useAgentChat({ threadId }) loads GET /agent/threads/:id and draws the same components (or texts) again

Per surface

SurfaceDeclaresReceives
Web, native stream (POST /agent/chat, useAgentChat)uiCapabilities in the body, or <AgentProvider uiCapabilities>ui frames for declared components and text frames for the rest. See Generative UI.
AG-UI (POST /agent/ag-ui)forwardedProps.uiCapabilitiesCUSTOM agora.ui events (with fallbackText) for declared components, text messages for the rest. See Web over AG-UI and what it costs you.
Text channel (WhatsApp, SMS, a Slack bot) run in-process through AgentServiceuiCapabilities: { components: [] }Only text. The model is offered no UI tool.
MCP (AgentMcpServerModule)nothingThe tool's domain output as JSON text. Components are not sent.
A file attachment (PNG/PDF)your channel codeWhat createReactServerRenderer renders from the same component. See Component registries and server rendering.

Declaring what a client can draw

A client sends the components it has renderers for, each with the exact version it implements. 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:

Frontend — uiCapabilities, as the client sends it
uiCapabilities: {
  components: [
    { name: 'DataTable', version: 1 },
    { name: 'Chart', version: 1 },
  ],
}
  • Omitted: nothing is negotiated. Every component of the authorized catalog is offered and drawn, which is how clients behaved before negotiation existed.
  • components: []: text only.
  • A list: the server intersects it with the catalog it would use anyway (the static one, or the resolver's for this tenant). A component matches only by name and version, where a definition without version is version 1. The list never adds a component or grants a permission.

The shape is strict: only components, each entry only name (a valid component name) and a positive integer version, no duplicates. Anything else is a 400 Invalid UI capabilities on POST /agent/chat, and a 400 on AG-UI.

Where the declaration travels:

  • POST /agent/chat body field uiCapabilities.
  • React: <AgentProvider uiCapabilities={…}> once, or useAgentChat({ uiCapabilities }) per chat. The chat's option wins. Every send carries it. The React client does not derive the list from your genui registry, so pass it explicitly.
  • AG-UI: forwardedProps.uiCapabilities. agUiChatStream forwards the send's uiCapabilities there.
  • In-process: AgentService.send({ …, uiCapabilities }) and AgentService.chat(…).
  • A queued message stores the declaration and runs under it. An action proposal stores it in its execution context, so a result that lands later is negotiated for the client that asked.

What changes for the model

The genui tools describe themselves per turn from the negotiated catalog:

  • The tree tool (ui__render) and the ui__show tool list only declared components — the tree tool's input schema too — and are removed when none are left.
  • With mode: 'per-component', a tool (ui__show_<name>) whose component the client did not declare is removed from that turn's tool list.
  • A call to a tool removed this way is refused as an unknown tool, even if the model names it.
  • A streaming tree is previewed only for a client that draws every node of it; anyone else gets the final push (or its text) alone.

With components: [] the model sees no UI tool at all and answers in prose.

What changes for your tools

All of this happens in the Nest process; the frontend receives only the result. ctx.emitUi and a tool's present hook go through the same negotiation when the server has a catalog (AgentGenuiModule):

  1. The props are validated against the server definition (name, version, schema). An unknown component or invalid props throws.
  2. If the client declared the component, it is pushed as a ui frame carrying fallbackText, the definition's text for those props.
  3. If not, the component's text is written as a text frame instead. The text is stored as an assistant message after the tool step and added to the model's context, so the conversation reads the same on reload.

A tree is all or nothing: if any node's component is not declared, the whole tree is sent as text (treeToText).

Capabilities need a server catalog

When a client sends uiCapabilities and the server has no catalog (AgentGenuiModule is not imported), ctx.emitUi throws UI capabilities require an authorized server catalog. Inside a present hook this is reported as a presentation error and the domain result is kept. In a tool body that calls ctx.emitUi directly, the call fails. With a catalog, every component a tool pushes must be defined in it.

fallbackText is the component definition's fallbackText(props). With none, or when it throws, the text is the props as a JSON code block, preceded by *title* when the props have a title. Text is Slack mrkdwn-compatible. For a nicer per-channel rendering, register channel renderers on a component registry and call registry.render(presentation, 'whatsapp'). See Custom components and renderers per channel.

Web over AG-UI

Backend. Add the adapter next to the native routes. It mounts POST /agent/ag-ui, behind the same actorResolver, guards and ownership checks:

Backend — api/src/app.module.ts
import { AgentModule, agUiAdapter } from '@dudousxd/nestjs-agent';

AgentModule.forRoot({
  // … as above
  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?, partial? }, 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 a RunAgentInput to that URL with uiCapabilities in forwardedProps. The React chat above sends its turns over AG-UI by replacing one method of the backend, openChatStream, with agUiChatStream. It forwards the send's uiCapabilities, staged attachments and regenerate; everything else stays on the native routes:

Frontend — web/src/agent-backend.ts
import { AgentClient, type ChatStreamRequest, agUiChatStream } from '@dudousxd/nestjs-agent-react';

export const readCookie = (name: string) =>
  decodeURIComponent(document.cookie.match(new RegExp(`(?:^|; )${name}=([^;]*)`))?.[1] ?? '');

/** Turns over AG-UI; threads, queue, decisions, uploads, cancel and re-attach over REST. */
export class AgUiAgentClient extends AgentClient {
  override openChatStream(request: ChatStreamRequest) {
    return agUiChatStream(request, {
      url: '/agent/ag-ui',
      // Not the provider's getHeaders: built here, per send.
      headers: { 'X-XSRF-TOKEN': readCookie('XSRF-TOKEN') },
    });
  }
}
Frontend — web/src/chat-page.tsx
import { AgUiAgentClient, readCookie } from './agent-backend';

const backend = new AgUiAgentClient({ getHeaders: () => ({ 'X-XSRF-TOKEN': readCookie('XSRF-TOKEN') }) });

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

agUiChatStream does not read the provider's connection settings: url is the full path (base URL and prefix included), headers are its own headers, and it fetches with credentials: 'same-origin' unless you pass a fetch of your own. 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 (tools, authorization, quota, durable runs, memory) is identical; what differs is what the wire carries 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 a backend whose openChatStream is agUiChatStream, which keeps the REST routes for everything but the turn.

CapabilityNative stream + React clientAG-UI
Texttext framesSame. TEXT_MESSAGE_START / CONTENT / END, one message per stretch of text
Reasoningreasoning framesSame. REASONING_START, one reasoning message, REASONING_END
Tool calls and resultstool-input-* with toolKind (read / action) and parentId; tool-output, -error, -deniedSame. TOOL_CALL_START / ARGS / END with metadata['agora.toolKind'] and ['agora.parentId'], which React reads back; TOOL_CALL_RESULT with a failure or refusal in metadata['agora.outcome']. A generic client sees nested calls flat
Generative UIui frames with fallbackText and componentVersions, drawn by GenerativeUISame content. The CUSTOM event agora.ui carries every field; React draws it unchanged. A generic AG-UI client ignores it unless it knows the name. Negotiation through forwardedProps.uiCapabilities works the same
pageContext, agent, persona, modelbody fields of POST /agent/chatSame, through forwardedProps. AG-UI context is added to the page context as agUiContext
Attachmentsuploaded to POST /agent/attachments, sent as { mediaId } refsSame for staged files: React sends each as a file part with provider: 'nestjs-agent' and the mediaId, resolved and ownership-checked as on chat. Inline data parts are staged through AGENT_ATTACHMENT_STAGING. A url part or another provider's file part is dropped with an agora.warning, and so is an attachment past the per-message limit (native: 400) or inline bytes of a type or size the limits refuse
Regenerateregenerate: true on POST /agent/chatSame. forwardedProps.regenerate: true, which React sends for chat.regenerate()
Approvals (action tools)the stream stays open; approval-requested / -settled frames; decided through POST /agent/tool-call/approve / rejectPartial. The AG-UI run ends with an Interrupt (reason: 'tool_approval'); a later request with resume decides it and streams the rest. React renders the card from agora.approval-requested and decides through the native tool-call routes, not resume: when the AG-UI run ends on an interrupt the chat already shows, React re-attaches to the parked run on GET /agent/chat/:runId/stream?after=<id> (the SSE id: of the last AG-UI event), so what the run does after the decision streams into the same message, as on the native stream
Questions (elicitation)elicitation frame, answered through POST /agent/tool-call/answer / skip while the stream continuesPartial. An Interrupt (reason: 'input_required') with a JSON Schema of the answers, answered through resume. agora.elicitation carries { id, runId, request } and React renders the question form from it, answered over the native routes, and the rest of the run streams in the same way as after an approval
Interrupt outcome in React—An interrupt the chat already shows (an approval card, a question form) adds nothing. Only an interrupt nothing showed — another producer's — becomes a ui part named AgUiInterrupt carrying it; your registry renders it, or your genui fallback
Independent action proposalsproposal routes; a text decision answers 200 JSON with proposalDecisionPartial. 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; React turns the event into the same transient data-proposal-decision part a native decision answers with
Message queuea send on a busy thread is queued (202); mode: 'queue' | 'interrupt'Not carried. POST /agent/ag-ui on a busy thread is 409 run_active; there is no mode. The queue's snapshots arrive as agora.queue. React queues through the REST route (enqueueMessage), so chat.queue keeps working
Transient threadstransient: true on POST /agent/chatNot carried. A threadId nobody has is created as a regular thread
Re-attach after a dropped connectionGET /agent/chat/:runId/stream?after=<seq> replays and followsNot on AG-UI. A dropped connection does not stop the run; the library's run id (agora.run, X-Agent-Run-Id) re-attaches over the native route. Every AG-UI event carries the run's own sequence number as its SSE id:, and React re-attaches with it (?after=), so nothing around the drop is skipped or repeated. A producer that numbers nothing gives React no cursor, and it does not re-attach
CancelPOST /agent/chat/:runId/cancelPartial. AG-UI has no cancel request: use the native route with the id from agora.run, as React does. A cancelled run ends with RUN_FINISHED, outcome: { type: 'cancelled' }
Durable runs, several replicasre-attach from any replicaSame. A parked run waits durably; the interrupt id carries everything a resume needs, so any replica sharing the token sink serves it
Title, usage, costtitle; step-finish with usage, costUsd, reasoningMsSame. agora.title; agora.step-usage right after STEP_FINISHED, which React puts back on step-finish; per-model RUN_FINISHED.usage for generic clients (React does not read it)
message-metadata framesmerged into the message's metadataNot carried. The encoder drops them (only a runner that is not this library's loop writes them)
Threads, history, fork, feedback, quota, models, agents, tools, memories, configREST routesNot part of AG-UI. React keeps them on REST
Frontend tools, AG-UI state—Not supported. tools is ignored with an agora.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.

A text channel in-process

Backend only. There is no frontend of yours: the channel's app is the client. A messaging webhook resolves its own actor, sends with an empty capability list, and reads the answer back when the run ends:

Backend — api/src/whatsapp/whatsapp-inbound.service.ts
@Injectable()
export class WhatsAppInbound {
  constructor(private readonly agent: AgentService) {}

  async handle(actor: Actor, threadId: string, text: string): Promise<string | undefined> {
    const sent = await this.agent.send({
      actor,
      threadId,
      message: text,
      uiCapabilities: { components: [] }, // text only
    });
    // With actionApprovalMode: 'independent', "yes" / "no #id" decide a pending action here
    // (the words are configurable: actionProposalText).
    if ('proposalDecision' in sent) return sent.text;
    if (sent.queued) return undefined; // runs after the turn in progress
    for await (const _chunk of this.agent.subscribe(sent.runId)) {
      // ends when the run settles
    }
    const thread = await this.agent.getThread(actor, sent.threadId);
    // Reply with the assistant messages stored after the user's message.
    return lastAssistantText(thread);
  }
}

lastAssistantText is your code. Under independent approvals an action tool does not hold the run open waiting for a click: the turn ends with the pending action, the user answers in a later message, and the result is posted to the thread when it runs. With the default blocking approvals, a text channel has to decide the call through AgentService.approve / reject itself.

To send a table as an image instead of text, take the stored component off the message (it is in ui, with its fallbackText) and render it with createReactServerRenderer(…).images(item) in your delivery code. Receiving a component never sends an attachment by itself.

For WhatsApp (Evolution API, Cloud API) and Telegram, @dudousxd/nestjs-agent-channels does all of this for you: it verifies and deduplicates the webhook, answers in the background, converts the reply's markdown and splits it at the channel's limit, attaches the person's media, asks questions as text, and offers proposals as buttons.

MCP

The MCP server exposes the same registry, with no conversation behind a call:

  • tools/call returns the tool's domain output as JSON text. That is the reason to keep execute's output complete and leave the visuals to present.
  • ctx.emitUi resolves to an id and discards the component. present runs, and what it returns is discarded too. No text fallback is produced.
  • action tools are not exposed unless actions: 'execute'. When they are, their preflight still runs before execution.

History and renderer drift

Every stored ui component keeps its version and fallbackText, and a tree keeps componentVersions for its nodes. When a renderer is later missing, its version changed, its props no longer validate or it throws, React's GenerativeUI shows your fallback. Without one it shows the stored fallbackText. A tree with stored text and no fallback falls back as a whole.

On this page