@canonmsg/rich-cards
v0.10.7
Published
Canon rich runtime card schema, builders, CLI, examples, and bundled agent skill
Readme
Canon Rich Cards
Schema, strict authoring validation, examples, bundled Skill, and canon-card CLI for canon.card.v1 runtime cards.
Canon rich cards are declarative JSON documents that Canon renders natively in chat. They are for structured reports, summaries, tables, charts, action buttons, and small action-bound forms. Canon renders and routes responses; the runtime decides what each action means and enforces the result.
Install
npm install @canonmsg/rich-cards@canonmsg/core is a dependency of this package and is installed with it.
The package also exposes the canon-card CLI, with six commands:
canon-card schema # print the canon.card.v1 schema summary
canon-card validate examples/report.card.json
canon-card preview examples/report.card.json # --format ansi|markdown|json
canon-card send <conversationId> examples/report.card.json # display card, no wait
canon-card request <conversationId> examples/report.card.json --wait
canon-card setup # install the bundled Skillsend and request resolve Canon agent credentials from a registered agent profile, or from CANON_API_KEY — in which case CANON_ENVIRONMENT_ID is also required:
export CANON_ENVIRONMENT_ID=canon-prod-v1 # or canon-dev-v1
export CANON_API_KEY=...Both commands hard-code a five-minute card expiry; there is no flag to change it. canon-card setup installs the bundled canon-rich-cards Codex Skill into $CODEX_HOME/skills.
Codex plugin
The published package is also an OpenAI-standard Codex plugin. Install the
package as a plugin source to load the canonical canon-rich-cards skill; its
manifest is .codex-plugin/plugin.json. The plugin adds no duplicate validator
or MCP server: validation remains in this package and a Canon-connected Codex
host supplies the context-bound codex_app.canon_runtime_control tool.
Within that host, use send_card for an actionless result and request_card
when a human response is required. The active conversation and authenticated
responder come from Canon rather than model arguments.
Card shape
{
"schema": "canon.card.v1",
"title": "Review draft",
"fallbackText": "Review draft: approve or request changes.",
"blocks": [
{ "kind": "summary", "text": "The runtime prepared a draft and needs a decision." },
{
"kind": "actions",
"actions": [
{ "id": "approve", "label": "Approve", "tone": "positive" },
{
"id": "revise",
"label": "Request changes",
"fields": [
{ "id": "note", "label": "What should change?", "type": "textarea", "required": true }
]
}
]
}
]
}Supported block kinds: summary, metricGrid, chart, table, list, callout, mediaPreview, details, and actions.
Supported action field types: text, textarea, select, multiSelect, boolean, date, number, currency, searchSelect, and lineItems.
Authoring limits enforced by this package include 24 blocks, 12 total actions, 8 fields per action, 12 choices per field (100 for searchSelect), 32 rows per details block, 8 columns and 50 submitted rows per lineItems field, and one actions block per card. The backend accepts a more lenient compatibility envelope, but strict validation is the developer-facing contract for predictable rendering.
A clean canon-card validate does not guarantee the backend will accept the card. Two rules are enforced server-side only: a 32 KiB cap on the whole serialized card, and the mediaPreview URL/MIME allowlist below (the strict validator only checks that URLs are strings of at most 2048 characters).
Document review and richer fields
Two display-only block kinds and five extended field types support inline document review (the AP invoice-review pattern, but the primitives are generic):
| Block kind | Use |
|---|---|
| mediaPreview | Inline image, or a PDF/file thumbnail plus a safe "open" link. media.url and media.thumbnailUrl MUST each be the exact url returned by POST /media/upload or resumable finalization — the server pins them to this deployment's own Storage bucket, so another Firebase project's bucket (or any external URL) makes the backend reject the whole card with a 400. Preserve uploadId and sizeBytes for resumable primary media, and thumbnailUploadId for a separately uploaded resumable thumbnail, so Canon can retain the temporary objects with the durable card. Anchor pre-validation on provenance, not on a hostname list. media.mimeType must be image/* or application/pdf (allowlist enforced server-side). Always set the block fallbackText (and the card-level fallbackText) so clients without the block still get the document name and open link. media also accepts fileName, page (1-based), and pageCount. |
| details | Read-only label/value rows for extracted fields. Each row may carry tone, confidence (0–1), and hint (e.g. "OCR low confidence"). Up to 32 rows. |
| Field type | Value shape | Constraints |
|---|---|---|
| date | YYYY-MM-DD string | min/max (also YYYY-MM-DD strings) |
| number | finite number | min, max, step, precision (max decimal places) |
| currency | finite number | currencyCode (ISO-4217 display metadata), precision (default 2), min, max |
| searchSelect | choice value string | filterable single-select; same choices as select, cap raised to 100; degrades to select on older clients |
| lineItems | [{ colId: cell, ... }, ...] | columns (each text/number/currency/date/select, with required and per-type constraints), minRows, maxRows (cap 50, up to 8 columns) |
A required correction/rejection reason is not a new field type — use a textarea field with required: true on the reject/correct action.
These primitives are additive. On clients that do not yet recognize them, an unknown block degrades to its fallbackText and an unknown field type degrades to a plain text input; the approve/reject path never depends on a new block or field, so it always works. See references/examples.md in the bundled skill for a full worked AP invoice-review card and a small example per block/field.
Builder methods
The fluent card(...) builder exposes .mediaPreview(media, { title?, fallbackText? }) and .details(rows, title?) alongside the existing .summary(), .metricGrid(), .chart(), .table(), .list(), .callout(), and .actions():
import { card } from '@canonmsg/rich-cards';
const built = card('Invoice 4471 — review', 'Invoice 4471 from Acme — open the PDF to review.')
.mediaPreview(
{
kind: 'file',
mimeType: 'application/pdf',
url: uploadedUrl, // verbatim from POST /media/upload
thumbnailUrl: uploadedThumbnailUrl,
fileName: 'ACME-4471.pdf',
},
{ title: 'Source invoice', fallbackText: 'ACME-4471.pdf — open to review.' },
)
.details([
{ label: 'Supplier', value: 'Acme Ltd', confidence: 0.98 },
{ label: 'Total', value: '$12,480.00', tone: 'warning', confidence: 0.71, hint: 'OCR low confidence' },
], 'Extracted fields')
.actions([
{ id: 'approve', label: 'Approve', tone: 'positive' },
{
id: 'correct',
label: 'Submit corrections',
fields: [
{ id: 'total', label: 'Total', type: 'currency', currencyCode: 'USD', min: 0 },
{
id: 'lines',
label: 'Line items',
type: 'lineItems',
columns: [
{ id: 'desc', label: 'Description', type: 'text', required: true },
{ id: 'qty', label: 'Qty', type: 'number', min: 0 },
{ id: 'amount', label: 'Amount', type: 'currency', currencyCode: 'USD' },
],
},
{ id: 'note', label: 'Reason for correction', type: 'textarea', required: true },
],
},
{ id: 'reject', label: 'Reject', tone: 'danger', fields: [{ id: 'reason', label: 'Reason', type: 'textarea', required: true }] },
], 'Decision')
.build(); // strictly validated; throws on an invalid cardThe backend also enforces runtime boundaries such as card size, responder identity, expiry, JSON safety, and pending-state consumption. Do not put secrets, tokens, passwords, or hidden raw tool output in cards.
SDK example
@canonmsg/agent-sdk does not depend on this package and performs no card validation — it forwards the document to Canon as-is. Validate with validateCard or the card() builder here, then hand the result to the SDK.
import { CanonAgent } from '@canonmsg/agent-sdk';
const agent = new CanonAgent({
apiKey: process.env.CANON_API_KEY!,
environmentId: process.env.CANON_ENVIRONMENT_ID!,
});
agent.on('message', async ({ requestCard, replyFinal }) => {
const result = await requestCard({
card: {
schema: 'canon.card.v1',
title: 'Review draft',
fallbackText: 'Review draft: approve or request changes.',
blocks: [
{ kind: 'summary', text: 'The runtime prepared a draft and needs a decision.' },
{
kind: 'actions',
actions: [
{ id: 'approve', label: 'Approve', tone: 'positive' },
{
id: 'revise',
label: 'Request changes',
fields: [
{ id: 'note', label: 'What should change?', type: 'textarea', required: true },
],
},
],
},
],
},
});
if (result.status === 'submitted' && result.actionId === 'revise') {
await replyFinal(`Revision requested: ${String(result.values?.note ?? '')}`);
return;
}
await replyFinal(result.status === 'submitted' ? 'Approved.' : 'No decision received.');
});Raw REST / CLI flow
Raw REST/SSE runtimes use Canon's unified interaction endpoints:
- Validate the card locally with
canon-card validate card.json. - Send it with
POST /runtime-interaction/requestandinteractionKind: "card". - Poll or consume the answer with
POST /runtime-interaction/consumeand the sameinteractionKind. - Switch on
actionIdand read action-fieldvalues.
The CLI wraps that flow when Canon agent credentials are available:
canon-card request <conversationId> card.json --wait # action card: waits for the decision
canon-card send <conversationId> card.json # display card: posts and returnsAction-less cards are display-only — there is no pending response to consume. send is the direct path for them; request --wait also handles them, short-circuiting to { "status": "displayed" } instead of waiting.
Cards vs runtime input vs approval
- Use rich cards for structured display, action buttons, and small action-bound forms. Responses return
{ actionId, values }. - Use runtime input for standalone clarification, sudo, and secret prompts. Structured question responses return
answers. - Use runtime approval for allow/deny decisions before a tool or action.
Card values are ephemeral runtime-control data. They are not written into visible chat text or card metadata. If a value is truly secret, use the runtime-input secret path instead of an action field.
