Three protocols now define the agent interoperability stack, and most teams have only adopted two of them.
MCP standardizes how an agent talks to tools and data. A2A standardizes how an agent talks to another agent. But the layer where an agent talks to a human — streaming tokens into a chat window, rendering a half-finished tool call, syncing a plan the user can edit mid-run — has stayed stubbornly bespoke. Every framework invented its own SSE payload shape. LangGraph streams one way, CrewAI another, your homegrown loop a third. Swap the backend and you rewrite the frontend.
AG-UI (the Agent-User Interaction Protocol) closes that gap. It is an open, event-based protocol with roughly two dozen standardized event types flowing over a pluggable transport — Server-Sent Events, WebSockets, or webhooks. If your agent emits AG-UI events, any AG-UI-compatible frontend can render it. Rip out LangGraph, drop in a raw Anthropic loop, and the UI never notices.
In this tutorial you'll build the whole thing from scratch: a custom AG-UI agent powered by Claude, exposed through a Next.js route handler over SSE, and consumed by a React client that renders streaming text, live tool calls, and a shared state object the agent and the user both mutate.
Prerequisites
Before starting, make sure you have:
- Node.js 20+ and a package manager (this guide uses
pnpm, butnpmworks identically) - Next.js 15 or 16 familiarity — App Router, route handlers, and client components
- TypeScript comfort, including generics and discriminated unions
- An Anthropic API key from console.anthropic.com, for the step where we plug in real intelligence
- Basic understanding of RxJS Observables — we'll explain the parts you need, but knowing that
observer.next()pushes a value will help
You do not need CopilotKit, LangGraph, or any agent framework. AG-UI is deliberately framework-agnostic, and building against the raw protocol is the fastest way to understand what those frameworks are doing for you.
What You'll Build
A single Next.js application with two halves:
Backend — an AbstractAgent subclass that wraps the Anthropic Messages API. It translates Claude's native stream into AG-UI events: run lifecycle, text deltas, tool calls, and state patches. A route handler at /api/agent serializes those events as SSE frames.
Frontend — a client component that instantiates HttpAgent, points it at /api/agent, and subscribes to the event stream. Text renders token-by-token. Tool calls render as live status cards before their results arrive. A shared "research plan" object stays synchronized between agent and user via JSON Patch deltas.
By the end you'll understand not just how to use AG-UI, but why its event taxonomy is shaped the way it is.
Step 1: Project Setup
Scaffold a fresh Next.js app. AG-UI has an official starter (npx create-ag-ui-app my-agent-app), but we're building from bare metal so nothing is hidden.
pnpm create next-app@latest ag-ui-demo --typescript --tailwind --app --no-src-dir
cd ag-ui-demoInstall the AG-UI packages plus the Anthropic SDK:
pnpm add @ag-ui/client @ag-ui/core rxjs @anthropic-ai/sdkThree packages do the work:
@ag-ui/coreholds the protocol itself — theEventTypeenum, the Zod schemas that validate every event, and the shared TypeScript types. It has no runtime opinions.@ag-ui/clientprovidesAbstractAgent(the base class you extend on the server) andHttpAgent(the client that consumes an SSE endpoint). It also ships theAgentSubscriberinterface.rxjssupplies theObservableprimitive thatAbstractAgent.run()returns.
Add your API key to .env.local:
ANTHROPIC_API_KEY=sk-ant-your-key-hereNever commit .env.local. The Next.js default .gitignore already excludes it, but verify before your first push — leaked Anthropic keys get scraped from public repos within minutes.
Step 2: Understand the Event Taxonomy
Everything in AG-UI is an event. Before writing code, internalize the categories — this is the entire protocol, and it fits in your head.
Run lifecycle. RUN_STARTED and RUN_FINISHED bracket a single agent invocation. RUN_ERROR replaces RUN_FINISHED when something breaks. STEP_STARTED and STEP_FINISHED mark internal phases within a run, letting the UI show "Searching…" then "Summarizing…".
Text messages. TEXT_MESSAGE_START announces a new assistant message with a messageId and a role. TEXT_MESSAGE_CONTENT carries a delta — a token or chunk. TEXT_MESSAGE_END closes it. There is also a convenience TEXT_MESSAGE_CHUNK that collapses all three when you don't need fine-grained control.
Tool calls. TOOL_CALL_START includes the toolCallName. TOOL_CALL_ARGS streams the JSON arguments in fragments as the model produces them. TOOL_CALL_END closes the call, and TOOL_CALL_RESULT delivers the execution output. That args-streaming detail matters: you can render a partially-typed search query in the UI before the model has even finished deciding what to search for.
State. STATE_SNAPSHOT sends a complete state object. STATE_DELTA sends an array of JSON Patch operations (RFC 6902) to mutate it incrementally. This is the mechanism behind shared state — the agent owns a document, the frontend mirrors it, and both can patch it.
Reasoning and thinking. THINKING_START, THINKING_TEXT_MESSAGE_CONTENT, REASONING_MESSAGE_CHUNK and friends expose extended reasoning traces so the UI can render a collapsible "thought process" panel distinct from the answer.
Escape hatches. CUSTOM carries arbitrary named payloads. RAW passes through the underlying provider's native event untouched. MESSAGES_SNAPSHOT resets the entire conversation history — useful after a reconnect.
Every event is a plain JSON object with a type field. That's it. No RPC, no bidirectional handshake at the protocol level — just a typed stream flowing from agent to UI, with user input flowing back as ordinary HTTP request bodies.
Step 3: Build a Minimal Agent
Start with the simplest possible agent so the shape of AbstractAgent is unmistakable. Create lib/agent/simple-agent.ts:
import {
AbstractAgent,
BaseEvent,
EventType,
RunAgentInput,
} from "@ag-ui/client"
import { Observable } from "rxjs"
export class SimpleAgent extends AbstractAgent {
run(input: RunAgentInput): Observable<BaseEvent> {
const { threadId, runId } = input
return new Observable<BaseEvent>((observer) => {
// Open the run
observer.next({
type: EventType.RUN_STARTED,
threadId,
runId,
} as BaseEvent)
const messageId = crypto.randomUUID()
observer.next({
type: EventType.TEXT_MESSAGE_START,
messageId,
role: "assistant",
} as BaseEvent)
observer.next({
type: EventType.TEXT_MESSAGE_CONTENT,
messageId,
delta: "Hello from AG-UI.",
} as BaseEvent)
observer.next({
type: EventType.TEXT_MESSAGE_END,
messageId,
} as BaseEvent)
// Close the run
observer.next({
type: EventType.RUN_FINISHED,
threadId,
runId,
} as BaseEvent)
observer.complete()
})
}
}Three rules govern run():
- Always bracket with
RUN_STARTEDand a terminal event. A run that never emitsRUN_FINISHEDorRUN_ERRORleaves the client spinning forever. messageIdmust be stable across the START, CONTENT, and END trio. The client uses it to route deltas into the correct message buffer.- Call
observer.complete()when the stream is done. Forgetting it holds the SSE connection open.
The input parameter carries everything the agent needs: threadId, runId, the messages array (full conversation history), a state object, tools the frontend has made available, and context — arbitrary key-value pairs the frontend streams in, like the current viewport or the row the user just selected.
Step 4: Wire In Claude
Now replace the canned response with a real model. Create lib/agent/claude-agent.ts:
import {
AbstractAgent,
BaseEvent,
EventType,
RunAgentInput,
} from "@ag-ui/client"
import Anthropic from "@anthropic-ai/sdk"
import { Observable } from "rxjs"
const anthropic = new Anthropic({
apiKey: process.env.ANTHROPIC_API_KEY,
})
export class ClaudeAgent extends AbstractAgent {
run(input: RunAgentInput): Observable<BaseEvent> {
const { threadId, runId, messages } = input
return new Observable<BaseEvent>((observer) => {
const emit = (event: Record<string, unknown>) =>
observer.next(event as BaseEvent)
const execute = async () => {
emit({ type: EventType.RUN_STARTED, threadId, runId })
const messageId = crypto.randomUUID()
let opened = false
const stream = anthropic.messages.stream({
model: "claude-sonnet-5",
max_tokens: 2048,
messages: messages
.filter((m) => m.role === "user" || m.role === "assistant")
.map((m) => ({
role: m.role as "user" | "assistant",
content: m.content ?? "",
})),
})
for await (const chunk of stream) {
if (
chunk.type === "content_block_delta" &&
chunk.delta.type === "text_delta"
) {
if (!opened) {
emit({
type: EventType.TEXT_MESSAGE_START,
messageId,
role: "assistant",
})
opened = true
}
emit({
type: EventType.TEXT_MESSAGE_CONTENT,
messageId,
delta: chunk.delta.text,
})
}
}
if (opened) {
emit({ type: EventType.TEXT_MESSAGE_END, messageId })
}
emit({ type: EventType.RUN_FINISHED, threadId, runId })
observer.complete()
}
execute().catch((error: unknown) => {
emit({
type: EventType.RUN_ERROR,
message: error instanceof Error ? error.message : "Unknown error",
})
observer.complete()
})
// Cleanup runs if the client unsubscribes mid-stream
return () => {
// abort the upstream request here in production
}
})
}
}Notice the opened flag. We only emit TEXT_MESSAGE_START once the first text delta actually arrives — otherwise a run that immediately calls a tool would open an empty message bubble the user sees flash and vanish.
The catch block is doing real work too. An unhandled rejection inside an Observable silently kills the stream with no terminal event. Converting it to RUN_ERROR means the frontend can render a failure state instead of hanging.
The teardown function returned from the Observable constructor fires when the client disconnects. In production, call stream.abort() there. Without it, a user closing the tab mid-generation still burns tokens until the model finishes.
Step 5: Expose the Agent Over SSE
AG-UI's default transport is Server-Sent Events. Each event becomes one SSE frame: the literal prefix data: , the JSON-serialized event, then two newlines.
Create app/api/agent/route.ts:
import { ClaudeAgent } from "@/lib/agent/claude-agent"
import type { RunAgentInput } from "@ag-ui/client"
import type { NextRequest } from "next/server"
export const runtime = "nodejs"
export const dynamic = "force-dynamic"
export async function POST(req: NextRequest) {
const input = (await req.json()) as RunAgentInput
const agent = new ClaudeAgent()
const encoder = new TextEncoder()
const stream = new ReadableStream({
start(controller) {
const subscription = agent.run(input).subscribe({
next(event) {
const frame = `data: ${JSON.stringify(event)}\n\n`
controller.enqueue(encoder.encode(frame))
},
error(err) {
const payload = JSON.stringify({
type: "RUN_ERROR",
message: String(err),
})
controller.enqueue(encoder.encode(`data: ${payload}\n\n`))
controller.close()
},
complete() {
controller.close()
},
})
// Unsubscribe when the HTTP connection drops
req.signal.addEventListener("abort", () => {
subscription.unsubscribe()
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",
},
})
}Two headers save hours of debugging. no-transform stops intermediate proxies from gzipping the stream (which buffers it). X-Accel-Buffering: no tells nginx not to hold frames back. Without them the stream works flawlessly on localhost and arrives in one giant lump in production.
export const runtime = "nodejs" matters as well. The Edge runtime works, but Node gives you the full stream-abort semantics of the Anthropic SDK.
Step 6: Consume the Stream with HttpAgent
Now the client half. HttpAgent handles the SSE connection, frame parsing, event validation, and message accumulation. You supply an AgentSubscriber — an object of optional callbacks, one per event type.
Create lib/agent/client.ts:
import { HttpAgent } from "@ag-ui/client"
export const agent = new HttpAgent({
url: "/api/agent",
})And drive it:
await agent.runAgent(
{ /* additional RunAgentInput fields */ },
{
onTextMessageStartEvent() {
console.log("assistant message opened")
},
onTextMessageContentEvent({ event }) {
process.stdout.write(event.delta)
},
onTextMessageEndEvent() {
console.log("\nassistant message closed")
},
onToolCallStartEvent({ event }) {
console.log("tool call:", event.toolCallName)
},
onToolCallArgsEvent({ event }) {
console.log("args delta:", event.delta)
},
onToolCallResultEvent({ event }) {
console.log("tool result:", event.content)
},
onRunErrorEvent({ event }) {
console.error("run failed:", event.message)
},
},
)The subscriber pattern is the cleanest part of AG-UI. You never write a switch over event types, never hand-parse SSE frames, and never worry about whether TEXT_MESSAGE_CONTENT arrived before its START. HttpAgent validates ordering against the protocol schema and throws on violations.
Step 7: Render It in React
Wire the subscriber into component state. Create app/components/chat.tsx:
"use client"
import { HttpAgent } from "@ag-ui/client"
import { useCallback, useRef, useState } from "react"
type Message = { id: string; role: "user" | "assistant"; text: string }
export function Chat() {
const [messages, setMessages] = useState<Message[]>([])
const [running, setRunning] = useState(false)
const [input, setInput] = useState("")
const agentRef = useRef(new HttpAgent({ url: "/api/agent" }))
const send = useCallback(async () => {
if (!input.trim() || running) return
const userMessage: Message = {
id: crypto.randomUUID(),
role: "user",
text: input,
}
setMessages((prev) => [...prev, userMessage])
setInput("")
setRunning(true)
const agent = agentRef.current
agent.messages = [
...agent.messages,
{ id: userMessage.id, role: "user", content: userMessage.text },
]
await agent.runAgent(
{},
{
onTextMessageStartEvent({ event }) {
setMessages((prev) => [
...prev,
{ id: event.messageId, role: "assistant", text: "" },
])
},
onTextMessageContentEvent({ event }) {
setMessages((prev) =>
prev.map((m) =>
m.id === event.messageId
? { ...m, text: m.text + event.delta }
: m,
),
)
},
onRunErrorEvent({ event }) {
setMessages((prev) => [
...prev,
{
id: crypto.randomUUID(),
role: "assistant",
text: `Error: ${event.message}`,
},
])
},
onRunFinishedEvent() {
setRunning(false)
},
},
)
}, [input, running])
return (
<div className="mx-auto flex h-screen max-w-2xl flex-col p-4">
<div className="flex-1 space-y-3 overflow-y-auto">
{messages.map((m) => (
<div
key={m.id}
className={
m.role === "user"
? "ml-auto max-w-[80%] rounded-lg bg-blue-600 p-3 text-white"
: "mr-auto max-w-[80%] rounded-lg bg-gray-100 p-3"
}
>
{m.text}
</div>
))}
</div>
<div className="mt-4 flex gap-2">
<input
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && send()}
placeholder="Ask the agent…"
className="flex-1 rounded-lg border px-4 py-2"
disabled={running}
/>
<button
onClick={send}
disabled={running}
className="rounded-lg bg-blue-600 px-6 py-2 text-white disabled:opacity-50"
>
{running ? "…" : "Send"}
</button>
</div>
</div>
)
}Mount it in app/page.tsx, run pnpm dev, and you have a streaming chat over the AG-UI protocol in about 120 lines.
The important detail is agent.messages. HttpAgent maintains conversation history internally and sends it with every run, so you push the user turn onto it before calling runAgent(). Assistant turns are appended automatically as the stream resolves.
Step 8: Shared State with JSON Patch Deltas
This is where AG-UI stops being "yet another streaming format" and starts earning its name. Chat is one-directional. Shared state is not.
Imagine a research agent that builds a plan. It should stream the plan into a sidebar as it forms, mark steps complete as it executes them, and let the user reorder or delete a step mid-run.
The agent emits a STATE_SNAPSHOT to establish the shape, then STATE_DELTA events carrying JSON Patch operations:
// Inside your agent's run() method
emit({
type: EventType.STATE_SNAPSHOT,
snapshot: {
plan: [
{ id: "1", title: "Gather sources", status: "pending" },
{ id: "2", title: "Extract claims", status: "pending" },
{ id: "3", title: "Draft summary", status: "pending" },
],
},
})
// Later, when step 1 completes — patch instead of resending everything
emit({
type: EventType.STATE_DELTA,
delta: [
{ op: "replace", path: "/plan/0/status", value: "complete" },
{
op: "add",
path: "/plan/0/sourceCount",
value: 12,
},
],
})Each object in delta is an RFC 6902 JSON Patch operation. The supported ops are add, remove, replace, move, copy, and test. Sending a patch instead of a snapshot means a 50-step plan with one status change costs about 80 bytes on the wire rather than several kilobytes.
On the client, subscribe to both:
type PlanStep = { id: string; title: string; status: string }
type AgentState = { plan: PlanStep[] }
const [state, setState] = useState<AgentState>({ plan: [] })
await agent.runAgent(
{},
{
onStateSnapshotEvent({ event }) {
setState(event.snapshot as AgentState)
},
onStateDeltaEvent({ event }) {
setState((prev) => applyPatch(structuredClone(prev), event.delta))
},
},
)Use a library such as fast-json-patch for applyPatch rather than writing it yourself — the move and test operations have subtle ordering semantics that are easy to get wrong.
Deltas only make sense if the client's state never drifts from the agent's. If a patch fails to apply — a test op returns false, or a path no longer exists — do not silently ignore it. Request a fresh STATE_SNAPSHOT and resynchronize. Silent drift produces UIs that look correct and are subtly lying.
Because state is a plain object mirrored on both sides, the reverse direction is simply the next request body. When the user drags step 3 above step 2, mutate local state, then pass the updated object as the state field of the next runAgent() call. The agent reads input.state and sees the user's edit. That round-trip is what "bi-directional state synchronization" means in practice — no WebSocket required.
Step 9: Generative UI from Tool Calls
The last piece is rendering tool calls as UI rather than as text. Because TOOL_CALL_ARGS streams argument fragments, you can show progress before the tool has even been invoked.
Give Claude a tool, forward its stream events, and let the frontend decide how to render each toolCallName:
// Server: translate Claude's tool_use blocks into AG-UI events
for await (const chunk of stream) {
if (
chunk.type === "content_block_start" &&
chunk.content_block.type === "tool_use"
) {
emit({
type: EventType.TOOL_CALL_START,
toolCallId: chunk.content_block.id,
toolCallName: chunk.content_block.name,
})
}
if (
chunk.type === "content_block_delta" &&
chunk.delta.type === "input_json_delta"
) {
emit({
type: EventType.TOOL_CALL_ARGS,
toolCallId: currentToolCallId,
delta: chunk.delta.partial_json,
})
}
if (chunk.type === "content_block_stop" && currentToolCallId) {
emit({ type: EventType.TOOL_CALL_END, toolCallId: currentToolCallId })
}
}Then map tool names to components on the client:
const TOOL_COMPONENTS: Record<string, React.FC<{ args: string }>> = {
search_web: SearchCard,
run_query: QueryCard,
generate_chart: ChartCard,
}
// In your subscriber
onToolCallStartEvent({ event }) {
setToolCalls((prev) => [
...prev,
{ id: event.toolCallId, name: event.toolCallName, args: "" },
])
},
onToolCallArgsEvent({ event }) {
setToolCalls((prev) =>
prev.map((t) =>
t.id === event.toolCallId ? { ...t, args: t.args + event.delta } : t,
),
)
},
onToolCallResultEvent({ event }) {
setToolCalls((prev) =>
prev.map((t) =>
t.id === event.toolCallId ? { ...t, result: event.content } : t,
),
)
},SearchCard receives a partial JSON string. Parse it leniently — a helper that tolerates truncated JSON lets you render Searching for "AG-UI proto…" while the model is still emitting tokens. That is the small detail that makes an agent UI feel alive rather than laggy.
Testing Your Implementation
Verify each layer independently rather than debugging the whole pipeline at once.
Test the event stream with curl. You should see raw SSE frames, one per line:
curl -N -X POST http://localhost:3000/api/agent \
-H "Content-Type: application/json" \
-d '{
"threadId": "t1",
"runId": "r1",
"messages": [{ "id": "m1", "role": "user", "content": "Say hello" }],
"state": {},
"tools": [],
"context": []
}'The -N flag disables curl's buffering. If frames arrive all at once when the run finishes, your buffering headers are wrong — revisit Step 5.
Validate events against the schema. @ag-ui/core exports Zod schemas for every event type. Assert on them in a unit test:
import { EventSchemas } from "@ag-ui/core"
import { describe, expect, it } from "vitest"
describe("ClaudeAgent", () => {
it("emits protocol-valid events in order", async () => {
const events: BaseEvent[] = []
await new Promise<void>((resolve) => {
new ClaudeAgent()
.run(testInput)
.subscribe({ next: (e) => events.push(e), complete: resolve })
})
for (const event of events) {
expect(() => EventSchemas.parse(event)).not.toThrow()
}
expect(events.at(0)?.type).toBe(EventType.RUN_STARTED)
expect(events.at(-1)?.type).toBe(EventType.RUN_FINISHED)
})
})Inspect with the AG-UI Dojo. The reference implementations at dojo.ag-ui.com include a raw event inspector. Point it at your local endpoint to see exactly which events your agent emits, in what order.
Troubleshooting
Events arrive in one batch instead of streaming. Almost always a buffering proxy. Confirm Cache-Control: no-cache, no-transform and X-Accel-Buffering: no are on the response. On Vercel, make sure the route exports dynamic = "force-dynamic" so it isn't statically optimized.
"Received TEXT_MESSAGE_CONTENT before TEXT_MESSAGE_START". Your opened guard is inverted, or you're emitting deltas for a messageId you closed already. Each messageId supports exactly one START/CONTENT/END lifecycle.
The run never finishes. You forgot observer.complete(), or an exception escaped the async function inside the Observable. Wrap the body in try/catch and always emit a terminal event.
State deltas apply to the wrong index. JSON Patch paths are positional, so /plan/0/status refers to whatever is currently first. If the user reorders the list locally between runs, the agent's next patch targets a different item than it intended. Prefer stable ID-keyed objects over arrays for anything the user can rearrange, or resend a snapshot after a reorder.
Tool arguments arrive as invalid JSON. That's expected — TOOL_CALL_ARGS deltas are fragments. Only parse after TOOL_CALL_END, or use a partial-JSON parser for the optimistic rendering path.
Type errors on observer.next(). The event union in @ag-ui/core is strict. The published examples cast with as BaseEvent for brevity; in production, build small typed factory functions like runStarted(threadId, runId) so the compiler catches missing fields.
Next Steps
You now have a working AG-UI agent, but the protocol has more surface worth exploring:
- Swap the backend. Replace
ClaudeAgentwith a LangGraph, Mastra, or Pydantic AI agent using their official AG-UI adapters. The frontend from Step 7 should keep working untouched — that's the whole promise, and it's worth proving to yourself. - Add middleware. AG-UI's transport layer is pluggable. Insert a subscriber that logs every event to your observability stack, or one that redacts PII from
TEXT_MESSAGE_CONTENTdeltas before they leave the server. - Add human-in-the-loop. Emit a
CUSTOMevent when the agent needs approval, pause the run, and resume with the user's decision ininput.context. - Combine with MCP. Have your AG-UI agent call MCP servers for its tools. See our guide on building an MCP server in TypeScript — the tool results flow straight into
TOOL_CALL_RESULTevents. - Compare with A2A. AG-UI covers agent-to-user; A2A covers agent-to-agent. Production systems increasingly speak both.
Conclusion
AG-UI is a small protocol solving an unglamorous problem: the interaction layer between an agent and the person watching it work. Its bet is that roughly two dozen typed events — run lifecycle, text deltas, tool calls, state patches, reasoning traces — are enough to express every agent UI worth building, and that standardizing them frees you to change agent frameworks without rewriting your frontend.
The pieces are straightforward once assembled. AbstractAgent gives you a place to translate any model's native stream into protocol events. An SSE route handler ships them. HttpAgent plus an AgentSubscriber turns them back into React state. JSON Patch deltas keep a shared document synchronized cheaply in both directions.
What you should take away is the discipline the protocol imposes. Bracket every run. Keep message IDs stable. Emit a terminal event on every path, including failure. Never let client state drift from agent state without resynchronizing. These are the rules that separate an agent UI that feels solid from one that mysteriously hangs at three percent of runs — and AG-UI makes them explicit rather than leaving each team to rediscover them.