Skip to content

Latest commit

 

History

History

agui-events

Folders and files

NameName
Last commit message
Last commit date

parent directory

..
 
 
 
 
 
 

README.md

A deployment of mcp_agent_api, and the client that ships with it

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 service

That 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 dev

One 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.

Comparing a value with its own earlier version

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.

Is this really AG-UI?

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.

Standard, and free with any AG-UI client

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.

What a consumer has to know that the protocol does not tell it

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.

What to look at

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.

The files

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.

What ships and what does not

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.