Agentic Frontends & Harness Engineering
IC4IC5

Agentic Frontends: AG-UI, CoAgents & Generative UI

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.

15 min read · 12 sections
0

1. Quick anchor

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.

2. Why interviewers probe this

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.

  • IC4 — Can you explain why a long-running, streaming, human-in-the-loop process can't be modeled as REST request/response? Can you name the four hard problems (long-running, nondeterministic control, mixed I/O, recursive composition) and wire up a streaming UI that shows tool calls and partial output?
  • IC5 — Can you design the trust boundary? Where does authoritative state live, how do you reconcile optimistic UI with the agent's view, how do approvals gate side effects, and how do you prevent an agent from acting before a human says yes? Can you reason about generative UI as an attack surface and pick the right rendering pattern for a given risk profile?
  • Both — Honesty about failure modes: state drift between client and agent, race conditions on interrupt, partial-render flicker, and the cost of keeping a human in the loop (latency, attention budget). The strong candidate treats the frontend as a reliability layer, not decoration.

3. Concept build-up

Beginner explainerNew here? The words first

The words first.

  • Agentic frontend — the UI layer that lets a human watch and control a running agent in real time, instead of waiting for a final answer.
  • AG-UI — an open standard for the events that flow between an agent and a frontend (think "the WebSocket protocol for agents").
  • Event-sourced state — instead of sending the whole state every time, you send small diffs (what changed) and rebuild state by replaying them.
  • Frontend action — a function defined in the browser (e.g. "highlight this row", "open this modal") that the agent is allowed to call.
  • Generative UI — the agent decides which UI components to render at runtime, instead of a developer hard-coding every screen.
  • Human-in-the-loop (HITL) — the agent pauses and waits for a person to approve, edit, or reject before continuing.
  • Interrupt / steering — the human redirects or pauses the agent mid-task without losing its context.
  • CoAgents — CopilotKit's feature for streaming a (LangGraph) agent's state into a React app and back.

Step by step.

  1. The user types a request; the frontend opens an event stream to the agent.
  2. The agent streams text tokens and tool-call events as it works — the UI updates live.
  3. The agent emits state diffs; the frontend patches its shared state object and re-renders.
  4. The agent hits a risky step and emits an interrupt (e.g. "approve this delete?").
  5. The UI shows an approval card; the user clicks approve/edit/reject.
  6. The frontend sends that decision back as an event; the agent resumes with it.
  7. The agent can also emit a UI component spec, and the frontend renders a real, interactive widget.

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.

3.1 Why request/response dies here

A REST endpoint assumes a short, deterministic, single-shot exchange: client sends, server computes, server returns, connection closes. Agents violate every assumption:

  1. Long-running — an agent may run for minutes across dozens of tool calls. A 200-second HTTP request is a timeout, a spinner, and a furious user.
  2. Nondeterministic control flow — the agent decides at runtime which tools to call and which UI to show. The server, not the client, is dynamically shaping the interface. REST inverts this: the client drives.
  3. Mixed I/O — the same turn carries structured tool calls, unstructured text, state diffs, and (with voice) audio. One JSON response body can't model an interleaved stream of all four.
  4. Recursive composition — sub-agents spawn sub-agents, each producing its own stream of events that must be multiplexed into one coherent UI.

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 eventsTEXT_MESSAGE_START / TEXT_MESSAGE_CONTENT / TEXT_MESSAGE_END: token streaming.
  • Tool eventsTOOL_CALL_START / _ARGS / _END / _RESULT: real-time tool-execution feedback so the UI can show "searching the database…".
  • State eventsSTATE_SNAPSHOT (full) and STATE_DELTA (diff): state synchronization via event-sourced diffs with conflict resolution.
  • Lifecycle / custom events — run started/finished, plus extension points for your own protocol additions.

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.

3.2 Shared state as the contract

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.

