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).
If you are wiring a model rather than reading the contract, go straight to Schemas as tool definitions: cardTools projects the schemas below into tool definitions and cardFromToolCall turns the model’s call back into an envelope, so none of the mapping on this page has to be written by hand.
The loop
Section titled “The loop”agent / server ──CardEnvelope(s)──▶ <kai-cards>.cards ▲ │ dispatcher renders the kai-* for each `type` │ ▼ └──── result ◀── CardPolicy ◀── kai-card event ◀── user interactsOne 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.
The Card Contract
Section titled “The Card Contract”Everything the agent sends is a CardEnvelope:
interface CardEnvelope { type: string; // which card — confirm | choice | form | tasks | link | embed | artifact 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:
type | Use it for | Renders | Terminal event |
|---|---|---|---|
confirm | Approve / decline an action | kai-confirm | action |
choice | Pick one option from a list | kai-choice | action |
form | Collect structured input (JSON-Schema form) | kai-form | submit |
tasks | Select from a proposed plan | kai-tasks | submit |
link | Preview a URL (title/desc/image) | kai-link-preview | — |
embed | Lazy media embed (YouTube, Vimeo) | kai-embed | — |
artifact | Frame a generated page plus the source files behind it | kai-artifact | — |
artifact reaches a deliberately narrow slice of the <kai-artifact> element: an envelope sets src, files, tab, activeFile, displayUrl and height, never the iframe sandbox, the toolbar composition or the view state. A model says what to show; the host keeps control of how the viewer behaves. tab and activeFile seed the first render only, so revising the card leaves a user who has since switched views where they are.
Each data shape has a published JSON Schema, importable as cardSchemas from @kitn.ai/ui/schemas (or as a raw document, @kitn.ai/ui/schemas/confirm.schema.json, for a backend that is not JavaScript). 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" } ] }}Prepping your model
Section titled “Prepping your model”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:
Tool calling (recommended for agents)
Section titled “Tool calling (recommended for agents)”Give the model one tool per card type, with the tool’s parameters set to that card’s data schema. cardTools builds those definitions from the shipped schemas and cardFromToolCall maps the call back, so both halves come off one contract:
import { cardTools, cardFromToolCall } from '@kitn.ai/ui/schemas';
const tools = cardTools({ provider: 'anthropic' }); // one per card type, named kai_*
// When the model calls one, the call IS the envelope.function onToolCall(call) { const card = cardFromToolCall(call.name, call.input ?? {}, { id: call.id }); if (card) cards.cards = [...cards.cards, card];}The schema guides the model, it does not constrain it. Provider strict modes cannot express any of the seven card types today, so validate what comes back rather than assuming the arguments match. Schemas as tool definitions has the detail, including why re-using the provider’s tool call id is what lets a model revise a card in place.
Structured output / JSON mode
Section titled “Structured output / JSON mode”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.
Validate before you render
Section titled “Validate before you render”<kai-cards> already validates a built-in card’s data before rendering it and emits { kind: 'error' } when it fails. To check earlier, on the server, use the same validator against the same schema:
import { validateAgainstSchema } from '@kitn.ai/ui';import { cardSchemas } from '@kitn.ai/ui/schemas';
const result = validateAgainstSchema(cardSchemas.confirm, envelope.data); // schema firstif (!result.valid) { // result.errors is a string[] — hand it back to the model to fix the card}It implements a lean keyword subset: allOf, anyOf, oneOf, format and additionalProperties are not enforced. Schemas as tool definitions lists what that lets through.
Wiring the host
Section titled “Wiring the host”Render envelopes with <kai-cards> and route every interaction through a CardPolicy:
<kai-cards></kai-cards><script type="module"> import '@kitn.ai/ui/elements';
await customElements.whenDefined('kai-cards');
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>.
Keep resolved cards across reloads
Section titled “Keep resolved cards across reloads”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.
Your own card types
Section titled “Your own card types”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.
- Generative UI cards — the rendering pattern, live.
- confirm · choice · form · tasks — per-card APIs.