Generative UI, safely
@mihcm/ai-ui lets a language model return a UI as JSON. The renderer validates the descriptor, looks up each component in an explicit allowlist, caps recursion depth, and renders via the universal primitives. The same renderer works on React, Next.js, and React Native — same input, same output.
Server — define the tool
import { streamText } from 'ai';
import { renderUITool } from '@mihcm/ai-ui/tools';
const result = streamText({
model: 'anthropic/claude-sonnet-4-6',
tools: { renderUI: renderUITool },
prompt: userMessage,
});The tool's schema constrains what the model can produce. It cannot hallucinate component names, prop shapes, or recursive structures — the Zod schema rejects anything off the allowlist before the tool call returns.
Client — render the descriptor
import { renderDescriptor } from '@mihcm/ai-ui';
return renderDescriptor(descriptor, {
actions: { save, cancel },
});The actions map is the only escape hatch. The model references handlers by id — onActionId: 'save' — and the renderer looks them up in this map. There is no way for a model to define a handler or pass a URL through; only references to handlers the host supplies will fire.
Security guarantees
The renderer enforces five invariants, in order:
- Zod parse. Malformed descriptors throw
AIUIRenderErrorand never paint. - Allowlist. Component names not in
ALLOWED_COMPONENTSare rejected, even if Zod somehow accepts them — defense in depth. - Depth cap. Maximum recursion depth of 5. Prompt-injected explosions terminate before they finish parsing.
- Action lookup by id. Handlers come from the host's
actionsmap. The model never gets to define a function body. - No raw HTML. There is no descriptor shape that produces a string of HTML. The renderer renders components only.
- Safe URLs only. Descriptor URLs are scheme-restricted. Avatar image URLs must be
https:; Breadcrumb links must be root-relative, hash links, orhttps:URLs.javascript:,data:, and protocol-relative URLs are rejected by Zod.
The full threat model — including the dangerouslySetInnerHTML ban, the no-eval rule, and the audit checklist for adding a new descriptor — lives in docs/security-playbook.md §6.
Live demo
Try the interactive playground — paste a descriptor and see it rendered in real time.
Today's allowlist
The AI renderer is intentionally smaller than the full UI package. A component is model-renderable only when it has all of these controls:
- A Zod schema in
src/packages/ai-ui/src/schemas/<Name>.ts. - An entry in
componentDescriptorSchema. - An entry in
ALLOWED_COMPONENTS. - A renderer
switchcase. - A coverage entry in
AI_UI_COMPONENT_COVERAGE.
The package test (pnpm -F @mihcm/ai-ui test) fails if the UI package adds a primitive without an explicit AI-UI decision, or if a supported component is missing its schema, allowlist entry, or renderer wiring.
Supported descriptors — the model can produce any of these as JSON:
Accordion— bounded item array; content is plain text.Alert— contextual feedback withvariantand optionaltitle.AlertDialog— host-owned open state; text labels only.Avatar— user portrait withfallback,src,size, and optionalstatus.AvatarGroup— bounded avatar array with URL/fallback-only entries.Badge— inline label withvariant.Banner— text banner with constrained tone.Breadcrumb— flat bounded label/href list.Button— primary affordance withvariant,size,isLoading, andonActionId.Card— title, description, and plain text body.Checkbox— toggle withlabel,defaultChecked,disabled. State managed by renderer.Dialog— modal dialog withtitle,description,body,size. Renders as button + modal.DropdownMenu— flat menu items only; no arbitrary submenus or handler code.EmptyState— text empty state with optional host-resolved action id.Input— single-line text entry withtype,variant,size,invalid,required.Label— text label with constrained tone and association token.Pagination— numeric page data only; renderer computes visible links.Progress— numeric value and constrained tone/size props.RadioGroup— bounded option array; host owns state.SectionHeader— section title with optionaldescription.Select— bounded option array; host owns value/change.Separator— visual divider withorientation.Sheet— bounded drawer/sheet descriptor with host-owned open state.StatCard— metric display withvalue,label, andtone.StatusBadge— small icon-label chip withtone.Switch— binary toggle withlabel,defaultChecked,size,disabled. State managed by renderer.Table— bounded columns/rows with string cell values.Tabs— tabbed content withdefaultValueandtabsarray.Tag— short chip label with constrained presentation props.Text— type primitive withsize,weight, andtone.Textarea— host-owned value/change; no model-supplied form data.TitleBar— page heading withtitleand optionalsupertitle.Toggle— host-owned pressed state; text label only.
Layout — structural container:
Stack— flex container withdirection(row, column) andgap(sm, md, lg). Recursive: children can be any supported descriptor, including nestedStacks up to the depth cap.
Host-composed only — these are valid design-system components, but the model cannot emit them directly. The host app may compose them around validated descriptors:
AccessLevelGroup,CheckboxGrid,TransferList— authorization or bulk assignment semantics need host-owned data and policy.AspectRatio,ScrollArea,Skeleton— layout/loading utilities that should wrap trusted host content.Calendar,DatePicker,Slider,Combobox,SearchField— controlled inputs with host-owned value, async data, date/range policy, or telemetry.Carousel,Collapsible,HoverCard,Popover,Popper,Tooltip— slot/overlay primitives where trigger and content pairing belongs to the host tree.Chart,DataTable— data, formatters, sorting/filtering, and series mappings must be host-supplied.Command,ContextMenu,Menubar,NavigationMenu— actions, shortcuts, links, and nested destinations require host allowlists.Drawer,PageShell,IconSidebar,MainSidebar,Sidebar,TopBar,Logo,NotificationBadge— shell, brand, navigation, user, and app chrome context must stay host-owned.Resizable— panel state and resize constraints belong to the host layout.
Blocked — these are intentionally not model-renderable:
Form— model-defined forms can collect sensitive data. Hosts must compose explicit validated fields.InputOTP— OTP fields handle authentication secrets.RichTextEditor— rich text can carry unsafe markup or user content and must remain host-controlled and sanitized.
Adding a new primitive to the renderer
Six steps, in this order, for every new primitive:
- Author the Zod schema in
src/packages/ai-ui/src/schemas/<Name>.ts. Constraintypeenums tightly; never let the model supply event handlers or freeform URLs. - Re-export from
schemas/index.tsand add to thecomponentDescriptorSchemaunion. - Add the component name to
ALLOWED_COMPONENTS. - Add a
caseto the renderer's switch — spread only defined props (theexactOptionalPropertyTypesrule). - Mark the component
supportedinAI_UI_COMPONENT_COVERAGEwith the security rationale. - Cover the new path with at least one valid and one invalid descriptor in tests or the Storybook AI-UI story.
If any of those steps is missing, the descriptor doesn't render. By design.