AI generation & editing
The registry is the single source of truth for what a model may emit: it can only be taught components that will actually resolve at render time. Everything here is a pure function — you bring your own model, SDK and calling code; the package never talks to a provider.
The prompt catalog
registryPrompt(registry, options) renders the spec grammar plus one section
per registered component — and, when you pass them, your
dataset schemas and action catalog, so the model
binds real columns and wires real handlers instead of inventing them:
import { createViewRegistry, defineActions, registryPrompt } from '@zuilib/apps'
import { coreViewTypes } from '@zuilib/apps/core'
import { chartViewTypes } from '@zuilib/apps/charts'
import { dataGridViewTypes } from '@zuilib/apps/data-grid'
const registry = createViewRegistry(
coreViewTypes, chartViewTypes, dataGridViewTypes)
const actions = defineActions(
{ name: 'openAccount', description: 'Navigates to the account record.',
params: { id: { valueType: 'string', required: true } } },
)
const system = [
'You design console views for our CRM. Answer with one JSON view spec, nothing else.',
registryPrompt(registry, { datasets: [accountsSchema, revenueSchema], actions }),
].join('\n\n')
Register a domain component, declare a new dataset, add an action — the catalog updates automatically. Prompt, validator and renderer can't drift apart because they all read the same registry.
Structured output
For providers with schema-constrained decoding, viewJsonSchema(registry)
emits a JSON Schema whose node type is an enum of the registered types and
whose known props are described per type — including full nested schemas for
props that declare one (the chart node ships its ChartSpec schema, so even
the chart internals are constrained):
import { viewJsonSchema } from '@zuilib/apps'
const response = await client.messages.create({
model: 'claude-sonnet-5',
system,
messages: [{ role: 'user', content: 'A renewal-risk view for Q3' }],
output_format: { type: 'json_schema', schema: viewJsonSchema(registry) },
})
Streaming: render while it generates
Don't make people watch a spinner while JSON accumulates.
parsePartialViewSpec(text, { registry }) turns a truncated stream into a
best-effort renderable spec — open strings closed, brackets balanced,
danglers trimmed — so the view assembles live, component by component:
let text = ''
for await (const chunk of stream) {
text += chunk
const { spec, complete } = parsePartialViewSpec(text, { registry })
if (spec) setDraft(spec) // <ViewRenderer spec={draft} …/> updates live
if (complete) break
}
Fences and prose around the JSON are skipped automatically
(extractJsonObject is exported separately if you need just that). With the
registry, a node that doesn't validate yet — a chart whose spec hasn't
arrived, a type cut mid-word — comes back hidden and appears once it is
whole; without it, the renderer's per-node boundaries still contain a
half-arrived node, but you'll see them trip along the way.
Validate, repair
Never persist model output unchecked — parse, validate, and hand problems back:
import { parseViewSpec, repairPrompt, validateViewSpec } from '@zuilib/apps'
const spec = parseViewSpec(modelOutput)
const issues = validateViewSpec(spec, registry, { datasets, actions })
if (issues.some((issue) => issue.severity === 'error')) {
// repairPrompt formats the issues (with spec paths) as the retry message
const retry = await complete(system, [assistant(modelOutput), user(repairPrompt(issues))])
return parseViewSpec(retry)
}
Even a spec that slips through can't take the page down: the renderer
placeholders unknown types and contains per-node render errors, reporting
both through onIssue.
AI edits are patches — the same currency as user edits
Regenerating a whole view to change one chart is wasteful and destroys the
user's context. editPrompt(spec, registry, options) teaches the model to
answer with a minimal ViewPatch list against the current view's node
ids — the exact same operations <ViewEditor> emits when a person drags a
card or retypes a title:
import { applyViewPatches, editPrompt, patchJsonSchema, validateViewPatches } from '@zuilib/apps'
const system = editPrompt(currentSpec, registry, { datasets, actions })
const answer = await client.messages.create({
model: 'claude-sonnet-5',
system,
messages: [{ role: 'user', content: 'Make the revenue chart taller and hide the churn table' }],
output_format: { type: 'json_schema', schema: patchJsonSchema(registry) },
})
const { patches } = JSON.parse(answer)
const { issues, spec } = validateViewPatches(currentSpec, patches, registry, { datasets })
if (!issues.some((issue) => issue.severity === 'error')) save(patches)
validateViewPatches checks each patch is well-formed and applicable, then
validates the patched result — attributing only newly introduced problems
to the edit. Because AI edits and manual edits share one format, they merge
in one list, undo through the editor's history, persist in one column, and
review as one diff. diffViewSpecs(base, next) closes the loop from the
other side: any externally produced spec revision converts back into patches.
Tool calling & agents
viewTools(registry, { datasets, actions }) bundles the schemas as
provider-neutral tool definitions — create_view (a full spec) and
update_view (a patch list) — in the shape the Anthropic API accepts
directly (map input_schema → parameters for OpenAI-style APIs). Mount
them in your agent, handle the calls with the validators above, and any
assistant in your product can build and refine views.
Why this beats generated code
- On-brand by construction. The model composes your components on your tokens; it cannot invent styling.
- Reviewable and storable. A JSON diff is auditable; generated code is a liability to re-run.
- Editable after the fact. A generated view is a normal view — users customize it with patches, and the next model call edits incrementally with the same patches instead of regenerating.