AI agent progress
When an LLM is just responding, you stream tokens. When it's an agent (calling tools, searching the web, reading files, branching into sub-tasks), there's a lot more state to communicate than text deltas. The user wants to know "what is it doing right now?" and "how far along is it?".
This guide pushes structured agent state to the UI in real time using Mercure.
Streaming structured AI agent events over Mercure
For a token stream you push text chunks. For an agent you push events that describe what just happened:
// Streaming Structured AI Agent Events over Mercure { "type": "step.started", "step": "search_web", "input": {"query": "mercure protocol"} } { "type": "tool.called", "tool": "fetch", "url": "https://mercure.rocks" } { "type": "tool.completed", "tool": "fetch", "bytes": 14732 } { "type": "step.completed", "step": "search_web", "results": 5 } { "type": "step.started", "step": "summarize" } { "type": "token", "text": "Mercure is a..." } { "type": "token", "text": " protocol for..." } { "type": "step.completed", "step": "summarize" } { "type": "run.completed", "summary": "..." }
The browser keeps a state machine fed by these events: a status line ("searching the web..."), a step list, partial output. The UI can render whatever fidelity you want (collapsed status pill, full timeline, debug view) without the server needing to know which.
Topics
A run gets its own topic. Subscribe to that topic and you receive everything happening in that run:
// Topics const url = new URL("https://hub.example.com/.well-known/mercure"); url.searchParams.append("match", `https://example.com/runs/${runId}`); const es = new EventSource(url, { withCredentials: true }); const state = { steps: [], output: "" }; es.onmessage = (event) => { const msg = JSON.parse(event.data); switch (msg.type) { case "step.started": state.steps.push({ name: msg.step, status: "running" }); break; case "step.completed": state.steps[state.steps.length - 1].status = "done"; break; case "token": state.output += msg.text; break; case "run.completed": es.close(); break; } render(state); };
A user with several runs in flight (say, a chat with multiple turns or a dashboard of background agents) opens one EventSource and uses match_urlpattern:
// Topics url.searchParams.append("match_urlpattern", "https://example.com/runs/:id");
Now every run the user is allowed to see flows over the same connection. The id field on each event tells you which run it belongs to. Or set the topic per-event and read it from the SSE id.
Publisher: a Python agent
A pseudocode harness for a tool-using agent that emits events as it goes:
# Publisher: a Python agent import json import requests from openai import OpenAI HUB = "https://hub.example.com/.well-known/mercure" PUBLISHER_JWT = os.environ["MERCURE_PUBLISHER_JWT"] def publish(topic: str, event: dict, type_: str = "message") -> None: requests.post( HUB, headers={"Authorization": f"Bearer {PUBLISHER_JWT}"}, data={"topic": topic, "data": json.dumps(event), "type": type_}, timeout=2, ) def run_agent(run_id: str, prompt: str) -> None: topic = f"https://example.com/runs/{run_id}" publish(topic, {"type": "run.started", "prompt": prompt}) client = OpenAI() messages = [{"role": "user", "content": prompt}] while True: publish(topic, {"type": "step.started", "step": "model"}) response = client.chat.completions.create( model="gpt-4o", messages=messages, tools=TOOLS, ) msg = response.choices[0].message publish(topic, {"type": "step.completed", "step": "model"}) if not msg.tool_calls: publish(topic, {"type": "run.completed", "output": msg.content}) return messages.append(msg) for call in msg.tool_calls: publish(topic, { "type": "tool.called", "tool": call.function.name, "args": json.loads(call.function.arguments), }) result = TOOLS_IMPL[call.function.name](**json.loads(call.function.arguments)) publish(topic, {"type": "tool.completed", "tool": call.function.name}) messages.append({ "role": "tool", "tool_call_id": call.id, "content": json.dumps(result), })
Same pattern with Anthropic's tool use, Vercel AI SDK, LangGraph, or your own harness. The events you emit are yours to design.
Per-user run topics
For private runs, scope the topic to the user that owns it:
# Per-user run topics USER_TOPIC = f"https://example.com/users/{user_id}/runs/{run_id}" publish(topic=USER_TOPIC, data=event, private=True)
Each update goes to one topic that embeds the owning user's ID. Only that user is authorized for their own run space, so even if someone guesses the run ID they can't subscribe to it.
This is the per-user authorization pattern applied to agent runs.
What the UI gets for free
Because every event has a Mercure event ID and the hub buffers history:
Reconnect resilience. User closes the laptop mid-run and opens it again; the UI reconnects and replays the events it missed. No dropped progress.
Late join. A second tab opened halfway through a run sees the run from the start (if the buffer is sized for it). Useful for "share this run" links.
Cross-device. A user starts a run on desktop, walks away, and the same run shows up on their phone if it's listening to the same user topic.
Cancel a run
Send a POST from the browser to a small origin endpoint that flips a flag the agent harness checks between steps. The harness publishes a run.cancelled event and exits. There's no direct "cancel this Mercure subscription". Mercure only carries the state, not the control plane.
Backpressure for AI agent event streams
Tool-heavy agents can produce a lot of events (an agent that runs hundreds of small tool calls in a loop will publish thousands of messages). The hub takes them all, but the UI may struggle to render them fast enough.
Two practical mitigations:
Coalesce on the publisher side. Group rapid events of the same type before publishing.
Throttle on the subscriber side. Use
requestAnimationFrameto batch state updates instead of rendering on every message.
Authorization sketch
// Authorization sketch (header: { "alg": "...", "typ": "at+jwt" }) { "iss": "https://example.com", "aud": "https://hub.example.com/.well-known/mercure", "exp": 4102444800, "authorization_details": [ { "type": "https://mercure.rocks/authorization-detail", "actions": ["subscribe"], "topics": [ { "match": "https://example.com/users/42/runs/:runId", "match_type": "urlpattern", }, ], "payload": { "username": "alice" }, }, ], }
The hub assigns each connection a random urn:uuid: subscriber ID; clients can't choose it. For a stable per-user identity across the user's tabs (convenient if you also want to surface presence, see Active subscriptions), put it in the subscribe grant's payload. For instance, "Alice is watching this run" pills on a shared dashboard.
When this is overkill
If your agent finishes in a few seconds and the only thing you'd push is a final result, just await the call from the browser. Mercure earns its keep when:
runs take long enough that users want to see progress, not just wait;
the same agent state needs to reach multiple clients (multi-tab, multi-device, observers);
you're already running an agent worker and don't want to keep request-handling threads tied to it.
Next steps for AI agent streaming with Mercure
LLM token streaming: for the simpler "just stream tokens" case.
Active subscriptions: show who else is watching the run.
Authorization: per-user run gating.