ResonanceM

component-schema-ai-contracts

Designs strict Zod/JSON metadata contracts that map a React (or other) design system to LLM function-calling tools via MCP and the Vercel AI SDK, so AI agents generate deterministic, accessible, brand-compliant UI blocks with zero code hallucinations. Use this skill whenever the user wants to expose a component library or design system to an LLM, build an MCP server or AI SDK tool set over a UI kit, let an agent "generate UI" or "build a form/dashboard/screen" from natural language, wire Storybook metadata into an AI pipeline, or asks how to stop an AI from inventing components, props, or class names that don't exist. Also trigger for "AI design system bridge," "component registry for LLMs," "JSON UI tree," "text-to-UI," or any request to make AI-generated interfaces provably valid against a real codebase rather than plausible-looking.

ResonanceM 0 Updated 1w ago

Resources

1
GitHub

Install

npx skillscat add resonancem/type-safe-component-schemas-ai-contracts

Install via the SkillsCat registry.

SKILL.md

System architecture: type-safe component schemas & AI contracts

Designing strict JSON/Zod metadata contracts that map React design systems
to LLM function tools (via MCP and the Vercel AI SDK), guaranteeing that AI
agents generate deterministic, accessible, and brand-compliant UI blocks
without code hallucinations.

The core problem this solves

An LLM asked to "build a login form using our design system" will, from
weights alone, produce plausible JSX: components that sound right
(<GradientButton>), props that sound right (glow, elevation), enum
values that sound right (variant="fancy"). None of it may exist. This is
hallucination, and it's structurally unavoidable if the model is generating
from memory of design systems in general rather than from this one.

The fix is not a better prompt. It's an architecture where the model cannot
express anything the schema doesn't allow, and where nothing renders until
it's checked against that schema. That's what this skill builds.

The pipeline

[ Design System ] -> [ Component Contract ] -> [ MCP / AI SDK Tools ] -> [ Validated UI ]
  React + Tailwind      Zod schema + JSON       Exposes tools/prompts      Zero hallucinations

Four stages, each with one job:

  1. Design system — the real, compiled components. Source of truth for
    everything downstream; nothing is hand-maintained twice.
  2. Component contract — a Zod schema per component, generated (not
    hand-written) from the component's real prop types, plus semantic
    metadata (description, constraints, accessibility rules, brand tokens)
    that types alone can't express.
  3. MCP / AI SDK tools — the contract exposed as callable tools so a
    model can discover components and fetch their exact schema before using
    them, instead of guessing.
  4. Validated UI — the model emits a JSON tree, never raw JSX. The tree
    is parsed against the Zod schema before anything renders. Reject on any
    mismatch.

Skip stage 4 and stages 1-3 are decoration — a well-documented system an
LLM can still hallucinate its way around. The validation gate is what makes
"zero hallucinations" a guarantee instead of a hope.

Stage 1 → 2: generate the contract, don't hand-write it

Hand-maintained metadata drifts from the real component within a few PRs.
Generate props/types from the component source; hand-write only what types
genuinely can't express.

