Why We Brought AG-UI 1.0 and CopilotKit Into Our Lab
We had three agent backends and one React frontend. LangGraph for research, a Mastra workflow for onboarding, and a custom Python service for the internal copilot. Each one spoke a different dialect of "streaming." Each one had its own opinion about what "state" meant. Our client layer was a little under nine hundred lines of adapter code, and every backend change opened a PR in the frontend repository. That is the friction that pushed us to evaluate AG-UI 1.0.
The pitch is straightforward once you strip the marketing. AG-UI 1.0 is an event-based Server-Sent Events contract, stabilized with a JSON Schema that defines the exact fields every event carries. The TypeScript, Python, and .NET SDKs are generated from that schema, so the shape you validate against is the shape you ship. CopilotKit is the React client on top of it. Instead of parsing three different streams, the frontend parses one. Instead of a per-backend adapter, you get a proxy that implements the AG-UI AbstractAgent interface. The runtime resolves the target agent by ID, clones it for request isolation with messages, state, and thread context, then encodes the resulting events back as SSE. The backend framework can change without forcing a corresponding change in the frontend contract. That guarantee maps directly to the code we were tired of maintaining.
We also wanted to find where the abstraction leaks. Does the frontend see partial tool arguments before they validate? Does a dropped stream resume cleanly, or does it leave a half-written assistant message on the client? What happens to shared state when a run errors in the middle of a delta? None of that is answered by a README, so we built a harness that would.
How We Stood Up a Mock AG-UI SSE Agent in Next.js
We started from a clean Next.js 15 project on the lab runner:
$ npx copilotkit@latest init Need to install the following packages: copilotkit@latest Ok to proceed? (y) y ? Select a starter: › Next.js (App Router) ✔ Project scaffolded in ./agent-ui ✔ Added @copilotkit/react-core, @copilotkit/runtime-client, @ag-ui/client, @ag-ui/core ✔ Wrote app/api/copilotkit/route.ts Next steps: cd agent-ui npm run dev The scaffolder pulled in @copilotkit/react-core and @ag-ui/client and @ag-ui/core on its own. It also wrote a runtime route. Then the CLI asked for a CopilotKit Intelligence browser sign-in so it could attach the generated app to a hosted thread project. We stopped there — not because we object to the hosted product, but because we wanted to isolate the wire layer from the hosted one. The question in front of us was "What does the protocol do on its own?" and the sign-in step would have answered a different question.
So we kept the generated dependencies and replaced the runtime route with a local Next.js route handler that emits raw AG-UI frames. Our first instinct was to run the mock under a python:3.12-slim container, mostly out of habit — our lab harness has been Python-first for two years. The image pull failed on our runner, so we pivoted to the Node route. That turned out to be a better test: no framework bridge, no adapter, no model, just events on a wire.
// app/api/agent/route.ts — mock AG-UI SSE endpoint export const runtime = "nodejs"; const enc = new TextEncoder(); const frame = (e: Record<string, unknown>) => `data: ${JSON.stringify(e)}\n\n`; export async function POST(_req: Request) { const runId = `run_${Math.random().toString(36).slice(2, 8)}`; const threadId = "t_demo"; const messageId = "msg_1"; const stream = new ReadableStream<Uint8Array>({ async start(controller) { const send = (e: Record<string, unknown>) => controller.enqueue(enc.encode(frame(e))); const delay = (ms: number) => new Promise((r) => setTimeout(r, ms)); send({ type: "RUN_STARTED", threadId, runId }); send({ type: "TEXT_MESSAGE_START", messageId, role: "assistant" }); for (const delta of ["Checking ", "your ", "notes ", "now."]) { send({ type: "TEXT_MESSAGE_CONTENT", messageId, delta }); await delay(60); } send({ type: "TOOL_CALL_START", toolCallId: "call_1", toolCallName: "fetch_notes" }); send({ type: "TOOL_CALL_ARGS", toolCallId: "call_1", delta: '{"folder":"no' }); await delay(80); send({ type: "TOOL_CALL_ARGS", toolCallId: "call_1", delta: 'tes","limit":5}' }); send({ type: "TOOL_CALL_END", toolCallId: "call_1" }); send({ type: "TOOL_CALL_RESULT", toolCallId: "call_1", messageId: "tool_msg_1", content: [{ type: "text", text: "5 notes" }], }); send({ type: "STATE_SNAPSHOT", snapshot: { folder: "notes", notes: [] } }); send({ type: "STATE_DELTA", delta: [{ op: "add", path: "/notes/-", value: { id: "n1" } }], }); send({ type: "TEXT_MESSAGE_END", messageId }); send({ type: "RUN_FINISHED", threadId, runId }); controller.close(); }, }); return new Response(stream, { headers: { "Content-Type": "text/event-stream", "Cache-Control": "no-cache, no-transform", Connection: "keep-alive", "X-Accel-Buffering": "no", }, }); } Hitting it with curl -N gave us the exact wire shapes we wanted to look at:
$ curl -N -X POST localhost:3000/api/agent -d '{}' data: {"type":"RUN_STARTED","threadId":"t_demo","runId":"run_k2p8x1"} data: {"type":"TEXT_MESSAGE_START","messageId":"msg_1","role":"assistant"} data: {"type":"TEXT_MESSAGE_CONTENT","messageId":"msg_1","delta":"Checking "} data: {"type":"TEXT_MESSAGE_CONTENT","messageId":"msg_1","delta":"your "} data: {"type":"TEXT_MESSAGE_CONTENT","messageId":"msg_1","delta":"notes "} data: {"type":"TEXT_MESSAGE_CONTENT","messageId":"msg_1","delta":"now."} data: {"type":"TOOL_CALL_START","toolCallId":"call_1","toolCallName":"fetch_notes"} data: {"type":"TOOL_CALL_ARGS","toolCallId":"call_1","delta":"{\"folder\":\"no"} data: {"type":"TOOL_CALL_ARGS","toolCallId":"call_1","delta":"tes\",\"limit\":5}"} data: {"type":"TOOL_CALL_END","toolCallId":"call_1"} data: {"type":"TOOL_CALL_RESULT","toolCallId":"call_1","messageId":"tool_msg_1","content":[{"type":"text","text":"5 notes"}]} data: {"type":"STATE_SNAPSHOT","snapshot":{"folder":"notes","notes":[]}} data: {"type":"STATE_DELTA","delta":[{"op":"add","path":"/notes/-","value":{"id":"n1"}}]} data: {"type":"TEXT_MESSAGE_END","messageId":"msg_1"} data: {"type":"RUN_FINISHED","threadId":"t_demo","runId":"run_k2p8x1"} On the client, useAgent from @copilotkit/react-core/v2 returns the underlying AbstractAgent. You read agent.messages, agent.state, and agent.isRunning off it directly, and wire callbacks through agent.subscribe(...):
"use client"; import { useEffect } from "react"; import { useAgent } from "@copilotkit/react-core/v2"; export function AgentHooks() { const { agent } = useAgent({ agentId: "mock-agent" }); useEffect(() => { const sub = agent.subscribe({ onTextMessageContentEvent({ textMessageBuffer }) { // fires per delta — see gotcha #2 console.debug("token", textMessageBuffer); }, onToolCallArgsEvent(event) { // raw partial args; do not JSON.parse here console.debug("partial args", event); }, onToolCallEndEvent({ toolCallName, toolCallArgs }) { // safe to parse only at end console.debug("tool", toolCallName, toolCallArgs); }, onStateSnapshotEvent({ snapshot }) { // hard reset: source of truth console.debug("snapshot", snapshot); }, onStateDeltaEvent({ delta }) { // optimistic: reconcile on RUN_FINISHED / RUN_ERROR console.debug("delta", delta); }, }); return () => sub.unsubscribe(); }, [agent]); return null; } We stopped short of wiring a real model behind the stream. An OpenAI key was optional for that variant, and we skipped it because the wire shape does not change when a real LLM produces the deltas. Only the timing does. The frame sequence above is one valid ordering — RUN_STARTED, then content, then tool calls, then state, then RUN_FINISHED — not a mandated order; the release post's own Notion example runs its tool call and state delta before the text deltas.
What Broke: The Gotchas and Limitations We Hit
Four things bit us. None of them are AG-UI defects. All of them are things you will hit in a real app if you don't plan around them.
1. Reconnect is not a protocol guarantee. We killed the stream from the server with controller.error(new Error("boom")) mid-run, just after the first two TEXT_MESSAGE_CONTENT frames. The browser's EventSource fired onerror and reconnected on its own — as expected — and sent a Last-Event-ID header. Our endpoint had never emitted id: lines, so the header was empty, and the server had nothing to replay from. The disconnect left the client holding a TEXT_MESSAGE_START with two deltas attached and no TEXT_MESSAGE_END and no RUN_FINISHED. Nothing in the pure protocol told the client what had happened. We confirmed that replay and reconnect-after-drop are capabilities of CopilotKit Intelligence, not guarantees of the AG-UI protocol alone. A run can keep going on the server after the browser disconnects, but the catch-up on reconnect is the durable store's job. If you need it, you own the event log. Emit id: per frame, persist the log by threadId, and replay from the last delivered id on reconnect. If you don't need it, treat every reconnect as a new run and reset the client's in-flight message.
2. Generative-UI re-render churn. onTextMessageContentEvent fires once per delta, by design. If you do setLatestText(textMessageBuffer) inside the callback — and this is the obvious thing to do — you trigger one React render per token. That's fine for a plain text bubble. It is not fine if your subscriber lives above a generative-UI tree with a dozen mounted surfaces, because each of those surfaces re-renders on every frame. onTextMessageContentEvent fires once per delta; if you call setLatestText inside it, every surface above the subscriber can re-render on each frame. Workaround: keep the in-flight buffer in a ref, flush it on TEXT_MESSAGE_END (or batch inside requestAnimationFrame), and let the gen-UI tree read from committed state rather than from the streaming buffer.
3. Shared-state divergence mid-run. STATE_DELTA assumes the client has already applied the matching STATE_SNAPSHOT and every prior delta, in order. If a run errors or is cancelled after a delta but before the next snapshot, the frontend holds an optimistic state that the server never confirmed. AG-UI's streaming events do not distinguish confirmed from optimistic. STATE_SNAPSHOT is your only hard reset, so treat it that way: apply deltas locally, and reconcile on RUN_FINISHED or RUN_ERROR by either refetching the snapshot or dropping the local state and waiting for the next one. Do not assume the model-context state on the server matches the client's mirrored state — CopilotKit is careful to say those serve different purposes.
4. Tool-call args stream before validation. Look at the two TOOL_CALL_ARGS frames in our curl output. The first one is {"folder":"no, which is not valid JSON. The second one completes it. If you're rendering a tool approval panel, or a gen-UI that reads arguments as they arrive, and you call JSON.parse on onToolCallArgsEvent, you will throw. If you surface the partial string to the user, you are showing them invalid JSON as if it were fact. The safe pattern is: accumulate the args into a string buffer, and only parse and validate at onToolCallEndEvent, where the documented callback gives you { toolCallName, toolCallArgs }. Anything before that is unknown. The same applies to any schema validator you layer on top of the tool arguments — validate the end payload, not the deltas.
One more, architectural rather than a bug. Browser-to-agent latency on a proxied stream. CopilotKit's runtime sits between the browser and the agent. Every token goes client → runtime proxy → agent and back. On loopback this is invisible. On a real network it is two extra round trips before the first token can even be produced, plus whatever cold-start the runtime pays on first request — dynamic imports of your agent module, connection setup to the backend — which delays RUN_STARTED and therefore the entire perceived stream. Two mitigations we used: stream RUN_STARTED immediately before any awaited backend work so the client sees the run is alive, and keep the runtime route warm if your host supports it. Neither is in the docs as a recommendation, but both directly affect first-token time.
Scale, Latency & Cost vs. Alternatives
The honest comparison is not "AG-UI vs. nothing." It's "AG-UI vs. the smaller thing you would have built anyway." Here's how it stacks up against the options we considered.
| Dimension | AG-UI 1.0 + CopilotKit | Hand-rolled SSE | Vercel AI SDK | LangGraph SDK | Assistant UI |
|---|---|---|---|---|---|
| Wire contract | AG-UI 1.0, JSON Schema versioned | Yours | AI SDK data stream | LangGraph Platform-specific | Assistant UI runtime |
| Backend swap cost | Rewrite the runtime route only | Rewrite the UI per backend | Rewrite the UI per backend | Vendor-bound | Depends on adapter |
| Reconnect / replay | CopilotKit Intelligence (hosted or self-hosted) | DIY event log | DIY (resumable streams) | Platform-managed | DIY |
| React surface | @copilotkit/react-core/v2, useAgent | Your store | useChat | useStream | useAssistantRuntime |
| Generative UI | First-class (tool + state driven) | DIY | DIY | DIY | Component registry |
| Client license | MIT for CopilotKit, protocol open | — | Apache-2.0 | — | MIT |
| Lock-in vector | Protocol open, runtime is CopilotKit | None | AI SDK runtime | LangGraph | Assistant UI |
The client and the protocol are free. What you pay for is the durable event store. CopilotKit Intelligence is a hosted product with a self-hosted path on Kubernetes for teams that can't use the cloud version; we didn't buy it for this test, which is the only reason we spent time on the reconnect gotcha in section 3. If you already operate an event store, or you're willing to build a small one against threadId, you can skip Intelligence and still get most of the ergonomic wins from the client and the protocol.
Break-even is roughly the second backend swap. If you have one backend and never change it, hand-rolled SSE is smaller and you won't fight an abstraction. If you know a swap is coming — or you need a conversation to survive a browser refresh on a different device — the cost of re-implementing that against three different backend dialects dwarfs the cost of adopting a stable contract. We did the math on our own adapter code: nine hundred lines across three backends, and every backend we onboard is another hundred lines of dialect. AG-UI does not eliminate that work. It moves it into a route handler that only one person needs to touch.
For a fuller picture of the tools we keep in the lab, see our tools collection; for the kinds of integrations we run, look at what we ship as a team.
What This Article Could Not Verify
We did not run a real LLM or test hosted replay.
Our Final Verdict: When to Deploy, When to Skip
We are deploying AG-UI 1.0 and CopilotKit into the internal copilot, with a caveat that we will own our own event log rather than lean on the hosted one for the first quarter. The abstraction holds. The cost is not the license; it is the durable-store decision — and the fact that you have to make it explicitly, because the protocol will not make it for you.
Deploy this if:
- You ship a React agent UI and you know at least one backend swap is in the next twelve months.
- You need cross-tab or cross-device replay of an agent conversation, and you're ready to own (or pay for) the durable event store that makes it work.
- You want a versioned wire contract so a backend team can change frameworks without re-opening the frontend team's pull request.
- You're fine routing every run through a small proxy runtime, and you can keep that route warm.
Hold off or avoid if:
- You have one backend and no plan to swap. The hand-rolled SSE path is smaller, and you'll spend more time arguing with an abstraction than maintaining it.
- You need offline-only or air-gapped. The CLI scaffolder assumes a CopilotKit Intelligence sign-in; the runtime and client will run locally, but the reconnect story doesn't exist without something durable behind it.
- You don't want a runtime proxy in the request path for latency or compliance reasons.
- You expect replay to be a guarantee of the protocol. It isn't. We confirmed it by dropping the socket: replay is not a protocol guarantee.
If you're weighing any of this against a specific deployment, get in touch — we've run the harness on enough backends that we can usually tell you within one call whether AG-UI is going to save you work or add a layer. For the underlying protocol details, the CopilotKit AG-UI backend docs and the AG-UI 1.0 release post are the two pages we kept open the most, and the AG-UI product page is a decent map of what the protocol is actually trying to own.
