Prototyper UI

Component Catalog

Zod-based component definitions for AI-driven UI generation

The catalog system lets you define your UI components with Zod schemas, then automatically generate LLM system prompts and JSON Schema exports. This is how the AI learns what components are available and how to use them.

What is a Catalog?

A catalog is a structured registry of component definitions. Each component declares:

  • A Zod schema for its props (validated at runtime)
  • Supported events the component can emit
  • Named slots for child content
  • A human-readable description for prompt generation
  • An optional example props object

When you build a system prompt from a catalog, the AI receives a precise description of every component it can use, including prop types, events, and usage examples.

Defining Components

Use defineComponent() in .catalog.ts files to define individual components with full type inference:

import { z } from "zod"
import { defineComponent } from "@prototyperai/stream-ui/catalog"

export default defineComponent({
  description: "A clickable button that triggers an action",
  props: z.object({
    label: z.string().describe("Button text"),
    variant: z.enum(["default", "destructive", "outline", "ghost"]).optional(),
    size: z.enum(["sm", "md", "lg"]).optional(),
    disabled: z.boolean().optional(),
  }),
  events: ["press"],
  example: { label: "Click me", variant: "default" },
})

Prop

Type

Defining Actions

Custom actions can also be defined with Zod schemas using defineAction():

import { z } from "zod"
import { defineAction } from "@prototyperai/stream-ui/catalog"

export const submitForm = defineAction({
  description: "Submit the form data to the server",
  params: z.object({
    formId: z.string(),
    validate: z.boolean().optional(),
  }),
})

Building a Catalog

Collect component and action definitions into a catalog with defineCatalog():

import { defineCatalog } from "@prototyperai/stream-ui/catalog"
import buttonDef from "./button.catalog"
import cardDef from "./card.catalog"
import inputDef from "./input.catalog"
import { submitForm } from "./actions"

const catalog = defineCatalog({
  components: {
    Button: buttonDef,
    Card: cardDef,
    Input: inputDef,
  },
  actions: {
    submitForm,
  },
})

The returned Catalog object provides several methods:

Prop

Type

Spec Validation

catalog.validate(spec) checks that:

  • The spec has a valid root and elements structure
  • The root element exists in the elements map
  • Every element references a known component type
  • Element props pass Zod schema validation
  • Child references point to existing elements
  • Element-level keys (visible, on, repeat, watch) are not misplaced inside props
const result = catalog.validate(spec)
if (!result.valid) {
  for (const issue of result.issues) {
    console.error(`[${issue.severity}] ${issue.code}: ${issue.message}`)
  }
}

See the Enhanced Validation section below for details on the structured result format.

Full Spec Schema

The catalog can generate a Zod schema or JSON Schema for the entire spec format, not just individual components. This is useful for structured outputs from LLMs and for validating complete specs at the boundary.

zodSchema()

Returns a Zod schema that validates a complete Spec object against the catalog. Component type fields are constrained to the catalog's registered component names.

const schema = catalog.zodSchema()

// Use for runtime validation
const result = schema.safeParse(specFromLLM)
if (!result.success) {
  console.error(result.error.issues)
}

jsonSchema()

Returns the full spec schema in JSON Schema format. This is ideal for structured output modes in LLM APIs (e.g., OpenAI's response_format or Anthropic's tool use):

const jsonSchema = catalog.jsonSchema()

// Pass to an LLM API as the response schema
const response = await openai.chat.completions.create({
  model: "gpt-4o",
  messages: [...],
  response_format: {
    type: "json_schema",
    json_schema: { name: "ui_spec", schema: jsonSchema },
  },
})

The schema includes the full spec structure (root, elements, state), all expression types, visibility conditions, action bindings, and validation configs.

For more on using structured outputs with the catalog, see the Prompt Generation section.

Enhanced Validation

catalog.validate(spec) returns a CatalogValidationResult with structured issues instead of a flat error string array. Each issue includes a machine-readable code, severity level, and optional element key for precise error reporting.

const result = catalog.validate(spec)

if (!result.valid) {
  for (const issue of result.issues) {
    console.log(`[${issue.severity}] ${issue.code}: ${issue.message}`)
    // e.g. [error] unknown-component: Element 'header' uses unknown component type 'Header'
    // e.g. [warning] misplaced-visible: Element 'card' has 'visible' inside props
  }
}

// On success, result.data contains the validated Spec
if (result.valid && result.data) {
  renderSpec(result.data)
}

Prop

Type

Each SpecIssue has:

Prop

Type

Issue codes include: missing-root, root-not-found, unknown-component, invalid-props, missing-child, orphaned-element, misplaced-visible, misplaced-on, misplaced-repeat, misplaced-watch.

