Awesome-omni-skill ai-generative-ui
Data-driven generative UI — tool results render as rich React components in chat instead of raw JSON. Uses a registry pattern with _ui field, not createStreamableUI(). Use this skill when the user says "generative ui", "rich tool cards", "custom tool rendering", or "tool components".
git clone https://github.com/diegosouzapw/awesome-omni-skill
T=$(mktemp -d) && git clone --depth=1 https://github.com/diegosouzapw/awesome-omni-skill "$T" && mkdir -p ~/.claude/skills && cp -r "$T/skills/frontend/ai-generative-ui" ~/.claude/skills/diegosouzapw-awesome-omni-skill-ai-generative-ui && rm -rf "$T"
skills/frontend/ai-generative-ui/SKILL.mdAI Generative UI
Experience layer that renders tool results as interactive React components inside the chat stream instead of raw JSON. Uses a data-driven approach: tool
execute functions return a _ui field that names a registered component, and the client-side renderer looks it up in a registry.
This does not use
createStreamableUI() (that is an older RSC pattern incompatible with the current architecture). Instead, tool results are plain data objects with a _ui type hint that the client uses to select the appropriate React component.
Prerequisites
- Next.js app with
directory and App Routersrc/
skill installed (providesai-core
)getModel()
skill installed (provides chat UI, route pipeline, message renderer)ai-chat
skill installed (provides tool calling framework andai-tools
rendering)tool-invocation
Installation
No additional packages required. Uses
ai, zod, and @ai-sdk/react already installed by prerequisite skills.
What Gets Created
src/ ├── lib/ │ └── ai/ │ └── ui-registry.ts # Component registry — maps tool _ui values to React components └── components/ └── ai/ └── gen-ui/ ├── weather-card.tsx # Example: weather display with temperature + conditions ├── data-card.tsx # Example: structured key-value display card └── confirmation.tsx # Example: interactive yes/no confirmation card
What Gets Modified
src/ ├── app/ │ └── api/ │ └── ai/ │ └── chat/ │ └── route.ts # Tool execute functions return _ui field └── components/ └── ai/ └── message.tsx # Check tool-result for _ui field, render registered component
Comment Slots
- message.tsx:
— checks tool results for// [ai-generative-ui]: check for _ui field
field and renders registered component_ui - message.tsx:
— side-effect imports that register components// [ai-generative-ui]: import gen-ui components to trigger registration
Setup Steps
Step 1: Create src/lib/ai/ui-registry.ts
src/lib/ai/ui-registry.tsimport { type ComponentType } from "react"; /** * Registry mapping `_ui` field values from tool results to React components. * * When a tool's `execute` function returns `{ ...data, _ui: "WeatherCard" }`, * the message renderer looks up "WeatherCard" in this registry and renders * the matched component with the tool result as the `data` prop. * * If no component is found, the renderer falls back to the default JSON * tool result card from ai-tools. */ type GenUIProps<T = Record<string, unknown>> = { data: T; }; type GenUIComponent = ComponentType<GenUIProps>; const registry = new Map<string, GenUIComponent>(); /** * Register a component for a given _ui key. * Call this at module scope in your component files. */ export function registerUIComponent( key: string, component: GenUIComponent ): void { registry.set(key, component); } /** * Look up a component by _ui key. * Returns undefined if no component is registered. */ export function getUIComponent( key: string ): GenUIComponent | undefined { return registry.get(key); } /** * Check if a tool result has a _ui field that maps to a registered component. */ export function hasUIComponent(result: unknown): result is { _ui: string; [key: string]: unknown; } { return ( typeof result === "object" && result !== null && "_ui" in result && typeof (result as Record<string, unknown>)._ui === "string" && registry.has((result as Record<string, unknown>)._ui as string) ); } export type { GenUIProps, GenUIComponent };
Step 2: Create src/components/ai/gen-ui/weather-card.tsx
src/components/ai/gen-ui/weather-card.tsx"use client"; import { memo } from "react"; import { registerUIComponent, type GenUIProps } from "@/lib/ai/ui-registry"; type WeatherData = { location: string; temperature: number; unit: string; conditions: string; humidity: number; windSpeed: number; windUnit: string; feelsLike: number; _ui: string; }; function getWeatherIcon(conditions: string): string { const lower = conditions.toLowerCase(); if (lower.includes("sun") || lower.includes("clear")) return "sun"; if (lower.includes("cloud") && lower.includes("part")) return "cloud-sun"; if (lower.includes("cloud")) return "cloud"; if (lower.includes("rain") || lower.includes("drizzle")) return "cloud-rain"; if (lower.includes("snow")) return "snowflake"; if (lower.includes("thunder") || lower.includes("storm")) return "cloud-lightning"; if (lower.includes("fog") || lower.includes("mist")) return "cloud-fog"; return "thermometer"; } function WeatherIcon({ conditions }: { conditions: string }) { const icon = getWeatherIcon(conditions); const icons: Record<string, React.ReactNode> = { sun: ( <svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="text-yellow-500" > <circle cx="12" cy="12" r="4" /> <path d="M12 2v2" /> <path d="M12 20v2" /> <path d="m4.93 4.93 1.41 1.41" /> <path d="m17.66 17.66 1.41 1.41" /> <path d="M2 12h2" /> <path d="M20 12h2" /> <path d="m6.34 17.66-1.41 1.41" /> <path d="m19.07 4.93-1.41 1.41" /> </svg> ), cloud: ( <svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="text-gray-400" > <path d="M17.5 19H9a7 7 0 1 1 6.71-9h1.79a4.5 4.5 0 1 1 0 9Z" /> </svg> ), "cloud-rain": ( <svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="text-blue-400" > <path d="M4 14.899A7 7 0 1 1 15.71 8h1.79a4.5 4.5 0 0 1 2.5 8.242" /> <path d="M16 14v6" /> <path d="M8 14v6" /> <path d="M12 16v6" /> </svg> ), thermometer: ( <svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="text-orange-400" > <path d="M14 4v10.54a4 4 0 1 1-4 0V4a2 2 0 0 1 4 0Z" /> </svg> ), }; return <>{icons[icon] ?? icons.thermometer}</>; } const WeatherCard = memo(function WeatherCard({ data }: GenUIProps<WeatherData>) { return ( <div className="overflow-hidden rounded-xl border bg-gradient-to-br from-blue-50 to-sky-50 dark:from-blue-950 dark:to-sky-950"> <div className="p-4"> {/* Location + Icon row */} <div className="flex items-start justify-between"> <div> <p className="text-sm font-medium text-muted-foreground"> {data.location} </p> <p className="mt-1 text-3xl font-bold tracking-tight"> {Math.round(data.temperature)}{data.unit === "celsius" ? "\u00B0C" : "\u00B0F"} </p> </div> <WeatherIcon conditions={data.conditions} /> </div> {/* Conditions */} <p className="mt-1 text-sm capitalize text-muted-foreground"> {data.conditions} </p> {/* Details row */} <div className="mt-4 grid grid-cols-3 gap-3 border-t pt-3"> <div> <p className="text-xs text-muted-foreground">Feels like</p> <p className="text-sm font-medium"> {Math.round(data.feelsLike)}{data.unit === "celsius" ? "\u00B0" : "\u00B0"} </p> </div> <div> <p className="text-xs text-muted-foreground">Humidity</p> <p className="text-sm font-medium">{data.humidity}%</p> </div> <div> <p className="text-xs text-muted-foreground">Wind</p> <p className="text-sm font-medium"> {data.windSpeed} {data.windUnit} </p> </div> </div> </div> </div> ); }); // Register the component so the message renderer can find it registerUIComponent("WeatherCard", WeatherCard as unknown as React.ComponentType<GenUIProps>); export { WeatherCard };
Step 3: Create src/components/ai/gen-ui/data-card.tsx
src/components/ai/gen-ui/data-card.tsx"use client"; import { useId, memo } from "react"; import { registerUIComponent, type GenUIProps } from "@/lib/ai/ui-registry"; type DataCardData = { title: string; subtitle?: string; fields: Array<{ label: string; value: string | number | boolean; type?: "text" | "number" | "badge" | "link"; }>; _ui: string; }; const DataCard = memo(function DataCard({ data }: GenUIProps<DataCardData>) { const fieldId = useId(); return ( <div className="overflow-hidden rounded-xl border"> {/* Header */} <div className="border-b bg-muted/50 px-4 py-3"> <h3 className="text-sm font-semibold">{data.title}</h3> {data.subtitle && ( <p className="mt-0.5 text-xs text-muted-foreground"> {data.subtitle} </p> )} </div> {/* Fields */} <div className="divide-y"> {data.fields.map((field) => ( <div key={`${fieldId}-${field.label}`} className="flex items-center justify-between px-4 py-2.5" > <span className="text-sm text-muted-foreground"> {field.label} </span> <span className="text-sm font-medium"> {field.type === "badge" ? ( <span className="inline-flex items-center rounded-full bg-primary/10 px-2 py-0.5 text-xs font-medium text-primary"> {String(field.value)} </span> ) : field.type === "link" ? ( <a href={String(field.value)} target="_blank" rel="noopener noreferrer" className="text-primary underline-offset-4 hover:underline" > {String(field.value)} </a> ) : ( String(field.value) )} </span> </div> ))} </div> </div> ); }); registerUIComponent("DataCard", DataCard as unknown as React.ComponentType<GenUIProps>); export { DataCard };
Step 4: Create src/components/ai/gen-ui/confirmation.tsx
src/components/ai/gen-ui/confirmation.tsx"use client"; import { useState, useCallback, memo } from "react"; import { registerUIComponent, type GenUIProps } from "@/lib/ai/ui-registry"; type ConfirmationData = { title: string; description: string; confirmLabel?: string; cancelLabel?: string; callbackUrl?: string; payload?: Record<string, unknown>; _ui: string; }; type ConfirmationState = "pending" | "confirmed" | "cancelled" | "loading"; const Confirmation = memo(function Confirmation({ data }: GenUIProps<ConfirmationData>) { const [state, setState] = useState<ConfirmationState>("pending"); const handleAction = useCallback( async (action: "confirmed" | "cancelled") => { if (!data.callbackUrl) { setState(action); return; } setState("loading"); try { await fetch(data.callbackUrl, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ action, ...(data.payload ?? {}), }), }); setState(action); } catch { setState(action); } }, [data.callbackUrl, data.payload] ); if (state === "confirmed") { return ( <div className="flex items-center gap-2 rounded-xl border border-green-200 bg-green-50 px-4 py-3 dark:border-green-800 dark:bg-green-950"> <svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="text-green-600 dark:text-green-400" > <path d="M22 11.08V12a10 10 0 1 1-5.93-9.14" /> <polyline points="22 4 12 14.01 9 11.01" /> </svg> <p className="text-sm font-medium text-green-700 dark:text-green-300"> Confirmed: {data.title} </p> </div> ); } if (state === "cancelled") { return ( <div className="flex items-center gap-2 rounded-xl border border-gray-200 bg-gray-50 px-4 py-3 dark:border-gray-700 dark:bg-gray-900"> <svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="text-gray-400" > <circle cx="12" cy="12" r="10" /> <path d="m15 9-6 6" /> <path d="m9 9 6 6" /> </svg> <p className="text-sm text-muted-foreground"> Cancelled: {data.title} </p> </div> ); } return ( <div className="overflow-hidden rounded-xl border"> <div className="p-4"> <h3 className="text-sm font-semibold">{data.title}</h3> <p className="mt-1 text-sm text-muted-foreground"> {data.description} </p> </div> <div className="flex gap-2 border-t bg-muted/30 px-4 py-3"> <button type="button" onClick={() => handleAction("confirmed")} disabled={state === "loading"} className="inline-flex items-center justify-center rounded-lg bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90 disabled:opacity-50" > {state === "loading" ? ( <span className="flex items-center gap-2"> <span className="h-3.5 w-3.5 animate-spin rounded-full border-2 border-primary-foreground border-t-transparent" /> Processing... </span> ) : ( data.confirmLabel ?? "Confirm" )} </button> <button type="button" onClick={() => handleAction("cancelled")} disabled={state === "loading"} className="inline-flex items-center justify-center rounded-lg border px-4 py-2 text-sm font-medium transition-colors hover:bg-muted disabled:opacity-50" > {data.cancelLabel ?? "Cancel"} </button> </div> </div> ); }); registerUIComponent("Confirmation", Confirmation as unknown as React.ComponentType<GenUIProps>); export { Confirmation };
Step 5: Modify src/components/ai/message.tsx
src/components/ai/message.tsxUpdate the
tool-invocation case to check for a _ui field on tool results before falling back to the default tool card. This must be inserted before any existing artifact check or the default ToolInvocationCard.
Find this in
src/components/ai/message.tsx:
case "tool-invocation": { return ( <ToolInvocationCard key={`${partId}-${index}`} part={part} /> ); }
Replace with:
case "tool-invocation": { // [ai-generative-ui]: check for _ui field → render registered component if ( part.state === "output-available" && part.output && hasUIComponent(part.output) ) { const Component = getUIComponent(part.output._ui); if (Component) { return ( <Component key={`${partId}-${index}`} data={part.output as Record<string, unknown>} /> ); } } return ( <ToolInvocationCard key={`${partId}-${index}`} part={part} /> ); }
Add the imports at the top of the file. Find:
import { useId } from "react";
Replace with:
import { useId } from "react"; import { hasUIComponent, getUIComponent } from "@/lib/ai/ui-registry"; // [ai-generative-ui]: import gen-ui components to trigger registration import "@/components/ai/gen-ui/weather-card"; import "@/components/ai/gen-ui/data-card"; import "@/components/ai/gen-ui/confirmation";
Important: If
ai-artifacts is also installed, the generative UI check should come before the artifact check, since _ui is a more specific match. The combined case would look like:
case "tool-invocation": { // [ai-generative-ui]: check for _ui field → render registered component if ( part.state === "output-available" && part.output && hasUIComponent(part.output) ) { const Component = getUIComponent(part.output._ui); if (Component) { return ( <Component key={`${partId}-${index}`} data={part.output as Record<string, unknown>} /> ); } } // [ai-artifacts]: render artifact refs as clickable cards if ( part.state === "output-available" && part.output && typeof part.output === "object" && "_artifact" in part.output ) { const artifactResult = part.output as { id: string; type: string; title: string; _update?: boolean; }; return ( <ArtifactCard key={`${partId}-${index}`} id={artifactResult.id} type={artifactResult.type} title={artifactResult.title} isUpdate={!!artifactResult._update} onClick={onArtifactClick} /> ); } return ( <ToolInvocationCard key={`${partId}-${index}`} part={part} /> ); }
Step 6: Modify src/app/api/ai/chat/route.ts
src/app/api/ai/chat/route.tsUpdate existing tool definitions to include
_ui fields in their return values. This example shows how to add _ui to the weather tool (from ai-tools). Apply the same pattern to any tool that should render a custom component.
Find this in
src/lib/ai/tools/index.ts (or wherever the weather tool is defined):
export const weather = tool({ description: "Get current weather for a location", parameters: z.object({ location: z.string().describe("City name or location"), }), execute: async ({ location }) => {
After the existing
execute return statement, ensure it returns a _ui field:
execute: async ({ location }) => { // ... existing weather fetch logic ... return { location, temperature: data.temperature, unit: "celsius", conditions: data.conditions, humidity: data.humidity, windSpeed: data.windSpeed, windUnit: "km/h", feelsLike: data.feelsLike, _ui: "WeatherCard", }; },
For tools that should render a structured data card, return
_ui: "DataCard":
execute: async (params) => { // ... compute result ... return { title: "Calculation Result", fields: [ { label: "Expression", value: params.expression }, { label: "Result", value: result, type: "number" }, ], _ui: "DataCard", }; },
For tools that need user confirmation before proceeding, return
_ui: "Confirmation":
execute: async (params) => { return { title: "Delete all completed tasks?", description: "This will permanently remove 5 completed tasks.", confirmLabel: "Delete", cancelLabel: "Keep", callbackUrl: "/api/ai/sessions/tasks/bulk-delete", payload: { status: "done" }, _ui: "Confirmation", }; },
Usage
Adding Custom Generative UI Components
To create a new generative UI component:
- Create the component in
:src/components/ai/gen-ui/
"use client"; import { registerUIComponent, type GenUIProps } from "@/lib/ai/ui-registry"; type StockData = { symbol: string; price: number; change: number; changePercent: number; _ui: string; }; function StockCard({ data }: GenUIProps<StockData>) { const isPositive = data.change >= 0; return ( <div className="rounded-xl border p-4"> <div className="flex items-center justify-between"> <span className="font-bold">{data.symbol}</span> <span className={isPositive ? "text-green-600" : "text-red-600"}> {isPositive ? "+" : ""}{data.changePercent.toFixed(2)}% </span> </div> <p className="text-2xl font-bold">${data.price.toFixed(2)}</p> </div> ); } registerUIComponent("StockCard", StockCard as unknown as React.ComponentType<GenUIProps>); export { StockCard };
- Import the component in
to trigger registration:message.tsx
import "@/components/ai/gen-ui/stock-card";
- Return
from the tool in your tool's_ui
function:execute
return { symbol: "AAPL", price: 182.5, change: 3.2, changePercent: 1.78, _ui: "StockCard" };
Fallback Behavior
When a tool result contains a
_ui field but no component is registered for that key, or when the result has no _ui field at all, the renderer falls back to the default ToolInvocationCard from ai-tools (which shows the tool name, parameters, and JSON result in a collapsible card).
This means you can incrementally add generative UI components. Tools without
_ui fields continue to work exactly as before.
Acceptance Criteria
- Ask "what's the weather in Tokyo" -- WeatherCard component renders with temperature, conditions, humidity, and wind (not raw JSON)
- Ask a math question that uses the calculator tool -- default tool card renders (calculator has no
field or no registered component)_ui - The DataCard component renders structured key-value pairs when a tool returns
_ui: "DataCard" - The Confirmation component renders confirm/cancel buttons and transitions to confirmed/cancelled state on click
- Unregistered
values fall back gracefully to the default JSON tool result card_ui - Tools without a
field render the default collapsible tool card_ui - Adding a new generative UI component requires only: (1) create component file with
, (2) import it in message.tsx, (3) returnregisterUIComponent
from tool_ui
passes with no errorstsc