Core Concepts

Understand the fundamental patterns that power GenUIKit: registries, validation, resolution, and the self-healing correction loop.

The Registry Pattern

At the heart of GenUIKit is the ComponentRegistry -- a lookup table that maps a component name to a Zod schema and a React component. When an LLM produces output like { type: "WeatherCard", props: { ... } }, the registry knows exactly which schema to validate against and which component to render.

How the registry works internally:

Component Name-->Zod Schema+React Component

Create a registry and register components using the chainable register() API. Each call returns this, so you can chain multiple registrations together:

registry.tstsx
1import { z } from 'zod';
2import { ComponentRegistry } from '@genuikit/core';
3 
4// Define schemas
5const weatherSchema = z.object({
6 city: z.string(),
7 temperature: z.number(),
8 condition: z.enum(['sunny', 'cloudy', 'rainy', 'snowy']),
9});
10 
11const alertSchema = z.object({
12 title: z.string(),
13 message: z.string(),
14 severity: z.enum(['info', 'warning', 'error']),
15});
16 
17// Create registry with chainable register() calls
18const registry = new ComponentRegistry()
19 .register('WeatherCard', weatherSchema, WeatherCard)
20 .register('AlertBanner', alertSchema, AlertBanner);
21 
22// You can also register components individually
23registry.register('UserProfile', userProfileSchema, UserProfile);

The registry stores entries in an internal Map<string, RegistryEntry>. Each entry holds the component name, its Zod schema, and the React component reference:

1// Each entry in the registry has this shape
2interface RegistryEntry<S extends z.ZodType = z.ZodType> {
3 readonly name: string;
4 readonly schema: S;
5 readonly component: ComponentType<z.infer<S>>;
6}

By default, registering a component with a name that already exists throws a DuplicateRegistrationError. You can opt into overwrites by passing { allowOverwrite: true } to the constructor:

1// Allow replacing existing registrations
2const registry = new ComponentRegistry({ allowOverwrite: true });
3registry.register('Button', schemaV1, ButtonV1);
4registry.register('Button', schemaV2, ButtonV2); // replaces V1

LLM Component Output

GenUIKit expects a specific shape from the LLM: an object with type (the registered component name) and props (the data matching the Zod schema). This is the contract between your LLM and the registry.

1// The shape your LLM must produce
2interface LLMComponentOutput {
3 readonly type: string; // registered component name
4 readonly props: Record<string, unknown>; // data matching the Zod schema
5}

When structuring LLM tool calls, instruct the model to return this exact shape. Here is an example of what the LLM should produce when asked about weather:

LLM output examplejson
1{
2 "type": "WeatherCard",
3 "props": {
4 "city": "San Francisco",
5 "temperature": 18,
6 "condition": "cloudy"
7 }
8}

In your API route or server action, parse the LLM response and pass it directly to the registry for resolution:

api/chat/route.tstsx
1import { registry } from '@/lib/registry';
2 
3export async function POST(req: Request) {
4 const { message } = await req.json();
5 
6 // Call your LLM (OpenAI, Anthropic, etc.)
7 const llmResponse = await callLLM(message, {
8 tools: registry.toToolDefinition(),
9 });
10 
11 // The LLM returns { type: "WeatherCard", props: { ... } }
12 return Response.json(llmResponse);
13}

Resolving Output

The registry provides two methods for resolving LLM output into validated components: resolve() for strict resolution and tryResolve() for lenient resolution.

resolve() -- Strict Resolution

The resolve() method validates the LLM output against the registered schema. It returns a discriminated union: either a success result with the component and validated props, or a failure result with detailed errors and a correction prompt.

1const output = { type: 'WeatherCard', props: { city: 'Tokyo', temperature: 22, condition: 'sunny' } };
2const result = registry.resolve(output);
3 
4if (result.ok) {
5 // Success: result has component + validated props
6 const { component: Component, props, name } = result;
7 // Render: <Component {...props} />
8} else {
9 // Failure: result has errors + correction prompt
10 const { errors, correctionPrompt, name } = result;
11 // Send correctionPrompt back to the LLM for retry
12}

On success, the result contains the resolved component and the parsed (and possibly transformed) props. On failure, you get structured validation errors and a correction prompt:

