AG-UI — Agent-User Interaction Protocol
Overview
AG-UI standardizes how an agent backend talks to a user-facing app. The app POSTs one JSON RunAgentInput (thread id, run id, messages, state, tools, context, forwarded props); the agent answers with a stream of typed events. It sits beside MCP (agents to tools) and A2A (agent to agent). The protocol was created by the CopilotKit team and is implemented by LangGraph, Mastra, CrewAI, Pydantic AI, Google ADK, Microsoft Agent Framework, AWS Strands, Agno, LlamaIndex, AG2 and others. Version 1.0 shipped in September 2026; this skill was checked against @ag-ui/core/client/encoder 1.0.1 and the Python ag-ui-protocol 1.0.0.
There are no @ag-ui/server or @ag-ui/react packages and no AgentServer/useAgent in @ag-ui/*. The SDK packages are @ag-ui/core (types and constants), @ag-ui/client (HttpAgent, subscribers), @ag-ui/encoder (EventEncoder) and, per framework, @ag-ui/langgraph, @ag-ui/mastra and so on. The React layer comes from CopilotKit (@copilotkit/react-core/v2).
Instructions
Event vocabulary
Every event has a type. The families:
- Lifecycle:
RUN_STARTED, RUN_FINISHED, RUN_ERROR, STEP_STARTED, STEP_FINISHED
- Text:
TEXT_MESSAGE_START, TEXT_MESSAGE_CONTENT (with delta), TEXT_MESSAGE_END, plus a compact TEXT_MESSAGE_CHUNK
- Tool calls:
TOOL_CALL_START, TOOL_CALL_ARGS (streamed JSON delta), TOOL_CALL_END, TOOL_CALL_RESULT
- State:
STATE_SNAPSHOT (replace the whole state), STATE_DELTA (JSON Patch operations), MESSAGES_SNAPSHOT
- Activity:
ACTIVITY_SNAPSHOT, ACTIVITY_DELTA; Reasoning: REASONING_* (these replace the deprecated THINKING_*); Subagents: SUBAGENT_STARTED / FINISHED / ERROR
- Escape hatches:
RAW and CUSTOM (name plus value)
A run starts with RUN_STARTED and ends with RUN_FINISHED or RUN_ERROR. Every opened message, tool call or reasoning span must be closed before the run finishes, or the 1.0 client fails the run.
A Node.js server (HTTP + SSE)
npm install --save-exact @ag-ui/core@1.0.1 @ag-ui/encoder@1.0.1 @ag-ui/client@1.0.1
// server.mjs: a minimal AG-UI endpoint on POST /agent
import { createServer } from 'node:http';
import { randomUUID } from 'node:crypto';
import { EventType } from '@ag-ui/core';
import { EventEncoder } from '@ag-ui/encoder';
createServer(async (req, res) => {
if (req.method !== 'POST' || req.url !== '/agent') return res.writeHead(404).end();
let body = '';
for await (const chunk of req) body += chunk;
const input = JSON.parse(body); // RunAgentInput
const encoder = new EventEncoder({ accept: req.headers.accept });
res.writeHead(200, { 'Content-Type': encoder.getContentType(), 'Cache-Control': 'no-cache' });
const send = (event) => res.write(encoder.encode(event));
send({ type: EventType.RUN_STARTED, threadId: input.threadId, runId: input.runId });
send({ type: EventType.STATE_SNAPSHOT, snapshot: { status: 'searching', progress: 0 } });
const callId = randomUUID();
send({ type: EventType.TOOL_CALL_START, toolCallId: callId, toolCallName: 'search_orders' });
send({ type: EventType.TOOL_CALL_ARGS, toolCallId: callId, delta: JSON.stringify({ email: 'maria@brightbasket.dev' }) });
send({ type: EventType.TOOL_CALL_END, toolCallId: callId });
send({ type: EventType.TOOL_CALL_RESULT, messageId: randomUUID(), toolCallId: callId, content: JSON.stringify({ orders: 3 }), role: 'tool' });
send({ type: EventType.STATE_DELTA, delta: [{ op: 'replace', path: '/progress', value: 100 }] });
const messageId = randomUUID();
send({ type: EventType.TEXT_MESSAGE_START, messageId, role: 'assistant' });
for (const word of ['Maria', 'has', '3', 'open', 'orders.']) {
send({ type: EventType.TEXT_MESSAGE_CONTENT, messageId, delta: word + ' ' });
}
send({ type: EventType.TEXT_MESSAGE_END, messageId });
send({ type: EventType.RUN_FINISHED, threadId: input.threadId, runId: input.runId });
res.end();
}).listen(8787, '127.0.0.1');
If you hit a failure after the stream has opened, send RUN_ERROR (message, optional code) instead of changing the HTTP status. Reject bad input before streaming with a normal HTTP error status.
A Python server
pip install ag-ui-protocol fastapi uvicorn
# app.py
import uuid
from fastapi import FastAPI, Request
from fastapi.responses import StreamingResponse
from ag_ui.core import (RunAgentInput, EventType, RunStartedEvent, RunFinishedEvent,
TextMessageStartEvent, TextMessageContentEvent, TextMessageEndEvent)
from ag_ui.encoder import EventEncoder
app = FastAPI()
@app.post("/agent")
async def agent(input_data: RunAgentInput, request: Request):
encoder = EventEncoder(accept=request.headers.get("accept"))
async def events():
yield encoder.encode(RunStartedEvent(type=EventType.RUN_STARTED, thread_id=input_data.thread_id, run_id=input_data.run_id))
message_id = str(uuid.uuid4())
yield encoder.encode(TextMessageStartEvent(type=EventType.TEXT_MESSAGE_START, message_id=message_id, role="assistant"))
yield encoder.encode(TextMessageContentEvent(type=EventType.TEXT_MESSAGE_CONTENT, message_id=message_id, delta="Hello from AG-UI"))
yield encoder.encode(TextMessageEndEvent(type=EventType.TEXT_MESSAGE_END, message_id=message_id))
yield encoder.encode(RunFinishedEvent(type=EventType.RUN_FINISHED, thread_id=input_data.thread_id, run_id=input_data.run_id))
return StreamingResponse(events(), media_type=encoder.get_content_type())
Run it with uvicorn app:app --port 8000. Python field names are snake_case; on the wire they are camelCase (threadId, runId).
A TypeScript client
// client.mjs
import { HttpAgent } from '@ag-ui/client';
const agent = new HttpAgent({ url: 'http://127.0.0.1:8787/agent' });
agent.messages = [{ id: 'm1', role: 'user', content: 'How many open orders does maria@brightbasket.dev have?' }];
await agent.runAgent({}, {
onTextMessageContentEvent: ({ event }) => process.stdout.write(event.delta),
onToolCallEndEvent: ({ toolCallName, toolCallArgs }) => console.log('[tool]', toolCallName, toolCallArgs),
onStateChanged: ({ state }) => console.log('[state]', JSON.stringify(state)),
onRunFailedEvent: ({ error }) => console.error('run failed', error),
});
console.log(agent.messages, agent.state); // the client has applied every event for you
runAgent applies events to agent.messages and agent.state (including STATE_DELTA patches) and calls subscriber hooks such as onRunStartedEvent, onTextMessageStartEvent, onToolCallResultEvent, onStateSnapshotEvent, onCustomEvent and onNewMessage.
React with CopilotKit
CopilotKit is the React layer built on AG-UI. Register your AG-UI endpoint in the Copilot Runtime, then use the hooks. Check the CopilotKit docs for your framework's exact quickstart.
// app/api/copilotkit/[[...slug]]/route.ts
import { CopilotRuntime, createCopilotRuntimeHandler, InMemoryAgentRunner } from "@copilotkit/runtime/v2";
import { HttpAgent } from "@ag-ui/client";
const runtime = new CopilotRuntime({
agents: { support: new HttpAgent({ url: "http://127.0.0.1:8787/agent" }) }, // the agent's own URL, not /api/copilotkit
runner: new InMemoryAgentRunner(),
});
const handler = createCopilotRuntimeHandler({ runtime, basePath: "/api/copilotkit" });
export const GET = handler; export const POST = handler; export const PATCH = handler; export const DELETE = handler;
// app/page.tsx
import { CopilotKit, CopilotChat } from "@copilotkit/react-core/v2";
export default function Page() {
return (
<CopilotKit runtimeUrl="/api/copilotkit" useSingleEndpoint={false}>
<CopilotChat agentId="support" />
</CopilotKit>
);
}
For a fully custom UI use useAgent({ agentId }) (exposes messages, isRunning) and useCopilotKit() (copilotkit.runAgent({ agent })), plus useFrontendTool, useHumanInTheLoop and useRenderToolCall for tools the UI handles itself. Scaffold a project with npx create-ag-ui-app@latest or npx copilotkit@latest create.
Examples
Example 1: "Stream my agent's progress and results to the browser"
Run the Node server above (node server.mjs) and the client (node client.mjs). The client prints [state] {"status":"searching","progress":0}, then [tool] search_orders { email: 'maria@brightbasket.dev' }, then the streamed sentence Maria has 3 open orders., and ends with agent.state equal to { status: 'searching', progress: 100 } because the STATE_DELTA patch was applied.
Example 2: "Put our existing LangGraph agent behind a React chat"
Keep the LangGraph agent as is, expose it through its AG-UI integration (@ag-ui/langgraph for JS; the Python package from the LangGraph quickstart), register the agent's URL with new HttpAgent({ url }) in the runtime agents map as shown above, and render <CopilotChat agentId="support" />. Tool calls and state changes then appear in the UI without custom parsing.
Guidelines
- Pin
@ag-ui/client to one version across your app and @copilotkit/runtime; two copies cause subtle type and instance errors.
- 1.0 is strict: unknown event types are dropped with a warning, unknown properties are stripped (put extra data in
metadata, or send it to the agent in forwardedProps), a malformed known field fails the run, and unclosed messages, tool calls or reasoning spans at RUN_FINISHED fail the run. Optional fields must be absent, not null.
- Validators moved to
@ag-ui/core/schemas and need zod (3.25.18+ or 4.x); the @ag-ui/core main entry is types only.
- Replace
THINKING_* events with REASONING_*; the client still translates old streams and warns, but the shims expire.
- Use
STATE_DELTA (JSON Patch) for small frequent changes and STATE_SNAPSHOT to resync; keep agent state serializable and free of secrets, because it reaches the browser.
- Authenticate the endpoint yourself (headers, cookies, tokens); the protocol does not. Human-in-the-loop confirmations are enforced on the server, not only in the UI.
- Debug with the AG-UI Dojo (dojo.ag-ui.com) and by
curl -N against your endpoint with Accept: text/event-stream.