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:

lib/genui-catalog.ts
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 GenUICatalog

If 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:

components/message.tsx
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:

agent/nodes/propose.ts
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:

components/thread.tsx
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:

lib/genui-tools.ts
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:

components/chat.tsx
"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:

EventEffect
TOOL_CALL_STARTAdds a call in the streaming state
TOOL_CALL_ARGSRe-parses the arguments so far, so the component fills in as the agent writes
TOOL_CALL_ENDThe call is ready. Arguments that aren't valid JSON fail it
TOOL_CALL_CHUNKThe shorthand for the three above
TOOL_CALL_RESULTStores the result (parsed from JSON), which restores the final state
RUN_ERRORFails the calls still streaming, with the error's message. Calls that finished stay usable
RUN_FINISHEDCloses any call the agent never ended
MESSAGES_SNAPSHOTRebuilds 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" } }]
    } }
  ]
}
  • $bind is the sibling's id, then a dot path into its result (slot.label, prefs.values.guests).
  • default is 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 raw start). 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.

  1. Give each tool result a short ref id the model can see, e.g. { "ref": "search", "results": [...] }.
  2. Build the prompt with refs: true. That adds the rule to the system prompt and lets every scalar field in each tool's jsonSchema take { "$ref": "..." }. With the AI SDK, pass jsonSchema(tools[name].jsonSchema) as the input schema, so provider validation accepts refs.
  3. 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:

app/api/chat/route.ts
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 results

With 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>

PropTypeNotes
callGenUICallFrom an adapter, or built yourself
catalogGenUICatalogName → component
onResult(call, result) => voidSend the user's decision back to the agent
fallbackReactNode?For names the catalog doesn't have
refsRecord<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

FieldTypeNotes
idstringThe tool call ID, or the UI message ID
namestringCatalog key
propsRecord<string, unknown>What the agent generated
status"streaming" | "ready" | "failed"
resultunknown?A stored result, which restores the final state
errorstring?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 }.

Source

On this page