Generative UI cards
Instead of waiting for the user to type a follow-up, the assistant streams a card directly into the conversation. The user answers it in one click; your CardPolicy receives the result.
How it works
Section titled “How it works”Feed <kai-cards> an array of CardEnvelope objects and a CardPolicy. Each envelope has a type (confirm, choice, form), a stable id, a title, and data that conforms to that type’s schema. When the user interacts, the matching CardPolicy callback fires — no event listeners needed.
import '@kitn.ai/ui/elements';
await customElements.whenDefined('kai-cards');
const cards = document.getElementById('cards');
// Assign the envelope stream in JavaScript — arrays can't be HTML attributes.cards.cards = [/* CardEnvelope objects, one per card type — see below */];
// Wire a CardPolicy — one callback per interaction verb.cards.policy = { onAction: (cardId, action, payload) => console.log('action', cardId, action, payload), onSubmit: (cardId, data) => console.log('submit', cardId, data), onDismiss: (cardId) => console.log('dismiss', cardId), onError: (cardId, message) => console.error('error', cardId, message),};CardEnvelope shape — all card types share the same wrapper:
| field | type | notes |
|---|---|---|
type | string | Built-ins: confirm, choice, form, tasks, link, embed, artifact. Extend via types prop. |
id | string | Stable across re-renders; passed back in every policy callback. |
title | string | Rendered as the card heading. |
data | unknown | Shape depends on type — see each card below. |
resolution | CardResolution | Set to re-hydrate the read-only state (e.g. on history load). |
CardPolicy callbacks — only supply the ones you need:
| callback | when it fires |
|---|---|
onAction(cardId, action, payload?) | A confirm button or choice option was submitted |
onSubmit(cardId, data) | A form was submitted; data is the validated values object |
onDismiss(cardId) | The user dismissed a dismissible card |
onReopen(cardId) | The user tapped Reopen on a dismissed card’s stub |
onError(cardId, message) | The card definition was invalid, or the card failed to render |
Each card type below is live — interact with it and watch its policy events land in the Console beneath the preview.
kai-confirm — ask for a decision
Section titled “kai-confirm — ask for a decision”A confirm card poses a question and offers one or more actions. Tapping an action fires onAction(cardId, action), where action is the button’s id.
{ body?: string; tone?: 'default' | 'warning' | 'danger'; actions: Array<{ id: string; label: string; style?: 'primary' | 'default' | 'destructive'; payload?: unknown; // echoed back in onAction's third arg default?: boolean; // auto-focused when autofocus is set }>; dismissible?: boolean;}kai-choice — pick from options
Section titled “kai-choice — pick from options”A choice card presents a list of options; the user selects one and submits. The chosen option’s id arrives via onAction(cardId, optionId).
{ prompt?: string; options: Array<{ id: string; label: string; description?: string; meta?: string; // trailing label (price, badge…) recommended?: boolean; // renders a "Recommended" pill disabled?: boolean; payload?: unknown; // echoed back in onAction }>; allowOther?: boolean | { label?: string; placeholder?: string }; submitLabel?: string; // defaults to 'Submit'}kai-form — collect structured input
Section titled “kai-form — collect structured input”A form card renders a JSON Schema subset as fields, validates on submit, and delivers the values object through onSubmit(cardId, data). Use x-kai-* hints to pick widgets and control layout.
{ type: 'object'; description?: string; required?: string[]; 'x-kai-submitLabel'?: string; 'x-kai-order'?: string[]; // explicit field order properties: Record<string, { type: 'string' | 'number' | 'boolean' | 'array'; title?: string; enum?: unknown[]; 'x-kai-widget'?: 'textarea' | 'select' | 'radio' | 'slider' | 'rating' | 'switch' | 'checkbox'; 'x-kai-placeholder'?: string; }>;}Streaming cards in as the agent decides
Section titled “Streaming cards in as the agent decides”Append envelopes to the array as the agent generates them — <kai-cards> renders whatever is in the array at that moment:
// Start with nothing; add cards as the model emits them.cards.cards = [];
for await (const event of streamFromYourAgent()) { if (event.type === 'card') { cards.cards = [...cards.cards, event.envelope]; }}Building those envelopes by hand is optional. cardFromToolCall turns a model’s tool call straight into one, keyed on the provider’s tool call id so a revision replaces the card instead of appending a second copy. See Schemas as tool definitions.
Dismissing a card without losing it
Section titled “Dismissing a card without losing it”Set dismissible: true in a card’s data and the user gets a × to wave it away. Dismissing defers the card — it collapses to a small “Proposed: … — dismissed · Reopen” stub instead of vanishing — and fires onDismiss(cardId). Tapping Reopen fires onReopen(cardId).
Defer, don’t delete: a dismissed card can come back. Your onReopen decides whether it returns live or has expired (e.g. the agent already acted on it).
The dismissRecovery() helper wires both callbacks against your card store and adds an Undo toast:
import { dismissRecovery, toast } from '@kitn.ai/ui';
cards.policy = { ...dismissRecovery({ get: () => cards.cards, set: (next) => { cards.cards = next; }, toast: { show: ({ message, action, durationMs }) => { const t = toast(message, { duration: durationMs, action: action && { label: action.label, onAction: action.onClick }, }); return { dismiss: t.dismiss }; }, }, }), onAction, onSubmit,};onDismiss writes { kind: 'dismissed' } and shows “Dismissed · Undo” (Undo restores the prior state). onReopen brings the card back live, or stamps { kind: 'expired' } when it’s no longer reopenable — pass isReopenable or staleAfterMs to control that. See the kai-cards reference for the full options.
Re-hydrating resolved cards
Section titled “Re-hydrating resolved cards”Pass resolution on the envelope to render the read-only (already-answered) state without re-emitting policy events:
cards.cards = [ { type: 'confirm', id: 'confirm-deploy', title: 'Deploy to production?', data: { /* … */ }, resolution: { kind: 'action', action: 'deploy', at: '2026-06-17T09:12:00Z' }, },];Next steps
Section titled “Next steps”kai-cardsreference —typesmap for custom card tags, all props.kai-confirmreference —autofocus,tone, full action shapes.kai-choicereference —allowOther, media options.kai-formreference — full JSON Schema subset + everyx-kai-*hint.- Drop-in chat — wire cards into a streaming conversation with
<kai-chat>.