Skip to content
kitn AI/UI

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.

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:

fieldtypenotes
typestringBuilt-ins: confirm, choice, form, tasks, link, embed, artifact. Extend via types prop.
idstringStable across re-renders; passed back in every policy callback.
titlestringRendered as the card heading.
dataunknownShape depends on type — see each card below.
resolutionCardResolutionSet to re-hydrate the read-only state (e.g. on history load).

CardPolicy callbacks — only supply the ones you need:

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

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;
}

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'
}

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;
}>;
}

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.

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.

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' },
},
];