Turn an agent from a background batch job into a streaming, interruptible collaborator that renders its own UI — by mastering the AG-UI event protocol, CoAgents state sync, and human-in-the-loop approvals.
An agentic frontend is the contract that turns a long-running, nondeterministic agent into something a human can watch, steer, and trust. The model is a stateless token generator; the harness around it streams partial reasoning, executes tool calls, renders UI, and pauses for approval. AG-UI is the open event protocol (backed by Google, LangChain, AWS, Microsoft, Mastra, PydanticAI) that carries this two-way traffic: agent-to-frontend events (tokens, tool calls, state diffs, UI specs) and frontend-to-agent events (approvals, edits, redirection). CopilotKit/CoAgents is the React implementation: it streams agent state into your app and lets the agent trigger frontend actions you defined. The mental shift is the same one that happened with chat APIs years ago — but now the unit of streaming isn't tokens, it's typed state: the UI and the agent share a single, conflict-resolved, event-sourced state object.
The "product contract" is the new frontier. Anyone can call an LLM in a loop; the senior signal is whether you understand the interface between a nondeterministic process and a human who is accountable for what it does.
The words first.
Step by step.
Remember this: the frontend isn't displaying the agent's output — it shares the agent's state over a stream of typed events, and that shared state is the product contract.
A REST endpoint assumes a short, deterministic, single-shot exchange: client sends, server computes, server returns, connection closes. Agents violate every assumption:
AG-UI answers this with an event-based architecture, not request/response. The transport is a stream (SSE or WebSocket); the payload is a sequence of typed events. The canonical families:
TEXT_MESSAGE_START / TEXT_MESSAGE_CONTENT / TEXT_MESSAGE_END: token streaming.TOOL_CALL_START / _ARGS / _END / _RESULT: real-time tool-execution feedback so the UI can show "searching the database…".STATE_SNAPSHOT (full) and STATE_DELTA (diff): state synchronization via event-sourced diffs with conflict resolution.The key word is typed. The handoff from agent to frontend-executed action and back is type-checked, which is what makes the agent a collaborator (it can safely delegate UI actions to the client) instead of a black box that emits a wall of text.
The deepest idea is that the agent and the UI don't exchange messages — they share one state object, synchronized by diffs. The agent holds the authoritative copy (its working memory: the plan, the draft document, the search results); the frontend holds a replica it rebuilds from STATE_SNAPSHOT + a stream of STATE_DELTA events.
This is event sourcing applied to the agent's mind. A STATE_DELTA is a small patch (a JSON Patch / RFC-6902 op like {op: "replace", path: "/draft/title", value: "Q3 Plan"}). The frontend applies it and re-renders only what changed. Because both sides can mutate state (the agent writes the draft; the human edits a field), you need conflict resolution: when the human's optimistic edit and the agent's incoming delta touch the same path, a defined policy (last-write-wins, agent-authoritative, or merge) decides. Getting this wrong is the #1 source of "the UI flickered and lost my edit" bugs.
Name each symbol in plain words: state is the shared object both sides hold; delta is one patch op the agent streams; path is where in the object to apply it; version is a monotonic counter to order patches.
Start: frontend replica is {draft: {title: "", body: ""}, version: 7}.
STATE_DELTA {op: "replace", path: "/draft/title", value: "Q3 Plan", version: 8}. Frontend checks 8 == 7 + 1 so it applies in order, replica becomes {draft: {title: "Q3 Plan", body: ""}, version: 8}. Only the title <input> re-renders.body: "Goals" but stamps it as pending (no version bump yet).STATE_DELTA {op: "replace", path: "/draft/body", value: "Objectives", version: 9} — a conflict on /draft/body. Policy = agent-authoritative-with-notice: replica takes "Objectives", the pending human edit is surfaced as a diff toast ("agent overwrote your change — keep yours?").What it did to the data: instead of re-sending the whole draft object twice, two ~40-byte patches kept a live React form in sync with the agent and made the one genuine conflict explicit instead of silently clobbering it.
Two capabilities turn a viewer into a collaborator.
Frontend actions are client-side tools. You register a function in the browser — highlightRow(id), openInvoice(id), navigate(route) — and it surfaces to the agent as an available action as the page mounts it. The agent calls it like any other tool, but execution happens in the user's browser with the user's session and permissions. This is the typed handoff: the agent reasons, the client acts. CopilotKit's CoAgents wires this so frontend actions appear to the LangGraph agent dynamically as components render.
Generative UI is the agent choosing what to render. Instead of fixed developer-defined screens, the agent emits a component spec and the UI builds it at runtime. There are three patterns, and the difference is entirely about the trust boundary:
{component: "Chart", props: {...}}); your frontend maps it to a native, pre-vetted React component. The agent picks which component and what data; it can never inject markup. Safest, least expressive.ui:// URIs. Richest interactivity (full HTML/JS) but requires host orchestration and an iframe sandbox as the trust boundary.The rule of thumb: the more rendering freedom you give the agent, the harder the sandbox you need. Controlled UI trusts your component library; open-ended UI trusts an iframe origin policy. Never let an agent's free-text output reach dangerouslySetInnerHTML — that's prompt-injection-to-XSS in one hop.
CopilotKit's stated design philosophy is blunt: agents won't lead to full automation for most use cases — humans remain deeply in the loop. The frontend's job is to make oversight cheap. AG-UI provides three primitives:
The non-negotiable invariant: the side effect must be gated by the resume event, server-side. The agent emits the intent to delete; it does not execute, then ask forgiveness. Execution is a separate node that only runs after the frontend posts the approval back into the run. If your "approval" is a UI affordance that the agent has already bypassed, you have theater, not control.
A runnable sketch of the contract: a Python agent (server) streaming AG-UI-style events including a human-in-the-loop interrupt, and the React glue. The agent proposes a destructive action, pauses, and resumes only on an approval event.
# server.py — minimal AG-UI-style event stream with a HITL approval gate.
# pip install fastapi uvicorn sse-starlette
import asyncio, json, uuid
from fastapi import FastAPI, Request
from sse_starlette.sse import EventSourceResponse
app = FastAPI()
# Authoritative state + pending approvals live SERVER-SIDE, never trust the client copy.
RUNS: dict[str, dict] = {}
def ev(t, **data): # one typed AG-UI event
return {"event": t, "data": json.dumps(data)}
async def run_agent(run_id: str, prompt: str):
st = RUNS[run_id]
yield ev("RUN_STARTED", run_id=run_id)
# 1) stream a tool call the UI can render live
tc = str(uuid.uuid4())
yield ev("TOOL_CALL_START", id=tc, name="search_rows")
await asyncio.sleep(0.2)
yield ev("TOOL_CALL_RESULT", id=tc, result={"matched": 1843})
# 2) push a state diff (event-sourced; frontend patches its replica)
st["version"] += 1
yield ev("STATE_DELTA", version=st["version"],
ops=[{"op": "replace", "path": "/candidates", "value": 1843}])
# 3) HITL: emit the INTENT, then PAUSE. We do NOT execute yet.
approval = asyncio.Event()
st["pending"] = {"action": "DELETE", "sql": "DELETE FROM rows WHERE stale=true",
"rows": 1843, "approval": approval, "decision": None}
yield ev("INTERRUPT", reason="approval_required",
payload={"action": "DELETE", "rows": 1843,
"sql": st["pending"]["sql"]})
await approval.wait() # blocks the run until the human responds
decision = st["pending"]["decision"]
if decision != "approve":
yield ev("TEXT_MESSAGE_CONTENT", delta="Rejected by human. No rows deleted.")
yield ev("RUN_FINISHED", run_id=run_id); return
# 4) ONLY now does the side effect run — gated by the resume event.
await asyncio.sleep(0.3) # actually execute the delete here
yield ev("TEXT_MESSAGE_CONTENT", delta="Deleted 1843 stale rows.")
yield ev("RUN_FINISHED", run_id=run_id)
@app.post("/run")
async def start(req: Request):
body = await req.json()
run_id = str(uuid.uuid4())
RUNS[run_id] = {"version": 0, "pending": None}
async def gen():
async for e in run_agent(run_id, body["prompt"]):
yield e
return EventSourceResponse(gen())
@app.post("/run/{run_id}/resume")
async def resume(run_id: str, req: Request):
body = await req.json() # {"decision": "approve" | "reject"}
p = RUNS[run_id]["pending"]
p["decision"] = body["decision"]
p["approval"].set() # unblock run_agent()
return {"ok": True}What matters here, not the framework specifics: (1) events are typed and streamed — the UI can render TOOL_CALL_START as a live spinner and STATE_DELTA as a patch, with no polling. (2) State is server-authoritative — RUNS holds the truth; the client's replica is rebuilt from deltas. (3) The approval gate is real — await approval.wait() physically blocks execution; the DELETE line is after the gate. The browser cannot make it run early because the side effect lives in a code path the client never reaches without posting /resume.
On the React side, CopilotKit collapses this to useCoAgent({ name, initialState }) for the shared state replica and useCopilotAction({ name, handler, renderAndWaitForResponse }) to register frontend actions and approval cards. The renderAndWaitForResponse pattern is the client mirror of the server's approval.wait(): it renders the interrupt UI and resolves the promise only when the user clicks.
| Dimension | Controlled (AG-UI typed) | Declarative (A2UI/JSON) | Open-ended (MCP Apps iframe) |
|---|---|---|---|
| Trust boundary | Your vetted component map | Data-only render tree | iframe sandbox + origin policy |
| Expressiveness | Low (fixed catalog) | Medium (compose nodes) | High (full HTML/JS) |
| XSS / injection risk | Minimal | Low (no script exec) | Real — sandbox is the only defense |
| Latency to first paint | Lowest | Low | Higher (iframe + template fetch) |
| Best for | Dashboards, forms, charts | Dynamic layouts | Rich apps, third-party widgets |
Cost & latency. Streaming adds a persistent connection per active run — cheap per-connection but it changes your scaling shape from stateless request/response to stateful long-lived sessions (sticky routing, connection limits, backpressure). The bigger cost is human: HITL trades wall-clock latency and operator attention for safety. Every approval is a context switch for a person; gate only the genuinely irreversible actions or you'll train operators to rubber-stamp (approval fatigue defeats the control).
Quality / failure modes. The dominant bugs are state-layer, not model-layer: (1) State drift — client replica diverges from agent truth because a STATE_DELTA was dropped or applied out of order; fix with version counters and periodic STATE_SNAPSHOT resync. (2) Interrupt races — agent emits a second action while the human is still deciding on the first; serialize interrupts per run. (3) Optimistic-edit clobber — human edit overwritten by an incoming delta with no conflict policy. (4) Generative-UI injection — agent output flows into raw HTML; only the iframe/typed-component boundary saves you. (5) Approval theater — the gate isn't actually server-side, so the agent acted before the human saw it.
What changes at scale. Recursive composition (sub-agents spawning sub-agents) multiplexes many event streams into one UI; you need a run-tree and stable IDs so a deep tool call updates the right card, not a random one. Reconnection becomes mandatory: a 10-minute run survives a dropped socket only if you can resume from the last acknowledged version. This connects directly to /context-engineering — the state you stream to the UI and the context you keep in the agent's window are two views of the same working memory, and they must not diverge.
DELETE.
The agent emits the intent as a typed INTERRUPT event carrying the exact SQL and row count, then pauses on a server-side checkpoint — it does not execute. Authoritative state and the pending-approval record live server-side (the client's view is a replica rebuilt from deltas). The UI renders a structured approval card from the typed payload and posts the decision to a /resume endpoint. The destructive code path runs only after the resume event unblocks the run, so the browser physically cannot trigger the delete early — the side effect lives behind the gate. Add: idempotency keys on the delete, a hard timeout that defaults to reject, an audit record of who approved what, and serialized interrupts so a second action can't slip past while the human decides.ui:// URIs — richest interactivity but the iframe sandbox and origin policy are your only defense, so it's the real attack surface. Pick controlled for dashboards/forms where you know the catalog, declarative for dynamic layouts, open-ended only when you genuinely need third-party-grade interactivity and can afford to harden the sandbox. The rule: more rendering freedom demands a stronger sandbox.highlightRow, openModal, navigate) that surfaces to the agent as an available action as the page mounts. The agent calls it like any tool, but it executes in the user's browser with the user's session and permissions. This is the typed handoff that makes the agent a collaborator: it reasons server-side, then delegates the act to the client, so it can manipulate the live UI the user is looking at instead of just describing what to do.STATE_SNAPSHOT plus a stream of STATE_DELTA JSON-Patch ops, each carrying a monotonic version. The frontend applies deltas in version order and re-renders only the changed path. Failures: dropped or out-of-order deltas cause drift (fix with version gaps triggering a snapshot resync), and concurrent human edits collide with incoming deltas (fix with an explicit conflict policy — agent-authoritative-with-notice, last-write-wins, or merge — never silent clobber). Periodic snapshots bound how far a replica can drift before self-healing.STATE_DELTA vs. optimistic edits means the user's typing vanishes. Always define merge behavior and surface conflicts.dangerouslySetInnerHTML is prompt-injection-to-XSS. Use typed components or sandboxed iframes — never raw markup from the model.Flashcard. AG-UI isn't a UI library — it's the typed event protocol (text, tool, state-delta, interrupt) that lets agent and frontend share one event-sourced state object, so a nondeterministic long-running process becomes watchable, steerable, and gateable.
useCoAgent / useCopilotAction.Next: /harness/evaluation-driven-development — once your frontend can stream and gate an agent, how do you prove the whole harness actually works against benchmarks like SWE-EVO before you ship it.