1// Success result shape
2interface ResolveSuccess<P = unknown> {
3 readonly ok: true;
4 readonly name: string;
5 readonly component: ComponentType<P>;
6 readonly props: P;
7}
8 
9// Error result shape
10interface ResolveError {
11 readonly ok: false;
12 readonly name: string;
13 readonly errors: ValidationError[];
14 readonly correctionPrompt: string;
15}

Important: If the type does not match any registered component name, resolve() throws a ComponentNotFoundError rather than returning an error result. This is because an unknown component name cannot be corrected through validation alone.

tryResolve() -- Lenient Resolution

The tryResolve() method works the same as resolve(), but returns null instead of throwing when the component name is not found. This is useful when you want to silently skip unknown components:

1const result = registry.tryResolve(output);
2 
3if (result === null) {
4 // Component not registered -- silently skip or show fallback
5 return <div>Unknown component</div>;
6}
7 
8if (result.ok) {
9 const { component: Component, props } = result;
10 return <Component {...props} />;
11} else {
12 // Validation failed -- use correctionPrompt for retry
13 console.log(result.correctionPrompt);
14}

When to use each method:

  • --Use resolve() when you expect the component to exist and want to catch mismatches as errors (e.g., in a controlled chat UI).
  • --Use tryResolve() when the LLM might produce arbitrary component names and you want graceful fallbacks (e.g., in a plugin system).

Auto-Correction Prompts

When schema validation fails, GenUIKit generates a structured correction prompt that describes exactly what went wrong. You can send this prompt back to the LLM so it can fix its output -- creating a self-healing loop.

Here is how the flow works. Suppose the LLM produces invalid props:

Step 1: LLM produces invalid outputtsx
1// LLM returns invalid props (temperature is a string, not a number)
2const invalidOutput = {
3 type: 'WeatherCard',
4 props: {
5 city: 'Berlin',
6 temperature: 'warm', // should be a number
7 condition: 'foggy', // not in the enum
8 },
9};
10 
11const result = registry.resolve(invalidOutput);
12// result.ok === false

The correctionPrompt in the error result contains a detailed message for the LLM:

Step 2: Generated correction prompttsx
1console.log(result.correctionPrompt);
2// Output:
3// The props you provided for component "WeatherCard" failed validation.
4//
5// Errors:
6// - "temperature": Expected number, received string, expected: number, received: string
7// - "condition": Invalid enum value. Expected 'sunny' | 'cloudy' | 'rainy' | 'snowy',
8// received 'foggy'
9//
10// Expected schema:
11// {
12// city: string
13// temperature: number
14// condition: "sunny" | "cloudy" | "rainy" | "snowy"
15// }
16//
17// Please provide corrected props for "WeatherCard" as a valid JSON object
18// matching the schema above.

Send the correction prompt back to the LLM and it will produce corrected output:

Step 3: The self-healing retry looptsx
1async function resolveWithRetry(
2 registry: ComponentRegistry,
3 output: LLMComponentOutput,
4 maxRetries = 2
5) {
6 let result = registry.resolve(output);
7 let attempts = 0;
8 
9 while (!result.ok && attempts < maxRetries) {
10 // Send correction prompt back to the LLM
11 const corrected = await callLLM(result.correctionPrompt);
12 result = registry.resolve(corrected);
13 attempts++;
14 }
15 
16 return result;
17}

The self-healing loop:

LLM Output-->resolve()-->Valid? Render
|
Invalid?-->correctionPrompt-->Send to LLM-->(retry)

Tool Definitions

The toToolDefinition() method generates a JSON description of all registered components and their expected props. Send this to the LLM as part of your system prompt or tool definitions so it knows which components are available.

Generating tool definitionstsx
1import { ComponentRegistry } from '@genuikit/core';
2import { z } from 'zod';
3 
4const registry = new ComponentRegistry()
5 .register('WeatherCard', z.object({
6 city: z.string(),
7 temperature: z.number(),
8 condition: z.enum(['sunny', 'cloudy', 'rainy', 'snowy']),
9 humidity: z.number().min(0).max(100).optional(),
10 }), WeatherCard)
11 .register('AlertBanner', z.object({
12 title: z.string(),
13 message: z.string(),
14 severity: z.enum(['info', 'warning', 'error']),
15 }), AlertBanner);
16 
17const toolDefs = registry.toToolDefinition();
18console.log(JSON.stringify(toolDefs, null, 2));

