MiHCM

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:

  1. Zod parse. Malformed descriptors throw AIUIRenderError and never paint.
  2. Allowlist. Component names not in ALLOWED_COMPONENTS are rejected, even if Zod somehow accepts them — defense in depth.
  3. Depth cap. Maximum recursion depth of 5. Prompt-injected explosions terminate before they finish parsing.
  4. Action lookup by id. Handlers come from the host's actions map. The model never gets to define a function body.
  5. No raw HTML. There is no descriptor shape that produces a string of HTML. The renderer renders components only.
  6. Safe URLs only. Descriptor URLs are scheme-restricted. Avatar image URLs must be https:; Breadcrumb links must be root-relative, hash links, or https: 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 switch case.
  • 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 with variant and optional title.
  • AlertDialog — host-owned open state; text labels only.
  • Avatar — user portrait with fallback, src, size, and optional status.
  • AvatarGroup — bounded avatar array with URL/fallback-only entries.
  • Badge — inline label with variant.
  • Banner — text banner with constrained tone.
  • Breadcrumb — flat bounded label/href list.
  • Button — primary affordance with variant, size, isLoading, and onActionId.
  • Card — title, description, and plain text body.
  • Checkbox — toggle with label, defaultChecked, disabled. State managed by renderer.
  • Dialog — modal dialog with title, 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 with type, 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 optional description.
  • Select — bounded option array; host owns value/change.
  • Separator — visual divider with orientation.
  • Sheet — bounded drawer/sheet descriptor with host-owned open state.
  • StatCard — metric display with value, label, and tone.
  • StatusBadge — small icon-label chip with tone.
  • Switch — binary toggle with label, defaultChecked, size, disabled. State managed by renderer.
  • Table — bounded columns/rows with string cell values.
  • Tabs — tabbed content with defaultValue and tabs array.
  • Tag — short chip label with constrained presentation props.
  • Text — type primitive with size, weight, and tone.
  • Textarea — host-owned value/change; no model-supplied form data.
  • TitleBar — page heading with title and optional supertitle.
  • Toggle — host-owned pressed state; text label only.

Layout — structural container:

  • Stack — flex container with direction (row, column) and gap (sm, md, lg). Recursive: children can be any supported descriptor, including nested Stacks 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:

  1. Author the Zod schema in src/packages/ai-ui/src/schemas/<Name>.ts. Constrain type enums tightly; never let the model supply event handlers or freeform URLs.
  2. Re-export from schemas/index.ts and add to the componentDescriptorSchema union.
  3. Add the component name to ALLOWED_COMPONENTS.
  4. Add a case to the renderer's switch — spread only defined props (the exactOptionalPropertyTypes rule).
  5. Mark the component supported in AI_UI_COMPONENT_COVERAGE with the security rationale.
  6. 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.