AG-UI Serve Transport (nexus.io.agui)
nexus.io.agui exposes Nexus over the AG-UI protocol
(“Agent-User Interaction”), the open, event-based standard for connecting
streaming agents to user-facing applications. It stands up an HTTP listener so
that any standards-compliant AG-UI client — CopilotKit/React, the AG-UI
terminal client, or a framework integration (LangGraph, CrewAI, Pydantic AI,
Google ADK, Mastra, …) — can drive a Nexus agent with no Nexus-specific client
code.
This is a serve transport: it accepts AG-UI requests and streams AG-UI
responses. It is additive and external-facing — it does not replace the
nexus.io.browser / nexus.io.wails Envelope wire that backs the built-in web
and desktop UIs. See I/O Transport Plugins for the transport
family, and .claude/docs/io-transport.md for why AG-UI intentionally sits
outside the browser↔wails parity rule.
Details
| ID | nexus.io.agui |
| Dependencies | None |
| Wire format | AG-UI over HTTP + SSE (defined by pkg/agui, not the pkg/ui Envelope) |
| Endpoint | POST /agui (plus OPTIONS /agui for CORS preflight) |
| Spec version | v1 (docs.ag-ui.com, 2026-07-10) — pinned in pkg/agui as agui.SpecVersion |
The codec is hand-rolled in pkg/agui (no third-party SDK), matching the
raw-net/http, minimal-dependency house style. Because the spec is tracked
manually, the targeted version is pinned in one place (agui.SpecVersion) and
quoted above.
How it works
A client POSTs a RunAgentInput JSON body to /agui and receives a
text/event-stream (SSE) response carrying one run: a well-formed AG-UI
lifecycle from RUN_STARTED to RUN_FINISHED (or RUN_ERROR). The stream flushes
incrementally as bus events arrive — nothing is buffered until the end.
Inbound (client → bus). The request’s messages are mapped to a Nexus
io.input:
- The trailing
usermessage becomes the live turn content. - Any earlier messages ride as
PreloadMessages, so a resumed thread keeps its prior context. threadIdis recorded as the Nexus session id;runIdidentifies the turn.
io.input is published vetoably (before:io.input first); a veto ends the run
with RUN_ERROR.
Outbound (bus → client). The plugin subscribes to the same engine bus events as the browser transport and translates each into canonical AG-UI SSE. Bus handlers only enqueue translated events onto the active run’s channel; a single HTTP handler goroutine is the sole SSE writer, so the stream is race-free.
Event mapping
Nexus bus events map near-1:1 onto the canonical AG-UI event taxonomy. The
AG-UI wire type discriminator is UPPER_SNAKE_CASE (per the AG-UI protocol);
the values below are the exact strings emitted on the SSE stream:
| Nexus bus event | AG-UI event(s) | Notes |
|---|---|---|
| (run accepted) | RUN_STARTED | Emitted eagerly on accept so even an agent-less run is well-formed. threadId / runId echoed. |
agent.turn.start | STEP_STARTED | Each turn/iteration opens a step; the step name derives from TurnID. |
agent.turn.end | STEP_FINISHED, then RUN_FINISHED | A top-level turn end closes the open step and terminates the run/stream. |
llm.stream.chunk | TEXT_MESSAGE_START → TEXT_MESSAGE_CONTENT | TEXT_MESSAGE_START (role assistant) is emitted lazily on the first non-empty delta; subsequent deltas append content. |
llm.stream.end | TEXT_MESSAGE_END | Closes the open streamed text message. |
io.output | TEXT_MESSAGE_START → TEXT_MESSAGE_CONTENT → TEXT_MESSAGE_END | Self-contained triple. Skipped when the same content was already streamed via llm.stream.chunk; still rendered when a non-streaming provider (mock / batch) flags output streamed but emitted no chunks, so text is never dropped. |
tool.invoke | TOOL_CALL_START → TOOL_CALL_ARGS → TOOL_CALL_END | The agent emits tool.invoke (not tool.call) to run a tool. Arguments are fully resolved on the bus (not streamed), so the three events are emitted together; args are JSON-encoded. Internal sub-calls (non-empty ParentCallID) still render but never suspend the run. |
tool.result | TOOL_CALL_RESULT | Correlated to the call by toolCallId; Error content is surfaced in place of Output when present. |
thinking.step | REASONING_START → REASONING_MESSAGE_CONTENT | REASONING_START opens lazily on the first step; REASONING_END is emitted at turn end. |
| (failure / disconnect / veto / concurrent run) | RUN_ERROR | Terminal; ends the stream. |
Both RUN_FINISHED and RUN_ERROR are terminal — the SSE stream ends on either.
Non-canonical events: the CUSTOM superset
Nexus emits rich events that have no canonical AG-UI equivalent. Rather than
drop them, they ride the AG-UI CUSTOM event as a documented superset: the
Custom.name is the Nexus bus event type and Custom.value is the JSON-encoded
payload. Stock AG-UI clients that only understand canonical events can safely
ignore CUSTOM without losing the run’s canonical lifecycle; Nexus-aware
clients can opt in.
The bridged bus events are:
workflow.progresssubagent.startedsubagent.iterationsubagent.completecode.exec.stdout
AG-UI also defines a
RAWevent for passthrough of an upstream provider’s native event shape. Nexus-specific events useCUSTOM(name + JSON value) consistently;RAWis available inpkg/aguifor future passthrough needs.
Interrupts: HITL and client-executed tools
AG-UI uses a terminal-run model for anything that needs input mid-run: the
run ends with an interrupt outcome and the client starts a continuation run
carrying resume[]. Nexus emulates this as virtual runs over one persistent
in-process session — the agent stays parked in-process and a continuation POST
unblocks it.
One Nexus turn spans multiple AG-UI runs
The load-bearing consequence of the terminal-run model is that a single Nexus turn can span several AG-UI runs. When an agent needs input, the current run ends — but the Nexus session (and the parked agent) stays alive. Each subsequent resume opens a new run over the same thread until the turn finally completes:
POST /agui { threadId: T, runId: R1, messages:[…] } ← run 1 begins the turn
… RUN_STARTED … STEP_STARTED … (agent calls ask_user) …
… STATE_SNAPSHOT … MESSAGES_SNAPSHOT … RUN_FINISHED(interrupt) ← run 1 ends, agent PARKED
POST /agui { threadId: T, runId: R2, resume:[…] } ← run 2 continues the SAME turn
… RUN_STARTED … (agent unblocks, finishes) … RUN_FINISHED ← turn complete
The threadId is identical across the runs; each continuation uses a fresh
runId. No messages are needed on a resume — the resume[] items are the
payload the server correlates back to its pending interrupt(s). Because the
session is persistent and in-process, a threadId must route to the same
Nexus instance across its runs (see threadId / runId semantics below).
Two flows ride the identical suspend/resume machinery:
- Human-in-the-loop (HITL). A
hitl.requestedduring a run (e.g. the agent calling theask_usertool) emits aSTATE_SNAPSHOT+MESSAGES_SNAPSHOTthenRUN_FINISHED(interrupt); the resume emitshitl.respondedto unblock the waiter. - Client-executed (frontend) tools. Tools the client advertises via
RunAgentInput.toolsare surfaced to the agent (the plugin appends them to the synchronoustool.catalog.querysnapshot, scoped to exactly the advertising run — they never leak into later runs or shadow a same-named server tool). When the agent calls one, itstool.invokestreams theTOOL_CALL_START/ARGS/ENDsequence and then the run ends interrupt-style: there is no in-process handler to produce atool.result, so the client runs the tool and resumes with a tool result. The plugin feeds that result back to the parked agent as thetool.resultit was waiting on, and the continuation streams on a fresh run.
A server-side Nexus catalog tool is never intercepted: its own handler runs
inline and produces the tool.result that streams as a normal TOOL_CALL_RESULT.
Client tools are distinguished purely by origin (they came from
RunAgentInput.tools).
The interrupt anchor
RUN_FINISHED(interrupt) carries an Interrupt payload in its result field.
The client renders it and echoes its interruptId in the resume. It provides:
| Field | Meaning |
|---|---|
interruptId | The anchor the client echoes back in resume[].interruptId. Distinct from any internal request id. |
prompt | The rendered question/approval text (HITL) or a client-tool hint. |
mode | free_text, choices, or both — controls the response affordance. |
choices / defaultChoiceId | The options (and deadline default) for a choices/both interrupt. |
The interrupt kind (HITL vs client tool) is also mirrored in the STATE_SNAPSHOT
under an interrupt (HITL) or toolCall (client tool) anchor, so a client that
restores from state alone — rather than replaying MESSAGES_SNAPSHOT — still has
everything it needs to resume.
The resume[] wire shape
Each resume[] item names an interruptId, a status, and an optional
payload. The payload fields depend on the interrupt kind:
status | Interrupt kind | payload fields | Effect |
|---|---|---|---|
resolved | HITL | choiceId, freeText, editedPayload | Answers the prompt. A choices-only interrupt drops stray freeText. All fields optional; an empty payload accepts the default. |
resolved | client tool | output, error | Becomes the parked agent’s tool.result. Empty resolves the call with empty output (the agent still advances). |
cancelled | either | (none) | Abandons the interrupt: a HITL waiter unblocks as cancelled; a client-tool call resolves with an error tool.result so the agent’s loop still advances. |
// HITL resume: pick a choice.
{ "threadId":"T", "runId":"R2",
"resume":[ { "interruptId":"int-…", "status":"resolved",
"payload": { "choiceId":"staging" } } ] }
// Client-tool resume: return the tool's output.
{ "threadId":"T", "runId":"R2",
"resume":[ { "interruptId":"int-…", "status":"resolved",
"payload": { "output":"sunny, 24C" } } ] }
// Cancel either kind.
{ "threadId":"T", "runId":"R2",
"resume":[ { "interruptId":"int-…", "status":"cancelled" } ] }
As AG-UI requires, all open interrupts on a thread must be addressed in one
resume request: a resume that references an unknown/expired interrupt, addresses
one twice, or leaves an open interrupt unaddressed is rejected with a clean
terminal RUN_ERROR stream and leaves the parked agent untouched for a corrected
retry.
The reusable pure-Go conformance client (pkg/agui/aguiclient) provides
constructors for these payloads — ResumeInput, ResolveChoice, ResolveText,
ResolveToolResult, and Cancel — plus Result.Interrupt() to extract the
anchor from a RUN_FINISHED(interrupt). The end-to-end interrupt/resume and
client-tool round-trips are exercised in
tests/integration/agui_hitl_test.go.
threadId / runId semantics
threadId↔ Nexus session. ThethreadIdis recorded as the session id on the inboundio.input. Because the serving session is persistent and lives in-process, athreadIdmust route to the same Nexus instance across runs — the terminal-run/resume model is emulated as virtual runs over one live session, not by reconnecting to a stateless backend.runId↔ Nexus turn. EachPOSTis one run == one turn. Message ids in the outbound stream are derived deterministically from therunIdso a client can correlate streamed text, tool calls, and results within the run.
Concurrency and scope
One in-flight run per listener (single engine/session per listener, mirroring
nexus.io.browser). A second POST while a run is active receives a terminal
RUN_STARTED + RUN_ERROR stream rather than interleaving into the live run. On
client disconnect or engine shutdown, the active run fails with RUN_ERROR and
its handler returns promptly, releasing the slot.
Exposure, auth, and CORS
Safe by default: the listener binds loopback (127.0.0.1:8090) so the
endpoint is never network-exposed without an explicit operator opt-in.
- Bearer auth is enforced only when a non-empty token is resolved. An inline
bearer_tokentakes precedence; otherwisebearer_token_envnames an environment variable to read it from. When set, every request must carryAuthorization: Bearer <token>. - CORS is off by default (same-origin only).
cors_originsaccepts a YAML list (or comma-separated string); a single*echoes any requestOrigin, while an explicit list echoes only matching origins.OPTIONS /aguianswers preflight for browser AG-UI clients.
Configuration
The canonical, always-current key list lives in the Configuration Reference. The keys are summarized here for convenience:
| Key | Type | Default | Description |
|---|---|---|---|
bind | string | 127.0.0.1:8090 | host:port the HTTP listener binds to. Loopback by default. |
bearer_token | string | (empty) | Inline bearer token. Takes precedence over bearer_token_env. |
bearer_token_env | string | (empty) | Env var name to read the bearer token from (used only when bearer_token is empty). |
cors_origins | list<string> | (empty) | Allowed CORS origins. * echoes any Origin; a list echoes only matches; empty means same-origin only. Also accepts a comma-separated string. |
emit_state | bool | false | Opt-in AG-UI shared-state emission: mirror the scene store as a shared-state document and emit STATE_SNAPSHOT/STATE_DELTA on the run stream. See Shared state below. |
Shared state
With emit_state: true, the transport mirrors the session’s scene store
(nexus.scene) as the AG-UI shared state document so a frontend can render
and track agent state. The mapping is:
- The scene store emits
scene.created/scene.patched/scene.deletedon the bus, each carrying the scene’s full post-mutation content. The transport tracks these into a document keyed byscene_id(value = the scene’s current content). It never calls the scene plugin directly — the bus events are the sole input. - On run start, a
STATE_SNAPSHOTof the current document is emitted right afterRUN_STARTED. - Each scene mutation during the run emits a
STATE_DELTAwhosedeltais an RFC 6902 JSON Patch from the previous document to the new one. TheSTATE_SNAPSHOTalways precedes anySTATE_DELTAon the stream, and applying the deltas in order to the snapshot reconstructs the state (verified end to end by theTestAGUIState_*integration tests as well as thepkg/aguiunit tests). This aligns AG-UI’sSTATE_DELTAwith the scene store’s patch model while normalizing the scene store’s shallow-merge semantics into a valid JSON Patch computed from full content.
The document is session-scoped and persists across runs on the listener, so a later run’s snapshot reflects scenes created by an earlier run.
Inbound state (client → agent)
A client may send a shared-state document on RunAgentInput.state to seed or
edit state the agent then observes. The document uses the same scene-keyed
shape the transport emits outbound: a JSON object whose keys are scene_ids
and whose values are that scene’s content.
- Inbound state is applied at run start (and on a resume/continuation run)
before the initial
STATE_SNAPSHOTis emitted, so the snapshot reflects the client’s view and the agent’s first turn observes it. - To make a client write real (not just a mirror update), each
scene_id → contententry is pushed into the scene store via a bus-emittedscene_createtool.invokecarrying an explicitscene_id. The scene plugin creates the scene under that id, or shallow-merges the content as a patch when the scene already exists (client edits a scene the agent created preserve keys the client did not send). The agent then reads the seeded state through the normalscene_get/scene_listtools. No direct plugin-to-plugin call is made — the bus is the only channel. - A non-object state document (or otherwise malformed) is logged and skipped; it
never fails the run. Inbound state is a no-op when
emit_stateis off.
Conflict / ordering semantics — client-state-seeds-then-agent-wins. The
client seed is fully applied before the run’s io.input is emitted, so the agent
always starts from the seeded state. For the rest of the run, agent-side scene
mutations are last-writer over the same scene_id: a later scene_patch
overwrites the client’s value per the scene store’s shallow-merge semantics, and
that change flows back out as a STATE_DELTA (completing the round-trip). The
transport’s stateMu and the scene store’s own lock serialize concurrent client
and agent mutations, so ordering is deterministic (client seed first, then agent
writes in bus order) and no half-applied document is ever observed.
Because the mirror is seeded to the same value the scene store echoes back, the
seed itself produces no STATE_DELTA — only genuine agent mutations do. The
TestAGUIState_InboundSeedObserved and TestAGUIState_ConflictAgentWins
integration tests exercise this round-trip: the client seed appears in the
initial STATE_SNAPSHOT and is read back through scene_get, and a subsequent
agent scene_patch on the same scene_id wins on the overlapping key (with the
client’s untouched keys preserved by shallow-merge) and surfaces as exactly one
STATE_DELTA.
The scene_create tool accepts an optional scene_id argument to support this
seeding; when omitted the store assigns an id as before, so existing agent usage
is unchanged.
Example configuration
plugins:
nexus.io.agui:
bind: "127.0.0.1:8090"
bearer_token_env: "AGUI_BEARER_TOKEN"
cors_origins:
- "https://app.example.com"
See also
- Configuration Reference —
nexus.io.agui— canonical config keys. - I/O Transport Plugins — the transport family.
- Browser UI — the session-scoped Envelope transport AG-UI mirrors for scope/exposure.