Extract real prop types with react-docgen-typescript (or your
framework's equivalent — vue-docgen-api for Vue, etc.):

import { withCustomConfig } from 'react-docgen-typescript';

const parser = withCustomConfig('./tsconfig.json', {
  shouldExtractLiteralValuesFromEnum: true,
  shouldRemoveUndefinedFromOptional: true,
});
const docs = parser.parse('./components/Button.tsx')[0];

Turn extracted props into a Zod schema, not a loose JSON blob. Zod
gives you three things a hand-rolled schema doesn't: a single definition
that produces both runtime validation and a JSON Schema for tool
exposure, refinements for cross-field rules, and .safeParse() for the
validation gate in stage 4.

import { z } from 'zod';

const ButtonPropsSchema = z.object({
  variant: z.enum(['primary', 'secondary', 'ghost', 'danger']).default('primary'),
  size: z.enum(['sm', 'md', 'lg']).default('md'),
  disabled: z.boolean().default(false),
  children: z.string().min(1, 'Button must have visible or aria-label text'),
});

Map docgen output to Zod primitives mechanically:

docgen shape Zod
{ name: 'enum', value: [{value:'"a"'},{value:'"b"'}] } z.enum(['a','b'])
{ name: 'boolean' } z.boolean()
{ name: 'string' } z.string()
{ name: 'ReactNode' } z.string() — a JSON tree can't carry arbitrary nodes, only text or child trees
function-typed prop (onClick, etc.) drop it — see "What never goes in the contract" below

Add semantic metadata the types can't carry — description, category,
usage constraints, accessibility rules, curated examples — from a
co-located source, not a giant central table that drifts. If the project
uses Storybook, read it straight out of the story file's
parameters.metadata block:

// Button.stories.tsx
const componentMetadata = {
  description: 'Primary interactive element for actions.',
  category: 'action',
  constraints: [
    "Never use variant 'danger' for non-destructive actions",
    'Icon-only buttons must include an accessible label via children',
  ],
  a11y: ['Renders a native <button>; no role override needed', 'disabled buttons must not also carry onClick'],
  brandTokens: { colorSource: 'tailwind.config.js: theme.colors.brand.*', spacingScale: '4px base unit' },
};

const meta: Meta<typeof Button> = {
  component: Button,
  parameters: { metadata: componentMetadata },
};

The extraction script imports the story file directly (via tsx, so .tsx
imports just work in a Node script) and reads meta.parameters.metadata.
No duplication between what a human sees in Storybook and what the model
receives — same object, two consumers.

Stamp a content hash into every generated contract. This is what turns
"metadata can drift" into "metadata drift fails CI":

import crypto from 'node:crypto';
const propsHash = crypto.createHash('sha256')
  .update(JSON.stringify(zodSchemaShape, Object.keys(zodSchemaShape).sort()))
  .digest('hex').slice(0, 16);

A CI job recomputes this hash from current component source on every PR and
fails if it doesn't match the committed contract — the single highest-
leverage guard in this whole architecture, because every other stage is
only as trustworthy as the contract being current:

// check-contract-freshness.ts — run in CI on every PR touching components/
for (const component of components) {
  const currentHash = hashSchema(extractCurrentProps(component));
  const savedHash = JSON.parse(fs.readFileSync(`contracts/${component}.json`)).propsHash;
  if (currentHash !== savedHash) {
    console.error(`${component}: contract is stale. Run \`npm run extract\` and commit the result.`);
    process.exit(1);
  }
}

Stage 2 → 3: expose the contract as tools

Two delivery mechanisms, pick based on audience:

  • MCP server — for external clients (Claude Desktop, Claude Code, any
    MCP-speaking agent). Use when you want humans-with-agents to be able to
    query the design system directly.
  • Vercel AI SDK tools — for generation happening inside your own
    product (generateText/streamText calls your backend makes). Use when
    the AI-generates-UI feature lives in your app, not in an external client.

They should be thin wrappers over the same contract files — don't
maintain two copies of "what components exist."

MCP server, three tools, minimum viable set:

server.setRequestHandler(ListToolsRequestSchema, async () => ({
  tools: [
    { name: 'list_components', description: 'List every component, category, one-line description. Call first.' },
    { name: 'get_component_schema', description: 'Full Zod-derived schema, constraints, and examples for one component. Call before using any component.' },
    { name: 'search_components', description: 'Find components by capability, e.g. "form input" or "destructive action".' },
  ],
}));

get_component_schema should return the JSON Schema form of the Zod
contract (zod-to-json-schema(ButtonPropsSchema)), not a prose description
of it — the model needs the actual enum values and required fields, not a
summary.

AI SDK tool, same contract, different transport:

import { tool } from 'ai';
import { z } from 'zod';

const getComponentSchema = tool({
  description: 'Get the schema for one component. Call before using it — never guess props or enum values.',
  parameters: z.object({ name: z.string() }),
  execute: async ({ name }) => loadContract(name), // same contract file the MCP server reads
});

System prompt discipline — tell the model explicitly what it's allowed
to output, or the tool-calling discipline collapses under a good enough
guess:

You generate UI as a single JSON object: { "component": "Name", "props": {...}, "children": [...] }.
Call getComponentSchema for every component before using it. Never invent
components or props not returned by the tools. Output JSON only — no prose,
no markdown fences, no raw JSX.

Stage 3 → 4: the validation gate (this is the whole point)

The model emits a JSON tree, not JSX, for one reason: JSON can be
schema-validated before it touches the DOM; JSX evaluated at runtime
cannot be cheaply or safely checked at all. Treat "the model outputs
directly renderable code" as the anti-pattern, full stop.

function validateNode(node: unknown, path = 'root'): ValidationResult {
  const { component, props, children } = node as any;

  const contract = loadContract(component);
  if (!contract) return fail(`${path}: unknown component "${component}"`);

  const parsed = contract.propsSchema.safeParse(props ?? {});
  if (!parsed.success) {
    return fail(`${path} (${component}): ${parsed.error.issues.map(i => i.message).join('; ')}`);
  }

  for (const child of children ?? []) {
    const childResult = validateNode(child, `${path} > ${component}`);
    if (!childResult.ok) return childResult;
  }
  return ok();
}

z.enum(...).safeParse() rejects an invalid variant with a specific,
actionable error — exactly the failure mode most worth catching, since
"close enough" enum guesses (variant: "rainbow" instead of "danger")
are the most common hallucination shape.

Render through a lookup table, never eval, as defense in depth even
after validation passes:

const REGISTRY: Record<string, React.ComponentType<any>> = { Button, Card, Input };

function renderNode(node: UINode): React.ReactNode {
  const Component = REGISTRY[node.component]; // undefined if not registered — full stop
  if (!Component) return null;
  return <Component {...node.props}>{node.children?.map(renderNode)}</Component>;
}

Two independent gates (schema validation, then a closed component
registry) mean a bug in one doesn't turn into an exploit or a broken
render — this is the same defense-in-depth reasoning as sanitizing input
and parameterizing a query, not choosing one.

Baking accessibility and brand compliance into the contract itself

Don't bolt these on as a linter pass after generation — encode them as
Zod refinements so a non-compliant tree is a type error, not a style
warning:

const InputPropsSchema = z.object({
  label: z.string().min(1), // required — a11y non-negotiable, not optional-with-a-lint-warning
  type: z.enum(['text', 'email', 'password', 'number']),
  error: z.boolean().default(false),
  helpText: z.string().optional(),
}).refine(
  (p) => !p.error || !!p.helpText,
  { message: 'error=true requires helpText for screen-reader users' }
);

const ButtonPropsSchema = z.object({
  variant: z.enum(['primary', 'secondary', 'ghost', 'danger']),
  children: z.string().min(1),
}).refine(
  (p) => p.variant !== 'danger' || /delete|remove|discard/i.test(p.children),
  { message: 'danger variant should be reserved for destructive actions' }
);

For brand compliance, never let free-form color/spacing strings into the
contract — enumerate the actual design tokens as a Zod enum sourced from
tailwind.config.js, so "bg-blue-500" (off-brand, guessed) is
structurally impossible while "bg-brand-primary" (a real token) is the
only thing that parses:

const brandColors = Object.keys(tailwindConfig.theme.colors.brand);
const ColorTokenSchema = z.enum(brandColors as [string, ...string[]]);

What never goes in the contract

  • Function-typed props (onClick, onChange). A JSON tree can't
    carry a closure. Either the renderer wires up standard handlers by
    convention (a Button always gets a default click behavior appropriate
    to context) or the prop is omitted from the AI-facing contract entirely
    and applied by the host application after render.
  • Arbitrary ReactNode children when a plain string will do. If the
    model can put components as children, use a recursive tree; if it just
    needs text, type it z.string() and don't tempt fate.
  • Anything not derived from real component source. The moment
    someone hand-adds a prop to the contract that isn't in the component,
    the guarantee breaks in the worst direction — the model believes
    something exists that a human never actually shipped.

Common failure modes and their fix

Symptom Root cause Fix
Model invents a component name that sounds plausible No discovery step before generation Force list_components/search_components before any get_component_schema call; system prompt forbids guessing
Model gets props right most of the time, wrong sometimes Contract is a prose description, not a machine schema Return actual Zod-derived JSON Schema from the tool, not a paragraph
Validation passes locally, breaks in prod weeks later Contract wasn't regenerated after a component changed CI freshness check (propsHash) as a required PR status check
Generated UI is schema-valid but off-brand or inaccessible Contract only captured types, not constraints Add Zod .refine() rules for a11y and brand token enums, not a separate lint pass
Renderer occasionally throws on unknown component despite validation Two independent sources of truth (validator's contract dir vs. renderer's registry) can drift Both read from the same generated contract files; add a startup assertion that Object.keys(REGISTRY) matches the contract index

Checklist for a new component

  • Component ships with a co-located story/doc file carrying
    description, constraints, a11y, and curated examples
  • npm run extract regenerates its .contract.json/Zod module from
    real prop types + that story metadata
  • propsHash is committed alongside the contract
  • Component is registered in the MCP server's / AI SDK's index
    (list_components returns it)
  • Component is added to the renderer's closed lookup table
  • CI's freshness check passes
  • At least one accessibility refinement and one brand-token constraint
    exist on the schema if the component takes color or interactive props