@json-render/core

Core types, schemas, and utilities.

defineCatalog

Creates a type-safe catalog definition with schema validation.

import { defineCatalog } from '@json-render/core';
import { schema } from '@json-render/react';

function defineCatalog<T extends ZodType>(
  s: T,
  config: CatalogConfig
): Catalog

// Use the React schema for standard UI specs
const catalog = defineCatalog(schema, {
  components: {...},
  actions: {...},
});

CatalogConfig

interface CatalogConfig {
  components: Record<string, ComponentDefinition>;
  actions?: Record<string, ActionDefinition>;
  functions?: Record<string, FunctionDefinition>;
}

interface ComponentDefinition {
  props: ZodObject;         // Use .nullable() for optional props
  slots?: string[];         // Named slots (e.g., ["default"])
  description?: string;     // Help AI understand usage
}

interface ActionDefinition {
  params?: ZodObject;
  description?: string;
}

interface FunctionDefinition {
  description?: string;
}

Catalog Instance

The returned catalog provides methods for AI prompt generation, validation, and schema export:

interface Catalog {
  // Data
  readonly data: CatalogConfig;         // The catalog configuration
  readonly componentNames: string[];    // List of component names
  readonly actionNames: string[];       // List of action names

  // AI Prompt Generation
  prompt(options?: PromptOptions): string;

  // Validation
  validate(spec: unknown): SpecValidationResult;
  zodSchema(): z.ZodType;               // Get the Zod schema for specs

  // Export
  jsonSchema(): object;                 // Export as JSON Schema
}

interface PromptOptions {
  system?: string;           // Custom system message intro
  customRules?: string[];    // Additional rules to append
  mode?: "generate" | "chat"; // Output mode (default: "generate")
}

interface SpecValidationResult<T> {
  success: boolean;
  data?: T;               // Validated spec (if success)
  error?: z.ZodError;     // Validation errors (if failed)
}

Catalog Methods

// Generate AI system prompt
const systemPrompt = catalog.prompt({
  customRules: ["Always use Card as root element"],
});

// Validate a spec from AI
const result = catalog.validate(aiOutput);
if (result.success) {
  render(result.data);
} else {
  console.error(result.error);
}

// Get Zod schema for custom validation
const schema = catalog.zodSchema();
const parsed = schema.safeParse(aiOutput);

// Export as JSON Schema (for structured outputs)
const jsonSchema = catalog.jsonSchema();

Schema System

json-render uses a flexible schema system that defines both the AI output format (spec) and what catalogs must provide. Each renderer package provides its own schema (e.g., @json-render/react exports schema).

schema

The schema for flat UI element trees. This is exported from @json-render/react.

import { defineCatalog } from '@json-render/core';
import { schema } from '@json-render/react';

// schema defines:
// - Spec shape: { root: string, elements: Record<string, UIElement> }
// - Catalog shape: { components: {...}, actions: {...} }

const catalog = defineCatalog(schema, {
  components: {
    Card: {
      props: z.object({ title: z.string() }),
      slots: ["default"],
      description: "Container card",
    },
  },
  actions: {
    submit: {
      params: z.object({ formId: z.string() }),
      description: "Submit a form",
    },
  },
});

SchemaOptions

When creating schemas with defineSchema, you can pass options:

interface SchemaOptions {
  promptTemplate?: PromptTemplate;  // Custom AI prompt generator
  defaultRules?: string[];          // Default rules injected before custom rules in prompts
  builtInActions?: BuiltInAction[]; // Actions always available at runtime, auto-injected into prompts
}

interface BuiltInAction {
  name: string;        // Action name (e.g. "setState")
  description: string; // Human-readable description for the LLM
}

Built-in actions are injected into prompts as [built-in] and are handled by the runtime (e.g. ActionProvider) without requiring handlers in defineRegistry. The React schema declares setState, pushState, and removeState as built-in.

defineSchema

Create custom schemas for different output formats (e.g., page-based, block-based).

import { defineSchema } from '@json-render/core';

const mySchema = defineSchema((s) => ({
  // What the AI outputs (spec)
  spec: s.object({
    title: s.string(),
    blocks: s.array(s.object({
      type: s.ref("catalog.blocks"),
      content: s.any(),
    })),
  }),

  // What the catalog must provide
  catalog: s.object({
    blocks: s.map({
      props: s.zod(),
      description: s.string(),
    }),
  }),
}));

Schema Builder API

The schema builder provides these methods:

// Primitive types
s.string()           // String value
s.number()           // Number value
s.boolean()          // Boolean value
s.any()              // Any value

// Compound types
s.array(item)        // Array of items
s.object({ ... })    // Object with shape
s.record(value)      // Record/map with value type

// Catalog references (for type safety)
s.ref("catalog.components")      // Reference to catalog key (becomes enum)
s.propsOf("catalog.components")  // Props schema from catalog entry

// Catalog definitions
s.map({ props: s.zod(), ... })   // Map of named entries with shared shape
s.zod()                          // Placeholder for user-provided Zod schema

