Renderer
Turn an agent's tool calls into components, composed into one response. Works with the Vercel AI SDK, LangGraph and AG-UI.
The Renderer is the piece that turns a generated-UI call into a component. It looks up the call's name in your catalog, passes the props the agent generated, restores results from history, and sends the user's decision back. Several calls in one reply stack into one response.
See it on the home page: one reply, four tool calls, streaming in one after another.
Installation
npx shadcn@latest add https://your-site.com/r/renderer.json
This adds genui-renderer.tsx, genui-adapters.ts (AI SDK and LangGraph), genui-agui.ts (AG-UI), genui-prompt.ts (system prompt and tools), genui-context.ts (refs, binds and UI state), genui-validate.ts (errors worded for the model) and genui-markdown.ts (a text fallback). All plain functions with no dependency on any framework.
The catalog
Map the tool names your agent calls to the components you installed:
import type { GenUICatalog } from "@/components/genui/genui-renderer"
import { ChangePreview } from "@/components/genui/change-preview"
import { DataTable } from "@/components/genui/data-table"
import { FollowUpChips } from "@/components/genui/follow-up-chips"
export const catalog = {
propose_changes: ChangePreview,
show_table: DataTable,
suggest_follow_ups: FollowUpChips,
} satisfies GenUICatalogIf a call names a component the catalog doesn't have, the Renderer says so in one muted line, so nothing in the agent's response disappears silently. Pass fallback to render something else.
With the Vercel AI SDK
Register each component's tool definition on the server (see each component's page). On the client, turn tool parts into calls with fromAISDK:
import { GenUIRenderer, GenUIStack } from "@/components/genui/genui-renderer"
import { fromAISDK } from "@/components/genui/genui-adapters"
import { catalog } from "@/lib/genui-catalog"
export function AssistantMessage({ message, addToolResult }) {
return (
<GenUIStack>
{message.parts.map((part, i) => {
if (part.type === "text") return <p key={i}>{part.text}</p>
const call = fromAISDK(part)
if (!call) return null
return (
<GenUIRenderer
key={call.id}
call={call}
catalog={catalog}
onResult={(call, result) =>
addToolResult({ tool: call.name, toolCallId: call.id, output: result })
}
/>
)
})}
</GenUIStack>
)
}fromAISDK maps input-streaming to the streaming state. When a part has a stored output, it's passed as the result, so a reloaded conversation shows each component's final state instead of asking again.
With LangGraph
On the server, a graph node pushes a UI message for the tool call. Include toolCallId in the props so the user's answer can be matched to it:
import { typedUi } from "@langchain/langgraph-sdk/react-ui/server"
import type { catalog } from "@/lib/genui-catalog"
export async function propose(state, config) {
const ui = typedUi<typeof catalog>(config)
const message = await llm.bindTools(tools).invoke(state.messages)
const call = message.tool_calls?.find((c) => c.name === "propose_changes")
if (call) {
ui.push(
{ name: "propose_changes", props: { toolCallId: call.id, ...call.args } },
{ message },
)
}
return { messages: [message], ui: ui.items }
}On the client, render the thread's UI messages with fromLangGraph, and answer with a tool message:
import { useStream } from "@langchain/langgraph-sdk/react"
import { GenUIRenderer } from "@/components/genui/genui-renderer"
import { fromLangGraph, toLangGraphToolMessage } from "@/components/genui/genui-adapters"
import { catalog } from "@/lib/genui-catalog"
export function Thread() {
const thread = useStream({ apiUrl, assistantId })
return thread.values.ui?.map((ui) => (
<GenUIRenderer
key={ui.id}
call={fromLangGraph(ui, thread.messages)}
catalog={catalog}
onResult={(call, result) =>
thread.submit({ messages: [toLangGraphToolMessage(call, result)] })
}
/>
))
}fromLangGraph looks for a tool message answering the call, so results already in the thread restore the final state. The result travels as JSON in the tool message's content, which the agent reads like any tool result.
These snippets follow the pattern in LangChain's LangGraph.js generative UI examples (archived February 2026). Check the current LangGraph docs for API changes.
AG-UI (CopilotKit, LangGraph via AG-UI, others)
AG-UI is an open event protocol between agents and frontends. CopilotKit speaks it, and so do agent frameworks with AG-UI integrations, LangGraph among them. An agent streams events such as TOOL_CALL_START, TOOL_CALL_ARGS and TOOL_CALL_END, and the frontend answers its own tools with tool messages in the next run.
genui-agui.ts turns that stream into calls. reduceAGUI is a pure reducer, so it plugs straight into useReducer, and it has no dependency on any AG-UI package.
Declare the components as frontend tools. AG-UI tools take a JSON Schema, which Zod generates from each component's schema:
import { z } from "zod"
import { changePreviewTool } from "@/components/genui/change-preview.schema"
import { dataTableTool } from "@/components/genui/data-table.schema"
export const tools = [changePreviewTool, dataTableTool].map((tool) => ({
name: tool.name,
description: tool.description,
parameters: z.toJSONSchema(tool.inputSchema),
}))Then feed every event to the reducer and render the calls:
"use client"
import { useEffect, useReducer } from "react"
import { HttpAgent } from "@ag-ui/client"
import { GenUIRenderer, GenUIStack } from "@/components/genui/genui-renderer"
import {
callsFromAGUI,
createAGUIState,
reduceAGUI,
toAGUIToolMessage,
} from "@/components/genui/genui-agui"
import { catalog } from "@/lib/genui-catalog"
import { tools } from "@/lib/genui-tools"
const agent = new HttpAgent({ url: "/api/agent" })
export function Chat() {
const [state, dispatch] = useReducer(reduceAGUI, undefined, () =>
// Only track tools the catalog can render.
createAGUIState({ names: Object.keys(catalog) }),
)
useEffect(() => {
const { unsubscribe } = agent.subscribe({
onEvent: ({ event }) => dispatch(event),
})
return unsubscribe
}, [])
return (
<GenUIStack>
{callsFromAGUI(state).map((call) => (
<GenUIRenderer
key={call.id}
call={call}
catalog={catalog}
onResult={(call, result) => {
agent.addMessage(toAGUIToolMessage(call, result))
agent.runAgent({ tools })
}}
/>
))}
</GenUIStack>
)
}The client is only a source of events. Anything that yields AG-UI events works the same way: call dispatch(event) for each one, whether they come from a subscriber, an Observable or a for await loop over your own stream.
What the reducer does with each event:
| Event | Effect |
|---|---|
TOOL_CALL_START | Adds a call in the streaming state |
TOOL_CALL_ARGS | Re-parses the arguments so far, so the component fills in as the agent writes |
TOOL_CALL_END | The call is ready. Arguments that aren't valid JSON fail it |
TOOL_CALL_CHUNK | The shorthand for the three above |
TOOL_CALL_RESULT | Stores the result (parsed from JSON), which restores the final state |
RUN_ERROR | Fails the calls still streaming, with the error's message. Calls that finished stay usable |
RUN_FINISHED | Closes any call the agent never ended |
MESSAGES_SNAPSHOT | Rebuilds the calls from the conversation |
Arguments arrive as fragments of JSON text, which aren't valid JSON until the call ends. parsePartialJson reads them anyway: it closes open strings, objects and arrays, completes tru to true, and leaves out a key until its value starts. It never throws, and it's exported if you need it elsewhere.
Answering. toAGUIToolMessage(call, result) returns { id, role: "tool", toolCallId, content } with the result as JSON. Add it to the agent's messages and start the next run, and the agent reads the user's decision as the tool's result.
Restoring. For a stored conversation, callsFromAGUIMessages(messages, { names }) builds the calls from assistant messages' toolCalls and matches each tool message by toolCallId, so components show their final state instead of asking again. A tool message with an error shows the failed state. The reducer does the same when a MESSAGES_SNAPSHOT arrives.
The event and message shapes follow the AG-UI events, messages and tools docs. The client calls (subscribe, addMessage, runAgent) are from @ag-ui/client. Check its docs for API changes.
Composing a response
GenUIStack spaces a reply the way the components expect: text and components 12px apart, stacked components 24px apart. Put text and renderers directly inside it, in the order the agent produced them.
Layouts
Stack, Grid, Split, Steps, Tabs and Columns hold other components. A layout marks itself with genuiLayout = true, and the Renderer passes it a renderChild(child, key) function. Children render from the same catalog, so nothing else needs wiring. This is how the agent composes shapes nobody pre-designed: a KPI over a chart, a map beside its list, a picker above its confirmation.
- One level of nesting. A layout may hold another layout (a stack with columns inside), and no deeper. A third level renders a notice instead.
- One tool call, one result per child. A layout is a single tool call. Each interactive child's result comes back as
{ child, result }(nested:{ child, result: { child, result } }), and a stored{ child, result }restores that child.
Binds: one component drives another
Inside a layout, a child's prop may point at a sibling's latest result instead of holding a literal. The Renderer copies the value in each time the sibling sends a result, with no call to the model:
{
"steps": [
{ "id": "slot", "component": "pick_slot", "props": { "…": "…" } },
{ "component": "request_approval", "props": {
"scope": [{ "label": "Drop-off", "value": { "$bind": "slot.label", "default": "Pick a time above" } }]
} }
]
}$bindis the sibling'sid, then a dot path into its result (slot.label,prefs.values.guests).defaultis used until the sibling has sent a result. Without one, the field is left out, and the component shows its own default.- Binds only copy values, unformatted. Bind to a display-ready field when the result has one (the slot picker returns
label, e.g. "Monday, October 12 at 9:30 AM", next to the rawstart). There are no expressions, so a wrong number can't be computed on the way. Put arithmetic in a Calculator, in the receiving component, or in a tool. - Refs and binds compose: a bound value can't contain a ref, but a child's other props can.
buildGenUIPrompt adds the rule to the system prompt (bindsRule), and every layout's child props accept binds. Resolve them yourself with resolveBinds(props, { slot: latestResult }) when you render outside the Renderer.
What a component does with a changed prop is the component's business: display components re-render; components that hold the user's edits (a calculator, a scaler) follow the bound value until the user touches the field, and keep the user's value after.
Values from tool results (refs)
Models retype values badly: a price gets rounded, an image URL gets a typo, an ID gets made up. With refs, the model points at a value in a tool result and the host fills it in, so prices, URLs, IDs and names arrive exactly as the tool returned them.
- Give each tool result a short ref id the model can see, e.g.
{ "ref": "search", "results": [...] }. - Build the prompt with
refs: true. That adds the rule to the system prompt and lets every scalar field in each tool'sjsonSchematake{ "$ref": "..." }. With the AI SDK, passjsonSchema(tools[name].jsonSchema)as the input schema, so provider validation accepts refs. - Pass the results to the Renderer as
refs. Refs resolve before the component renders. A ref that doesn't resolve is dropped, never guessed: the component shows its loading state while streaming, and leaves the field out once ready.
const { system, tools } = buildGenUIPrompt(entries, { refs: true })
// The model writes: { "src": { "$ref": "search.results.0.image" } }
<GenUIRenderer call={call} catalog={catalog} refs={{ search: searchResult }} />To validate on the server, resolve first, then parse: schema.parse(resolveRefs(input, sources).value). resolveRefs also returns the refs it couldn't find, as missing. Paths use dots or brackets (results[0].image). Only own properties are read, and prototype keys are rejected.
Returning validation errors to the model
Invalid fields in a call are dropped, and the model never learns it made a mistake, so it repeats it. validateGenUIInput parses the input with the component's schema (after resolving refs) and, when it fails, words the problems for the model. Send that back as the tool's result:
import { tool } from "ai"
import { validateGenUIInput } from "@/components/genui/genui-validate"
import { priceDetailsTool } from "@/components/genui/price-details.schema"
const tools = {
[priceDetailsTool.name]: tool({
description: priceDetailsTool.description,
inputSchema: z.any(), // validate yourself, so refs and the wording are yours
execute: async (input) => {
const check = validateGenUIInput(priceDetailsTool.inputSchema, input, refs)
if (!check.ok) return { error: check.message }
// ...render on the client; the result arrives from the component
},
}),
}The message reads like this, one line per problem, at most 12:
The interface wasn't shown: the call didn't match the component's schema. Fix these and call again:
- price.current: expected number, received string
- $ref "search.results.9.image": not found in the tool resultsWith AG-UI, put the message in the tool message's error; with LangGraph, return it as the tool message's content. formatIssues(zodError.issues) is exported for your own wording.
It pays most on small models. In our eval (15 prompts, 28 tools), Claude Haiku 5.5 went from 10/15 to 14/15 with one round trip: it had picked the right layout but written a nested array as a JSON string, and fixed it when told. Opus 5.5 and Sonnet 5.5 passed 15/15 without a retry.
A Markdown fallback
Some hosts can't render components: a plain-text client, an email digest, a transcript, a log. callToMarkdown(call) writes a call as Markdown from its props: a heading from the title, bullet lines for scalar fields, tables for lists of records. Interactive parts become text, so say in prose what the user can ask for.
import { callToMarkdown } from "@/components/genui/genui-markdown"
callToMarkdown(call)
// ### Revenue by region
// - **Type:** bar
// **Data**
// | Region | Q2 | Q3 |
// | --- | --- | --- |
// | North America | 1840000 | 1925000 |
callToMarkdown(call, { formatters: { show_timer: (p) => `Timer: ${p.label}, ${p.seconds}s` } })A formatter that returns null leaves the call out. The output is capped at 2,000 characters (maxChars). It's generic, so it reads well for display components and roughly for everything else; add a formatter for any tool whose fallback you want worded by hand.
Telling the model what the user changed
Interactive components report every change through onResult. The model doesn't see the interface, so on the next turn it would work from the old state. describeUIState turns the latest results into a short note you add to the next message:
const note = describeUIState(
[{ id: call.id, name: call.name, result: latestResult }],
{ formatters: { show_itinerary: (r) => `Removed: ${r.removed.join(", ")}` } },
)
// "Interface state the user set:\n- show_itinerary (call_1): Removed: Lombard Street"Without a formatter, the result is included as JSON. A formatter that returns null leaves that call out. The note is capped at 1,200 characters by default (maxChars).
API
<GenUIRenderer>
| Prop | Type | Notes |
|---|---|---|
call | GenUICall | From an adapter, or built yourself |
catalog | GenUICatalog | Name → component |
onResult | (call, result) => void | Send the user's decision back to the agent |
fallback | ReactNode? | For names the catalog doesn't have |
refs | Record<string, unknown>? | Tool results the model can point into with { $ref } |
onRetry | (call) => void? | Failed calls show Try again |
onRefresh | (call) => void? | Stale, expired, empty and failed calls show a refresh action |
GenUICall
| Field | Type | Notes |
|---|---|---|
id | string | The tool call ID, or the UI message ID |
name | string | Catalog key |
props | Record<string, unknown> | What the agent generated |
status | "streaming" | "ready" | "failed" | |
result | unknown? | A stored result, which restores the final state |
error | string? | Failed calls: what went wrong |
GenUIChild (layout children): { component: string; props?: Record<string, unknown>; id?: string }. A layout's result is { child: string; result: unknown }.