Component Schemas

catalog.componentSchemas() returns per-component JSON Schemas (the behavior of the previous jsonSchema() method). Use this when you need individual component prop schemas rather than the full spec schema:

const schemas = catalog.componentSchemas()
// {
//   "$schema": "http://json-schema.org/draft-07/schema#",
//   "components": {
//     "Button": { "type": "object", "properties": { ... } },
//     "Card": { ... },
//   },
//   "actions": {
//     "submitForm": { ... },
//   },
// }

This is useful for documentation generation, API endpoint definitions, or when you need to validate props for a single component type.

Wiring to React Renderers

Use defineRegistry() to connect a catalog to React component renderers:

import { defineRegistry } from "@prototyperai/stream-ui/catalog"
import { Button } from "./components/Button"
import { Card } from "./components/Card"
import { Input } from "./components/Input"

const { registry, catalog } = defineRegistry(catalog, {
  Button,
  Card,
  Input,
})

The function warns at runtime if:

  • A catalog component has no renderer in the map
  • A renderer key is not in the catalog

The returned registry object can be passed directly to the <Renderer> component.

Prompt Generation

buildSystemPrompt()

Generate a complete system prompt from a catalog. The prompt describes:

  • The JSONL streaming output format (RFC 6902 JSON Patch)
  • The flat spec structure (root, elements, state)
  • All dynamic value expressions ($state, $bindState, $item, $cond, etc.)
  • Repeat/list rendering
  • Visibility conditions
  • Event and action bindings
  • Every registered component with its prop types, events, and slots
  • Custom actions with their parameter schemas
  • Rules the AI must follow
import { buildSystemPrompt } from "@prototyperai/stream-ui/catalog"

const systemPrompt = buildSystemPrompt(catalog)
// Pass as the system message to your AI model

The prompt includes a concrete streaming example built from the first two components in your catalog, so the AI sees the exact output format.

buildUserPrompt()

Wrap a user's request with optional context:

import { buildUserPrompt } from "@prototyperai/stream-ui/catalog"

// Simple prompt
const prompt = buildUserPrompt("Build a login form with email and password fields")

// Refinement mode — include current spec so the AI outputs patches only
const refinementPrompt = buildUserPrompt("Add a forgot password link", {
  currentSpec: existingSpec,
})

// With state context — provide data for data-driven generation
const dataPrompt = buildUserPrompt("Show a table of these users", {
  stateContext: { users: [{ id: 1, name: "Alice" }] },
})

Prop

Type

In refinement mode, the prompt includes the full current spec as JSON and instructs the model to output only RFC 6902 patches to modify it, rather than regenerating the entire spec.

The Build Pipeline

In a typical project, .catalog.ts files live alongside your component source files:

registry/ui/
  button.tsx           # React component
  button.catalog.ts    # Catalog definition
  card.tsx
  card.catalog.ts
  input.tsx
  input.catalog.ts

A build script collects all .catalog.ts files, calls defineCatalog(), and outputs:

  1. JSON Schema — exported via an API route (e.g., /stream-ui/catalog.json)
  2. System prompt — exported via a text endpoint (e.g., /stream-ui/prompt.txt)
  3. Registry — wired to renderers and used by <Renderer> at runtime

This separation means the AI prompt always stays in sync with your actual component definitions.

Full Example

Putting it all together:

import { z } from "zod"
import {
  defineComponent,
  defineAction,
  defineCatalog,
  defineRegistry,
  buildSystemPrompt,
  buildUserPrompt,
} from "@prototyperai/stream-ui/catalog"

// Define components
const Button = defineComponent({
  description: "A clickable button",
  props: z.object({
    label: z.string(),
    variant: z.enum(["default", "destructive"]).optional(),
  }),
  events: ["press"],
  example: { label: "Submit" },
})

const Input = defineComponent({
  description: "A text input field",
  props: z.object({
    label: z.string().optional(),
    placeholder: z.string().optional(),
    type: z.enum(["text", "email", "password"]).optional(),
  }),
  events: ["change"],
  example: { label: "Email", type: "email" },
})

// Define custom actions
const submitForm = defineAction({
  description: "Validate and submit form data",
  params: z.object({ formId: z.string() }),
})

// Build catalog
const catalog = defineCatalog({
  components: { Button, Input },
  actions: { submitForm },
})

// Generate prompts
const systemPrompt = buildSystemPrompt(catalog)
const userPrompt = buildUserPrompt("Create a login form")

// Wire to React renderers
const { registry } = defineRegistry(catalog, {
  Button: ButtonRenderer,
  Input: InputRenderer,
})

On this page