Building it for real
The frameworks you'll actually install — CopilotKit, the Vercel AI SDK, assistant-ui, LangGraph, json-render and friends — mapped by what they do, with the same feature built in each, a decision guide, and the anti-patterns that bite in production.
- 01Place any library in one of eight categories and explain how the layers compose
- 02Build an approval card with human-in-the-loop in CopilotKit v2 and AI SDK v7
- 03Choose a stack for a given product with reasons, not brand loyalty
- 04Avoid the thirteen anti-patterns that break generative UI in production
- 05Isolate framework code so your tools and components survive a framework swap
The shared vocabulary
Nearly every vendor now uses the same three tiers, so learn to hear them in docs:
- Controlled — you author the components; the model picks one and fills its props, usually through a tool call. (Rung 3.)
- Declarative — the model emits a spec against a catalog of allowed components; a renderer turns it into native UI. A2UI, json-render, OpenUI Lang. (Rung 4.)
- Open-ended — someone ships arbitrary HTML and JS that runs in a sandbox. MCP Apps, the Apps SDK. (Rung 5.)
Pick the least free tier that meets the goal. Controlled lets you test every state. Declarative lets you validate against the catalog but layouts become combinatorial. Open-ended needs a sandbox, consent prompts and behavioural testing.
Eight categories
| Category | What it does | Examples (Nov 2026) |
|---|---|---|
| A · Agent ↔ UI runtimes | turn an agent's stream into reactive client state: messages, tool parts, agent state, interrupts | CopilotKit, AI SDK UI, LangGraph's React client, Cloudflare Agents, Hashbrown |
| B · Chat/thread UI kits | render the thread: messages, tool UIs, composer | assistant-ui, shadcn AI Elements, OpenAI ChatKit |
| C · Spec renderers | render model-composed specs from a catalog | json-render, A2UI renderers, OpenUI Lang, a2ui_flutter |
| D · UI-from-API services | the vendor owns model → spec → render | Thesys OpenUI Cloud (formerly C1) |
| E · Hosted-chat app SDKs | ship UI into ChatGPT, Claude, VS Code, Copilot | MCP Apps (ext-apps), OpenAI Apps SDK, MCP-UI |
| F · App generators | generate source code a person reviews and ships | v0, Lovable, Bolt, Figma Make, Google Stitch |
| G · Canvas & multiplayer | AI as a co-editor in documents and canvases | tldraw agent kit, Liveblocks AI Copilots, Tiptap AI, Puck AI |
| H · Agent frameworks with UI adapters | the backend that emits the stream | LangGraph, Google ADK, Mastra, Pydantic AI, CrewAI, Claude Agent SDK, OpenAI Agents SDK |
How they compose: an agent framework (H) emits a stream — AG-UI events, the AI SDK's UI message stream, or LangGraph's stream; a runtime (A) turns it into client state; a kit (B) renders the thread; and each tool call is rendered by your components (controlled), a spec renderer (C), or a sandboxed iframe (E). Services (D) collapse several layers into one vendor. Generators (F) are build-time, a different discipline.
Market signals worth knowing
- The AI SDK is the JavaScript gravity well (
ai≈ 45M weekly downloads). Many libraries — assistant-ui, json-render, Mastra, Pydantic AI — speak its UI message stream. - AG-UI is the de facto agent-backend ↔ rich-frontend protocol, with first-party adapters for LangGraph, CrewAI, Mastra, Pydantic AI, Agno, LlamaIndex, ADK, AWS Strands and Microsoft's Agent Framework.
- MCP Apps is the distribution channel into hosted chat, and you can now render MCP Apps inside your product too (AI SDK
MCPAppRenderer, assistant-uiMcpAppRenderer, CopilotKit'smcpAppsoption). - Declarative specs exploded in 2026: json-render reached ~19k GitHub stars in ten months; A2UI and OpenUI are each around 15–17k.
- Attrition is real. Tambo's cloud shut down, LangChain archived its reference chat UIs, OpenAI's Agent Builder is shutting down, AI Elements is folding into shadcn. Pick standards over vendors.
The ecosystem's half-life is about six months
In the twelve months to November 2026: the AI SDK went from v5 to v7; CopilotKit deprecated its entire v1 hook API; assistant-ui replaced its tool-UI API; LangGraph shipped a new React client and renamed its platform; Thesys renamed C1; Flutter's GenUI SDK is being replaced by A2UI renderers. Things that will break a tutorial you find online:
| Library | Old (mid-2025) | Now (Nov 2026) |
|---|---|---|
| AI SDK | v5; addToolResult; needsApproval |
v7 (ESM, Node 22+); addToolOutput; toolApproval; Output.object replaces streamObject; MCPAppRenderer added |
| CopilotKit | useCopilotAction, useCopilotReadable, useCoAgent |
@copilotkit/react-core/v2: useFrontendTool, useHumanInTheLoop, useRenderTool, useAgent, useAgentContext, useInterrupt |
| assistant-ui | makeAssistantToolUI |
defineToolkit |
| LangGraph client | useStream + submit({ command: { resume } }) |
@langchain/react v1: stream.respond(value); Platform is now LangSmith Deployment |
| Thesys | C1 API + Crayon | OpenUI Cloud + OpenUI Lang 1.0 |
The defence is architectural: isolate framework code behind your own tool and component registry, so your tool definitions and components survive a framework swap. And check examples against the installed package's type definitions — several official doc pages lag their own releases, and coding agents happily generate deprecated APIs.
One feature, two stacks
The task: the agent can call showExpenseApproval({ title, amount, submitter }); the UI renders an approval card; the person's Approve/Reject goes back to the agent. A plain presentational component, shared by both:
type Expense = { title: string; amount: number; submitter: string };
function ExpenseCard({ e, onDecide }: { e: Partial<Expense>; onDecide?: (approved: boolean) => void }) {
return (
<div role="group" aria-label="Expense approval">
<h4>{e.title ?? "…"}</h4>
<p>{e.submitter ?? "…"} requests {e.amount != null ? `$${e.amount.toFixed(2)}` : "…"}</p>
{onDecide && (<>
<button onClick={() => onDecide(true)}>Approve</button>
<button onClick={() => onDecide(false)}>Reject</button>
</>)}
</div>
);
}
CopilotKit (v2 API)
"use client";
import { CopilotKitProvider, CopilotChat, useHumanInTheLoop } from "@copilotkit/react-core/v2";
import { ToolCallStatus } from "@copilotkit/core";
import { z } from "zod";
function ExpenseApprovalTool() {
useHumanInTheLoop({
name: "showExpenseApproval",
description: "Show an expense to the user and wait for Approve/Reject before continuing.",
parameters: z.object({ title: z.string(), amount: z.number(), submitter: z.string() }),
render: ({ args, status, respond, result }) => {
if (status === ToolCallStatus.InProgress) return <ExpenseCard e={args} />; // args still streaming
if (status === ToolCallStatus.Executing && respond)
return <ExpenseCard e={args} onDecide={(approved) => respond({ approved })} />;
if (status === ToolCallStatus.Complete && result)
return <p>{JSON.parse(result).approved ? "Approved" : "Rejected"}</p>;
return null;
},
}, []);
return null;
}
export default function Page() {
return (
<CopilotKitProvider runtimeUrl="/api/copilotkit">
<ExpenseApprovalTool />
<CopilotChat />
</CopilotKitProvider>
);
}
The same tool is offered to a LangGraph, ADK or Mastra backend as a frontend tool over AG-UI. For server-decided pauses, the graph calls interrupt() and you render it with useInterrupt. (CopilotKit renamed several v2 exports during the 2.0 release candidate; check the provider name against your installed version.)
Vercel AI SDK v7
// app/api/chat/route.ts
import { streamText, convertToModelMessages, createUIMessageStreamResponse, toUIMessageStream, type UIMessage } from "ai";
import { z } from "zod";
export async function POST(req: Request) {
const { messages }: { messages: UIMessage[] } = await req.json();
const result = streamText({
model: "openai/gpt-5",
instructions: "You process expense reports. Always get approval via showExpenseApproval.",
messages: await convertToModelMessages(messages),
tools: {
showExpenseApproval: { // no execute → the client supplies the output
description: "Show an approval card and wait for the user's decision.",
inputSchema: z.object({ title: z.string(), amount: z.number(), submitter: z.string() }),
outputSchema: z.object({ approved: z.boolean() }),
},
},
});
return createUIMessageStreamResponse({ stream: toUIMessageStream({ stream: result.stream }) });
}
// app/page.tsx
"use client";
import { useChat } from "@ai-sdk/react";
import { DefaultChatTransport, lastAssistantMessageIsCompleteWithToolCalls } from "ai";
export default function Chat() {
const { messages, addToolOutput } = useChat({
transport: new DefaultChatTransport({ api: "/api/chat" }),
sendAutomaticallyWhen: lastAssistantMessageIsCompleteWithToolCalls,
});
return messages.map((m) => m.parts.map((part, i) => {
if (part.type === "text") return <p key={i}>{part.text}</p>;
if (part.type === "tool-showExpenseApproval") {
switch (part.state) {
case "input-streaming": return <ExpenseCard key={i} e={part.input ?? {}} />;
case "input-available": return <ExpenseCard key={i} e={part.input} onDecide={(approved) =>
addToolOutput({ tool: "showExpenseApproval", toolCallId: part.toolCallId, output: { approved } })} />;
case "output-available": return <p key={i}>{part.output.approved ? "Approved" : "Rejected"}</p>;
case "output-error": return <p key={i}>Error: {part.errorText}</p>;
}
}
return null;
}));
}
What to notice
- Both disable the decision while arguments stream. CopilotKit's
InProgress, the AI SDK'sinput-streaming— the card renders, the buttons don't. - Both of these are honour systems. A client-resolved tool is not an authorisation boundary: the model, or a prompt injection, could call a side-effecting tool directly and skip the card. When approval gates a real effect, enforce it on the server — in the AI SDK, make the side-effecting tool a normal server tool and set
toolApprovalon it (approvals can be signed so they bind to the exact call); in LangGraph, callinterrupt()inside the node that has the side effect; in the Claude Agent SDK,canUseTool. - Decided by the model vs decided by code. Client HITL tools are chosen by the model and live in the conversation — good for pickers and clarifications. Server interrupts and approvals are chosen by your policy and survive restarts — required for consent.
The full research file also builds this card in assistant-ui (defineToolkit), LangGraph (interrupt + @langchain/react respond()) and json-render (a declarative variant inside an AI SDK chat).
Choosing a stack
| If you need… | Reach for | Because |
|---|---|---|
| a chat-first product on Next.js, full control of messages and persistence | AI SDK v7 + assistant-ui or shadcn AI Elements | typed parts with lifecycle states, approvals, transports |
| an in-app copilot that sees and edits app state, Python backend | CopilotKit over AG-UI + LangGraph / ADK / CrewAI / Pydantic AI | shared state, frontend tools, both HITL styles, adapters for every backend |
| approvals that may wait hours or days, with audit | LangGraph interrupt + checkpointer, or a durable workflow agent |
durable pauses that resume after restarts |
| model-composed layouts from your design system | json-render (JS, many targets) or A2UI (cross-platform standard) | the catalog is the allowlist; flat specs stream |
| native mobile generative UI | A2UI renderers (Flutter, SwiftUI, Compose — alpha) | same spec, native components, no webviews |
| to ship UI inside ChatGPT, Claude, VS Code, Microsoft 365 Copilot | MCP Apps | one standard, many hosts |
| to render third-party MCP Apps inside your product | AI SDK MCPAppRenderer, assistant-ui McpAppRenderer, @mcp-ui/client |
the sandbox and bridge are solved |
| AI as a co-editor in a document or canvas | Liveblocks AI Copilots, Tiptap AI, tldraw agent kit | AI edits arrive as CRDT operations or suggestions |
| an Angular app | Hashbrown, CopilotKit Angular, A2UI Angular | first-class Angular support |
Thirteen anti-patterns
- Treating a client-side "Approve" button as authorisation. Enforce consent on the server.
- Rendering partial arguments as if final. Disable actions until arguments are complete.
- Starting new work on deprecated APIs —
streamUI/RSC, CopilotKit v1 hooks,makeAssistantToolUI, Agent Builder,text/html+skybridge. - One mega-tool with a free-form
ui: stringparameter injected into your DOM. Use a catalog, or a sandboxed iframe. - Persisting rendered HTML instead of data. Store tool calls, results and specs; render at read time.
- Renaming tools or changing schemas with no migration. Old threads crash on load. Version tool names; keep a fallback renderer for unknown tools.
- Bloated registries with vague descriptions. Forty components named
Card1…degrade selection. Fewer, orthogonal, well-described components. - Re-sending huge readable context every turn. Send ids and summaries; let the agent fetch.
- Agent and UI writing the same state keys with no ownership rule. Flicker and lost edits. (Module 03)
- Pending tool calls with no exit. If the person ignores the card and types something else, many providers reject a history with an unanswered call. Write an explicit result on cancel or timeout.
- Depending on one vendor's hosted backend for core state. Several sunset in 2026. Keep threads in your database.
- Copying doc snippets across versions. Check against the installed package's types.
- Testing only with screenshots of happy paths. Check behaviour in a real browser: that buttons resolve the tool, partial states don't break layout, sandboxed apps can't escape. (Module 06)
What experts know
- The tool schema is the component contract and a prompt. Field names and descriptions steer the model. Version schemas like public APIs.
- Know exactly which parts reach the model. AI SDK data parts are dropped unless converted; CopilotKit's agent context is sent every run; MCP Apps separate model-visible content from UI-only metadata. Most "the agent didn't know what the user clicked" bugs live here.
- A pending tool call is a protocol state, not just UI. Something must answer it, or the next turn fails.
- Streaming re-render cost is the silent killer. Every token re-renders the active message. Memoise per message, throttle, keep heavy charts out of the streaming path, use streaming-safe markdown.
- Reconcile, don't append. Stable ids let one component evolve (data parts with ids,
STATE_DELTA,ACTIVITY_DELTA, spec patches) instead of spawning a new card per update. - Make generated UI operable by agents too. Stable names, roles and ids help browser agents, accessibility and tests at once.
Check yourself
- Place CopilotKit, assistant-ui, json-render, MCP Apps and LangGraph in the eight categories, and describe how three of them would compose in one product.
- Why is a client-resolved approval tool an honour system, and what are two ways to enforce consent on the server?
- Your app shows a stale
useCopilotActionexample from a blog post. What do you do? - A person ignores an approval card and types a new question. What breaks, and how do you handle it?
- Design a registry boundary so you could switch from CopilotKit to the AI SDK in a week.
Go deeper
- Labs: 02 · watch an agent run, 03 · render before the brace closes, 07 · one card, six protocols
- The full reference, with assistant-ui, LangGraph and json-render versions of the card:
research/02-frameworks-and-sdks.md - Docs: CopilotKit, AI SDK, assistant-ui, LangGraph, json-render, MCP Apps
- Next: 05 · Interfaces agents can use