AG-UI and CopilotKit
Serving an AG-UI frontend
AG-UI is a shared protocol that lets a chat frontend talk to any agent backend. Frontends such as CopilotKit speak it. To serve one from your Parlant agent, you add a small adapter that reshapes Parlant's conversation events into AG-UI events. The agent itself does not change. Use this when you want an AG-UI frontend instead of the official chat widget.
- What AG-UI is and why Parlant needs only a small adapter
- The three parts of the adapter: source, translator, and server
- How each Parlant event maps to AG-UI events
- How a tool's widget reaches a CopilotKit page
For the two to work together, they need to agree on how messages and tool results are delivered. AG-UI provides an event-based protocol for exactly that connection.
AG-UI standardizes the stream a chat UI needs: streaming text, tool calls, shared state, and generative UI. Any AG-UI frontend (for example, CopilotKit's React components) can talk to any AG-UI backend.
The good news is that Parlant is already an event-streaming backend. A session is one conversation between a customer and an agent. Parlant stores it as an ordered log of events (customer messages, agent messages, tool calls, and status updates), which your clients read over SSE (server-sent events).
So serving AG-UI takes no change to Parlant at all. Your coding agent writes a small adapter that reshapes Parlant's session events into AG-UI events. Parlant keeps owning what the agent does; AG-UI and CopilotKit own how it looks.
The shape of the adapter
The adapter runs as its own small HTTP service, sitting between the frontend and a normal Parlant server:
CopilotKit / AG-UI client ──POST /agui (RunAgentInput)──▶ Adapter ──REST + SSE──▶ Parlant
◀────── AG-UI event stream ───── ◀── session events ──
It has three parts: a source, a translator, and a server.
The source
The source drives a Parlant session on the frontend's behalf. It keeps a map from the AG-UI thread id to a Parlant session id, posts the latest user message as a customer message, and then streams that turn's events until the agent's message completes, or until a ready or error status arrives.
A turn is Parlant's handling of one customer message, and status events are the events that report its progress.
The translator
The translator converts Parlant events into AG-UI events. Because a streaming message arrives in pieces, it also tracks which message chunks it has already sent within the run, so nothing goes out twice.
The server
The server exposes POST /agui to the frontend. It frames each run with RUN_STARTED and RUN_FINISHED, and streams the translated events as SSE using the protocol's encoder.
Install the adapter's dependencies in its own environment: pip install ag-ui-protocol fastapi uvicorn httpx.
The mapping
With the three parts in place, the real work is deciding which AG-UI events each Parlant event becomes. The table below pairs each thing Parlant records in the session with the AG-UI events your translator emits for it.
One term first: the trace_id is the id Parlant stamps on every event a turn produces, which is exactly what AG-UI's runId needs.
| Parlant | AG-UI |
|---|---|
| A turn on a session | A run, RUN_STARTED through RUN_FINISHED |
Session id and the turn's trace_id | threadId and runId |
Agent message event, streaming chunks | TEXT_MESSAGE_START, one TEXT_MESSAGE_CONTENT per new chunk, TEXT_MESSAGE_END |
tool event with tool_calls[] | TOOL_CALL_START, TOOL_CALL_ARGS, TOOL_CALL_END, TOOL_CALL_RESULT |
Tool result.metadata (files, widgets) | CUSTOM named parlant.tool_result_metadata |
status of error | RUN_ERROR |
status with narration | CUSTOM named parlant.status |
custom event | CUSTOM named parlant.custom |
Inbound RunAgentInput.state | STATE_SNAPSHOT, echoed at run start |
Two shape differences do most of the work here.
The first is how a tool call is recorded. Parlant records it as one event carrying the name, the arguments, and the result, where the result's data is what the model read and its metadata is frontend-only. AG-UI models the same call as a four-event sequence, so your translator expands each call into those four events.
The second is identity. Parlant tool calls carry no id of their own, so the adapter synthesizes one as <event_id>:<index> and reuses it across all four events.
Note that the translator is stateful within a run, and message streaming is the reason. An agent's output mode decides whether its reply is written into the session piece by piece (stream, the default) or whole (block).
A streaming message arrives as several snapshots. Each snapshot's chunks list grows, and the last one ends with null. So the translator emits one start, one content event per chunk it hasn't sent yet, and one end when that terminator appears. A block-mode message is simpler: start, one content event, end.
Streams and blocks describes the exact shapes the translator is reading.
Generative UI
With text and tool calls flowing through, a widget (a weather card, say) gets onto the frontend like this.
Widgets travel on a tool's frontend-only metadata, exactly as they do for the official widget. A tool is a Python function your agent can call during a turn. It returns a p.ToolResult whose data the model reads and whose metadata only the frontend sees.
An observation is a rule that names a situation in plain language, with no instruction of its own. Giving it tools means the tool becomes callable whenever that situation holds.
In your Parlant app, then, the tool returns the payload and an observation attaches it:
import parlant.sdk as p
@p.tool
async def show_weather(city: str) -> p.ToolResult:
return p.ToolResult(
data=f"Shared a sample weather card for {city}, not live weather.",
metadata={"widget": {"type": "weather_card", "city": city, "temp_c": 22, "condition": "Sunny"}}, # frontend only
)
await agent.create_observation(
condition="the customer asks about the weather in a specific place", # judged against the conversation each turn
tools=[show_weather], # callable when the condition holds
)
Here's the sequence. When the observation matches, the tool becomes available. If the agent calls it, your adapter forwards its metadata to the frontend as a CUSTOM event named parlant.tool_result_metadata, and it arrives before the text of the reply.
On the frontend side, a CopilotKit page renders it through its generative-UI hooks, and a hand-written page renders it from the event's value.metadata.widget.
Rich output covers the metadata channel itself, and Designing policies covers observations.
What the adapter doesn't cover
The adapter design above gives you streaming chat, server-side tool calls, generative UI, custom events, and the run lifecycle. That's enough for most chat UIs.
Note that a few AG-UI features need more integration work:
- Bidirectional shared state. Inbound state is echoed once as a snapshot; there's no live
STATE_DELTAsync. A fuller adapter would map AG-UI state onto Parlant variables, the per-customer values Parlant keeps and shows the agent. - Frontend-defined tools and human-in-the-loop interrupts. Both need a call, pause, client-executes, result round trip, and Parlant tools, which run server-side, don't provide one.
- Streamed tool-call arguments. The adapter sends arguments whole, because Parlant records them complete.
- Dedicated thinking events. Narration maps to a custom event, not to AG-UI's thinking or reasoning families.
- Per-token streaming. If you read the single-event SSE endpoint,
GET /sessions/{id}/events/{event_id}?sse=true, you get finer chunks than the session feed gives. The translator already handles progressive chunks, so that's a source-side change only.
The adapter is the same shape as any channel integration. AG-UI is just the channel's protocol.
Email & other channels builds the general pattern, and the framework repository's AG-UI guide carries the full translator, source, and server code to copy from.