# Cards (/docs/agents/cards)

Typed rich cards the agent shows instead of a wall of text — the card schema, the agent tools, the /v1/cards data endpoints, limits and billing.

A **card** is a small, typed piece of UI the agent puts in the conversation: a comparison table, a chart, a weather forecast, a list of places, link previews, a choice to make. Cards are defined once, in the `@codai/cards` catalog (Zod 4), and rendered natively by every codai surface — desktop, web console, phone and watch. Surfaces that cannot draw a card (Telegram, email, text-to-speech, the model's own history) use the card's text twin, so nothing is lost.

## How a card travels [#how-a-card-travels]

1. The agent calls one of the [card tools](#agent-tools) (`show_card` or a typed shortcut).
2. The host validates the card against the schema, fills in the text twins and renders it. An invalid card is refused with a precise error (`blocks.0.rows: …`) so the model can repair its call.
3. The model only gets back a short text summary (`fallback.text`), never the full card, so it does not repeat the content in its reply.

As a tolerant fallback, a model that cannot call tools may emit the same JSON in a fenced block with the language `codai-card`; hosts render it like a tool-emitted card.

## Live cards [#live-cards]

A card with `live: { key }` can be updated after it is shown. The update is a session event `card_update` with `{ cardId, patch }`: `cardId` is the card's `live.key` and `patch` is a JSON Merge Patch (RFC 7396) over the card — objects merge, `null` removes a field, and arrays such as `blocks` and `actions` are replaced whole. The patched card is validated again (size cap included) and its text twins are recomputed; an update that changes the key or produces an invalid card is ignored and the previous card stays.

Every surface redraws the card where it first appeared — no new message, no flicker — and screen readers hear one short status line per change. The watch's last card follows the update.

```json
{ "kind": "card_update", "payload": { "cardId": "job:12", "patch": { "subtitle": "Background job · Done · 42s", "actions": [] } } }
```

### Live run card [#live-run-card]

When the agent works for a while (three or more tool steps, or when it waits for your approval) and for every background job it starts, desktop and phone show a live run card: the recent steps, elapsed time, cost so far and, while it waits, the approval itself. **Pause**, **Resume** and **Stop** act on the run directly (they never go through the model); **Allow / Deny** answers the pending permission. In the web console and on another device's session page, Stop, Allow and Deny are sent as session controls; pause and resume need the device that runs the agent.

## The card envelope [#the-card-envelope]

The full JSON Schema is published at `packages/cards/schemas/cards.schema.json` (generated from the Zod catalog; a drift test keeps them in sync, and the Kotlin types used by phone and watch are generated from the same source).

| Field | Type | Meaning |
| --- | --- | --- |
| `v` | `1` | Schema version (default `1`). |
| `id` | string | Optional stable id (letters, digits, `_ . : -`, ≤ 64 chars). |
| `title`, `subtitle` | string | Header shown above the blocks. |
| `blocks` | array, 1–24 | The content, in order (see the table below). |
| `actions` | array, ≤ 6 | Buttons under the card (see [actions](#actions)). |
| `fallback` | `{ text, speak? }` | Markdown and spoken twins; filled from the blocks when omitted. |
| `a11y` | `{ label?, summary? }` | Accessible name and summary; filled automatically when omitted. |
| `provenance` | `{ source, url?, asOf?, licence? }` | Where the data came from; shown as a badge. |
| `live` | `{ key, ttlS? }` | Live card: later updates with the same key patch it in place. |
| `display` | `inline` \| `expanded` | Initial size (default `inline`). |

### Block types [#block-types]

| `type` | What it shows | Key fields |
| --- | --- | --- |
| `text` | Markdown paragraph | `md` |
| `callout` | Highlighted note | `tone` (`neutral`, `info`, `success`, `warning`, `danger`), `title`, `md` |
| `metric` | One number with a trend | `label`, `value`, `unit`, `delta`, `higherIsBetter`, `sparkline` |
| `kv` | Key–value list | `items[{ k, v }]` (≤ 40) |
| `table` | Sortable table | `columns[{ key, label, format }]` (≤ 12), `rows` (≤ 200), `highlightRow` |
| `chart` | Line, bar, area, pie, scatter or sparkline | `kind`, `x`, `series` (≤ 8), `data` (≤ 500), required `summary` |
| `map` | Markers and an optional route | `center`, `zoom`, `markers` (≤ 50), `route` (encoded polyline), `staticImage` |
| `places` | Places with rating and opening state | `items[{ id, name, address, lat, lng, rating, openNow, … }]` (≤ 20) |
| `products` | Product carousel | `items[{ id, title, price, url, merchant, sponsored }]` (≤ 12) |
| `links` | Link previews or numbered sources | `variant` (`preview` \| `sources`), `items[{ url, title, description, image }]` |
| `weather` | Current conditions, hourly and daily forecast | `location`, `current`, `hourly` (≤ 48), `daily` (≤ 14) |
| `gallery` | Images or videos with a lightbox | `items[{ url, alt, caption }]` (≤ 24, `alt` required) |
| `choice` | Options as buttons | `id`, `prompt`, `mode` (`single`, `multi`, `rank`), `options` (2–12) |
| `approval` | Confirm a risky action | `id`, `action`, `risk`, `diff`, `undoSeconds` |
| `steps` | Checklist or guided steps (recipes, setup) | `items[{ title, md, done, durationS }]`, `guided` |
| `diagram` | Mermaid diagram | `source`, `summary` |
| `math` | KaTeX formula | `tex`, `display` |
| `code` | Highlighted code | `lang`, `code`, `path` |
| `file` | A file with a preview | `path`, `size`, `mime`, `url`, `preview` |
| `file_tree` | Tree of files with change status | `entries[{ path, kind, status }]` (≤ 300) |
| `diff` | Unified diff | `unified`, `path` |
| `progress` | A job's progress | `label`, `state`, `value` (0–1), `steps`, `costMicroUsd` |
| `timeline` | Dated events | `items[{ t, title, md, tone }]` |
| `countdown` | Time left until a moment | `label`, `target` |
| `qr` | QR code | `data`, `label` |
| `contact` | Contact details | `name`, `org`, `phone`, `email`, `url`, `address` |
| `visual` | Custom HTML or SVG the agent writes (diagram, interactive explainer), in a sandbox | `kind` (`html` \| `svg`), `source` (≤ 64 KB), required `summary` (≤ 500), `height` (80–1200), `title` |

Every URL inside a card must be `https://`; images may also be `data:image/*` URIs. A small screen (the watch) declares the blocks it can draw; anything else is replaced by a text block built from its markdown twin, and long cards are cut to a few blocks plus a spoken summary.

### Actions [#actions]

| `kind` | Required field | Effect |
| --- | --- | --- |
| `prompt` | `prompt` | Sends the text as the user's next message (click-to-follow-up). |
| `tool` | `tool` (+ `args`) | Calls a tool; the host's allowlist applies. |
| `open_url` | `url` | Opens the link in the browser. |
| `copy` | `value` | Copies the value to the clipboard. |
| `handoff` | `device` (`phone`, `desktop`, `web`) | Continues the conversation on another device. |

Action ids must be unique within a card; `style` is `primary`, `secondary` or `danger`, and `confirm` asks before running.

### Fallbacks, accessibility and provenance [#fallbacks-accessibility-and-provenance]

- **Text twins**: when the agent omits `fallback`, the host builds `fallback.text` (markdown) and `fallback.speak` (one or two sentences) from the blocks. These are what the model sees and what voice surfaces read aloud.
- **Accessibility**: `a11y.label` defaults to the title (or the first block), `a11y.summary` to the spoken twin. Charts require a plain-language `summary`; gallery images require `alt`.
- **Provenance**: cards built from gateway data carry their source, attribution and fetch time, shown as a badge on the card.

### Size cap [#size-cap]

A card is at most **32 KB** of JSON after the text twins are filled in. A larger card is refused with `card exceeds 32768 bytes`; shorten the data or split it into several cards.

Inline `data:image/*` payloads have their own 256 KB budget, and a `visual` block's `source` has its own **64 KB** budget; neither counts towards the 32 KB. A card holds at most one `visual` block.

### Sandbox [#sandbox]

A `visual` block runs in a sandboxed frame with an opaque origin (`sandbox="allow-scripts"`, never `allow-same-origin`): it cannot read the app, your cookies or storage, and its Content-Security-Policy allows no network at all (`default-src 'none'`, inline scripts and styles, `data:` images and fonts only). Sources that reference `http(s)://` or `//` URLs, contain `<base>` or a meta refresh, or an `svg` that does not start with `<svg` are refused with an error the agent can fix. Desktop serves the document once from the app's own `mcpapp:` scheme with the policy as a header; the web console uses a `srcdoc` frame; the phone uses a hardened WebView with no bridge to the app. The watch, the `minimal` cards mode and text-only surfaces show the `summary` instead.

### Maps [#maps]

A `map` block renders the same way everywhere, and the Google Maps key never leaves the gateway:

- **Desktop and web console**: a static map image from `GET /v1/cards/static-map`, fetched with your own sign-in (the console goes through a same-origin server proxy). Pins are lettered A–T and the list below the map uses the same letters. A grey placeholder shows while the image loads. Each map is fetched once per view (each fetch is billed $0.002).
- **Phone**: an interactive Google map (lite mode: markers and route, it scrolls with the chat) when the app was built with a Maps key; otherwise the same gateway static image.
- **Watch**: the first pin labels as text.
- **Always**: one row per pin that opens it in your maps app, plus **Open in Google Maps** and **Open in OpenStreetMap**. The image's alt text names the pins.
- **No image** (offline, quota spent, maps not configured): you get the pins with their coordinates and a note that the map image is unavailable.

An interactive Google Maps JS view on desktop and web is not enabled yet: it is waiting for a referrer-restricted JS key. Hosts can plug one in through `renderInteractiveMap`.

## Agent tools [#agent-tools]

| Tool | What the agent uses it for |
| --- | --- |
| `show_card` | Any card: pass the full envelope. Used when data is structured (3+ comparable items, a numeric series, places, links, a choice, steps, a file tree or diff). |
| `show_table` | Shortcut for a single table card (`columns`, `rows`, `caption`). |
| `show_chart` | Shortcut for a chart card; `summary` (the takeaway) is required. |
| `ask_choice` | Choice buttons (2–12 options, `mode` single / multi / rank). Non-blocking: the pick arrives as the user's next message. |
| `show_weather` | Weather card for a place, from `/v1/cards/weather`. |
| `find_places` | Places card from `/v1/cards/places` (billed, see below). |
| `show_link_previews` | Link preview cards from `/v1/cards/unfurl`. |
| `show_products` | Shopping card for 1-6 product pages from `/v1/cards/product`: name, image, price, old price, rating, seller and stock, read from each page's schema.org Product data. |
| `show_fx` | Exchange-rate card (BNR or ECB) from `/v1/cards/fx`. |
| `show_usage` | Your own cost & quota meter: spend today with a 14-day trend, daily/monthly budget and task limits, plan and wallet, from `/v1/account` and `/v1/usage`. |
| `ask_approval` | Approval card before a risky step: the action, details, a diff or the files affected, the risk, Allow / Deny. With `reversible` Allow keeps an undo window (`undo_seconds`, default 10 s). Non-blocking: the answer arrives as your next message. On the watch it is a one-tap approve with the same undo window. |
| `start_timer` | Countdown timer (`seconds`, `minutes` or an absolute `target`, up to 7 days): a live countdown in the chat, an ongoing notification with a chronometer on the phone, a "time is up" notification at the end, and the end time on the watch tile. |
| `show_comparison` | Sortable comparison table with the source of each row (a link column plus numbered sources) and a recommended row. Export CSV and copy markdown on desktop and web; Share CSV on the phone. |
| `show_handoff` | "Continue on phone" / "Continue on PC" / "Continue in browser": opens this same session on your other device (the existing handoff, needs a shared chat). |
| `show_share` | QR code (`kind: qr`), contact (`kind: contact`, with a vCard QR, copy and share/save) or location (`kind: location`, with a map pin, a `geo:` QR, Open in Maps and copy). Generated on the device. |
| `show_changes` | Timeline of the steps and files a task changed (plus a diff) with a **Revert** button that restores the files through the app's own undo: the checkpoint restore on desktop (snapshots) and on the phone (undo journal), after a confirmation. |
| `show_visual` | A custom visual the agent writes itself (`svg` or `html`, with a required `summary`) when no typed card fits: a bespoke diagram or a small interactive explainer. Runs in the sandbox above, with no network. Desktop, web console and phone. |
| `show_device_status` | This device's battery, storage, network, model and OS (read locally), your other codai devices with online / last seen (`/v1/devices`) and a find-my-device link. Battery and storage of other devices are not shared. |
| `show_fiscal_status` | Your e-Factura status next to the BNR rate: the documents codai issued to you through Brivio with their ANAF status (`/v1/cards/efactura`) and `/v1/cards/fx`. |
| `show_transcript_highlights` | Summary, decisions, action items and open questions of a codai Transcripts recording, each with a tap-to-play link to that moment (`/v1/transcripts`). |
| `show_route` | Travel time, traffic delay and distance between two places, with the route on a map (`/v1/cards/route`, billed). |
| `show_agenda` | Phone only: the next days of your calendar, with travel times from where you are and a "leave by" countdown (`/v1/cards/route` per located event, at most 3). |
| `track_package` | Parcel history (Sameday, FAN Courier, Cargus, DPD) from `/v1/cards/track`, plus the easybox pickup code as a QR. |

Every data card shows a provenance badge in its footer: the source (linked when it has a page), how old the data is ("updated 2 hours ago", exact date on hover) and the licence (for example MET Norway, CC BY 4.0). The watch shows a compact `source · 2h`.

The six local tools (`ask_approval` to `show_changes`) need no gateway call: the card is built on the device by a pure builder (`buildLocalCard` in `@codai/cards`, Kotlin twin `LocalCards`), with labels in your app language (English or Romanian).

The agent is told not to repeat a card's content in its text reply; at most one sentence of commentary.

### Preference [#preference]

Each user picks how much the agent uses cards: **Settings → Chat → Rich cards** on the desktop, **Settings → Agent → Rich cards** on the phone (the watch follows the phone).

| Value | What the agent gets | How cards look |
| --- | --- | --- |
| `auto` (default) | All card tools and the proactive guidance above. | Full cards. |
| `minimal` | All card tools, but it is told to use the display tools only when you ask for a card, table, chart or choice; the data tools still answer requests for live data. | Compact cards; charts and galleries collapse to their text and table twin, at most 6 blocks. |
| `off` | No card tools at all and an instruction to answer in plain text. | Any card already in a chat is shown as its fallback text. |

The preference is read when a chat starts, so the tool list and system prompt stay the same for the whole chat (the prompt cache keeps working); a change applies to new chats, while the way cards look changes at once. The web console has no chat-rendering preferences and always shows full cards.

## Data endpoints [#data-endpoints]

The data tools call gateway endpoints that return `{ block, provenance }`, where `block` is already a valid card block that can be dropped into `show_card` unchanged (or `null` when nothing matched). You can call them directly with any codai API key; the full reference is on the [Cards API page](../api/gateway/cards.mdx).

| Method | Path | Returns | Source | Cost |
| --- | --- | --- | --- | --- |
| `GET` | `/v1/cards/weather?lat&lng&name` | `weather` block | MET Norway, cached 30–60 min | Free |
| `GET` | `/v1/cards/places?q` or `?lat&lng&radius&type` | `places` block | Google Places (New) | $0.032 per call |
| `GET` | `/v1/cards/geocode?address` | `map` block + `results` | Google Geocoding | $0.005 per call |
| `GET` | `/v1/cards/static-map?center\|markers&zoom&w&h` | PNG | Google Maps Static | $0.002 per call |
| `GET` | `/v1/cards/fx?source&from&to\|symbols` | `metric` or `table` block | BNR / ECB, cached until the next publication | Free |
| `POST` | `/v1/cards/unfurl` `{ url }` | `links` block (preview) | The page itself, cached 24 h | Free |
| `POST` | `/v1/cards/product` `{ url, lang? }` | `products` block (1 item) | The page's schema.org `Product` (or OpenGraph product tags), cached 6 h | Free, shares the 500/day link limit |
| `GET` | `/v1/cards/route?from\|origin&to\|destination&mode` | `map` block + `route` | Google Routes, live traffic when driving | $0.010 driving, $0.005 walk / bike / transit |
| `GET` | `/v1/cards/track?awb&carrier` | `timeline` block + `result` | Sameday public tracking; FAN Courier, Cargus, DPD with a carrier contract only; cached 10 min | Free |
| `GET` | `/v1/cards/efactura?limit` | `table` block + `documents` | Your documents issued through Brivio, with the ANAF e-Factura status Brivio reported | Free |

Parcel tracking: Sameday is tracked live through its public tracking endpoint. FAN Courier, Cargus and DPD only return parcels of a customer account, so their tracking is live only on gateways that hold that carrier's contract credentials; elsewhere the card says live tracking is unavailable and links to the carrier's own tracking page. The easybox code from the carrier's SMS is never sent to the gateway: the app draws it as a QR.

Tap-to-play: transcript links open `https://hub.codai.ro/transcripts/<id>?t=<seconds>`; the transcript page scrolls to and highlights the utterance at that second.

### Rendering a card to an image [#rendering-a-card-to-an-image]

`POST /v1/cards/render` `{ block, format?, width?, height?, theme?, scale? }` turns a `chart` or `qr` block into an image for surfaces that cannot draw it — watch tiles and complications, phone notifications, email, Telegram. The answer is the image itself: `image/png` (default) or `image/svg+xml`, with the pixel size in `x-codai-render-size`. Charts are laid out with Vega-Lite and rasterised on the gateway with a bundled font (no network, no system fonts); QR codes stay black on white in both themes. `width` and `height` are 64–1200 CSS px (defaults 480 × 300; a QR is square), `scale` 1–3 multiplies the PNG pixels.

**Not supported yet:** `diagram` (Mermaid) and `math` (KaTeX) blocks — and every other block type — answer `422 render_unsupported`; use the block's text twin instead. Rendering is local compute, so it is free.

### Limits [#limits]

| Bucket | Per user per UTC day |
| --- | --- |
| Places (text and nearby search) | 200 calls |
| Maps (geocoding, static maps and routes) | 500 calls |
| Parcel tracking (carrier lookups) | 300; cache hits do not count |
| Link previews (unfurl) | 500 fetches; cache hits do not count |
| Renders (`/v1/cards/render`) | 2000 images |

`super_admin` accounts are exempt. A spent quota answers `429`. All endpoints also pass your key's normal rate limit and your [rules](./tools.mdx): unfurl is gated as `browser_fetch`, so a rule that denies page fetching applies here too, and render as `cards_render`. When maps are not set up on a gateway, the maps endpoints answer `503 maps_not_configured` — fall back to a text answer.

### Billing [#billing]

Paid Google calls are billed to your usage at **Google's list price**, as a row in the same session as the conversation: Places text or nearby search **$0.032**, geocoding **$0.005**, static map **$0.002**, route **$0.010** when driving (traffic-aware) or **$0.005** on foot, by bike or transit per call. The charged cost is in `provenance.costMicroUsd` (USD micros) and the `x-codai-cost-micro-usd` response header. A failed upstream call is neither billed nor counted. Weather, exchange rates, link previews, parcel tracking, e-Factura status and renders are free.

## From the SDKs and MCP [#from-the-sdks-and-mcp]

- TypeScript: `client.cards.weather({ lat, lng })`, `.places({ q })`, `.geocode({ address })`, `.staticMap({ center })` (PNG bytes), `.fx()`, `.route({ from, to })`, `.track({ awb })`, `.efactura()`, `.unfurl(url)`, `.render({ block, format })` (image bytes) — see the [TypeScript SDK](../sdks/typescript.mdx).
- Python: `client.cards.weather(lat, lng)`, `.places(q=…)`, `.geocode(address)`, `.static_map(center=…)`, `.fx(from_=…, to=…)`, `.route(from_=…, to=…)`, `.track(awb)`, `.efactura()`, `.unfurl(url)`, `.render(block, format=…)` (image bytes) — see the [Python SDK](../sdks/python.mdx).
- MCP: the [codai MCP server](../gateway/clients.mdx) exposes `cards_weather`, `cards_places`, `cards_fx`, `cards_unfurl`, `cards_product`, `cards_route`, `cards_track` and `cards_efactura`; each returns `{ block, provenance, card }` as `structuredContent` (`card` is the block as a ready one-block card, `null` when nothing matched) and the markdown twin as text. Render is not an MCP tool: MCP clients receive the card JSON and draw it themselves, or use the MCP App below.

```ts
import { Codai } from 'codai-sdk';

const codai = new Codai({ apiKey: process.env.CODAI_API_KEY! });
const { block, provenance } = await codai.cards.weather({ lat: 44.43, lng: 26.1, name: 'București' });
// block is a ready `weather` card block; provenance.source === 'met_norway'
```

## Cards as an MCP App [#cards-as-an-mcp-app]

The codai MCP server also ships the cards as an [MCP App](https://modelcontextprotocol.io/extensions/apps) (MCP Apps 2026-01-26). Every `cards_*` tool carries `_meta.ui.resourceUri: "ui://codai/card"`, and the server lists that resource (`text/html;profile=mcp-app`). Hosts that support MCP Apps (VS Code, Claude, ChatGPT, Cursor, the codai desktop and phone apps) load it in a sandboxed frame and draw the tool's card with the same renderer the codai apps use, in the host's theme and language. Hosts without MCP Apps ignore the field and show the markdown twin.

- The view is one self-contained HTML file: it makes **no network requests** and declares an empty CSP domain list (connect, resources, frames, base URI).
- It is small (about 200 KB, 65 KB compressed) and both `resources/list` and `resources/read` carry its `_meta["codai/sha256"]`, so a host can cache it per version.
- Card buttons go back through the host: open a link (`ui/open-link`, the host confirms), send a prompt (`ui/message`), copy a value. The view never calls tools on its own.
- In VS Code the setting is `chat.mcp.apps.enabled`.

### MCP Apps from other servers in codai [#mcp-apps-from-other-servers-in-codai]

The desktop and phone apps also **host** MCP Apps from any MCP server you connect: when a tool declares a `ui://` resource, the result is shown inline under the tool step in a sandboxed frame.

- **Isolation**: the frame has its own opaque origin (`sandbox="allow-scripts"`, never same-origin). It cannot reach the codai app, your keys or the device bridge.
- **Network**: the strict CSP comes from the server's `_meta.ui.csp`. Only `https`/`wss` origins are accepted, never `localhost` or IP addresses, and the default is no network at all.
- **Tool calls from the app**: allowed only for the same server's tools, and only tools the server makes available to apps. Read-only tools run directly. Anything else follows your agent settings: in **Autopilot** (desktop) or **Allow all** / **Autopilot** (phone) it runs without asking; otherwise you get Allow / **Always allow** / Deny. "Always allow" is remembered for that tool the same way as for the agent's own calls (desktop: for the current chat; phone: in Settings → Agent, where you can reset it). The same-server rule, the app visibility rule and the cap of 10 calls per 10 seconds apply in every mode.
- **Context for the agent** (`ui/update-model-context`): an app can tell the agent what you are looking at (for example the item you selected). Only the latest update of each app is kept; the agent sees it once, at its next step, marked as untrusted data from a third-party app. An update never starts a turn by itself.
- **Messages from the app** (`ui/message`): an app can propose a message to send as you. You see the exact text and choose Send or Don't send; at most one proposal every 5 seconds. A sent message goes to the chat like one you typed (as a note if the agent is working).
- **Permissions**: links open only for `https` URLs. The camera, microphone and location are never granted.
- **Web console**: the session view in the console does not run the app. It shows the tool's result instead (text, images and the structured data behind a toggle) with a note to open the chat in the desktop or phone app for the interactive view.

## A2UI [#a2ui]

codai reads [A2UI](https://a2ui.org) **v0.9** (the current stable spec) and **v1.0** (the release candidate) and turns it into a codai card. When an MCP tool result carries A2UI messages, the desktop and phone apps draw the mapped card under the tool step with the regular card renderer, so no extra renderer is needed.

A result counts as A2UI when it has an embedded resource with `mimeType: "application/a2ui+json"` (v1.0) or `"application/json+a2ui"` (v0.9) (its `text` is a JSON array of messages, one message or JSONL) or a `structuredContent.a2ui` array. The adapter replays the four server-to-client messages (`createSurface`, `updateComponents`, `updateDataModel`, `deleteSurface`), keeps one data model per surface and resolves `{ "path": … }` bindings: absolute JSON Pointers, plus relative paths inside list templates. Each surface that is still alive and has a `root` component becomes one card. If the result had no text block, the model gets the card's text twin instead of raw JSON.

A surface follows the version of the message that created it. In a **v1.0** surface only `{ "@path": … }` and `{ "@call": … }` are bindings; an object with a plain `path` or `call` key is shown as literal data, `@@` keys lose one `@`, and unknown single-`@` keys are rejected with a warning. v1.0 also lets `createSurface` carry `components` and `dataModel` in one message, deletes a data key when `updateDataModel` sets `value` to `null`, resolves `@index` inside list templates, uses `Action.event.userMessage` as the start of the button's prompt and shows `Video.posterUrl` as the thumbnail. The v1.0 function-call messages (`callRendererFunction`, `agentFunctionResponse`) are ignored: a card only displays.

For developers: `@codai/cards/a2ui` exports `a2uiToCards(messages)`, which returns `{ cards, warnings }`, and `isA2uiPayload(x)`. The Kotlin twin is `ro.codai.cards.A2ui`. Both are checked against the same golden cases in `packages/cards/fixtures/a2ui/`.

| A2UI v0.9 component | codai card |
| --- | --- |
| `Text` | `text` block. The first `h1`–`h3` at the top level becomes the card title, other headings stay markdown headings |
| `Image`, `Video` | `gallery` block (https or `data:image` URLs only) |
| `AudioPlayer` | link in a `text` block |
| `Row` | one line of text joined with ` · ` (images go to the gallery) |
| `Column`, `List`, `Card` | flattened in order; template children repeat for every item of the bound list |
| `Divider` | `---` in a `text` block |
| `Button` with `action.event` | card action of kind `prompt`: a click sends `A2UI action <name> <context JSON>` with the context bindings resolved |
| `Button` with `openUrl` | card action of kind `open_url` (https only) |
| `ChoicePicker` | `choice` block (`mutuallyExclusive` = single, `multipleSelection` = multi) |
| `TextField`, `CheckBox`, `DateTimeInput`, `Slider` | read-only `kv` rows with the current value (`obscured` fields are masked) |
| `Tabs`, `Modal` | flattened: tab titles become bold lines, the modal content follows its trigger |
| `Icon` | dropped |
| any other component | its `text`/`label` as text, then its children, with a warning |

**Limitations.** Inputs cannot be edited in a card: their values are shown, and a button sends the values the server already knows. Local functions other than `openUrl`, validation `checks`, themes and `sendDataModel` are ignored. Of the formatting functions, `formatString`, `formatNumber`, `formatCurrency` and `pluralize` are supported. A card holds at most 24 blocks, 6 actions and 32 KB, so anything over those caps is truncated. Every drop is reported as a warning, and the adapter never fails on unknown input.

**v1.0.** When A2UI v1.0 is final, the adapter will accept its envelope next to v0.9 (one `version` switch). The mapping table above stays the same unless the basic catalog changes. The decision record is `docs/decisions/2026-10-14-a2ui-adapter.md`.
