One UI, Any Agent: Proving Framework Independence with AG-UI, Mastra, and React A developer built a single Vite + React client that renders both a real Mastra agent and a roughly 50-line hand-rolled emitter without any client-side changes, using the AG-UI event protocol as the interface between frontends and agents. Both backends funnel through one shared runPipeline() function that emits standard AG-UI events such as RUN_STARTED, TEXT_MESSAGE_CONTENT and TOOL_CALL_START, with a pure reducer folding the raw event log into the view. The project also documents a cancellation bug that nearly broke the demo. How I built a single React client that renders a Mastra agent and a hand-rolled emitter with zero client changes — and the cancellation bug that almost broke the demo. Almost every agent-framework demo hard-wires the UI to one framework. The frontend knows about Mastra's or LangChain's, or the Vercel AI SDK's response shapes, so swapping the agent means rewriting the client. That defeats the purpose of having a protocol. AG-UI https://docs.ag-ui.com Agent-User Interaction Protocol fixes this by defining a standard event contract between agents and frontends: RUN STARTED , TEXT MESSAGE CONTENT , TOOL CALL START/ARGS/END , STATE DELTA , RUN FINISHED , RUN ERROR . If every backend speaks AG-UI, the frontend only needs to speak AG-UI too — and becomes framework-independent. This post walks through a project that proves it: one Vite + React client, two backends a real Mastra agent and a ~50-line hand-rolled emitter , one unchanged component tree. Along the way I'll show the exact code, the ┌─────────────────────────────┐ POST /api/run/:backend SSE ┌──────────────────────────────┐ │ CLIENT :5173 │ ───────────────────────────────────▶ │ SERVER :3101 │ │ App.tsx → useAgui → agui │ │ /mastra ──┐ │ │ tabs, SSE reader, reducer │ ◀─────────────────────────────────── │ /minimal ─┴─▶ runPipeline │ └─────────────────────────────┘ data: {type: RUN STARTED…} └──────────────┬───────────────┘ │ fetch streaming ┌──────▼──────┐ │ Ollama / LM │ │ Studio / … │ └─────────────┘ The invariant that makes the whole demo honest: both routes funnel through a single runPipeline function. The Mastra route constructs a genuine Agent — name, instructions, model — but the AG-UI byte sequence comes from the shared pipeline. Conformance is structural, not aspirational. The client is equally strict: a pure reducer folds the raw event log into the view. There is no if backend === "mastra" anywhere in client/src . server/src/pipeline.ts is the heart of the project. Every run opens the same way: js const send = e: Omit