AG-UI Protocol Adapter¶
AG-UI is an open protocol (by CopilotKit) for streaming agent events to frontends. langgraph_events.agui maps EventGraph streams to AG-UI SSE events.
RunAgentInput -> SeedFactory -> EventGraph.astream_events -> [Mapper Chain] -> SSE
^
ResumeFactory -> astream_resume (if resuming)
RunAgentInput -> AGUIAdapter.connect/reconnect -> checkpoint snapshots + interrupts
AGUIAdapter streams LLM tokens and custom events; requires message_reducer() for authoritative message delivery.
Install¶
pip install "langgraph-events[agui]"
Also need starlette or fastapi for the HTTP layer.
Quick Start¶
from fastapi import FastAPI, Request
from ag_ui.core import RunAgentInput
from langgraph.checkpoint.memory import MemorySaver
from langgraph_events import Event, EventGraph, IntegrationEvent, MessageEvent, message_reducer, on
from langgraph_events.agui import AGUIAdapter, create_starlette_response
from langchain_core.messages import HumanMessage, AIMessage
class UserMessageReceived(IntegrationEvent, MessageEvent):
message: HumanMessage
class AssistantReplied(IntegrationEvent, MessageEvent):
message: AIMessage
@on(UserMessageReceived)
async def reply(event: UserMessageReceived) -> AssistantReplied:
return AssistantReplied(message=AIMessage(content="Hello from the agent!"))
graph = EventGraph([reply], checkpointer=MemorySaver(), reducers=[message_reducer()])
def seed_factory(input_data: RunAgentInput) -> list[Event]:
last_msg = input_data.messages[-1]
return [UserMessageReceived(message=HumanMessage(content=last_msg.content))]
adapter = AGUIAdapter(graph, seed_factory=seed_factory)
app = FastAPI()
@app.post("/api/copilotkit")
async def run(request: Request):
input_data = RunAgentInput.model_validate_json(await request.body())
return create_starlette_response(adapter.stream(input_data))
Error handling
Use error_message=... to avoid leaking exception details: AGUIAdapter(graph, seed_factory=..., error_message="Something went wrong.").
Built-in Mapper Chain¶
Mappers claim events in priority order; first non-None return wins. Unclaimed events fall through.
| Priority | Mapper | Handles | AG-UI Events Produced |
|---|---|---|---|
| 1 | SkipInternalMapper |
Resumed, SystemPromptSet |
(suppressed) |
| 2 | InterruptedMapper |
Interrupted subclasses |
CustomEvent (name="interrupted") |
| 3 | (user mappers) | (your custom logic) | (any AG-UI event) |
| 4 | FallbackMapper |
Unclaimed AGUISerializable events |
CustomEvent (name=agui_event_name if implemented else class name; value=agui_dict()) |
Events without agui_dict() are skipped with a one-time warning.
Unmapped-event policy¶
Most events in a real EventGraph are internal orchestration (Commands, routing/control-flow DomainEvents) that have no AG-UI representation, so the default per-class warning often reads as noise. Control it with on_unmapped:
AGUIAdapter(graph, seed_factory=..., on_unmapped="ignore")
| value | behavior |
|---|---|
"warn" (default) |
Once-per-class UserWarning, then drop. Non-breaking. |
"ignore" |
Silently drop. The off-switch for apps that are mostly internal events. |
"raise" |
Raise UnmappedEventError (a TypeError subclass) naming the offending class. Strict mode — turns the dev-lint into a hard CI gate. |
The policy applies to both FallbackMapper and the non-serializable branch of InterruptedMapper. Serializable events (and InterruptedWithPayload) are unaffected — they still emit.
Outside the chain, the adapter also emits:
StateSnapshot/MessagesSnapshotfromStreamFramereducer data (MessagesSnapshotrequiresmessage_reducer()).StateSnapshotFrame→StateSnapshot;CustomEventFrame→CustomEvent(name/value passthrough).- Lifecycle:
RunStarted,RunFinished,RunErroron exception. - Skips redundant snapshots when
changed_reducersis available.
Shaping client-facing state¶
AGUIAdapter(include_reducers=...) controls what reducer state crosses the wire:
| Value | Behaviour |
|---|---|
True (default) |
All user reducers |
list[str] |
Allow-list only (e.g. ["focus", "scene"]) |
False |
No user reducers (MessagesSnapshot still ships) |
Applied symmetrically to outbound state and inbound RunAgentInput.state echo (framework internals — events, _cursor, _pending, _round — always stripped first). The allow-list also gates which reducers EventGraph computes during streaming.
Symmetry is a security boundary
The same value applies to both directions — a stale or untrusted client cannot inject keys you've decided are internal by echoing them back in RunAgentInput.state. The framework-internal channels are always stripped first regardless of include_reducers.
adapter = AGUIAdapter(
graph=graph,
seed_factory=lambda inp: UserAsked(question=...),
include_reducers=["focus", "scene", "user", "context"], # debug_count, scratch hidden
)
For redaction or value transformation, write a custom EventMapper.
Message delivery uses two channels
Messages reach the client via two paths simultaneously: authoritative MessagesSnapshot from the message_reducer() channel, plus real-time TextMessageStart / Content / End tokens. AG-UI clients reconcile them by message id.
Message field mapping¶
MessagesSnapshot converts each LangChain BaseMessage to its AG-UI counterpart. The declared fields map like this:
| LangChain | AG-UI | Direction | Notes |
|---|---|---|---|
id, content |
id, content |
both | AssistantMessage.content is None for block content, and SystemMessage degrades it to "", because AG-UI declares both as text. A UserMessage or ToolMessage carries block content as content parts. See the content blocks row below. |
name |
name |
both | UserMessage, AssistantMessage, SystemMessage only — AG-UI's ToolMessage declares no name. |
AIMessage.tool_calls |
AssistantMessage.tool_calls |
both | args are JSON-encoded into function.arguments. |
ToolMessage.status |
ToolMessage.error |
both | Presence maps both ways. status="error" sends the content as error, or the literal "error" when the content is empty, because a falsy error reads as a success. A truthy inbound error sets status="error". The error text does not come back: LangChain's ToolMessage has no field for it, and content is where a tool's failure reason belongs. |
| (none) | encrypted_value |
neither | Declared on AG-UI BaseMessage, ToolMessage and ToolCall. The adapter never reads it inbound and never sets it outbound, so a value the client sent is dropped. It is a declared field, so it cannot ride through the extras slot either. |
content blocks |
content parts |
both | On a UserMessage or ToolMessage, each part maps to the LangChain standard block of the same modality. A document part maps to a file block. A data source maps to base64 with mime_type, and a url source maps to url. A part id is kept. Outbound, LangChain first translates each block to a standard block, so an image_url block also maps. A file_id maps to a file source. Outbound, a block with no AG-UI part is dropped with a WARNING that names the message. An example is a base64 block without mime_type. Inbound, a part with a file source is dropped with a WARNING. That source names a handle in the provider account. The server sends that handle with its own credentials. Inbound, part metadata is dropped with a WARNING, because a LangChain block has no field for it. |
additional_kwargs[AGUI_EXTRAS_KEY]["metadata"] |
metadata |
both | AG-UI 1.0 declares metadata as an open mapping of extra data. Before 1.0 it was an extra field, so it stays in the extras slot. AGUI_EXTRAS_OPEN_FIELDS lists each such field. A non-mapping value is dropped outbound, with a WARNING. |
| (none) | subagent_run_id |
neither | Declared on AG-UI 1.0 messages. The adapter never reads it inbound and never sets it outbound. |
additional_kwargs[AGUI_EXTRAS_KEY] |
(extra fields) | both | Passthrough — see below. |
Passing extra data on a message¶
AG-UI messages allow extra fields, so a client can receive structured data the protocol does not declare — a retry hint on a failure notice, for instance. Put a mapping under the reserved additional_kwargs key AGUI_EXTRAS_KEY (the literal string "langgraph_events.agui"). Each entry becomes a top-level field on the AG-UI message. Read the constant rather than the literal — additional_kwargs is a shared namespace that LangChain provider integrations also write into, so the key is package-qualified.
from langchain_core.messages import SystemMessage
from langgraph_events.agui import AGUI_EXTRAS_KEY
SystemMessage(
content="The step failed.",
additional_kwargs={AGUI_EXTRAS_KEY: {"failure": {"retryable": True}}},
)
# -> AG-UI SystemMessage(id=..., role="system", content="The step failed.",
# failure={"retryable": True})
The mapping round-trips. agui_messages_to_langchain and detect_new_tool_results both collect the extra fields an inbound AG-UI message carries back into additional_kwargs[AGUI_EXTRAS_KEY].
Only the reserved key crosses the wire
Nothing else in additional_kwargs is forwarded. Provider metadata and internal state stay on the backend.
Inbound extras are untrusted, and they persist
What the client sends arrives verbatim. It enters additional_kwargs, flows through add_messages into the checkpoint, and is served back out on the next MessagesSnapshot — to every client on that thread, not only the one that sent it. The adapter neither filters the keys nor inspects the values. Do not treat anything read out of the slot as trusted input, and strip or scope the slot yourself if more than one person can reach a thread.
AGUI_EXTRAS_MAX_BYTES (8 KiB of JSON per message) bounds how much a client can add. Extras over the cap, or extras that do not encode as JSON, are dropped with a WARNING naming the message. The message itself still converts.
The snapshot degrades, it never raises
MessagesSnapshot is rebuilt from the checkpointed message list on every connect(). If that build raises, the error escapes into your HTTP handler and never becomes a RunError. One bad value then breaks the thread until someone edits the checkpoint. Nothing in the extras path raises. Four cases drop something and warn once per message class:
| Case | Dropped |
|---|---|
| The reserved key holds a non-mapping | The whole value |
| An entry key is not a string | That entry |
An entry in AGUI_EXTRAS_OPEN_FIELDS holds a non-mapping |
That entry |
An entry addresses another declared AG-UI field (content, toolCallId, …) |
That entry. Otherwise it rewrites protocol data. |
Rename the entry, or set the field through the LangChain message.
Connect / Reconnect¶
connect() (alias reconnect()) emits checkpoint-backed state without running handlers — StateSnapshot + MessagesSnapshot from the checkpoint plus any pending Interrupted events.
events = [event async for event in adapter.connect(input_data)]
Use on page load / refresh.
Typical endpoint split
/api/copilotkit(run):adapter.stream(input_data)/api/copilotkit/connect(reconnect):adapter.connect(input_data)- Client: call
connecton load/refresh; callstreamonly to start/resume execution.
@app.post("/api/copilotkit")
async def run(request: Request):
input_data = RunAgentInput.model_validate_json(await request.body())
return create_starlette_response(adapter.stream(input_data))
@app.post("/api/copilotkit/connect")
async def connect(request: Request):
input_data = RunAgentInput.model_validate_json(await request.body())
return create_starlette_response(adapter.connect(input_data))
Custom Mappers¶
Implement EventMapper.map(): return None (pass), [] (suppress), or AG-UI events.
from ag_ui.core import BaseEvent, EventType, CustomEvent
from langgraph_events import Event, IntegrationEvent
from langgraph_events.agui import EventMapper, MapperContext
class PlanningStarted(IntegrationEvent):
goal: str
class PlanMapper:
def map(self, event: Event, ctx: MapperContext) -> list[BaseEvent] | None:
if not isinstance(event, PlanningStarted):
return None # pass to next mapper
return [
CustomEvent(type=EventType.CUSTOM, name="step_started", value={"goal": event.goal}),
]
adapter = AGUIAdapter(graph, seed_factory=seed_factory, mappers=[PlanMapper()])
User mappers (priority 3) intercept before FallbackMapper.
Resume Support¶
For resuming (e.g. after HITL), implement resume_factory(input_data, checkpoint_state). Return an Event to trigger astream_resume(); return None for a fresh run via seed_factory. checkpoint_state is optional — one-argument factories still work.
from ag_ui.core import RunAgentInput
from langgraph_events.agui import ResumeFactory
class ApprovalSubmitted(IntegrationEvent):
approved: bool
def resume_factory(
input_data: RunAgentInput,
checkpoint_state: dict[str, Any] | None,
) -> Event | None:
# checkpoint_state: CheckpointState TypedDict with reducers, events,
# messages, pending_interrupts, is_interrupted, snapshot.
state = input_data.state or {}
if "approved" in state:
return ApprovalSubmitted(approved=state["approved"])
return None # fresh run
adapter = AGUIAdapter(graph, seed_factory=seed_factory, resume_factory=resume_factory)
Frontend state on resume
When RunAgentInput.state is populated alongside a resume:
- State dict is projected via
include_reducers(framework internals + dedicated keys stripped). FrontendStateMutated(state=projected)event is built.- For each reducer subscribing to
FrontendStateMutated, the adapter writes viaapre_seedbefore the resume's domain dispatch — so handlers reading the channel via parameter injection see the updated value. - FSM is also injected as a seed to
astream_resume(appears in stream + audit log). - Domain-driven reducers win for shared keys — backend channels stay intact regardless of what the client echoes.
@on(FrontendStateMutated)handler callbacks do NOT fire on resume (reducer pipeline still runs). Use@on(Resumed)or@on(Resumed, interrupted=...)for resume-time side effects.
Reducers can subscribe to FrontendStateMutated to mirror selected client-state keys server-side:
from langgraph_events import ScalarReducer, SKIP
from langgraph_events.agui import FrontendStateMutated
focus = ScalarReducer(
event_type=FrontendStateMutated,
fn=lambda e: e.state.get("focus", SKIP),
)
Frontend Tools¶
The AG-UI spec positions tool calls as "inherently frontend-executed" and as the mechanism for HITL. The adapter wires all three halves to an EventGraph:
- Tool definitions in — page's
useFrontendToolregistrations arrive asRunAgentInput.tools.build_langchain_tools(...)converts them to OpenAI-format dicts forllm.bind_tools(...). - Tool calls out — two paths, both mapping to
ToolCallStart/ToolCallArgs/ToolCallEnd:- LLM-initiated — bound LLM
tool_call_chunksauto-translate to the streaming triple. - Handler-initiated — return
FrontendToolCallRequested(Interrupted)from a handler. Graph pauses; AG-UI emits the triple. (Same machinery asApprovalRequested(Interrupted)inexamples/expense_approval.py.)
- LLM-initiated — bound LLM
- Tool results back — frontend handler return value comes back as
role: "tool".detect_new_tool_results(input_data, checkpoint_state)returns the newToolMessages; wrap asMessageEventand return fromresume_factory.
from langgraph_events import on
from langgraph_events.agui import (
AGUIAdapter,
FrontendToolCallRequested,
build_langchain_tools,
detect_new_tool_results,
)
def seed_factory(input_data, checkpoint_state=None):
return [
ToolsRegistered(tools=tuple(input_data.tools or [])),
UserMessageReceived(message=HumanMessage(content=input_data.messages[-1].content)),
]
def resume_factory(input_data, checkpoint_state=None):
results = detect_new_tool_results(input_data, checkpoint_state)
return ToolsExecuted(messages=tuple(results)) if results else None
@on(UserMessageReceived, ToolsExecuted)
async def call_llm(event, messages, log):
registered = log.latest(ToolsRegistered)
tools = build_langchain_tools(list(registered.tools)) if registered else []
llm = ChatOpenAI(model="gpt-4o-mini").bind_tools(tools) if tools else ChatOpenAI(model="gpt-4o-mini")
return LLMResponded(message=await llm.ainvoke(messages))
Handler-initiated frontend tools¶
For backend-initiated tool calls (confirm dialogs, file pickers, deterministic prompts), return FrontendToolCallRequested(Interrupted). Graph pauses; AG-UI streams the call triple; frontend result becomes a typed event on resume.
from langgraph_events import on
from langgraph_events.agui import (
FrontendToolCallRequested,
detect_new_tool_results,
)
@on(ShipCommandReceived)
def request_confirmation(event: ShipCommandReceived) -> FrontendToolCallRequested:
return FrontendToolCallRequested(
name="confirm", # must match a useFrontendTool({ name: "confirm", ... }) registration
args={"prompt": f"Ship release {event.release}?"},
)
def resume_factory(input_data, checkpoint_state=None):
results = detect_new_tool_results(input_data, checkpoint_state)
if not results:
return None
return UserConfirmed(messages=tuple(results))
@on(UserConfirmed)
def ship(event: UserConfirmed) -> ShippedRelease:
approved = bool(json.loads(event.messages[0].content).get("approved"))
return ShippedRelease(release="v1", approved=approved)
CopilotKit versioning
- v1 (
useCopilotAction): usesMessagesSnapshot(no changes). - v2 (
useFrontendTool): needs streamingToolCallStart/Args/End. - Both coexist; CopilotKit reconciles by
tool_call_id.
parent_message_id caveat
For LLM-initiated tool calls, ToolCallStartEvent.parent_message_id may not match the final MessagesSnapshot id — LangChain doesn't expose the final AIMessage.id until the stream ends.
Reconnect replay
If a page refresh hits connect() while the graph is paused on FrontendToolCallRequested, the adapter replays the triple using the stored tool_call_id. CopilotKit's useFrontendTool is idempotent by tool_call_id — replay is safe.
Strict contract — no silent fallbacks
The adapter rejects malformed tool-call traffic at the source:
FrontendToolCallRequested(name="")(or whitespace-only) raisesValueErrorat construction.FrontendToolCallRequested.argsis JSON-serialized at emit time; non-serializable values raiseTypeError→ adapter surfacesRUN_ERROR. KeepargsJSON-compatible.- An LLM
tool_call_chunklackingindexraisesValueErrorfromastream_events. - First chunk of a streaming call must carry both
idandname; missing raisesValueError. Continuation chunks may omit. - Inbound
role: "tool"message must carry a non-emptytool_call_id; missing raisesValueErrorfromdetect_new_tool_results.
Streaming-path errors propagate to the frontend as a RUN_ERROR event with the diagnostic message.
Resume Helpers¶
detect_new_tool_results covers the frontend-tool-result arm of resume. A result the client marks with error becomes a LangChain ToolMessage with status="error", so the model reads the failure as a failure. The other arm — frontend sends Command(resume=…) plus new chat messages — has three helpers that collapse resume_factory boilerplate:
extract_resume_input(input_data)— pull & decodeRunAgentInput.forwarded_props["command"]["resume"].agui_messages_to_langchain(messages, *, drop_invalid_tool_calls=False)— convert AG-UI messages (UserMessage,AssistantMessage,SystemMessage,ToolMessage— multimodal content included, see Message field mapping) to LangChainBaseMessage.ReasoningMessage/DeveloperMessageskipped (DEBUG);ActivityMessage/ unknown roles raiseValueError. Withdrop_invalid_tool_calls=True, tool calls with unparseable JSON args are dropped (WARNING). AToolMessage.errorbecomesstatus="error", and extra fields land underadditional_kwargs[AGUI_EXTRAS_KEY](see Message field mapping).merge_frontend_messages(input_data, checkpoint_state, *, reducer_name="messages", drop_invalid_tool_calls=True)— read existing messages from checkpoint, convert, merge viaadd_messages(id-based dedup; missing ids get UUIDs assigned). Returns a tuple. A stored message wins over an inbound message with the same id. AG-UI definesMessagesSnapshotas the messages that the server owns, and a client echo of them can be lossy.
from langgraph_events import IntegrationEvent, MessageEvent
from langgraph_events.agui import (
detect_new_tool_results,
extract_resume_input,
merge_frontend_messages,
)
# Domain-specific resume event — define alongside your other events.
class UserResumed(IntegrationEvent, MessageEvent):
response: object # resume payload (dict, str, …); your shape
messages: tuple = ()
def resume_factory(input_data, checkpoint_state=None):
# 1. Frontend tool result path (covered above)
tool_results = detect_new_tool_results(input_data, checkpoint_state)
if tool_results:
return ToolsExecuted(messages=tuple(tool_results))
# 2. Command(resume=…) + new chat messages path
resume_input = extract_resume_input(input_data)
if resume_input is None:
return None
merged = merge_frontend_messages(input_data, checkpoint_state)
return UserResumed(response=resume_input, messages=merged)
Falsy resume handling
extract_resume_input treats all falsy values (0, "", [], {}, False) as "no resume". For meaningful "deny" signals, send {"approved": false} instead of bare false.
Use agui_messages_to_langchain directly for custom merging (e.g. in seed_factory).
Typed Interrupt Payloads¶
For HITL flows whose frontend needs an action-discriminated dict, subclass InterruptedWithPayload[PayloadT] (builds on Interrupted from Control Flow) and implement interrupt_payload(). InterruptedMapper emits the payload as CustomEvent(name="interrupted", value=...) — no agui_dict() override needed.
from typing import Literal, TypedDict
from langgraph_events import on
from langgraph_events.agui import InterruptedWithPayload
class ReviewPayload(TypedDict):
kind: Literal["review"]
draft: str
revision: int
class ReviewInterrupted(InterruptedWithPayload[ReviewPayload]):
draft: str
revision: int
def interrupt_payload(self) -> ReviewPayload:
return {"kind": "review", "draft": self.draft, "revision": self.revision}
@on(DraftReady)
def request_review(event: DraftReady) -> ReviewInterrupted:
return ReviewInterrupted(draft=event.draft, revision=event.revision)
Pure Interrupted (no payload) is still the right pick for non-frontend HITL.
LangGraph Config Passthrough¶
stream() / connect() accept LangGraph config via RunAgentInput.forwarded_props — keys langgraph_config, config, or the dict itself. The adapter always overrides configurable.thread_id from RunAgentInput.thread_id; other keys (recursion_limit, tenant routing) pass through.
Soft-timeout¶
stream() accepts an optional deadline: float keyword — an absolute time.monotonic() reference. When the graph's router observes a current time past the deadline between dispatch rounds, it emits RunPaused (see Control Flow → Soft-timeout) and the run finalises cleanly through the same drain + RunFinishedEvent path as a normal completion. No early break, no special control flow in the adapter.
from time import monotonic
@app.post("/api/copilotkit")
async def run(input_data: RunAgentInput) -> StreamingResponse:
# Worker has a hard job_timeout of 180s; soft-pause 30s before.
hard_budget_s = 180
soft_margin_s = 30
return create_starlette_response(
adapter.stream(
input_data,
deadline=monotonic() + (hard_budget_s - soft_margin_s),
)
)
RunPaused is not surfaced on the AG-UI wire by default (#88). The class deliberately does not implement AGUISerializable, so FallbackMapper skips it (one-time warning). The previous default — CustomEvent(name="interrupted", value={"kind": "soft_timeout", …}) — collided with HITL Interrupted events on the same wire name and forced every client to branch on value.kind. Apps that want a pause signal on the wire register their own mapper and pick a wire shape that suits their frontend:
from ag_ui.core import BaseEvent, CustomEvent, EventType
from langgraph_events import Event, RunPaused
from langgraph_events.agui import EventMapper, MapperContext
class PauseMapper:
def map(self, event: Event, ctx: MapperContext) -> list[BaseEvent] | None:
if not isinstance(event, RunPaused):
return None
return [
CustomEvent(
type=EventType.CUSTOM,
name="run.paused",
value={"elapsed_seconds": event.elapsed_seconds},
)
]
adapter = AGUIAdapter(graph, seed_factory=..., mappers=[PauseMapper()])
For an inline pause notice in the message channel (no custom wire event needed), see the reducer recipe in Control Flow → Surfacing the pause inline.
Resume is implicit: the consumer's "Continue" button issues a new /run on the same thread_id (with a fresh deadline). LangGraph's checkpointer replays from the last completed node — no Command(resume=...) required. Position deadline strictly tighter than whichever outer hard cancellation the caller has (asyncio.wait_for, SAQ job_timeout, LangGraph's own timeout=) so the soft boundary fires first.
AG-UI Spec Coverage¶
Built-in for 12 of 33 event types; the rest via custom mappers or N/A.
| Category | Count | Event Types |
|---|---|---|
| Built-in | 12 | RunStarted, RunFinished, RunError, TextMessageStart/Content/End, ToolCallStart/Args/End, StateSnapshot, MessagesSnapshot, Custom |
| User mapper | 17 | TextMessageChunk, ToolCallResult, ToolCallChunk, StepStarted/Finished, StateDelta, ActivitySnapshot/Delta, ThinkingStart/End, ThinkingTextMessageStart/Content/End, Raw, ReasoningStart/End |
| N/A | 4 | ReasoningMessageStart/Content/End/Chunk — extended reasoning, provider-specific |