STATE_DELTA reconciliation — on real numbers

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}.

  1. Agent streams 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.
  2. Meanwhile the human typed into the body box: optimistic local edit sets body: "Goals" but stamps it as pending (no version bump yet).
  3. Agent streams 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.

3.3 Frontend actions and generative UI

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:

  1. Controlled (AG-UI) — the agent sends a typed component specification ({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.
  2. Declarative (A2UI / Open-JSON-UI) — the agent sends a structured UI description as data (a tree of nodes), rendered natively and composable at runtime. More expressive than a fixed component map, still data-not-code, so no script execution.
  3. Open-ended (MCP Apps) — pre-built HTML templates served in sandboxed iframes, referenced by tools via 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.

3.4 Human-in-the-loop: interrupts and approvals

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:

  • Interrupts — the agent pauses, emits the partial state, and waits. The human can approve, edit the proposed action, or reject — without losing context, because the agent's state is checkpointed, not discarded.
  • Agent steering — the human injects a new instruction mid-run ("actually, only the EU rows") and the agent re-plans from current state.
  • Typed approvals — the pause carries a typed payload (the exact SQL, the exact API call), so the UI can render a structured approval card, not a "click yes" with no detail.

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.

4. Minimal implementation

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-authoritativeRUNS holds the truth; the client's replica is rebuilt from deltas. (3) The approval gate is realawait 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.

5. Production tradeoffs

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.

6. How it's asked

[IC4] Why an event-streaming protocol instead of REST, and what breaks with REST? REST assumes a short, deterministic, single-shot exchange where the client drives. Agents are long-running (minutes, dozens of tool calls — REST just times out), nondeterministic in control flow (the agent decides which UI to show, inverting REST's client-driven model), mixed-I/O (text, tool calls, state diffs, audio interleaved in one turn — can't fit one response body), and recursively composed (sub-agents multiplex streams). AG-UI uses a typed event stream so the UI can render partial reasoning, live tool calls, and state diffs incrementally instead of waiting for one final blob. With REST you get a spinner, a timeout, and no way to interrupt mid-run.
[IC5] Design HITL approval for an agent that can run production 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.
[IC5] Compare controlled vs. declarative vs. open-ended generative UI on the trust boundary. Controlled (AG-UI) sends typed component specs mapped to your vetted React components — the agent chooses which component and what data but can never inject markup; safest, least expressive, trust boundary is your component library. Declarative (A2UI/JSON) sends a structured render tree as data — more composable, still no script execution, low injection risk. Open-ended (MCP Apps) serves HTML templates in sandboxed iframes referenced by 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.
[IC4] What is a "frontend action" and why is it powerful? It's a client-side tool — a function registered in the browser (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.
[IC5] How do you keep the client's UI state in sync with the agent's state, and what fails? The agent holds authoritative state; the frontend rebuilds a replica from an initial 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.

7. Pitfalls & flashcards

  • Approval theater. If the agent can execute before the human's resume event reaches the server, your approval UI is decoration. The side effect must live behind the gate, server-side.
  • Silent state clobber. No conflict policy on STATE_DELTA vs. optimistic edits means the user's typing vanishes. Always define merge behavior and surface conflicts.
  • Injection via generative UI. Agent text reaching dangerouslySetInnerHTML is prompt-injection-to-XSS. Use typed components or sandboxed iframes — never raw markup from the model.
  • No resume after disconnect. A 10-minute run that can't survive a dropped socket is broken in production. Version your state and support resync from the last ack.
  • Approval fatigue. Gate everything and operators rubber-stamp; gate only irreversible/destructive actions so each approval carries signal.
  • Over-broad tool exposure. Focused frontend action sets beat sprawling ones — the agent reasons better about ten clear actions than fifty overlapping ones (same principle as /harness tool design).
  • Treating the frontend as cosmetic. It's a reliability layer: streaming, state sync, and gating are where agentic UX lives or dies.

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.

8. Further reading

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.

Primary sources
← More in Agentic Frontends & Harness Engineering