Agora

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 becomes over AG-UI — and what is not available there — is compared in What AG-UI costs you.

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

export default defineConfig({
  // …
  adapters: [agUiAdapter()], // or agUiAdapter({ path: 'ag-ui', quietMs: 750, a2ui: true })
})

That mounts POST <path>/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 and checks ownership exactly as chat does. A send on a thread whose turn is still running is refused with 409 { code: 'run_active' } — this route does not queue.

Each event that stands for something the run did also carries an SSE id: line: the run's own sequence number, the same one the native stream numbers its events with (several AG-UI events can share one). AG-UI clients ignore it; a client that wants the rest of a run this AG-UI run left parked re-attaches with GET <path>/chat/:runId/stream?after=<last id> (the run id is in agora.run) and misses nothing, repeats 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()

Two third-party frontends are set up against this route, and compared with the native client for generative UI, in Generative UI: native vs CopilotKit vs OpenUI: CopilotKit (setup) and OpenUI (setup). The same UI can also reach A2UI renderers, as A2UI surfaces in this stream or on a route of its own.

From React — useAgentChat over AG-UI

@adonis-agora/agent/react can drive this route (or any AG-UI 1.0 producer) through the same hooks as the native stream. agUiBackend() sends the chat turn over AG-UI and keeps everything else — threads, the queue, approvals, uploads — on the REST routes, with the session cookie and the shield CSRF token on every request:

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

<AgentProvider backend={agUiBackend()}><Chat /></AgentProvider>

When the run stops on an approval or a question set the chat already shows, the AG-UI run ends (see Interrupts and resume) but the chat does not: the message stays open, the client re-attaches to the parked run on the native stream route at the last id: it got, and once the person decides — the approval card and the question block answer through the native tool-call routes — the rest of the run streams into the same message. An interrupt nothing showed (another producer's) still becomes a ui part named AgUiInterrupt for the app to render, and a producer that writes no id: lines is not re-attached to. A text decision on a proposal (agora.action-proposal-decision) yields the same transient data-proposal-decision part a native one does.

agUiBackend({ path }) follows a non-default path; url points it at another producer; agUi takes the consumer's own options (forwardedProps, content for sending a file as AG-UI content parts). Agora owns the consumer — agUiChatStream, reframeAgUiStream, and the AgUiActivity / AgUiInterrupt UI parts — inside this package. agUiChatStream adds the Adonis CSRF header, read per call. No Aviary package is required.

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. 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 carried inline (data) is staged through the configured attachment store and sent to the model. A part by url or provider file handle 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.
forwardedPropsagent, model, persona, pageContext and uiCapabilities are read as the matching fields of a native send. A malformed uiCapabilities is a 400; see UI capabilities.
forwardedProps.uiActionA UI action that is the turn — a sandbox's agent.send, a button of yours: { name, context, text?, source?, surfaceId?, componentId?, title? }. The run answers its text (uiActionText: what the user said, then the action and its values as JSON); messages then only restate the conversation. A plain object with a short name, a JSON object context, at most 8192 bytes — else a 400 { code: 'invalid_ui_action' }. It is a user message, nothing more.
forwardedProps.a2uiActionThe same, from A2UI's AG-UI binding: { userAction: { name, surfaceId, sourceComponentId, context } } as AG-UI's A2UI middleware and CopilotKit send it, or the v0.9 { action }. Read when uiAction is absent, whether or not a2ui is on. See A2UI.
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.

With independent approvals, a user message on an existing thread that is exactly a decision command (and carries no media) decides the proposal instead of starting a turn. The answer is a short run: RUN_STARTED, a CUSTOM event agora.action-proposal-decision with { threadId, proposalDecision }, the confirmation as one assistant text message, and RUN_FINISHED. A proposal's approval interrupt carries its target (and the confirmation wording), and its interrupt id names the proposal: resuming it decides the proposal through the proposal service, like the proposal routes. A decision the caller may not make answers 403, an unknown proposal 404.

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
tool-output, -error, -deniedTOOL_CALL_RESULT (a failure or a refusal is marked in metadata['agora.outcome'])
an approval or question set the run parks onan Interrupt on the closing RUN_FINISHED
cancelledRUN_FINISHED with the cancelled outcome
an errorRUN_ERROR with the native code

RUN_FINISHED.usage reports the run's tokens, one entry per model, 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 <path>/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 (genui({ streaming: 'partial' })): sent repeatedly under the id the final push replaces, withdrawn by one with empty props. A component left out of forwardedProps.uiCapabilities is not sent here: its text arrives in the TEXT_MESSAGE_* events
agora.title{ title }
agora.step-usage{ usage, costUsd, reasoningMs } of one model step
agora.approval-requested, agora.approval-settled, agora.elicitationthe native frames, for a client that renders them live
agora.queuethe thread's message queue
agora.warning{ message } — input this producer could not use

A2UI surfaces — a2ui

agUiAdapter({ a2ui: true }) also sends every generative-UI frame as A2UI v0.9: after each agora.ui event, an ACTIVITY_SNAPSHOT with activityType: 'a2ui-surface', messageId: 'a2ui-surface-<frame id>' and replace: true, whose content.a2ui_operations holds the surface's createSurface and updateComponents messages — the shape AG-UI's A2UI middleware and CopilotKit's A2UI renderer read. Previews of a streaming tree repaint the same activity; a withdrawn preview repaints it with a deleteSurface. true maps the builtins onto A2UI's basic catalog; pass an object ({ components, unmapped, catalogId, theme }) for your own mappings or catalog. Off by default; the agora.ui events are sent either way.

Sub-agent attribution (subagentRunId) is not written: a delegated agent's work appears in the stream unattributed.

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": "Refund order #7?",
    "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. It outranks a free-form reason. A tool that declares no confirm reads as its name in words (refund_order → Refund order?, via resolveToolConfirmation), never as the raw identifier. 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 native decision 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 an interrupt that was already answered is a 409. Every entry is checked before the first one is delivered. 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.

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 ProtocolAdapter — { name, mount(host) }. The provider hands mount a ProtocolAdapterHost: the AgentService, post / get to mount routes under the agent's path, and the same gates the native routes pass through (resolveActor, assertOwner, mayDecide, refuseSend, conflictOnMismatch, the attachment store and its limits). agUiAdapter() is written against that interface and nothing else, so a protocol of your own is built the same way, without touching the provider.

Building your own route

The provider's route is assembled from exported pieces, for a host that mounts its own:

import { agUiEvents, agUiSse, parseRunInput, readUserTurn } from '@adonis-agora/agent/ag-ui'

for await (const event of agUiEvents(service.subscribe(runId), {
  threadId: input.threadId,
  runId: input.runId,
  streamRunId: runId,
})) {
  response.write(agUiSse(event))
}

The framework-free projection lives inside Agora and is exported from @adonis-agora/agent/ag-ui. It preserves the AG-UI wire contract without depending on Aviary. AgUiEncoder and agUiEvents read this package's StreamFrames (toAgUiFrame / agUiFrames are the mapping). AgUiEncoder is the pure projection underneath (frame in, events out); planResume, readApprovalPayload and readAnswersPayload read a resume list; encodeInterruptId / decodeInterruptId are the address codec.

On this page

Partner

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

Become a partner →