// Modifiers
s.optional()         // Mark field as optional

Zod Schemas

Pre-built Zod schemas for common json-render types:

Dynamic Value Schemas

import {
  DynamicValueSchema,    // string | number | boolean | null | { $state: string }
  DynamicStringSchema,   // string | { $state: string }
  DynamicNumberSchema,   // number | { $state: string }
  DynamicBooleanSchema,  // boolean | { $state: string }
} from '@json-render/core';

// Dynamic values can be literals or state path references
type DynamicValue<T> = T | { $state: string };

// Example: a prop that can be a literal or bound to state
const schema = z.object({
  label: DynamicStringSchema,  // "Hello" or { $state: "/user/name" }
});

Visibility Schemas

import { VisibilityConditionSchema } from '@json-render/core';

// Use in component props that need conditional rendering
const schema = z.object({
  visible: VisibilityConditionSchema.optional(),
});

Action Schemas

import {
  ActionSchema,           // Full action definition
  ActionConfirmSchema,    // Confirmation dialog config
  ActionOnSuccessSchema,  // Success handler config
  ActionOnErrorSchema,    // Error handler config
} from '@json-render/core';

Validation Schemas

import {
  ValidationCheckSchema,   // Single validation check
  ValidationConfigSchema,  // Full validation config with checks array
} from '@json-render/core';

SpecStream

SpecStream is json-render's streaming format for progressively building specs from JSONL patches.

createSpecStreamCompiler

Create a streaming compiler that incrementally builds a spec:

import { createSpecStreamCompiler } from '@json-render/core';

const compiler = createSpecStreamCompiler<MySpec>();

// Process streaming chunks
const { result, newPatches } = compiler.push(chunk);

// Get final result
const spec = compiler.getResult();

// Reset for reuse
compiler.reset();

compileSpecStream

Compile an entire SpecStream string at once:

import { compileSpecStream } from '@json-render/core';

const jsonl = `{"op":"add","path":"/root","value":{}}
{"op":"add","path":"/root/type","value":"Card"}`;

const spec = compileSpecStream<MySpec>(jsonl);

Low-Level Utilities

import {
  parseSpecStreamLine,
  applySpecStreamPatch,
} from '@json-render/core';

// Parse a single line
const patch = parseSpecStreamLine('{"op":"add","path":"/root","value":{}}');

// Apply patch to object (mutates in place)
const obj = {};
applySpecStreamPatch(obj, patch);

applySpecPatch

Apply a single SpecStream patch to a Spec object (mutates in place, returns the spec):

import { applySpecPatch } from '@json-render/core';

let spec: Spec = { root: "", elements: {} };
applySpecPatch(spec, { op: "add", path: "/root", value: "main" });

// For React state updates, spread to create a new reference:
setSpec({ ...applySpecPatch(spec, patch) });

nestedToFlat

Convert a nested element tree (with inline children) into the flat Spec format:

import { nestedToFlat } from '@json-render/core';

const flat = nestedToFlat({
  type: "Card",
  props: { title: "Hello" },
  children: [
    { type: "Text", props: { content: "World" }, children: [] }
  ],
});
// { root: "el-0", elements: { "el-0": ..., "el-1": ... } }

createJsonRenderTransform

Low-level TransformStream that separates text from JSONL patches in a mixed AI stream. Lines that parse as JSONL patches are emitted as data-spec parts; everything else passes through as text.

The transform properly splits text blocks around spec data by emitting text-end/text-start pairs, ensuring the AI SDK creates separate text parts and preserving correct interleaving of prose and UI in message.parts.

import { createJsonRenderTransform } from '@json-render/core';

const transform = createJsonRenderTransform();
// Use with ReadableStream.pipeThrough(transform) for custom pipelines

Most users should use pipeJsonRender() instead, which wraps this transform for the common AI SDK use case.

createMixedStreamParser

Parse a mixed stream of text and JSONL patches (used for Chat + GenUI mode):

import { createMixedStreamParser } from '@json-render/core';

const parser = createMixedStreamParser({
  onText: (text) => appendToMessage(text),
  onPatch: (patch) => applySpecPatch(spec, patch),
});

// As chunks arrive from the stream:
for await (const chunk of stream) {
  parser.push(chunk);
}
parser.flush();

pipeJsonRender

Pipe an AI SDK UIMessageStream through the json-render transform. Lines that parse as JSONL patches are emitted as data-spec parts; everything else passes through as text. Used in Chat mode API routes.

import { pipeJsonRender } from '@json-render/core';
import { createUIMessageStream, createUIMessageStreamResponse } from 'ai';

const stream = createUIMessageStream({
  execute: async ({ writer }) => {
    writer.merge(pipeJsonRender(result.toUIMessageStream()));
  },
});
return createUIMessageStreamResponse({ stream });

See Generation Modes for full Chat mode setup.

SpecStream Types

Fully compliant with RFC 6902:

interface SpecStreamLine {
  op: 'add' | 'remove' | 'replace' | 'move' | 'copy' | 'test';
  path: string;
  value?: unknown;  // Required for add, replace, test
  from?: string;    // Required for move, copy
}

interface SpecStreamCompiler<T> {
  push(chunk: string): { result: T; newPatches: SpecStreamLine[] };
  getResult(): T;
  getPatches(): SpecStreamLine[];
  reset(): void;
}

interface MixedStreamCallbacks {
  onText: (text: string) => void;
  onPatch: (patch: SpecStreamLine) => void;
}

interface MixedStreamParser {
  push(chunk: string): void;
  flush(): void;
}

Utility Functions

Path Utilities

import { getByPath, setByPath } from '@json-render/core';

// Get value by JSON Pointer path
const value = getByPath(state, '/user/name');  // "Alice"

// Set value by path (mutates object)
setByPath(state, '/user/email', 'alice@example.com');

resolveDynamicValue

import { resolveDynamicValue } from '@json-render/core';

// Resolve a dynamic value against state
const name = resolveDynamicValue("Hello", state);        // "Hello"
const name2 = resolveDynamicValue({ $state: "/user/name" }, state);  // "Alice"

findFormValue

import { findFormValue } from '@json-render/core';

// Find form values regardless of path format
// Checks: params.name, params["form.name"], state["form.name"], state.form.name
const value = findFormValue("name", params, state);

buildUserPrompt

Build structured user prompts for AI generation, with support for refinement and state context.

import { buildUserPrompt } from '@json-render/core';

function buildUserPrompt(options: UserPromptOptions): string

interface UserPromptOptions {
  prompt: string;                        // The user's text prompt
  currentSpec?: Spec | null;             // Existing spec to refine (triggers patch-only mode)
  state?: Record<string, unknown> | null; // Runtime state context to include
  maxPromptLength?: number;              // Max length for user text (truncates before wrapping)
}

Fresh generation

const userPrompt = buildUserPrompt({ prompt: "create a todo app" });

Refinement (patch-only mode)

When currentSpec is provided, the prompt instructs the AI to output only the patches needed for the change, not recreate the entire spec:

const userPrompt = buildUserPrompt({
  prompt: "add a dark mode toggle",
  currentSpec: existingSpec,
});

With state context

Include runtime state so the AI knows what data is available:

const userPrompt = buildUserPrompt({
  prompt: "show my data",
  state: { todos: [{ text: "Buy milk" }] },
});

evaluateVisibility

Evaluates a visibility condition against the state model.

function evaluateVisibility(
  condition: VisibilityCondition | undefined,
  ctx: VisibilityContext
): boolean

interface VisibilityContext {
  stateModel: StateModel;
  repeatItem?: unknown;    // Current repeat item (inside repeat scope)
  repeatIndex?: number;    // Current repeat array index (inside repeat scope)
}

type VisibilityCondition =
  | { $state: string }                                    // truthiness
  | { $state: string; not: true }                         // falsy
  | { $state: string; eq: unknown }                       // equality
  | { $state: string; neq: unknown }                      // inequality
  | { $state: string; gt: number }                        // greater than
  | { $state: string; gte: number }                       // gte
  | { $state: string; lt: number }                        // lt
  | { $state: string; lte: number }                       // lte
  | { $item: string }                                     // item field (repeat scope)
  | { $item: string; eq: unknown }                        // item field equality
  | { $index: true }                                      // index truthiness (repeat scope)
  | { $index: true; gt: number }                          // index comparison
  | VisibilityCondition[]                                 // implicit AND
  | { $and: VisibilityCondition[] }                       // explicit AND
  | { $or: VisibilityCondition[] }                        // OR
  | boolean;                                              // always / never

Types

UIElement

interface UIElement {
  type: string;
  props: Record<string, unknown>;
  children?: string[];          // Keys of child elements
  visible?: VisibilityCondition;
  on?: Record<string, ActionBinding | ActionBinding[]>;  // Event bindings
  repeat?: { statePath: string; key?: string };           // Repeat for arrays
}

Elements are stored in the elements map keyed by string IDs. The key comes from the map, not from the element itself.

Spec (Element Tree)

interface Spec {
  root: string | null;                        // Key of root element
  elements: Record<string, UIElement>;        // Flat element map
  state?: Record<string, unknown>;            // Initial state model
}

Elements are stored as a flat map with string keys. The tree structure is built by following the children arrays.

ActionBinding

interface ActionBinding {
  action: string;
  params?: Record<string, DynamicValue>;
  confirm?: {
    title: string;
    message: string;
    variant?: 'default' | 'danger';
  };
  onSuccess?: { set: Record<string, unknown> };
  onError?: { set: Record<string, unknown> };
  preventDefault?: boolean;  // Prevent default browser behavior (e.g. navigation on links)
}

ValidationSchema

interface ValidationSchema {
  checks: ValidationCheck[];
  validateOn?: 'change' | 'blur' | 'submit';
}

interface ValidationCheck {
  type: string;
  args?: Record<string, unknown>;
  message: string;
}