Skip to content
kitn AI/UI

Generative UI

Your model doesn’t have to answer in prose. It can ask the chat to render a typed, interactive card — a confirmation to approve, a form to fill, a plan to pick from, a link to preview — and get the user’s answer back as structured data. The kit handles rendering and event routing; you decide when a card beats a sentence.

This guide is the model/server side: the contract, how to get your model to emit cards, and how to feed the result back. For the rendering pattern see Generative UI cards; for each card’s props, the component pages (confirm, choice, form, tasks).

agent / server ──CardEnvelope(s)──▶ <kai-cards>.cards
▲ │ dispatcher renders the kai-* for each `type`
│ ▼
└──── result ◀── CardPolicy ◀── kai-card event ◀── user interacts

One envelope is what the agent sends (addressed data, not UI); one card is what the user sees. The dispatcher’s whole job is envelope in → card out.

Everything the agent sends is a CardEnvelope:

interface CardEnvelope {
type: string; // which card — confirm | choice | form | tasks | link | embed
id: string; // stable id; every event correlates back to it
data: unknown; // conforms to that type's JSON Schema
title?: string; // optional card-chrome heading
resolution?: CardResolution; // set once resolved → renders the read-only view
}

The built-in types and the element each renders:

typeUse it forRendersTerminal event
confirmApprove / decline an actionkai-confirmaction
choicePick one option from a listkai-choiceaction
formCollect structured input (JSON-Schema form)kai-formsubmit
tasksSelect from a proposed plankai-taskssubmit
linkPreview a URL (title/desc/image)kai-link-preview
embedLazy media embed (YouTube, Vimeo)kai-embed

Each data shape has a published JSON Schema (shipped in the package under dist/schemas/, e.g. confirm.schema.json). A confirm envelope looks like:

{
"type": "confirm",
"id": "deploy-1",
"title": "Deploy to production?",
"data": {
"body": "Applies 2 migrations and restarts 3 services.",
"tone": "danger",
"actions": [
{ "id": "deploy", "label": "Deploy now", "style": "primary", "default": true },
{ "id": "cancel", "label": "Cancel" }
]
}
}

There’s no special skill, agent framework, or fine-tune required — a card is just JSON your model emits and the kit renders. You need two things: the model must (1) know the card types and (2) produce data that matches the type’s schema. Two practical ways:

Give the model one tool per card type, with the tool’s parameters set to that card’s data schema. When the model calls the tool, map the call to an envelope and push it into the chat. This keeps the model on rails — the provider validates arguments against the schema for you.

// Tool definition (Anthropic / OpenAI function-calling shape) — params = the card schema.
const tools = [{
name: 'ask_confirm',
description: 'Ask the user to approve or decline an action.',
input_schema: confirmSchema, // dist/schemas/confirm.schema.json
}];
// When the model calls it, wrap the args in an envelope and render.
function onToolCall(call) {
if (call.name === 'ask_confirm') {
cards.cards = [...cards.cards, { type: 'confirm', id: call.id, data: call.input }];
}
}

If you’d rather have the model return envelopes directly, constrain its output to the CardEnvelope schema (or a union of your enabled types) and parse them out of the response. Validate before rendering — see below.

The data is model-generated, so check it against the schema first. The kit ships a validator:

import { validateAgainstSchema } from '@kitn.ai/ui';
// The card schemas ship in the package at dist/schemas/*.schema.json — load the
// one for the type (read it on the server, or copy it into your project).
const result = validateAgainstSchema(envelope.data, confirmSchema);
if (!result.valid) {
// result.errors is a string[] — hand it back to the model to fix the card
}

Render envelopes with <kai-cards> and route every interaction through a CardPolicy:

<kai-cards></kai-cards>
<script type="module">
import '@kitn.ai/ui/elements';
const cards = document.querySelector('kai-cards');
cards.cards = [/* envelopes from your model */];
cards.policy = {
onAction: (cardId, action, payload) => continueTurn(cardId, { action, payload }),
onSubmit: (cardId, data) => continueTurn(cardId, { data }),
};
</script>

onAction (confirm/choice) and onSubmit (form/tasks) are the terminal verbs — that’s the user’s answer. Feed it back into your next model call (as the tool result for the matching id, or as a new user turn) so the agent can continue. In React use <Chat>-level handlers; in SolidJS use renderCard / <CardRenderer> inside a <CardProvider>.

A card flips to a read-only view the moment the user acts. To make that survive a reload, persist the resolution with applyResolution and store the array:

import { applyResolution } from '@kitn.ai/ui';
cards.addEventListener('kai-card', (e) => {
cards.cards = applyResolution(cards.cards, e.detail); // resolved → re-hydrates read-only
save(cards.cards);
});

It’s pure and safe on every event — non-terminal events and unknown ids return the array unchanged.

The built-ins cover most flows, but the type → tag map is open. Register an element for a new type and merge it in:

import { mergeCardTags } from '@kitn.ai/ui';
const types = mergeCardTags({ chart: 'my-chart-card' }); // built-ins + yours
// el.types = types → <kai-cards> now renders `chart` envelopes with <my-chart-card>

For provider-owned, cross-origin cards you don’t want to bundle, render them in a sandboxed iframe with kai-remote — same envelope, same policy, over postMessage. See remote cards.