Skip to main content

Card catalog

A card is a typed, versioned block of structured output that the web and desktop surfaces render natively. Nine kinds exist. Eight are produced by the emit_card tool (a function the model can call); the ninth, canvas, is produced only by render_ui.

Payloads are data-shaped, never presentation-shaped. The model describes what the data is — a series, a set of rows, one record — and the client decides how it looks. No colors, no HTML, no chart specs, no URLs travel in a payload.

Source

Schemas: packages/web-contracts/src/cards.ts. Tools: extensions/tools-ui/src/emit-card.ts and extensions/tools-ui/src/render-ui.ts. Both tools carry the toolset ui.

Envelope

Every card travels as { kind, specVersion, payload } on ToolResult.structured.card. The tool stamps kind and specVersion; the model supplies only payload. CARD_SPEC_VERSION is 1.

The envelope is validated three times: at the tool (the model gets field-level issue paths and retries), before broadcast (invalid envelopes are stripped from the wire and logged), and on history replay (invalid or unknown kind renders a labeled fallback block). ToolResult.value is a short ack such as rendered: data_table: Sector flow — the payload never echoes into conversation history.

Surface boundary

Cards render on web and desktop only. Every channel adapter — Telegram, Discord, Slack, WhatsApp, email — drops the ui toolset before the model sees it, and both tools re-check ctx.platform as a backstop, returning ok: false with Cards render on the web surface; answer in prose here.

This is the design, not a gap. Prose is the durable answer on every surface; cards are additive on the two surfaces that can draw them. A turn that emits no card is never blank.

Composition rules

