Generative UI: native vs CopilotKit vs OpenUI
Why the native client wins on generative UI — model-designed views in your own design system, checked before they render, delivered on every channel — and the evidence, the same agent behind the native client, CopilotKit and OpenUI, every scenario run in a browser.
The agent's generative UI can reach a browser three ways: the native React client over the native stream, or a third-party frontend over AG-UI. This page compares the three for generative UI only — tool-driven components, progressive rendering, model-composed layouts, model-written views, interactive components, persistence, non-visual surfaces, validation, DX and cost. Every cell comes from running the same agent behind three pages:
- Native —
@adonis-agora/agent/react:<AgentProvider>,useAgentChat,<GenerativeUI>. - CopilotKit 1.77 — the v2 API (
@copilotkit/react-core/v2,CopilotChat) onagUiAdapter(). - OpenUI 0.17 —
@openuidev/react-ui'sAgentInterfaceonagUiAdapter().
The app is the frontends example (AdonisJS 7, SQLite, a scripted model, no API key). Its pnpm e2e drives every scenario on every page in headless Chromium and saves the screenshots its README shows. Versions: @adonis-agora/agent 0.66.0, @copilotkit/react-core 1.77.1, @openuidev/react-ui 0.17.0.
Why native
CopilotKit and OpenUI are frontends: they draw in the browser and stop there. The native client is the browser half of a generative UI that lives on the server, and that is where it pulls ahead:
- The model designs in your design system, not someone else's. For what no component fits, the sandbox lets the model write the view, with your styles, your components and your providers through a sandbox kit — Tailwind, Bootstrap, plain CSS, shadcn, a customized MUI. A view it invents looks like the rest of your app. OpenUI's views look like OpenUI's library.
- The model writes behavior, not just layout. A calculator, a simulation, a slider that recomputes a chart: the sandbox runs the model's own code in an isolated frame with no network, and a button sends values back as the next turn through a checked bridge. A layout of fixed components cannot do that.
- Nothing reaches the screen unchecked. Every component's props, every
ui__rendertree and every sandbox's code is checked on the server before it is accepted. A mistake goes back to the model as a tool error and it fixes it. CopilotKit hands renderers unvalidated arguments; OpenUI drops invalid output without telling anyone. - One answer, every channel. The same component is drawn in React, in React Native, as text on WhatsApp, Telegram or e-mail, or as a PNG or PDF — with per-channel conversions you choose, and the text the model writes knowing what was actually delivered. A UI that exists only as model text, or only as a browser renderer, has nothing to send a text channel.
- It is governed. UI tools declare an authorization ability like any other tool, approvals are part of the transcript, and every UI call is recorded as telemetry — first-try success, what the model got wrong, cost, time to first frame — on the dashboard's Generative UI page, metadata only by default.
- No glue. History, thread lists, approvals, tool-pushed components and skeletons work out of the box. Against this agent CopilotKit needs ~70 lines for history and OpenUI ~45 lines for history plus ~46 for approvals (see the matrix).
- And it still interoperates. The same agent serves AG-UI for CopilotKit and OpenUI and A2UI for Google's renderers, so choosing native closes no door.
The rest of this page is the evidence: the same agent behind all three frontends, every scenario run in a browser.
Three models of generative UI
They do not mean the same thing by "generative UI", and that drives most of the table below.
| Who decides what is drawn | What crosses the wire | Who renders | |
|---|---|---|---|
| Native | A tool (present, ctx.emitUi) or the model (ui__render / ui__show_*), checked against the server's catalog | A typed ui frame { id, component, props, version, fallbackText } | Your React registry — or the server, as text, HTML, PNG or PDF |
| CopilotKit | The client: it renders a tool call (its arguments and result) with a renderer registered for the tool's name | The ordinary tool-call events | Your React components, in the browser |
| OpenUI | The model: it writes the UI as text in OpenUI Lang, in the vocabulary of a component library it was told about in the prompt | Text | OpenUI's component library, in the browser |
So on the OpenUI page the server must hand the model the library's prompt, and the model answers in OpenUI Lang; on the CopilotKit page the server's own components (agora.ui events) are ignored unless app code reads them. The example does exactly that — the OpenUI setup and the CopilotKit setup below show how.
The matrix
Built in means the frontend's documented API does it against this agent. Glue is app code that bridges the frontend to this agent — the count is non-blank, non-comment lines in the example. No means the frontend has no way to do it.
| # | Capability | Native | CopilotKit | OpenUI |
|---|---|---|---|---|
| 1 | Tool-driven component | Built in. The tool pushes OrderList's skeleton before it reads, then the rows under the same id; <GenerativeUI> draws both from the registry, and only the rows are stored. | Built in. useRenderTool({ name: 'list_orders', render }) draws the tool call from its result; until it arrives, render draws your own loading state. | Built in, differently. There is no tool-driven UI: the model reads the result and writes OrderList([...]) in OpenUI Lang, so the data passes through the model. |
| 2 | Progressive render | Built in for pushes: a push under the same id replaces in place (a chart, then five updates). Built in for model-written layouts: with streaming: 'partial' the ui__render tree draws while the model writes it, nodes keep their place, renderers draw skeletons from useGenuiNode().incomplete, and a streaming: 'complete' chart is a placeholder until its props are whole. Only the final, validated tree is stored. | Glue (20 lines) for pushes: read the agora.ui events CopilotKit ignores. Built in for model-written layouts: render receives the partially parsed arguments while they stream. | Built in for model-written UI: each statement renders as it arrives, placeholders for the rest. Tool pushes: no (custom events are ignored). |
| 3 | Model-composed layout | Built in. genui({ catalog }), tree mode being the default: ui__render nests any catalog component, with an exact schema per component, validated node by node. The builtins are definitions only — you write every renderer, and its skeleton. For what no component fits, the sandbox lets the model write the view itself (JSX or HTML, CSS, JavaScript), drawn in an isolated frame with your design system — your styles, your components, your providers — and checked before it is accepted. | Glue (20 lines) to draw the ui__render tree with your renderers, including error boundaries for half-streamed arguments. CopilotKit's own route here is A2UI, which this agent can now serve (agUiAdapter({ a2ui: true }), see A2UI); this matrix did not run it through CopilotKit. | Built in, and the strongest. 84 ready, styled components (charts, tables, forms, tabs, carousels) the model composes freely; no renderer to write. |
| 4 | Interactive component → agent | Built in. Renderers are your React components; a small context reaches chat.sendMessage. The approval that follows is the transcript's call.approve.run(). | Built in. agent.addMessage + copilotkit.runAgent; useInterrupt answers the AG-UI interrupt with resume. | Clicks built in (Action([@ToAssistant(...)]) in Lang, useTriggerAction() in a component). Approvals: glue (46 lines) — OpenUI ignores interrupts. |
| 5 | Persistence (reload, reopen) | Built in. useAgentChat({ threadId }) and useThreads(); components redraw from the message's persisted ui list. | Glue (~70 lines). HttpAgent.connect is not implemented, so history is loaded from GET <path>/threads/:id and replayed as MESSAGES_SNAPSHOT; a thread list is your own (CopilotKit's needs its hosted platform). | Glue (~45 lines). A ChatStorage over the thread routes. The OpenUI Lang source is the message text, so it redraws exactly. |
| 6 | Non-visual surfaces, fallback | Built in. Capability negotiation and server-generated fallbackText: a client that draws only some of the dashboard's components received all of it as readable text (its previews withdrawn), and a text-only client, which is not offered ui__render, received the tools' components as text and no skeleton. The same React renderer renders to PNG/PDF on the server. | No. Rendering is client-side; a tool call with no renderer draws nothing (or the generic card). A text channel gets the prose. | No. The UI is the model's text: a text channel would receive OpenUI Lang source. No fallback, no server render. |
| 7 | Typing, validation, versioning | Built in. A props schema per component, validated on the server before anything streams, and on the client against the same catalog. An invalid ui__render (a chart type the schema does not list) came back to the model as a tool error and it retried. Each component has a version. | No. The zod parameters type the renderer; arguments reach render unvalidated (and partial while they stream). No component versioning. | No. zod props per component, but invalid arguments are dropped silently — the component does not appear and the model is not told. No versioning. |
| 8 | One new component, end to end | A definition in a file both sides import (schema, description, fallbackText, a loading state if it has a skeleton), the React renderer, one registry key. | The React renderer and a useRenderTool, all client-side; the component is bound to a tool, not to a catalog. | Leanest: one defineComponent (zod props, description, React component). The server imports that client module to build the prompt. |
| 9 | Targets, token cost | React; the framework-free browser client for anything else; HTML/PNG/PDF; text channels. ui__render over 9 components adds ~2.1k prompt tokens with its exact schema (~680 with treeSchema: 'loose'). | React, Vue, Angular and React Native packages (only React was run). No prompt of its own. | React, Vue, Svelte, Angular and React Email renderers (only React was run). The full chat library's prompt is ~14k tokens per turn (an 11-component library: ~1.3k). The same dashboard took 29% fewer output tokens than ui__render JSON (198 vs 279). |
| 10 | Model-written view with behavior | Built in. ui__sandbox: the model writes JSX or HTML, CSS and JavaScript; it is checked before it is accepted, streams as placeholder → styled preview → live view, runs in an isolated frame with no network, and sends values back through a checked agent.send. | Not in the library. CopilotKit's OpenIntelligentUI showcase app runs model-written HTML in an iframe, unchecked. Against this agent, draw ui__sandbox with SandboxView (glue). | No. The model composes the library's components; it writes no code of its own. |
| 11 | Model-designed UI in your design system | Built in. A sandbox kit (one Vite plugin, no presets) gives the frame your styles, your components and your providers, and tells the model their props from their types. | Partly. Your renderers draw tool calls in your look, but the model cannot compose a new screen from them. | Partly. Each of your components becomes one defineComponent, and every one adds to the prompt on every turn. |
| 12 | Per-channel delivery | Built in. channels: sandbox on the web, native conversions on WhatsApp, charts as PNG, text elsewhere — and the model is told what was delivered, so it does not describe a screen the user never saw. | No. | No. |
Token counts use the o200k tokenizer on the exact system prompt and tool definitions the model received, and on the two outputs for the same dashboard. Rows 1–9 were run on all three pages. Rows 10–12 were run on the native page (the sandbox scenario) and in this library's tests; the CopilotKit and OpenUI cells there come from their documentation and showcase apps, not from the example's e2e.
A scripted model
The example's model is scripted: it writes what a model would, and shows what each frontend does with it — including invalid output (row 7). It does not measure how reliably a real model writes valid OpenUI Lang or valid ui__render trees. Set AGENT_MODEL=openai:<model> to run the same pages on a real one.
Where each one wins
Native with the sandbox is now the strongest at letting the model design the screen, and it designs it in your app's look. With a sandbox kit the model writes JSX with your own components, your styles (Tailwind, Bootstrap, Sass, plain CSS) and your providers (MUI, emotion), so a view it invents looks like the rest of the app, not like a third-party library. It can write behavior too — state, inputs, a calculation, a chart of a shape no component draws — which a composition of fixed components cannot. The code is checked before it is accepted (it parses, it has an App, it uses only the kit's components and the agent.send bridge), and a failure goes back to the model as a tool error, so it retries instead of failing silently. It streams in an order that is usable before it is finished — the reserved box, then styles, then markup, then behavior — and a text channel gets its title and summary. What it costs: the views run in an isolated frame with no network, the model writes more output than an OpenUI Lang statement for the same screen, and you set up a kit once (one Vite plugin, found through your build — no presets).
OpenUI is the quickest way to model-designed screens when you have no design system to bring. Its library is large and styled out of the box, the model composes it freely, what it writes renders as it streams, a component is one definition, and it spends fewer output tokens for the same UI. What it costs: a long prompt on every turn with its full library, a look that is OpenUI's rather than yours, layouts made only of its components (no behavior of the model's own), UI that exists only as model text — no fallback text, nothing for a text channel, no server render — and invalid output that fails silently. Interactive approvals and history need glue against this agent.
CopilotKit draws a layout while the model is still writing its arguments with no setup, from the raw partial arguments (unvalidated; renderers have to survive half-written props), and it speaks AG-UI interrupts natively (useInterrupt, resume), so approvals work with no glue. It ships more framework targets. Against this agent it needs glue for history, a thread list, and the components tools push.
Native is also the strongest where the server matters: one validated, versioned catalog as the source of truth, the same output on a text channel, an image or a PDF, and persistence, approvals and interactive components with no glue. With streaming: 'partial' it draws a layout while the model writes it, with stable nodes, an incomplete flag for skeletons and only the validated tree stored, and a tool can show its component's skeleton before it reads (Loading states and skeletons). Its catalog builtins have no visuals, so you write those renderers and their skeletons, and its exact ui__render schema costs more prompt than a generic one.
Choosing
- Your own app, and the same agent behind WhatsApp, Slack or e-mail — native. Components are validated once on the server and degrade to text or images on every other surface (per channel).
- Open-ended, model-designed screens in your own design system (calculators, simulations, dashboards, explorations) — native with the sandbox and a kit: catalog components for what you planned, the sandbox for the rest.
- Model-designed screens with no design system of your own, where only the web matters — OpenUI, with a component library of your size to keep the prompt down.
- An app already on CopilotKit, or that needs its Vue/Angular/React Native components — CopilotKit over
agUiAdapter(); budget the glue for history and pushed components.
Sandboxed views and A2UI
Two more pages of the example go past the matrix:
- A sandbox scenario on the native page: with
genui({ catalog, sandbox: true }), the model answers a request no catalog component fits (a bill splitter) with a sandboxed view — the placeholder at its declared height, the styled preview while the markup streams, then the live view in an isolated iframe, whose button sends the values back as the next turn throughGenuiActionProviderandsendUiAction. - An A2UI page: the same agent served by
a2uiAdapter(), its JSON Lines read into@a2ui/web_core'sMessageProcessorand drawn with the official React renderer,@a2ui/react(A2uiSurface,basicCatalog). The builtins arrive mapped onto A2UI's basic catalog, previews grow the same surface, and an approval is an Approve / Reject pair of A2UI buttons. What it gives up next to the native client (no custom renderers without your own A2UI catalog, the sandbox as text) is listed in Known gaps.
Setting up CopilotKit
CopilotKit's v2 API takes an AG-UI HttpAgent directly. Point it at the AG-UI route, and add shield's CSRF header (the session cookie rides by default):
import { CopilotChat, CopilotKitProvider, HttpAgent, useRenderTool } from '@copilotkit/react-core/v2'
import '@copilotkit/react-core/v2/styles.css'
import { z } from 'zod'
const csrfFetch: typeof fetch = (input, init = {}) => {
const token = document.cookie.match(/(?:^|; )XSRF-TOKEN=([^;]*)/)?.[1]
const headers = new Headers(init.headers)
if (token) headers.set('X-XSRF-TOKEN', decodeURIComponent(token))
return fetch(input, { ...init, headers, credentials: 'same-origin' })
}
const agent = new HttpAgent({ url: '/agent/ag-ui', fetch: csrfFetch })
export default function Page({ threadId }: { threadId: string }) {
return (
<CopilotKitProvider agents__unsafe_dev_only={{ default: agent }}>
<Renderers />
<CopilotChat threadId={threadId} />
</CopilotKitProvider>
)
}
function Renderers() {
// A server tool's call, drawn from its result.
useRenderTool({
name: 'list_orders',
parameters: z.object({}),
render: ({ result }) => (result ? <OrderList {...JSON.parse(result)} /> : <p>Loading…</p>),
})
return null
}agents__unsafe_dev_onlyis CopilotKit's name for an agent the browser calls itself. For production CopilotKit expects aCopilotRuntime(@copilotkit/runtime/v2) in front of the agent; it then calls<path>/ag-uiserver to server, and has to forward the session cookie and the CSRF header (forwardHeaders).- Approvals and questions arrive as AG-UI interrupts.
useInterrupt({ render })draws them, andresolve({ approved: true }, interrupt.id)sends theresumethe interrupt section describes. - Components a tool pushes arrive as
agora.uicustom events; read them withagent.subscribe({ onCustomEvent }), keyed bytoolCallId. History: subclassHttpAgentand implementconnect()to replayGET <path>/threads/:idas aMESSAGES_SNAPSHOT. Both are in the example. @ag-ui/client1.0.2 refuses a run that leaves an open interrupt unanswered ("pending interrupt(s) not addressed by resume", read in its code): answer the interrupt before the next message.
See also
- OpenUI — the OpenUI setup: the library prompt on the server, custom components, threads, approvals.
- AG-UI — the route both third-party pages use, and its interrupt and resume contract.
- A2UI — the A2UI route, the builtin mappings, and A2UI over AG-UI.
- Sandboxed generated UI — the sandbox component and its security model.
- What AG-UI costs you — every capability, native stream vs AG-UI.
- Generative UI, Builtin components and Loading states and skeletons — the native side.
Generative UI telemetry
How often the model's UI calls are right the first time, what it gets wrong, what the UI costs and how long the user waits — recorded per UI call as metadata, shown on the dashboard's Generative UI page.
OpenUI
Drive the agent from OpenUI's AgentInterface over AG-UI — the client, the component-library prompt on the server, your own components, threads, approvals, and what OpenUI does not do.