Skip to main content

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_schemaparameters 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.