Frontends
Connect a React chat UI with the AI SDK's useChat, or any AG-UI client such as CopilotKit, straight to the host.
Every host speaks the two common chat UI protocols out of the box: the Vercel AI SDK UI message stream (what useChat reads) and AG-UI (CopilotKit, @ag-ui/client). Point the stock client at the host and you get streaming text, tool calls, approvals and questions in the browser. You don't need an adapter, a proxy or any extra config.
What you get for free:
- Streams survive drops. A reload or a flaky connection picks up the same run where it was. Retrying a message never starts a second run.
- Approvals in the UI. A tool that needs approval shows up as an approval request, and the user's answer resumes the run.
- Every frame comes from the log. What the browser shows is exactly what the timeline and replay show.
Both hosts serve the same routes, byte for byte:
| Method | Path | For |
|---|---|---|
POST | /v1/ui/ai-sdk/{agent} | useChat / DefaultChatTransport |
GET | /v1/ui/ai-sdk/{agent}/{chat_id}/stream | useChat's resume: true reconnect |
POST | /v1/ui/ag-ui/{agent} | AG-UI HttpAgent and CopilotKit |
GET | /v1/threads/{thread_id}/runs/{run_id}/ui/{protocol} | Custom clients: a run's frames, resumable by Last-Event-ID |
The routes use the host's authenticate, like the rest of the HTTP API.
React with the AI SDK
Install ai@7 and @ai-sdk/react, then point useChat at the host:
"use client";
import { useChat } from "@ai-sdk/react";
import {
DefaultChatTransport,
lastAssistantMessageIsCompleteWithApprovalResponses,
lastAssistantMessageIsCompleteWithToolCalls,
} from "ai";
import { useState } from "react";
export function Chat({ chatId }: { chatId: string }) {
const { messages, sendMessage, addToolApprovalResponse, addToolOutput, status } = useChat({
id: chatId,
resume: true,
transport: new DefaultChatTransport({
api: "/v1/ui/ai-sdk/support",
headers: { authorization: `Bearer ${token}` },
}),
// Send the user's approvals and answers as soon as the last message has them all.
sendAutomaticallyWhen: (chat) =>
lastAssistantMessageIsCompleteWithApprovalResponses(chat) ||
lastAssistantMessageIsCompleteWithToolCalls(chat),
});
const [text, setText] = useState("");
return (
<div>
{messages.map((m) => (
<div key={m.id}>
{m.parts.map((part, i) => {
if (part.type === "text") return <p key={i}>{part.text}</p>;
if (part.type.startsWith("tool-") && "approval" in part && part.state === "approval-requested")
return (
<p key={i}>
Allow {part.type.slice(5)}?{" "}
<button onClick={() => addToolApprovalResponse({ id: part.approval.id, approved: true })}>Yes</button>
<button onClick={() => addToolApprovalResponse({ id: part.approval.id, approved: false })}>No</button>
</p>
);
if (part.type === "tool-ask_user" && part.state === "input-available") {
// ask_user's input, as the tool's schema defines it.
const { question, options = [] } = part.input as { question: string; options?: string[] };
return (
<p key={i}>
{question}{" "}
{options.map((option) => (
<button
key={option}
onClick={() => addToolOutput({ tool: "ask_user", toolCallId: part.toolCallId, output: option })}
>
{option}
</button>
))}
</p>
);
}
return null;
})}
</div>
))}
<form
onSubmit={(e) => {
e.preventDefault();
sendMessage({ text });
setText("");
}}
>
<input value={text} onChange={(e) => setText(e.target.value)} disabled={status !== "ready"} />
</form>
</div>
);
}idis your chat's key. The host turns it into a thread for this caller and this agent, so two users with the same key never share a thread, and the key itself is never stored.resume: truereconnects to a run that is still going after a reload.- When a tool needs approval, or the agent asks the user a question with
ask_user, the user's answer is sent automatically and the run continues in the same assistant message. A free-text question has nooptions: send the typed text as theoutput.
AG-UI and CopilotKit
Any AG-UI 1.0 client works. With @ag-ui/client:
import { HttpAgent } from "@ag-ui/client";
const agent = new HttpAgent({
url: "https://api.example.com/v1/ui/ag-ui/support",
headers: { authorization: `Bearer ${token}` },
threadId: "chat-1",
});
agent.addMessage({ id: crypto.randomUUID(), role: "user", content: "Mail the report to Bob" });
await agent.runAgent();
// A tool that needs approval ends the run with an interrupt. One resume answers them all.
if (agent.pendingInterrupts.length > 0) {
await agent.runAgent({
resume: agent.pendingInterrupts.map((interrupt) => ({
interruptId: interrupt.id,
status: "resolved",
payload: { decision: "grant" },
})),
});
}threadIdis your chat's key, scoped to the caller and the agent just like the AI SDK'sid.- One
resumemust answer every pending interrupt: two tool calls that both need approval end the run with two, and the client refuses a resume that leaves one out. - An approval (
reason: "tool_approval") is answered with{decision: "grant" | "deny"}, andstatus: "cancelled"denies it. A question (reason: "user_input", fromask_user) is answered with{answer: "..."}, or a list of the chosen options. Each interrupt carries itsresponseSchema. - If someone else settled the interrupt first (an approver in Slack, say), resuming still works, even when their decision lands while yours is on its way. The stream carries a
threads.resume_conflictcustom event that tells you what was recorded. - After a dropped connection, call
runAgent()again with the same messages. The host sees the same last message id and streams that run again from aMESSAGES_SNAPSHOT. It never starts a second one.
Good to know
- Earlier messages after a reload. The host streams runs, not a chat history: after a reload,
useChat({resume: true})picks up a run that is still going, and nothing else. Keep the messages in your own store and pass them asmessages, or rebuild them from the thread's timeline. An AG-UI client gets the whole thread in theMESSAGES_SNAPSHOTof its next run. - Other approvers. A chat's thread belongs to the signed-in user who started it. An approver who isn't that user (a manager, an on-call engineer) decides through the HTTP API approval routes (
POST /v1/threads/{thread_id}/approvals/{challenge_id}), or in a channel such as Slack. The chat page sees the decision when its stream continues. - A retried model call. When a model call fails partway and is retried, the host tells the UI to drop what that attempt streamed: an AI SDK
data-attemptpart or an AG-UIthreads.attempt_abandonedcustom event. - AG-UI replays. A replay starts with a
MESSAGES_SNAPSHOTbuilt from the log, so messages the page only kept locally are dropped. Its size grows with the thread's history.
Custom clients
GET /v1/threads/{thread_id}/runs/{run_id}/ui/ai-sdk (or /ag-ui) streams a run's frames from the log. Each frame has an SSE id. Send the last one back as Last-Event-ID (or ?after=) to continue with no gap and no duplicate. A cursor that isn't a frame of the run answers 400 invalid_cursor.
Limits
- User messages are text. File parts and frontend-defined tools answer
400 invalid_request. Define tools on the agent instead. - The AI SDK's
regenerateisn't supported: to try again, fork the thread.