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.
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.
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:
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
RunAgentInput | What this producer does with it |
|---|---|
threadId | The 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. |
runId | Echoed on RUN_STARTED / RUN_FINISHED. The library's own run id travels in the agora.run custom event. |
messages | The 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 parts | text 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. |
resume | Answers to the interrupts of the previous run — see below. |
context | Rides in the turn's page context as agUiContext, where a PromptBuilder and the tools read it. |
forwardedProps | uiAction (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. |
tools | Frontend tools are not supported: the list is ignored, with a warning. |
state | Ignored; 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 frame | AG-UI |
|---|---|
text | TEXT_MESSAGE_START / CONTENT / END, one message per stretch of text |
reasoning | REASONING_START, a reasoning message, REASONING_END |
step-start / step-finish | STEP_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, -denied | TOOL_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 |
cancelled | RUN_FINISHED with the cancelled outcome |
message-metadata | nothing: not carried |
event: error | RUN_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:
name | value |
|---|---|
agora.run | { runId, threadId } — the library's ids, e.g. for POST /agent/chat/:runId/cancel |
agora.ui | a 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-settled | the 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.
{ "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 reason | status: 'resolved' with payload | status: '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):
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.
Bring your own UI
Build a chat on useAgentChat, useChatTranscript and the stored-history mappers — the library computes the model, you write the markup.
A2UI
Serve the agent's generative UI as A2UI v0.9 — a JSON Lines route any A2UI renderer draws, builtins mapped onto the basic catalog, your components mapped or sent as custom ones, A2UI actions and approvals as turns, and A2UI over AG-UI.