Four real MCP servers on ephemeral ports, a real agent with session state, and the web client the wheel carries: tokens streaming into the answer, tool calls appearing as they run, receipts landing beside the call they belong to, and a session-state panel whose values are a fetch away rather than on the wire.
The client is not in this directory. It lives in js/agent-ui
and is built into mcp_agent_api, because a deployment should get a usable
chat from installing one package rather than by copying an example. This is
where it is developed and where the wire it renders is exercised.
cd examples/agui-events
# the service: four MCP servers, an agent, the API and the page on :8765
PROVIDER_MODEL=mistral-small-latest PROVIDER_API_KEY=… \
uv run --extra api --with langchain-mistralai python -m serviceThat is the whole thing: open http://127.0.0.1:8765 and the page is served by
the same process, exactly as a deployed container serves it. It needs a built
client — ./scripts/build-js from the repository root, once.
To work on the client, run Vite in front of it instead, which reloads on a keystroke and proxies the API back to the same process:
cd ../../js/agent-ui && npm install && npm run devOne difference to expect there: mcp_agent_api.ui writes the deployment's
configuration into the page as it serves it, and Vite does not. So the dev
page opens on what GET /connections reports rather than on the greeting and
example questions service/app.py sets — which is the fallback every
deployment gets before it configures anything, and worth seeing.
python -m service and uvicorn service.app:app are the same application.
Both are required, and the service will not start without them. The point of this example is a model choosing to call a tool, and nothing scripted stands in for that convincingly enough to be worth the code.
They are the same two settings the CLI reads, from the
environment or a .env. The runtime declares no provider SDK —
init_chat_model is provider-agnostic and a deployment installs the driver it
uses — so the --with above is how this example gets one. Any other provider
works the same way with its own package: openai:gpt-4o-mini with
langchain-openai, and so on.
--extra api is not optional. mcp_agent_api ships in the base wheel, so it
imports far enough to look installed, but the AG-UI event vocabulary it is
built on lives in that extra — without it the service dies on No module named 'ag_ui'. A checkout developed with uv sync --all-extras already has it and
will not show this; a clean one, where uv run builds the environment from
this command alone, will.
service/ is laid out the way a deployment of this runtime is, because that is
the more useful thing for it to be. create_app supplies the agent's five
routes, its lifespan and CORS; the service supplies the build factory it
awaits, and adds the health probes an orchestrator wants. Connecting to the MCP
servers happens inside that factory, so the routes really do answer 503 until
they are up — which is what /health/readiness reports and /health/liveness
deliberately does not.
Ask for something the toolsets can do:
| find rainfall datasets and clip chirps to that area | the full turn: two tool calls, a publish, a receipt, a ui:// view |
| smooth the contours | a NotAuthored parameter nothing has published for — the refusal, and the model reading it |
| sketch a rough boundary around the Severn catchment and call it Severn | the other side of it: an untagged parameter, so the model writes the polygon, and the panel shows you what it wrote |
| a few paragraphs about anything | enough tokens to watch them land |
Session state persists across turns on the thread, so the second time you ask
for a clip, the model passes @state:<key> for a geometry an earlier turn
published — the payload is fetched by the panel, never re-sent.
sketch_area publishes under one key, so sketching twice means the second
boundary replaces the first — the ordinary shape of session state, and the
thing that makes "how does this compare with the one you found first"
unanswerable from state alone. Three turns show it:
sketch a rough boundary around the Severn catchment and call it Severn
now sketch one around the Thames and call it Thames
read sketch-ops/sketch_area/boundary out of session state and tell me what it holds
The third read returns the Thames boundary and, after it, a line the model has no other way to learn:
[2 turns of this conversation wrote this key, so an earlier one may hold a
different value; the above is the latest. Pass turn=<n> for the value a turn
ended with, counting the user's questions from 1.]
Ask for the Severn one and the model calls inspect_state(key, turn=1), which
resolves against the checkpoints service/agent.py already passes in. Nothing
extra is stored for this: every past tool_state is in the thread's
checkpoints, and the tool is only reading them.
Two things are worth watching for rather than assuming.
A handle still resolves to the present. Ask "what is the bounding box of
the Severn boundary you drew first, and of the Thames one?" and a model will
reasonably pass @state:sketch-ops/sketch_area/boundary to describe_geometry
twice — @state: names a key and nothing else, so both calls get the Thames
box. What it has to go on is the breadcrumb from the turn that overwrote the
key, which named turn 1 there and then; recovering means reading turn 1 rather
than passing a handle. Watch what it does next: noticing the collision is not
the same as recovering from it.
A small model needs the nudge. With mistral-small the note above lands and
is often not acted on until the question names the earlier turn. A capable model
follows it unprompted; the prompt fragment (SESSION_STATE_PROMPT) is what asks
it to.
Yes, and the client proves it rather than claiming it. chat.tsx builds an
HttpAgent from @ag-ui/client — the protocol's own reference client — and
renders from agent.messages. That matters because HttpAgent runs the stream
through verifyEvents and defaultApplyEvents on the way in: if this server
emitted something the protocol disallows, or in an order it forbids, the run
would fail in the client rather than render wrongly. Nothing here parses SSE by
hand.
A hand-rolled reader could only show that a format is self-consistent.
RUN_STARTED / RUN_FINISHED / RUN_ERROR |
run lifecycle |
TEXT_MESSAGE_START / CONTENT / END |
the answer, streamed |
TOOL_CALL_START / ARGS / END / RESULT |
the tool-call lifecycle |
MESSAGES_SNAPSHOT |
the thread as the server holds it, closing each run |
STATE_DELTA |
the state channel |
ACTIVITY_SNAPSHOT |
a first-class message role in AG-UI, which is what receipts ride |
Those are the only event types this server emits, out of the 33 AG-UI defines.
No STEP_*, REASONING_*, CUSTOM or RAW. Every message event is a
snapshot, complete in itself, because an activity delta for an unknown
messageId is dropped silently by the client and would make the wire depend on
a patch having applied. State is the exception, and deliberately: a state
snapshot replaces the whole shared state object, including the keys the client
keeps in it, so state moves by patch — see below.
The activityType values are ours. AG-UI standardises the envelope, not the
vocabulary. There are four — state.consumed, state.published, mcp.view,
answer.citations — and a client wanting to
style them switches on those strings. A client that does not know them is not
stuck: every activity carries a display line, generated by the
mcp_agent.host helpers, so printing content.display is a complete
implementation. This is the one place where reading the docs is unavoidable, and
it is deliberate: getCapabilities() is not implemented, so the vocabulary
is documented rather than advertised.
STATE_DELTA carries metadata, not state. This is the sharpest
difference. AG-UI's state channel is normally the agent's actual state, and
clients render or patch it wholesale. Here each key carries only
{tool, bytes, seq, inputs} — never the value, because the values are exactly
what session state exists to keep out of the conversation. Fetching one is
GET /threads/{id}/state/{key}, which is outside the protocol entirely. A stock
client showing "state" will show sizes and think it has everything.
The metadata sits under a toolState key rather than at the root of the
state object, and every operation names a path inside it — add of the whole
namespace to open a run, then add and remove of one key each. So whatever
the client keeps in state beside toolState survives a run untouched, which
is the reason this one channel is patched rather than snapshotted. chat.tsx
applies them in twelve lines; the only trap is RFC 6901 escaping, since a state
key is toolset/name and / is the pointer's own separator.
History is the server's. AG-UI's convention is client-authoritative:
RunAgentInput.messages is the conversation, and the client owns it.
HttpAgent duly posts its whole array every turn — and this server reads the
trailing user message and discards the rest, because the values kept out of the
model's context would otherwise have to live in the browser and be posted back.
The consequence for a consumer is concrete: mutating agent.messages does not
edit the thread. Editing a message, branching, or dropping a turn are
client-side illusions here.
The server says so on the wire: a MESSAGES_SNAPSHOT closes every
run, carrying the thread as the server holds it, so a client that had diverged
is corrected rather than drifting.
It closes the run rather than opening it, and that matters. A snapshot is applied by dropping every local message it does not name — sent up front, before the turn is checkpointed, it would take the question the user just typed off the screen and leave the answer under nothing. At the end everything the turn produced is in the thread.
Ids line up on purpose. The question keeps the id the client gave it, and
the answer is labelled with the id the thread will store, taken off the
provider's own stream. So the snapshot reconciles a client's list in place
instead of dropping every message and re-appending the server's — which would
leave activities stranded at the top. One caveat the server enforces for you: an
id the thread already holds is not reused, because the message reducer matches on
id and would replace that message rather than add one.
GET /threads/{id} remains the way to read a thread without running one — and
this client uses it. The thread id is in the URL as ?thread=, so
reloading the page brings the conversation back rather than starting a fresh
one. Two routes rebuild it: /threads/{id} for the transcript and
/threads/{id}/turns for what state held at the end of each turn, joined on the
question, since the stream carries no turn boundary a reloaded client could have
seen.
The activities come back too — receipts, publications, views and citations —
because an activity is a message in AG-UI, so /threads/{id} carries them
beside the calls they belong to. They are the same payloads the stream sent,
through the same builders, so a receipt cannot read one way live and another
after a reload. The cross-highlighting works on a restored thread with no
special case.
Two things a reload cannot bring back, and the server degrades rather than
guesses: a turn whose checkpoints have been pruned has no state to describe
its receipts against, so its view is not rebuilt; and a ui:// bundle comes
from the deployment as it stands, so a tool removed since has no view rather
than a dangling URI.
Views are a second protocol. mcp.view names a ui:// URI and carries the
tool's structured content; the HTML comes from GET /views/{toolset}/{view},
and feeding it is the MCP Apps ui/* postMessage protocol, not AG-UI. The
client answers the handshake itself rather than through
@modelcontextprotocol/ext-apps: the same four messages, hand-written, so the
client carries no second protocol library for the one frame it opens.
One ordering guarantee beyond the spec. A tool call's activities are all
emitted before the answer's TEXT_MESSAGE_START. The protocol permits them
later, but a message's position is fixed when it is created, so an activity
sent during the answer renders after it — a receipt explaining a tool call would
appear below the conclusion it supports. Citations are the deliberate exception:
they belong after the answer and are only known then.
Tokens arrive as tokens. The answer is written into the page a word at a time, with a caret where the next one lands. Nothing is waiting for the turn to finish — the same stream carries the tool calls before it.
Nothing is correlated client-side. The renderer maps over agent.messages
in order and that is the whole layout — no lookups by tool call id anywhere. An
activity is a message in AG-UI, so the library's own pipeline has already put
each receipt where it belongs, and the server emitted it before the answer's
text message opened so it cannot land after the answer it explains.
The heavy value is never on the wire. The state channel carries
{tool, bytes, inputs} per key. The panel renders inputs in full — each
argument of the producing call, and whether it came from the model or from
another key. The state-sourced half is what makes the chain walkable: every key
it names is another row in the same panel, one click away. Dropping it (as the
model-facing listing does, where every line costs context) would leave the
panel unable to answer "what does this rest on" without scrolling back to a
call that has long gone.
inputs carries names and keys, never values — a model-authored argument can
be arbitrarily large, the state channel is re-sent every turn, and nothing
filters an argument the way BLOCKED_KEY_PATTERN filters a captured field. So
the value is not on the wire.
The panel shows it anyway, because it does not need the wire to: it joins
state.published's toolCallId back to the call in the transcript it already
holds (producedArguments in the client), and reads the argument off that.
A value sits on a chip after an =, against the ← that marks one which came
from another key: both halves are monospace, so without the marks
dataset_id chirps reads as a single token. Long values fold into a
<details> — sketch_area is in the deployment
precisely so there is one to fold, since a model asked to draw a boundary
writes a few hundred characters of polygon into an untagged parameter, which is
the case worth seeing and the one that would otherwise fill the panel.
It normalises what it stores rather than echoing it — closing the ring,
rounding, and stamping the name — which is what any real geometry tool does
and what keeps the card from showing the same polygon as both its value and
its input. It is also the case that defeats inferring provenance from a
return: the normalised boundary looks derived, the same tool without the
rounding looks like a passthrough, and the record is of what the call was
given precisely so nothing has to tell those apart. The right-hand panel is built from that; clicking
a key fetches GET /threads/{id}/state/{key} and shows the 39 kB geometry that
the transcript never held.
A minimal client can print display and stop. Every activity carries a
rendered line beside its fields, generated by the mcp_agent.host helpers, so
the wire and the bundled UI cannot drift. js/agent-ui/src/chat.tsx prints it,
and a client with opinions reads key and tool instead.
No CORS anywhere. Vite proxies /api to the Python process, so the browser
sees one origin. CORS belongs to mcp_agent_api.app, not to the router.
js/agent-ui/src/chat.tsx |
the client: @ag-ui/client's HttpAgent, rendered from agent.messages |
js/agent-ui/src/agui.ts |
the reads AG-UI has no channel for: state values, a thread, what is connected |
js/agent-ui/src/config.ts |
what a deployment gets to say about the page without rebuilding it |
service/app.py |
create_app, plus the example's own opening-screen text |
service/agent.py |
the build factory the lifespan awaits |
service/model.py |
the provider, from PROVIDER_MODEL and PROVIDER_API_KEY |
service/servers.py |
the four MCP servers — the example's stand-in for a deployed index |
service/settings.py |
one BaseSettings, read once |
toolsets/clip_view |
the session-state example's clip_raster, plus VIEWS so mcp.view has a real ui:// to report |
toolsets/contour_ops |
takes a value nothing here publishes, so its calls are refused |
toolsets/sketch_ops |
leaves a structured parameter open, so the model writes the value itself |
The other two servers are examples/session-state's,
imported off the path: dataset-search, which publishes a 38 kB area of
interest, and terrain, a raw MCPServer that declares nothing at all.
Everything collapses: a tool call opens to its arguments and full result, an
activity to its structured content, and mcp.view to the bundle itself in an
iframe. The summary line is the event, so the shape of a turn is readable
without expanding anything.
The client does: js/agent-ui builds into mcp_agent_api/ui, and
create_app serves it. So a deployment installing mcp-toolsets-runtime[api]
has this page over its own toolsets, configured by MCP_AGENT_UI_* rather than
rebuilt.
The service does not. This directory is outside src/, so it reaches
nobody who installs the package: it is four MCP servers and a build factory,
here to be read and run from a checkout. A deployment writes its own, or uses
mcp_agent_api.app's default factory and writes none.