A personality (a directory of files that decides an agent's tools, memory, and model) whose toolset includes emit_card or render_ui receives these rules in its system prompt, from createUiGuidanceInjector:

  1. Prose is the durable answer. A card never replaces the explanation.
  2. Order within a turn: data cards first, then at most one short prose caption, then recommend_actions last.
  3. recommend_actions is emitted only when a genuine next step exists, and never more than three actions.
  4. Match the kind to the shape of the data: a series → metric_chart; rows → data_table; one record → detail; a set of like things → item_list.
  5. Reach for render_ui only for shapes the eight card kinds cannot express.

The injector also lists the personality's own Canvas templates, scanned once at composition time from <personality-dir>/ui/*.html.

text

For an explanation, a summary, or any prose block that deserves its own container instead of flowing inline.

FieldTypeRequiredConstraints
titlestringnoMax 120 chars.
textstringyesMarkdown body. 1–8 000 chars.
{ "kind": "text", "payload": { "title": "Why this setup", "text": "Volume dried up through the base and expanded on the breakout." } }

code

For source text the reader will copy: a file, a diff hunk, a query, a config block.

FieldTypeRequiredConstraints
titlestringnoMax 120 chars. Usually the file name.
languagestringnoMax 32 chars. Highlighting hint, e.g. typescript, sql.
codestringyesUnfenced source. 1–16 000 chars.
{ "kind": "code", "payload": { "language": "sql", "code": "select symbol, close from bars where date = current_date" } }

alert

For one status fact the reader must not miss — a failure, a completed run, a caveat that changes the answer's meaning.

FieldTypeRequiredConstraints
severityenumyesinfo — neutral notice. success — the thing worked. warning — proceed with care. error — it failed. Drives icon and tone; the client never uses color alone.
titlestringnoMax 120 chars.
messagestringyesOne or two sentences. 1–500 chars.
{ "kind": "alert", "payload": { "severity": "warning", "message": "Quotes are 20 minutes delayed — intraday levels are indicative." } }

detail

For exactly one entity described by labeled fields: a ticket, an order, a config, a single stock's setup.

FieldTypeRequiredConstraints
titlestringyes1–120 chars. What this record is.
statusstringnoMax 40 chars. One short state word, e.g. open, settled.
fieldsarrayyes1–12 entries of { label, value }. label 1–60 chars, value 1–500 chars, both display text.
{ "kind": "detail", "payload": { "title": "TITAN", "status": "watchlist", "fields": [{ "label": "Entry", "value": "3412" }, { "label": "Stop", "value": "3280" }] } }

item_list

For a set of like things, each with a stable identity: sessions, files, tickets, symbols. One resource shape per list.

FieldTypeRequiredConstraints
titlestringnoMax 120 chars.
itemsarrayyes1–50 entries.
items[].idstringyes1–120 chars. A stable identifier, never a URL — the client turns it into a link.
items[].namestringyes1–200 chars. Primary label.
items[].statusenumnook, warn, error, neutral — rendered as a status dot.
items[].metastringnoMax 200 chars. One short secondary line.
{ "kind": "item_list", "payload": { "items": [{ "id": "cli:ethos", "name": "cli:ethos", "status": "ok", "meta": "14 messages" }] } }

data_table

For rows sharing the same columns, when the reader compares down a column. Numbers stay numbers so the client can right-align them and use tabular numerals.

FieldTypeRequiredConstraints
titlestringnoMax 120 chars.
captionstringnoMax 300 chars. Short note below the table, e.g. the source.
columnsarrayyes1–8 entries of { key, label, numeric? }. key and label 1–60 chars; numeric: true right-aligns the column.
rowsarrayyesUp to 50 objects keyed by columns[].key. Cell values are string, number, or null.
totalsobjectnoOne summary row, keyed by columns[].key. Same cell types.
{ "kind": "data_table", "payload": { "columns": [{ "key": "sector", "label": "Sector" }, { "key": "flow", "label": "Net flow", "numeric": true }], "rows": [{ "sector": "Pharma", "flow": 412 }] } }

metric_chart

For a measured quantity over an ordered axis — time, buckets, categories. Up to four series that share the same axes and unit.

FieldTypeRequiredConstraints
titlestringnoMax 120 chars.
suggestedVizenumnoline (default client choice for ordered x), bar, area, scatter. Advisory only — the client decides the final form.
xLabelstringnoMax 60 chars.
yLabelstringnoMax 60 chars. Include the unit here.
seriesarrayyes1–4 entries of { name, points }. name 1–60 chars.
series[].pointsarrayyes1–200 ordered { x, y }. x is a category label or a numeric/ISO-date position; y is a number.
{ "kind": "metric_chart", "payload": { "yLabel": "Net flow (Cr)", "series": [{ "name": "FII", "points": [{ "x": "2026-08-08", "y": -1240 }, { "x": "2026-08-11", "y": 380 }] }] } }

Series beyond four, or points beyond 200, are rejected at the tool rather than truncated — split the answer instead.

recommend_actions

For the one to three follow-ups that genuinely advance the conversation. Rendered as pills that inject their prompt into the composer. Emit it last in a turn, or not at all.

FieldTypeRequiredConstraints
questionstringnoMax 200 chars. What the reader is picking between.
actionsarrayyes1–3 entries of { label, prompt }. label 1–60 chars (pill text); prompt 1–500 chars — the exact text injected into the composer.
{ "kind": "recommend_actions", "payload": { "question": "Next?", "actions": [{ "label": "Size the trade", "prompt": "Size TITAN at 1% portfolio risk." }] } }

canvas

The ninth kind, and the only one emit_card cannot produce. render_ui emits it for shapes the eight typed kinds cannot express. The HTML runs in an iframe with sandbox="allow-scripts" and no allow-same-origin, under a CSP that blocks all network access.

FieldTypeRequiredConstraints
titlestringnoMax 120 chars. Heading shown above the frame.
htmlstringyesDocument body. 1–65 536 chars. Supplied inline as html, or read from a template file.
dataunknownnoAny JSON value, max 32 768 chars serialized. Injected as the frozen global window.ethosData.
librariesarraynoUp to 4 entries from the allowlist. Only echarts@1 in spec 1; unknown names are rejected.

render_ui takes { title?, html?, template?, data?, libraries? } and requires exactly one of html or template. Template mode reads ui/<template>.html from the personality's own directory through its ScopedStorage boundary and inlines the bytes server-side.

{ "template": "sector-flow", "libraries": ["echarts@1"], "data": { "buckets": ["Mon"], "sectors": ["Banks"], "cells": [{ "sector": "Banks", "bucket": "Mon", "value": 412 }] } }

See Author a Canvas template for the full template contract.

Refusals

ToolResult.codeWhen
not_availableThe platform cannot draw cards; or template mode was used with no personality context, an unresolvable personality directory, no ScopedStorage, a missing or empty template file, or a template outside the personality's fs_reach (the declared filesystem allowlist).
input_invalidUnknown kind; payload failed schema validation (field carries the zod issue path); both or neither of html/template; a template name containing anything but letters, digits, hyphens, underscores; html, data, or libraries over cap.
execution_failedThe template file exists and is in reach but could not be read.

See also