# Generative UI cards

Let the assistant interrupt with interactive cards — confirm, choose, or fill a form — wired back to the host through a typed CardPolicy.

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

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.

```js

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

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

```ts
{
  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

A `choice` card presents a list of options; the user selects one and submits. The chosen option's `id` arrives via `onAction(cardId, optionId)`.

```ts
{
  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

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.

```ts
{
  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

Append envelopes to the array as the agent generates them — `<kai-cards>` renders whatever is in the array at that moment:

```js
// 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](/guides/schemas-as-tools/).

## 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:

```js

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](/components/cards/#dismiss-and-recover) for the full options.

## Re-hydrating resolved cards

Pass `resolution` on the envelope to render the read-only (already-answered) state without re-emitting policy events:

```js
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

- **[`kai-cards` reference](/components/cards/)** — `types` map for custom card tags, all props.
- **[`kai-confirm` reference](/components/confirm/)** — `autofocus`, `tone`, full action shapes.
- **[`kai-choice` reference](/components/choice/)** — `allowOther`, media options.
- **[`kai-form` reference](/components/form/)** — full JSON Schema subset + every `x-kai-*` hint.
- **[Drop-in chat](/examples/drop-in-chat/)** — wire cards into a streaming conversation with `<kai-chat>`.