The output is an array of objects, each with a name and a JSON Schema-like schema description:

Generated tool definition outputjson
1[
2 {
3 "name": "WeatherCard",
4 "schema": {
5 "type": "object",
6 "properties": {
7 "city": { "type": "string" },
8 "temperature": { "type": "number" },
9 "condition": { "type": "string", "enum": ["sunny", "cloudy", "rainy", "snowy"] },
10 "humidity": { "type": "number" }
11 },
12 "required": ["city", "temperature", "condition"]
13 }
14 },
15 {
16 "name": "AlertBanner",
17 "schema": {
18 "type": "object",
19 "properties": {
20 "title": { "type": "string" },
21 "message": { "type": "string" },
22 "severity": { "type": "string", "enum": ["info", "warning", "error"] }
23 },
24 "required": ["title", "message", "severity"]
25 }
26 }
27]

Include this in your LLM system prompt to teach the model what components it can produce:

Including tool definitions in your prompttsx
1const systemPrompt = `You are a UI assistant. When the user asks a question,
2respond with a JSON object matching one of these components:
3 
4${JSON.stringify(registry.toToolDefinition(), null, 2)}
5 
6Respond with: { "type": "<ComponentName>", "props": { ... } }`;
7 
8const response = await openai.chat.completions.create({
9 model: 'gpt-4o',
10 messages: [
11 { role: 'system', content: systemPrompt },
12 { role: 'user', content: 'What is the weather in Tokyo?' },
13 ],
14});

Error Handling

GenUIKit provides two specific error classes for registry operations. Understanding when each is thrown helps you build robust error handling.

ComponentNotFoundError

Thrown by resolve() when the type field in the LLM output does not match any registered component name. This error is not recoverable through the correction loop because the component simply does not exist in the registry.

1import { ComponentNotFoundError } from '@genuikit/core';
2 
3try {
4 const result = registry.resolve({
5 type: 'NonExistentWidget',
6 props: { title: 'Hello' },
7 });
8} catch (error) {
9 if (error instanceof ComponentNotFoundError) {
10 console.error(error.message);
11 // "Component "NonExistentWidget" is not registered."
12 
13 // Option 1: Show fallback UI
14 return <div>Unknown component type</div>;
15 
16 // Option 2: Use tryResolve() instead to avoid the exception
17 }
18}

DuplicateRegistrationError

Thrown when you attempt to register a component with a name that is already in use. This is a setup-time error that you typically catch during development:

1import { DuplicateRegistrationError } from '@genuikit/core';
2 
3const registry = new ComponentRegistry();
4registry.register('Button', buttonSchema, Button);
5 
6try {
7 registry.register('Button', altButtonSchema, AltButton);
8} catch (error) {
9 if (error instanceof DuplicateRegistrationError) {
10 console.error(error.message);
11 // "Component "Button" is already registered. Use allowOverwrite option to replace."
12 }
13}
14 
15// To allow overwrites, pass the option to the constructor:
16const flexibleRegistry = new ComponentRegistry({ allowOverwrite: true });

Comprehensive Error Handling

Here is a complete pattern that handles all error cases gracefully:

safe-render.tsxtsx
1import { ComponentNotFoundError } from '@genuikit/core';
2import type { LLMComponentOutput, ResolveResult } from '@genuikit/core';
3 
4function safeRender(
5 registry: ComponentRegistry,
6 output: LLMComponentOutput
7): { element: JSX.Element | null; correctionPrompt: string | null } {
8 // Handle unknown component names
9 const result = registry.tryResolve(output);
10 
11 if (result === null) {
12 // Component not in registry -- nothing to correct
13 return {
14 element: <div>Component "{output.type}" is not available.</div>,
15 correctionPrompt: null,
16 };
17 }
18 
19 if (result.ok) {
20 // Validation passed -- render the component
21 const { component: Component, props } = result;
22 return {
23 element: <Component {...props} />,
24 correctionPrompt: null,
25 };
26 }
27 
28 // Validation failed -- return correction prompt for LLM retry
29 return {
30 element: null,
31 correctionPrompt: result.correctionPrompt,
32 };
33}

What's Next

Now that you understand the core patterns, explore more advanced topics: