Skip to content
Kimenpre-v1
Color scheme

Guide

The UI spec format

@kimen/catalog is the GenUI boundary of Kimen: the machine-readable schema of what agents may emit — every published ki-* element with typed props, slots, events and when-to-use guidance — plus the validation entry point that accepts or rejects agent-emitted UI specs before anything renders. The catalog artifact is generated from the committed Custom Elements Manifest of @kimen/elements and gated against drift, so it can never disagree with the components.

A UI spec is data, never code: a JSON tree of catalog components, typed props, slotted children and declared actions.

import { catalogData, validateUiSpec } from '@kimen/catalog';
// What may an agent emit? One entry per published element:
catalogData.components['ki-button'].props.variant;
// { type: 'enum', values: ['ghost', 'primary', 'quaternary', 'secondary', 'tertiary'], ... }
// Validate an agent-emitted UI spec before anything renders:
const report = validateUiSpec({
version: 1,
actions: ['confirm-order'],
root: {
component: 'ki-card',
slots: {
header: ['Confirm your order'],
footer: [
{
component: 'ki-button',
props: { variant: 'primary' },
action: 'confirm-order',
slots: { '': ['Confirm'] },
},
],
},
},
});
report.ok; // true — or false with issues naming each offender and location

Validation rejects — naming the offender — unknown components, unknown props, wrong-typed values, undeclared slots, bindings to actions the spec’s actions list never declares, prototype-pollution keys (__proto__, constructor, prototype) anywhere in the document, payloads beyond the size budget and nesting beyond the depth budget.

Object input crosses an iterative purity wall before any other check: validation never invokes getters or toJSON on the input (accessor properties, functions and other non-JSON values are rejected as not-data), shared object references and cycles are rejected (a spec is a JSON tree), and every later check runs on a plain-data snapshot — so mutating the original mid-validation changes nothing.

The v1 spec format exposes no styling surface: no CSS values, no per-spec token reassignment. Appearance stays at the consuming application’s token layer.

renderUiSpec renders an untrusted spec into a host-owned surface, fail-closed and atomic — full validation precedes the first attach, so a rejected spec never touches the DOM:

import { renderUiSpec } from '@kimen/catalog';
const result = renderUiSpec(spec, {
surface: document.querySelector('#genui'),
onAction: (event) => console.log(event.action, event.data),
budgets: { maxDepth: 32, maxNodes: 512, maxBytes: 262_144 },
catalogSchemaVersion: '1.0.0',
});
if (!result.ok) console.warn(result.diagnostics); // machine-readable, inert

Over the validation layer it enforces the safe-render semantics:

  • No code path from spec data. Text is attached as inert text nodes, never parsed as markup; the catalog exposes no event-handler props; URL-typed props accept only http, https and relative references — every other scheme is rejected naming the prop and scheme.
  • Declared budgets. Depth, node count and payload size — a spec exactly at a budget renders, one beyond it is rejected before any node attaches.
  • Version skew. A spec declaring an unsupported catalogSchemaVersion is rejected naming both versions.
  • Declarative actions only. A bound control dispatches its one declared action, as data, on the single onAction channel — exactly one action fires even for nested action-bound nodes; no other callback exists.

A re-render on the same surface replaces the previous tree (and its action listeners) atomically once validation succeeds; a rejected re-render leaves the previous content intact. Every rejection is a RenderDiagnostic — node path, violated rule and offending value — pure data, safe to display because a host renders it as text.

createStreamingRenderer renders a streamed spec progressively: a node attaches only after it fully validates; an invalid node halts the stream fail-closed while previously validated content remains; the budgets bind the accumulated stream, so a stream that never closes still trips its payload budget; and once halted — by an invalid node, a tripped budget, version skew or close() — every further push is rejected.

URL-scheme allowlisting and markup inertness are render-path invariants owned by the guarded renderer: catalog validation is a schema boundary, never content sanitization. A host that renders outside the guarded renderer is outside the guardrail.

The catalog a spec validates against does not have to be the built-in one. createCatalog accepts a data-only definition of your components — the exact entry shape the generated catalog uses (tag, usage guidance, typed prop constraints, slots, events) — and returns an immutable catalog value that validateUiSpec, renderUiSpec and createStreamingRenderer take through their catalog option:

import { catalogData, createCatalog, renderUiSpec } from '@kimen/catalog';
const created = createCatalog(
{
components: {
'acme-kpi-card': {
tag: 'acme-kpi-card',
description: 'A KPI card for Acme dashboards.',
whenToUse: 'Show one operational metric with its trend.',
whenNotToUse: 'Tabular breakdowns of many metrics.',
props: {
tone: {
type: 'enum',
values: ['ok', 'warn', 'critical'],
description: 'Semantic severity of the metric.',
},
},
slots: { '': 'The metric label.' },
events: {},
},
},
},
{ extend: catalogData }, // omit to build a standalone catalog
);
if (created.ok) {
renderUiSpec(spec, { surface, catalog: created.catalog });
}

“Outside the catalog” then means outside the catalog in use; nothing else about validation or rendering changes, and when the option is absent the built-in catalog remains the boundary. The definition itself is treated as hostile input — it crosses the same purity wall as UI specs, plus tag, collision and guidance rules, and every rejection is a coded RegistrationIssue naming its offender. The returned catalog is deeply frozen: mutating it later can never alter validation or render outcomes. The catalog carries contracts, never implementations — your bundle keeps customElements.define ownership, and your components’ accessibility remains your contract.

The full registration surface, acceptance rules and security model live in the @kimen/catalog README.

The catalog and the ki-* elements are the durable assets; protocol adapters are deliberately disposable translations onto this one boundary:

The canonical package documentation lives in the repository: packages/catalog.