Aviary
Guides

AG-UI

Serve the agent over AG-UI 1.0 — the open agent-to-application protocol — next to the native stream.

AG-UI is an open protocol between an agent and the application in front of it. With the AG-UI adapter in adapters, the same agent answers it as well, so any AG-UI client (@ag-ui/client's HttpAgent, CopilotKit, a client in Python or .NET) can drive it without knowing this library. The native stream is unchanged and stays the richer surface; AG-UI is a second door onto the same runs. What each capability looks like on either side is in What AG-UI costs you.

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

AgentModule.forRoot({
  model,
  adapters: [agUiAdapter()], // or agUiAdapter({ path: 'ag-ui', quietMs: 750 })
});

That mounts POST /agent/ag-ui. The body is a RunAgentInput; the answer is text/event-stream, one AG-UI event per data: line, from RUN_STARTED to RUN_FINISHED or RUN_ERROR. The route resolves the actor with the module's actorResolver, sits behind its guards, and checks ownership exactly as POST /agent/chat does. adapters is static wiring, like surface: on AgentModule.forRootAsync it sits on the options object, not in useFactory's result, and surface: 'engine' mounts nothing.

Frontend — any AG-UI client
import { HttpAgent } from '@ag-ui/client';

const agent = new HttpAgent({ url: 'https://app.example/agent/ag-ui' });
agent.addMessage({ id: crypto.randomUUID(), role: 'user', content: 'Refund order 7' });
await agent.runAgent();

From React — useAgentChat over AG-UI

agUiChatStream (in @dudousxd/nestjs-agent-react) is the matching consumer: it POSTs a RunAgentInput and hands useAgentChat the answer re-framed in the native protocol, so the transcript, tool activity (with each call's kind and parent), generative UI, question forms and per-step usage render unchanged. Put it behind openChatStream and keep everything else (threads, the queue, approvals and answers, uploads, cancel) on the REST routes. AgentClient is a class, so override the method rather than spreading an instance, which would drop its methods:

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

export class AgUiAgentClient extends AgentClient {
  override openChatStream(request: ChatStreamRequest) {
    return agUiChatStream(request, { url: '/agent/ag-ui', headers: { /* auth, CSRF */ } });
  }
}

// <AgentProvider backend={new AgUiAgentClient()}>

The send's pageContext, agent, model, persona and uiCapabilities ride forwardedProps, and so does regenerate: true (chat.regenerate()). Attachments the composer staged travel by mediaId, as file parts with provider: 'nestjs-agent'. agUiChatStream does not read the provider's connection settings: url is the full path, headers are its own headers, and it fetches with credentials: 'same-origin' unless you pass a fetch.

What the input means here

RunAgentInputWhat this producer does with it
threadIdThe conversation. A thread the caller already owns is continued; an id nobody has is created under that id (CreateThreadInput.id — the bundled stores honour it; at most 255 characters). Another actor's thread is refused as on chat.
runIdEchoed on RUN_STARTED / RUN_FINISHED. The library's own run id travels in the agora.run custom event.
messagesThe last user message is the turn to answer. Earlier messages are not read back in: the thread's history is the one the server stored.
content partstext parts are the message's text. A media part with a file source whose provider is 'nestjs-agent' (AG_UI_MEDIA_PROVIDER) names an attachment already staged with this library (POST /agent/attachments) by its mediaId; it is resolved for the caller exactly as chat resolves attachments, and a mediaId the caller does not own is a 403. A media part carried inline (data) is staged through the bound AGENT_ATTACHMENT_STAGING store, within the attachment limits. A part by url, or a file handle of another provider, is dropped, and an agora.warning says so — the model is only handed bytes the server staged itself.
resumeAnswers to the interrupts of the previous run — see below.
contextRides in the turn's page context as agUiContext, where a PromptBuilder and the tools read it.
forwardedPropsuiAction (a UiAction, e.g. a sandbox's agent.send) or a2uiAction (an A2UI action) is the turn instead of the last user message; a refused one is 400 invalid_ui_action. agent, persona, model and pageContext are read as the matching fields of a native send (see Personas). uiCapabilities declares the components this client draws; the rest arrive as text (see One answer, every surface). An invalid uiCapabilities is a 400. regenerate: true re-runs the thread's last exchange, as regenerate on chat does (its user message may be empty); on a thread that does not exist yet it is a 400. Without it, a run input whose last user message is blank and carries no attachment is 400 no_user_message, the same code POST /agent/chat answers. A native send's mode and transient have no counterpart: a send on a thread with a turn running is 409 run_active.
toolsFrontend tools are not supported: the list is ignored, with a warning.
stateIgnored; this producer emits no state events.

Input that is malformed — no threadId, no user message, a resume entry without a status — is an HTTP 400 and no stream. A failure after the stream opened is a RUN_ERROR event.

Inline files and the JSON body limit

Express parses JSON bodies up to 100 kB by default. A client that sends files as inline content parts needs more: app.useBodyParser('json', { limit: '25mb' }) in main.ts.

What the stream carries

Native frameAG-UI
textTEXT_MESSAGE_START / CONTENT / END, one message per stretch of text
reasoningREASONING_START, a reasoning message, REASONING_END
step-start / step-finishSTEP_STARTED / STEP_FINISHED; the step's usage is folded into the run's
tool-input-*TOOL_CALL_START / ARGS / END; the call's toolKind and parentId ride TOOL_CALL_START's metadata['agora.toolKind'] and ['agora.parentId']
tool-output, -error, -deniedTOOL_CALL_RESULT (a failure or a refusal is marked in metadata['agora.outcome'])
approval-requested, elicitation (the run parks)an Interrupt on the closing RUN_FINISHED
cancelledRUN_FINISHED with the cancelled outcome
message-metadatanothing: not carried
event: errorRUN_ERROR with the native code

RUN_FINISHED.usage reports the run's tokens, one entry per model — keyed on the model each step-finish frame names — in the protocol's accounting: input and output are totals, cached and reasoning tokens are parts of them, and a count the provider did not report is absent rather than zero.

What AG-UI has no event for travels as CUSTOM events, which a client that does not know them ignores:

namevalue
agora.run{ runId, threadId } — the library's ids, e.g. for POST /agent/chat/:runId/cancel
agora.uia generative-UI component { id, component, props, version?, fallbackText?, componentVersions?, toolCallId?, partial? }; a repeat id replaces. partial: true is a preview of a tree the model is still writing (streaming: 'partial'): sent repeatedly under the id the final push replaces, withdrawn by one with empty props, and drawn by the React AG-UI backend like the native ones. fallbackText is what a client that cannot draw the component shows instead; one that never draws it should leave it out of uiCapabilities and get the text as a text message
agora.title{ title }
agora.step-usage{ usage, costUsd, reasoningMs } of one model step, right after its STEP_FINISHED
agora.approval-requested, agora.approval-settledthe native frames, for a client that renders them live
agora.elicitation{ id, runId, request }: the question set the run parked on, under the tool-call id an answer settles
agora.queue{ queue, started? }: the thread's message queue
agora.action-proposal-decision{ threadId, proposalDecision }: a proposal decided with no model run (below)
agora.warning{ message } — input this producer could not use

The names are shared with @adonis-agora/agent, which serves the same encoder.

With agUiAdapter({ a2ui: true }), every agora.ui event is followed by an ACTIVITY_SNAPSHOT of type a2ui-surface carrying the component as an A2UI v0.9 surface, for CopilotKit's A2UI renderer and other clients of AG-UI's A2UI binding; forwardedProps.a2uiAction / forwardedProps.uiAction make a UI action the turn. See A2UI.

Deciding an action proposal by text

With actionApprovalMode: 'independent', a user message to an existing thread that is a decision word ("yes", "no #id", …) and carries no media decides a pending proposal instead of starting a run. The answer is RUN_STARTED, a CUSTOM event named agora.action-proposal-decision with { threadId, proposalDecision }, the receipt as one text message, and RUN_FINISHED. No model run is created.

Interrupts and resume

AG-UI has no channel into a running run. A run that needs an approval or an answer therefore ends — RUN_FINISHED with outcome: { type: 'interrupt', interrupts } — and a later request continues it. Underneath, the library run is still parked where it stopped (durably, with the durable runner); the resume settles what it waits for and re-attaches to it.

Backend to client — the closing RUN_FINISHED
{ "type": "RUN_FINISHED", "threadId": "t1", "runId": "r1",
  "outcome": { "type": "interrupt", "interrupts": [{
    "id": "agora_…", "reason": "tool_approval", "toolCallId": "call_1",
    "message": "Approve refund?",
    "responseSchema": { "type": "object", "properties": { "approved": { "type": "boolean" } }, "required": ["approved"] },
    "metadata": { "agora.toolName": "refund", "agora.input": { "id": 7 } } }] } }

An approval's message is the tool's confirmation title — its presentation.confirm.title template filled from the call's input on the server ('Refund order #{orderId}?' → Refund order #1002?, the same words the native page shows), or what a preflight resolved — else Approve <tool>?. The whole confirmation (title, verb, detail) rides as metadata['agora.confirmation'] and on agora.approval-requested.

The next RunAgentInput answers each interrupt in resume:

Interrupt reasonstatus: 'resolved' with payloadstatus: 'cancelled'
tool_approval{ approved: true, remember? } runs the tool; { approved: false, reason? } rejects it (a bare boolean works too)rejects it
input_required (a question set){ answers: { <questionId>: string[] } }skips it — the agent proceeds on its defaults

The rules the tool-call routes enforce apply unchanged: only the run's owner (or the approver the approval policy names) may answer, an answer a question refuses is a 400, a request that lapsed is a 410, and a run that is no longer waiting is a 409 run_not_active. Every entry is checked before the first one is delivered, so a list with one bad entry settles nothing. An entry naming an interrupt this agent never raised is skipped with a warning; an interrupt the list leaves out stays open, and the resumed run ends with the interrupt outcome again. Decisions are recorded as having come via: 'ag-ui'.

An approval for an independent action proposal is an interrupt too; its id names the proposal, and resume decides it through the proposal service, as the native action-proposals routes do, without signalling the run that proposed it (which is not waiting). A resume list that only decides proposals is answered with agora.action-proposal-decision events and their receipts, with no run behind them.

agUiChatStream does not resume: the React client renders the approval card and the question form from agora.approval-requested and agora.elicitation and decides them through the native tool-call routes. The AG-UI run has ended on the interrupt, so the client re-attaches to the parked run on GET /agent/chat/:runId/stream?after=<id> — every event this route writes carries the run's own sequence number as its SSE id: — and what the run does after the decision streams into the same message, as on the native stream. See What AG-UI costs you.

The interrupt id is the whole address of what it answers — the run, the call, and how much of the stream was already delivered — so the server keeps nothing between the two requests and any replica can serve the resume, given a token sink the replicas share.

When is a run 'waiting'?

A run is reported interrupted as soon as every tool call it has open is waiting on a person. When it is waiting on someone and still has other work announced, the stream gets quietMs (default 750) of silence before the run is reported; what that other work produces afterwards is delivered by the resumed run.

Adapters

adapters takes any AgentProtocolAdapter — { name, controllers, providers? }. Its controllers mount under the agent's path, behind guards, and inject what the native ones do (AgentService, AGENT_ACTOR_RESOLVER). agUiAdapter() is written against that and nothing else, so a protocol of your own is built the same way.

Building your own route

The encoder is framework-free, in @dudousxd/nestjs-agent-core/ag-ui, for a host that mounts its own route (or runs somewhere that is not Nest):

Backend — your own route
import {
  agUiEvents,
  agUiFramesFromNdjson,
  agUiSse,
  parseRunInput,
} from '@dudousxd/nestjs-agent-core/ag-ui';

const frames = agUiFramesFromNdjson(agentService.subscribe(runId));
for await (const event of agUiEvents(frames, {
  threadId: input.threadId,
  runId: input.runId,
  streamRunId: runId,
})) {
  res.write(agUiSse(event));
}

AgUiEncoder is the pure projection underneath (frame in, events out); planResume, readApprovalPayload and readAnswersPayload read a resume list; encodeInterruptId / decodeInterruptId are the address codec. AgUiRunHandler (from @dudousxd/nestjs-agent) is the route's body, for extending under a path of your own.

On this page