AG-UI wire and frame reference¶
Every LangStage surface streams a turn the same way. The shared core wraps your
compiled graph in the official ag-ui-langgraph
adapter and maps the AG-UI events to small, typed Python dicts called frames.
There are two mappings of the same information:
- The event wire,
iter_event_frames(). It yields{"type": …}frames. The web app and the VS Code sidecar use it. - The chunk wire,
iter_chunk_frames(). It yields{"status": …}chunks. The terminal and JupyterLab use it.
Both are async generators in langstage_core.agui and need the [agui] extra
(pip install "langstage-core[agui]"), which every surface already installs.
Frame table¶
This table is the contract. Keys marked (additive) were added after 1.0. A client must ignore a frame type or a key it doesn't know.
| Event wire | Chunk wire | Meaning |
|---|---|---|
{"type": "content", "content", "role", "node", "message_id"} |
{"status": "streaming", "chunk", "node", "message_id"} |
A delta of assistant text. |
{"type": "reasoning", "content", "node"} |
{"status": "streaming", "reasoning", "node"} |
Chain-of-thought from a reasoning model, kept separate from the answer. |
{"type": "tool_start", "id", "name", "args", "node"} |
{"status": "streaming", "tool_calls": [{"name", "args", "id"}]} |
A tool call. |
{"type": "tool_end", "id", "name", "result", "status", "error_message", "duration_ms"} |
{"status": "streaming", "tool_result", "id", "name", "tool_status", "duration_ms"} |
A tool result, capped at max_result_len. |
{"type": "extraction", "tool_name", "extracted_type", "data"} |
{"status": "streaming", "extraction": {"tool_name", "extracted_type", "data"}} |
Structured data an extractor pulled out of a successful tool result. |
{"type": "interrupt", "action_requests", "review_configs", "allowed_decisions"} |
{"status": "interrupt", "interrupt": {…same keys}} |
A human-in-the-loop pause. |
{"type": "complete", "outcome"} |
{"status": "complete", "outcome"} |
The turn ended. |
{"type": "error", "error"} |
{"status": "error", "error"} |
The turn failed. |
The rules that matter¶
- A chunk carries exactly one payload key. A
streamingchunk has one ofchunk,reasoning,tool_calls,tool_resultorextraction. Branch on the key, and don't assumechunkis present. message_idmarks message boundaries (additive). It names theAIMessagea text delta belongs to. When it changes between two text frames, a new message started (for example, a second node's reply). Render a paragraph break, not an inline join.- Tool status and duration (additive).
status(event wire) andtool_status(chunk wire) are"success"or"error".duration_msis how long the tool ran. It isNonewhen the tool ran outside LangChain's tool runtime, such as a hand-written node. On the chunk wire,id,name,tool_statusandduration_msare additive, andtool_resultis still the result string. extractiononly follows a successful tool. A failed tool never produces one. It is emitted after the call'stool_end, so match it to its call by id.complete.outcome(additive) is"interrupted"if the turn paused on an interrupt, else"complete". An interrupted turn still ends withcomplete. Detect the pause from theinterruptframe or fromoutcome, not from whethercompletearrived.erroris terminal. Nothing follows it, and there is nocomplete. Content that earlier nodes already produced is streamed before it. WithLANGSTAGE_DEBUG=1, theerrorframe also carries atraceback.- Order. Frames arrive in message order. A node that returns a finished
AIMessage(no token streaming:model.invoke(), a router, a canned reply) is emitted when the node finishes, its text before its own tool calls.
The node label comes from the step's own update, so it is correct with an async
checkpointer too (core 1.0.37).
See every frame type without a key¶
The bundled tool demo, langstage_core.demo.tools, is deterministic and offline.
Its trigger phrases produce each frame type: "use a tool" (tool call, result,
extraction), "think" (reasoning), and "ask me" (an interrupt). Anything else is
echoed.
import asyncio
from langstage_core.agui import build_agent, iter_chunk_frames, iter_event_frames
from langstage_core.demo.tools import create_tool_demo_agent, demo_extractors
agent = build_agent(create_tool_demo_agent())
async def main():
seen = []
async for frame in iter_event_frames(agent, "use a tool", "t1", extractors=demo_extractors()):
if frame["type"] not in seen:
seen.append(frame["type"])
print("event wire:", seen) # ['tool_start', 'tool_end', 'extraction', 'content', 'complete']
keys = []
async for chunk in iter_chunk_frames(agent, "think about it", "t2"):
key = next((k for k in ("chunk", "reasoning", "tool_calls", "tool_result", "extraction")
if k in chunk), chunk["status"])
if key not in keys:
keys.append(key)
print("chunk wire:", keys) # ['reasoning', 'chunk', 'complete']
asyncio.run(main())
From a shell: langstage-agui --demo=tools -m "use a tool" --json,
langstage-vscode-sidecar --demo=tools --message "think about it" --json, or
langstage-cli -a langstage_core.demo.tools:graph "use a tool".
The served AG-UI endpoint¶
langstage-agui, build_app() and serve() expose the same turn as a standard
AG-UI HTTP endpoint (server-sent AG-UI events such as RUN_STARTED,
TEXT_MESSAGE_*, TOOL_CALL_*, RUN_FINISHED). The frames above are built from
exactly those events, so an AG-UI client and an in-process consumer see the same
turn. Differences worth knowing for an AG-UI client:
- An agent failure is a terminal
RUN_ERRORevent, not a dropped stream. - An interrupt arrives as a
CUSTOMon_interruptevent. - Resuming uses AG-UI's standard
RunAgentInput.resume(ag-ui-langgraph 0.0.43 and later). The olderforwardedProps.command.resumeis a deprecated fallback. - Use a new message id for every turn. The adapter dedupes messages by id, so a reused id silently drops the later turn.
See Serve over AG-UI.
Surface-specific protocol frames¶
The VS Code sidecar wraps the event wire in a stdio protocol and adds a few frames
of its own: ready, ack, cancelled and turn_end. They are documented on the
VS Code page.