{"slug": "one-ui-any-agent-proving-framework-independence-with-ag-ui-mastra-and-react", "title": "One UI, Any Agent: Proving Framework Independence with AG-UI, Mastra, and React", "summary": "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.", "body_md": "*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.*\n\nAlmost every agent-framework demo hard-wires the UI to one framework. The\n\nfrontend knows about Mastra's (or LangChain's, or the Vercel AI SDK's) response\n\nshapes, so swapping the agent means rewriting the client. That defeats the\n\npurpose of having a protocol.\n\n[AG-UI](https://docs.ag-ui.com) (Agent-User Interaction Protocol) fixes this by\n\ndefining a standard event contract between agents and frontends: `RUN_STARTED`,\n\n`TEXT_MESSAGE_CONTENT`, `TOOL_CALL_START/ARGS/END`, `STATE_DELTA`, `RUN_FINISHED`,\n\n`RUN_ERROR`. If every backend speaks AG-UI, the frontend only needs to speak\n\nAG-UI too — and becomes framework-independent.\n\nThis 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\nunchanged component tree.** Along the way I'll show the exact code, the\n\n```\n┌─────────────────────────────┐      POST /api/run/:backend (SSE)      ┌──────────────────────────────┐\n│  CLIENT :5173               │  ───────────────────────────────────▶  │  SERVER :3101                │\n│  App.tsx → useAgui → agui   │                                        │  /mastra ──┐                 │\n│  (tabs, SSE reader, reducer)│  ◀───────────────────────────────────  │  /minimal ─┴─▶ runPipeline() │\n└─────────────────────────────┘      data: {type: RUN_STARTED…}        └──────────────┬───────────────┘\n                                                                                     │ fetch (streaming)\n                                                                              ┌──────▼──────┐\n                                                                              │ Ollama / LM │\n                                                                              │ Studio / …  │\n                                                                              └─────────────┘\n```\n\nThe invariant that makes the whole demo honest: **both routes funnel through a\nsingle `runPipeline()` function.** The Mastra route constructs a genuine\n\n`Agent` — name, instructions, model — but the AG-UI byte sequence comes from the\n\nshared pipeline. Conformance is structural, not aspirational.\n\nThe client is equally strict: a pure reducer folds the raw event log into the\n\nview. There is no `if (backend === \"mastra\")` anywhere in `client/src`.\n\n`server/src/pipeline.ts` is the heart of the project. Every run opens the same way:\n\n``` js\nconst send = (e: Omit<AguiEvent, \"runId\" | \"timestamp\" | \"backend\">) =>\n  sseSend(res, { ...e, runId, timestamp: now(), backend } as AguiEvent);\n\nsend({ type: \"RUN_STARTED\", message: frameworkNote } as any);\nsend({ type: \"STATE_SNAPSHOT\", snapshot: { task, backend, plan: [], results: [] } } as any);\n\nconst messageId = randomUUID();\nsend({ type: \"TEXT_MESSAGE_START\", messageId } as any);\nfor await (const chunk of streamLLMText(settings, task, isAborted)) {\n  full += chunk;\n  send({ type: \"TEXT_MESSAGE_CONTENT\", messageId, delta: chunk } as any);\n}\nsend({ type: \"TEXT_MESSAGE_END\", messageId } as any);\n```\n\n`streamLLMText()` (`server/src/llm.ts`) calls any OpenAI-compatible\n\n`/chat/completions` endpoint with `stream: true` and yields **real token chunks**.\n\nIf the provider doesn't support SSE, it falls back to a non-streaming call and\n\nre-chunks the genuine response text — the UI still shows real model output\n\narriving incrementally, never canned strings.\n\nThe planning agent's system prompt (in `shared/defaults.json`, shared by both\n\nbackends) demands strict JSON:\n\n```\n{ \"plan\": [\"step1\", \"step2\", \"step3\"],\n  \"steps\": [{ \"tool\": \"<tool-name>\", \"args\": {...} }, ...] }\n```\n\nModels disobey. The pipeline's guardrail: strip code fences, `JSON.parse`, verify\n\n`plan[]`/` steps[]` — and if anything fails, **show the parse error instead of inventing a plan**:\n\n```\nif (!doc) {\n  send({ type: \"STATE_DELTA\",\n         deltaState: [{ op: \"add\", path: \"/parseError\", value: parseError }] } as any);\n  send({ type: \"RUN_ERROR\", code: \"PLAN_PARSE_ERROR\",\n         message: `Model did not return valid plan JSON (${parseError}). ...` } as any);\n  return;\n}\n```\n\nEach validated step then becomes real `TOOL_CALL_START` → two chunked\n\n`TOOL_CALL_ARGS` → `TOOL_CALL_END` (with a deterministically executed toy-tool result) → `STATE_DELTA` sequence, before the final `RUN_FINISHED`. Different tasks produce different plans, hence visibly different tool cards — acceptance criteria without a single fixture file.\n\nMy first implementation checked the abort flag between stream chunks. Testing against a stalled upstream revealed the flaw: **when the LLM `fetch()` hangs, no chunks arrive, so the flag is never checked** — Stop left the spinner running forever (well, until the client's own timeout).\n\nThe fix has two halves. Server-side, the upstream fetch gets an `AbortController`\n\ndriven by a 100 ms poll of the abort registry:\n\n``` js\n// server/src/llm.ts\nconst ctrl = new AbortController();\nconst poll = setInterval(() => { if (isAborted()) ctrl.abort(); }, 100);\n```\n\nand the pipeline maps the resulting `AbortError` to a first-class protocol event:\n\n```\n} catch (e: any) {\n  send({ type: \"TEXT_MESSAGE_END\", messageId } as any);\n  if (isAborted() || e?.name === \"AbortError\") {\n    send({ type: \"RUN_ERROR\", code: \"ABORTED\",\n           message: \"Run cancelled by user (interrupt).\" } as any);\n  }\n  ...\n}\n```\n\nClient-side, Stop tells the server first and only aborts local transport as a backstop 800 ms later — so the genuine server event arrives down the still-open stream:\n\n```\n// client/src/useAgui.ts\nawait fetch(\"/api/run/abort\", { method: \"POST\",\n  body: JSON.stringify({ runId: activeRunId }) });\nsetTimeout(() => abortRef.current?.abort(), 800);\n```\n\nI verified this end-to-end against a black-hole HTTP server: the stream closed with `TEXT_MESSAGE_END` → `RUN_ERROR code: \"ABORTED\"`, and the canvas rendered \"aborted\". The lesson generalizes: **cancellation in streaming systems must be tested against a hung upstream, not just a slow one.**\n\n`client/src/agui.ts` holds `reduceRun()` — ~80 lines folding an event log into a\n\n`RunView` (text + tool cards + state tree + status). `useAgui.ts` reads\n\nSSE-over-POST frames (`data: {...}` split on `\\n\\n`), stamps each with a\n\nwall-clock timestamp and inter-event latency, and feeds the log. `panes.tsx`\n\nrenders the two panes from that single state.\n\nConformance is a type-sequence diff ignoring ids and timestamps:\n\n``` js\nexport function diffSequences(a: string[], b: string[]): string[] {\n  const out: string[] = [];\n  const n = Math.max(a.length, b.length);\n  for (let i = 0; i < n; i++)\n    if (a[i] !== b[i]) out.push(`[${i}] mastra=${a[i] ?? \"∅\"} minimal=${b[i] ?? \"∅\"}`);\n  return out;\n}\n```\n\nEmpty diff → green badge. It's a small thing, but it turns \"framework\n\nindependence\" from a slide into a widget you can watch go green.\n\nThe Settings panel (base URL / API key / model + Particle.ai, LM Studio, Ollama, Gemini presets, persisted to `localStorage`) flows into every run request body — the server holds no keys. **Test connection** does a live `GET {baseURL}/models` and prints the true status and body. Pointing the demo at Ollama (`http://localhost:11434/v1`, `llama3.1`, empty key) requires zero code changes, as the acceptance criteria demand.\n\n`runPipeline()` — the badge should stay green with zero client changes.`reduceRun()` with no server.\nProtocols only matter if you prove the independence they promise. Two backends, one shared emitter, one pure client reducer, and a badge that stays red until the event streams actually match — that's the whole demo, and that's the point: **build to the event contract, and the framework becomes an implementation detail.**\n\nCode & more: [https://www.dailybuild.xyz/project/269-marionette](https://www.dailybuild.xyz/project/269-marionette)", "url": "https://wpnews.pro/news/one-ui-any-agent-proving-framework-independence-with-ag-ui-mastra-and-react", "canonical_source": "https://dev.to/harishkotra/one-ui-any-agent-proving-framework-independence-with-ag-ui-mastra-and-react-3llg", "published_at": "2026-09-30 18:37:19+00:00", "updated_at": "2026-09-30 18:46:39.733698+00:00", "lang": "en", "topics": ["ai-agents", "agent-protocols", "developer-tools", "ai-tools"], "entities": ["AG-UI", "Mastra", "React", "Vite", "Ollama", "LM Studio", "LangChain", "Vercel AI SDK"], "also_reported_by": [], "alternates": {"html": "https://wpnews.pro/news/one-ui-any-agent-proving-framework-independence-with-ag-ui-mastra-and-react", "markdown": "https://wpnews.pro/news/one-ui-any-agent-proving-framework-independence-with-ag-ui-mastra-and-react.md", "text": "https://wpnews.pro/news/one-ui-any-agent-proving-framework-independence-with-ag-ui-mastra-and-react.txt", "jsonld": "https://wpnews.pro/news/one-ui-any-agent-proving-framework-independence-with-ag-ui-mastra-and-react.jsonld"}}