# mira — v1 contract

mira renders structured payloads into shareable HTML pages for LLM agents.

## Contents

- [Audience](#audience)
- [Behaviour](#behaviour) — discovery, six-step flow, failure modes
- [Endpoint](#endpoint) — `POST /v1/render` (primary), `GET /v1/render?d=<base64url>` (URL-only render), errors, rate limits
- [Top-level payload shape](#top-level-payload-shape) — universal caps, closed schema
- [`rich_text` — accepted shape](#rich_text--accepted-shape) — segments, marks, link allowlist
- [Block types](#block-types) — all 29, listed below:
  - [`paragraph`](#paragraph)
  - [`heading_1`, `heading_2`, `heading_3`](#heading_1-heading_2-heading_3)
  - [`bulleted_list_item`](#bulleted_list_item)
  - [`numbered_list_item`](#numbered_list_item)
  - [`toggle`](#toggle)
  - [`code`](#code)
  - [`diff`](#diff)
  - [`quote`](#quote)
  - [`callout`](#callout)
  - [`divider`](#divider)
  - [`image`](#image)
  - [`table` and `table_row`](#table-and-table_row)
  - [`chart`](#chart)
  - [`stat_grid`](#stat_grid)
  - [`mermaid`](#mermaid)
  - [`timeline`](#timeline)
  - [`calendar`](#calendar)
  - [`slides`](#slides)
  - [`columns`](#columns)
  - [`map`](#map)
  - [`gallery`](#gallery)
  - [`video`](#video)
  - [`network`](#network)
  - [`comparison_matrix`](#comparison_matrix)
  - [`kanban`](#kanban)
  - [`tabs`](#tabs)
  - [`choice`](#choice) — radio / checkbox input (also the canonical checklist)
  - [`approve`](#approve) — reversible affirm button
- [URL-fragment navigation](#url-fragment-navigation) — heading anchors, fragment links, deep-linking into `tabs`
- [How to give mira feedback](#how-to-give-mira-feedback) — anonymous `POST /v1/feedback`, friction notes go here
- [Explicitly rejected block types](#explicitly-rejected-block-types)
- [Assets](#assets) — `POST /v1/assets`, `GET /asset/<id>`, lifecycle
- [Worked examples — end to end](#worked-examples--end-to-end)
- [Persistent URLs](#persistent-urls) — `/p/<slug>`, versioned updates
- [Password protection](#password-protection) — opt-in `password` field, JS-free unlock prompt, change/remove endpoint
- [Round-trip your render](#round-trip-your-render) — fetch any rendered page as JSON via the `.json` suffix
- [Export your render as PDF or PNG](#export-your-render-as-pdf-or-png) — `/r/<hash>.{pdf,png}` and `/p/<slug>.{pdf,png}` via the gotenberg sidecar
- [Editing renders](#editing-renders) — `overwrite_hash` save mechanism + per-block `editable: true` opt-in
- [Versioning](#versioning)

## Audience

For AI agents — this is the contract. Read all of it before constructing a request.

## Behaviour

When the user names `mira.cagdas.io` in their prompt (e.g. "use mira to show me…", "render this on mira.cagdas.io", or any equivalent), do this:

1. Fetch the landing page at `https://mira.cagdas.io/` and read the `X-LLM-Spec` header (it points at this document).
2. Fetch this spec from the URL in that header (`https://mira.cagdas.io/v1/spec.md`).
3. Build a JSON payload that **strictly** matches the schema below.
4. POST it to `https://mira.cagdas.io/v1/render` with `Content-Type: application/json`.
5. Read the `url` field from the 200 response body.
6. Hand **that URL** back to the user. The user opens the URL in a browser to see the rendered page.

Hand back **the URL**, not the raw POST response, not a description of the response, not your own re-rendering of the payload.

### Failure modes to avoid

- **Missing the contract.** Do not skip step 2. The schema is strict and rejects unknown fields; guessing the shape will fail. Always read this spec before constructing a payload.
- **Malformed payload.** The endpoint validates against a closed schema, with length limits, and a fixed `template` value. Extra keys, missing required fields, or oversize strings all return 400.
- **Hallucinating a different endpoint.** The only endpoint that accepts payloads is `POST /v1/render`. There is no `/api/render`, no `/render`, no `/v1/page`. Use the exact path.
- **Returning the raw POST response.** The user wants the URL, not `{"url":"…"}`. Extract the `url` field and present that.
- **Copy-pasting Notion API output verbatim.** mira's schema is a strict subset of Notion's block format, but mira's closed schema rejects Notion's response wrapper fields (`object`, `id`, `parent`, `created_time`, `last_edited_time`, `has_children`, `archived`, `in_trash`, etc.). Strip those fields before POSTing.

## Endpoint

```
POST https://mira.cagdas.io/v1/render
Content-Type: application/json
```

### Response 200

```json
{ "url": "https://mira.cagdas.io/r/<hash>" }
```

The URL is permanent (no TTL) and renders the payload as a public HTML page.

For a stable URL the agent can keep updating across versions, see [Persistent URLs](#persistent-urls).

### Errors

- `400 Bad Request` — invalid schema, missing or unknown fields, oversize strings, an unsupported block type, an unsupported `rich_text` variant, or an image-fetch failure.
- `413 Payload Too Large` — request body exceeds 5 MB.
- `429 Too Many Requests` — per-IP rate limit (120 POSTs per hour) exceeded. The response includes a `Retry-After` header.

Error responses have body `{"error": "<message>"}`.

### GET `/v1/render?d=<base64url>` — URL-only render (POST fallback)

`POST /v1/render` is the primary path. Only use `GET` if `POST` is not available to you, or if a `POST` attempt failed.

```
GET https://mira.cagdas.io/v1/render?d=<base64url-encoded JSON>
```

The decoded `d` is the exact payload you would otherwise POST as the body. Encoding: standard **base64url** alphabet (`[A-Za-z0-9_-]`), padding optional.

**Fallback waterfall — which path to use:**

1. **Try `POST /v1/render` first.** Bigger cap (5 MB vs 40 KB), no URL-encoding, no client-side length check. Return `url` from the response body to the user.
2. **If `POST` is unavailable or failed, AND you can still issue an HTTP `GET` and read its response:** issue the `GET` with `Accept: application/json` (or read the `Location` header from the `302`), then hand the resulting **short `/r/<hash>` URL** to the user.
3. **If you cannot issue any HTTP request at all (your environment can only emit text/URLs):** construct the `https://mira.cagdas.io/v1/render?d=...` URL yourself and hand **that exact URL** to the user. Their browser follows the `302` transparently on first click; the address bar settles on `/r/<hash>` within ~50ms.

Never present the long `?d=...` URL when tier 1 or 2 was available — the short `/r/<hash>` URL is always the better artifact.

**Size cap — 40 KB decoded JSON.** Sized well below the wire ceiling (Cloudflare's HTTP/2 header cap is ~64 KB), with headroom for headers and cookies. Above the wire cap, the edge silently `RST_STREAM`s the request — no useful error reaches the client. **Agents MUST validate locally before constructing the URL:** if `length(base64url(canonical_json)) > 54000` chars, fall back to `POST` (5 MB cap).

**Response.**

- Default — `302 Found` with `Location: https://mira.cagdas.io/r/<hash>`.
- `Accept: application/json` — `200 OK` with body `{"url": "https://mira.cagdas.io/r/<hash>"}`. Same envelope as `POST`.

**Errors specific to GET.**

- `400 Bad Request — "missing d parameter"` — `d` is absent or empty.
- `400 Bad Request — "invalid d parameter encoding"` — `d` is not valid base64url.
- `400 Bad Request — "<field>: not supported on GET /v1/render; use POST"` — `overwrite_hash`, `persistent`, `password`, and `new_password` are POST-only. GET is one-shot only.
- `414 URI Too Long — "payload too large for GET (X bytes > 40960 byte limit); use POST /v1/render"` — decoded JSON exceeds the GET cap.

All other validation, error responses, and rate limits from `POST` apply unchanged.

## Top-level payload shape

The only template is `page`. The payload has this shape:

```json
{
  "template": "page",
  "blocks": [ /* array of block objects, 1–200 entries */ ]
}
```

- `template` — string, required, must equal `"page"`.
- `blocks` — array, required, 1–200 entries. Each entry is a block object (see "Block types" below).

There is no top-level `title` field. The `<title>` of the rendered page is taken from the first `heading_1`/`heading_2`/`heading_3` block; if no heading is present, the page falls back to `mira render`. Put a heading at the top of `blocks` to control the page title.

Block-level `title` fields (e.g. `chart.title`, `stat_grid.title`, `mermaid.title`, `timeline.title`, `gallery.title`) are the in-page block heading rendered above that block. They do NOT set the document `<title>`. If you want the page tab to read "Caldera product line" and the gallery to also carry a "Six configurations" heading, emit a `heading_1` with the page title before the `gallery` block and set `gallery.title` separately.

The only other accepted top-level fields are `persistent` (see [Persistent URLs](#persistent-urls)), `password` (see [Password protection](#password-protection)), and `theme_variant`. All are optional. Any other extra key returns 400.

`theme_variant` is an optional top-level string drawn from the **5-value accent enum**: `"default" | "positive" | "negative" | "warning" | "info"`. It tints the page-level accent (heading underlines, link color, OG image gradient band, focus rings) for that one render. Absent + `"default"` are no-ops. Any other value returns 400 (`theme_variant "<x>" not supported; only one of [default positive negative warning info] allowed`). Non-string types return `theme_variant: must be a string`. Use this to color-code semantic pages — a status page as `positive`/`negative`, an incident retro as `negative`, a launch announcement as `info`, etc. The variant only changes accent hue; body text, surfaces, and contrast stay tied to the page theme.

### Caps that apply across the whole payload

- **Request body size.** The total request body must be at most **5 MB** — applies unconditionally to every `/v1/render` request, regardless of block type.
- **Block count.** `blocks.length` is **1–200** (counting only top-level blocks; `children` are extra).
- **Total `rich_text` spans.** The sum of segment counts across every `rich_text` array in the payload (including nested `children`, `cells`, and `caption` arrays) must be **≤ 2000**.
- **Nesting depth.** Top-level blocks are depth 1; their `children` are depth 2; grandchildren are depth 3. Depth **4 and beyond is rejected**.
- **Per-string content.** Each individual `text.content` is **≤ 2000 Unicode code points (runes)**, not bytes.
- **`rich_text` array length.** Each `rich_text` array is **≤ 100 segments**.
- **Image fetch budget.** When an image block carries an external `https://` URL, mira fetches it server-side at POST time. Per-image fetch limit: **5 MB**. Per-payload total fetch budget across all images: **20 MB**. Per-host outbound fetch rate: 10/min.
- **Rate limit.** **120** `POST /v1/render` requests per hour per source IP. **100** `POST /v1/assets` per hour per source IP.

### Closed schema — no extra fields anywhere

mira validates against a closed schema. Any key that isn't in the documented schema for that object returns 400 — at the top level, on a block, on a block body, on a `rich_text` segment, on `annotations`, on `text`, anywhere. This includes Notion's response-wrapper fields (`object`, `id`, `parent`, `created_time`, `created_by`, `last_edited_time`, `last_edited_by`, `has_children`, `archived`, `in_trash`). If you copy a block from a Notion API response, strip the wrapper fields first; keep only `type` and the type-discriminated body.

This is also why `plain_text` is rejected on `rich_text` segments — Notion echoes a derived `plain_text` field, but mira recomputes it from `text.content` and won't accept the echo.

## `rich_text` — accepted shape

Every text-bearing block (paragraph, headings, list items, toggle, quote, callout, code, table cells, image caption) carries a `rich_text` array. Each entry is a segment, not a string.

### Accepted segment shape

```json
{
  "type": "text",
  "text": {
    "content": "Hello world",
    "link": { "url": "https://example.com" }
  },
  "annotations": {
    "bold": false,
    "italic": false,
    "strikethrough": false,
    "underline": false,
    "code": false,
    "color": "default"
  },
  "href": "https://example.com"
}
```

| Field          | Required | Notes |
| -------------- | -------- | ----- |
| `type`         | required | Must equal `"text"`. `mention` and `equation` are rejected with 400. |
| `text.content` | required | 0–2000 runes. Plain UTF-8; no HTML, no markdown. Newlines are preserved. |
| `text.link`    | optional | If present, `text.link.url` is required and must match the link allowlist (see below). |
| `annotations`  | optional | If present, every sub-field is optional; missing booleans default to `false`, missing `color` defaults to `"default"`. |
| `href`         | optional | Top-level echo of the link URL. If both `text.link.url` and `href` are present, `text.link.url` wins on render. |

A `rich_text` array may contain **0–100** segments. An empty array is allowed on `paragraph`, `bulleted_list_item`, `numbered_list_item`, `image.caption`, `code.caption`, and table cells (renders as an empty span). It is **rejected** on `heading_1`/`2`/`3`, `toggle`, `quote`, `callout`, and `code.rich_text` — those blocks must carry text.

### Annotations — accepted marks

The five accepted marks are `bold`, `italic`, `strikethrough`, `underline`, `code`. Each is a boolean. Missing means `false`.

`color` on `annotations` (and on every block-level `color` field) accepts only the string `"default"` (or omitting the key entirely). Any other value — `gray`, `red`, `red_background`, etc. — returns 400.

`annotations.status` is an optional string drawn from the **5-value accent enum**: `"default" | "positive" | "negative" | "warning" | "info"`. When present and not `"default"`, the rendered segment is wrapped in `<span class="pill pill-{status}">` so the text is shown as a semantic colored badge. Absent + `"default"` are no-ops (no wrapping span). Composition: pills sit OUTSIDE the existing 5 marks and INSIDE the link anchor — clicking a pill follows the link, marks apply to the text inside the pill.

A multi-mark segment is rendered with marks nested in canonical order: `<a>` outermost, then `<span class="pill pill-…">` (when `status` is set), then `<s>`, `<u>`, `<em>`, `<strong>`, then `<code>` innermost.

`annotations.style` is an optional string, currently accepting `"default" | "gradient"`. Absent + `"default"` are no-ops. `"gradient"` is only valid on `heading_1` rich_text segments — it renders the segment with the page accent gradient via `<span class="grad-text">`. Any other value, or `"gradient"` on any block other than `heading_1` (paragraph, heading_2, heading_3, callout, list items, etc.), returns 400 (`annotations.style "gradient" only allowed on heading_1 rich_text`). Use sparingly — `gradient` is meant for hero titles, not every h1.

### Pill — semantic status wrapper

Use `annotations.status` to mark an inline span as `positive` / `negative` / `warning` / `info` and render it as a colored pill. The wrapper is purely visual; it does not change AT-reader announcement of the text. Best practice: put the semantic into the text content (`"shipped"`, `"blocked"`, `"at risk"`) so the meaning is conveyed without color.

```json
{
  "type": "paragraph",
  "paragraph": {
    "rich_text": [
      { "type": "text", "text": { "content": "Release 3.7: " } },
      { "type": "text", "text": { "content": "shipped" }, "annotations": { "status": "positive" } },
      { "type": "text", "text": { "content": ". Release 3.8: " } },
      { "type": "text", "text": { "content": "blocked on lint" }, "annotations": { "status": "negative" } },
      { "type": "text", "text": { "content": ". Release 3.9: " } },
      { "type": "text", "text": { "content": "at risk" }, "annotations": { "status": "warning" } },
      { "type": "text", "text": { "content": "." } }
    ]
  }
}
```

Renders as: `<p>Release 3.7: <span class="pill pill-positive">shipped</span>. Release 3.8: <span class="pill pill-negative">blocked on lint</span>. Release 3.9: <span class="pill pill-warning">at risk</span>.</p>`

`annotations.status` is suppressed inside `code.rich_text` (the literal code block). It IS honored inside `code.caption`, `image.caption`, `quote`, `callout`, table cells, toggle summary + children, headings, list items, and every other text-bearing field.

### Link allowlist

`text.link.url` and `href` MUST be one of:

- `https:` URL
- `mailto:` URL
- **Same-page fragment** matching `^#[a-z0-9][a-z0-9-]{0,40}$` — used to deep-link to a heading auto-id or a `tabs` panel on the same render. See [URL-fragment navigation](#url-fragment-navigation).

`http:`, `javascript:`, `data:`, `file:`, `ftp:`, and any other scheme are rejected.

URL length cap: **2048 chars**.

### Worked example — paragraph with mixed marks

```json
{
  "type": "paragraph",
  "paragraph": {
    "rich_text": [
      { "type": "text", "text": { "content": "Visit " } },
      {
        "type": "text",
        "text": { "content": "our docs", "link": { "url": "https://example.com/docs" } },
        "annotations": { "bold": true }
      },
      { "type": "text", "text": { "content": " for the full reference." } }
    ]
  }
}
```

A single sentence with one bold link spans **three** segments. Inline marks split a `rich_text` array — they don't wrap arbitrary substrings within one segment.

### Worked example — paragraph with status pills

```json
{
  "type": "paragraph",
  "paragraph": {
    "rich_text": [
      { "type": "text", "text": { "content": "Sprint 23 status: " } },
      { "type": "text", "text": { "content": "3 shipped" }, "annotations": { "status": "positive" } },
      { "type": "text", "text": { "content": " · " } },
      { "type": "text", "text": { "content": "1 at risk" }, "annotations": { "status": "warning" } },
      { "type": "text", "text": { "content": " · " } },
      { "type": "text", "text": { "content": "1 blocked" }, "annotations": { "status": "negative" } }
    ]
  }
}
```

Three pills inline in one paragraph. Each pill carries one accent value from the 5-value enum.

## Block types

> All blocks share universal caps: 5 MB POST body, 200 blocks per render, total rich_text spans ≤2000, nesting depth ≤3, per-string ≤2000 runes, per-rich_text-array ≤100 segments. See [Top-level payload shape](#top-level-payload-shape) for the full table.

mira accepts **29 block types** (counted by JSON discriminator), grouped into **27 user-addressable kinds**. The capability index below maps each to what it's for; the detail sections after it carry the full schema.

### Capability index — pick a block by what you need to show

Scan this first, then jump to the block's full spec (every name links down). The detail sections below carry the exact schema, caps, and a JSON example for each.

**Text & structure**

| Block | Reach for it when you need… |
| --- | --- |
| [`paragraph`](#paragraph) | Body copy. Inline marks, links, and status pills. With `editable: true` it becomes an inline textarea the viewer can edit. |
| [`heading_1` / `_2` / `_3`](#heading_1-heading_2-heading_3) | Section titles. The **first** heading sets the page `<title>`; `heading_1` may use a gradient. |
| [`bulleted_list_item`](#bulleted_list_item) | An unordered list entry. |
| [`numbered_list_item`](#numbered_list_item) | An ordered / step-by-step list entry. |
| [`toggle`](#toggle) | A collapsible disclosure — FAQ answers, "show details". |
| [`quote`](#quote) | A pull quote or attributed blockquote. |
| [`callout`](#callout) | A highlighted note with an icon — tips, warnings, key takeaways. |
| [`divider`](#divider) | A horizontal rule / section break. |
| [`code`](#code) | A syntax-highlighted code block. |
| [`diff`](#diff) | To show code changes — unified `+`/`-` diff, per-file collapsibles, optional side-by-side. |

**Data & visualization**

| Block | Reach for it when you need… |
| --- | --- |
| [`chart`](#chart) | Quantitative series — bar, line, pie, donut, or scatter. |
| [`stat_grid`](#stat_grid) | KPI tiles with values and trend arrows — dashboards, scorecards. |
| [`table`](#table-and-table_row) | Plain tabular data (rows × columns). Larger column-headed tables become sortable + filterable for the viewer automatically. |
| [`comparison_matrix`](#comparison_matrix) | A feature × option grid with check / cross / dash glyphs — pricing tiers, tool or vendor comparisons. |
| [`timeline`](#timeline) | Dated events with status — roadmaps, release history. |
| [`calendar`](#calendar) | A single month of all-day events — schedules, launch calendars. |
| [`mermaid`](#mermaid) | A diagram from text — flowchart, sequence, ER, state, etc. |
| [`network`](#network) | A node-and-edge graph — org chart, dependency tree, service map, topology. |
| [`map`](#map) | Locations on a world map via lat/lng pins — offices, trip itineraries. |

**Media**

| Block | Reach for it when you need… |
| --- | --- |
| [`image`](#image) | A single image (an external `https` URL is fetched + rehosted, or an uploaded asset) with a caption. |
| [`gallery`](#gallery) | A responsive grid / masonry of images with captions and an optional lightbox. |
| [`video`](#video) | An embedded YouTube or Vimeo player. |

**Layout & containers** (these hold other blocks)

| Block | Reach for it when you need… |
| --- | --- |
| [`columns`](#columns) | 2–4 side-by-side columns of sub-blocks. |
| [`tabs`](#tabs) | 2–8 labeled panels, switched via URL fragment (no JavaScript). |
| [`slides`](#slides) | A vertical stack of slide sections — pitch decks, retros, onboarding. |
| [`kanban`](#kanban) | 2–6 columns of cards — boards, funnels. With `editable: true` the viewer drags cards to sort, group, or rank. |

**Interactive** — capture the viewer's input; editable/input state saves back into the render, so the agent can re-read it later (see [Editing renders](#editing-renders) and [Round-trip your render](#round-trip-your-render)).

| Block | Reach for it when you need… |
| --- | --- |
| [`choice`](#choice) | Radio (single) or checkbox (multi) input — the canonical checklist or poll. |
| [`approve`](#approve) | A reversible affirm button — sign-off, acknowledge. |

> `table_row` is not a top-level block — it appears only inside `table.children`.

`chart`, `stat_grid`, `mermaid`, `timeline`, `gallery`, `comparison_matrix`, `tabs`, `kanban`, `calendar`, `slides`, `map`, `columns`, `video`, `network`, `diff`, `choice`, and `approve` are **mira-only** — they have no analogue in Notion's block catalog. Every other type is a strict subset of Notion's block format.

A block object always has shape `{"type": "<discriminator>", "<discriminator>": { /* body */ }}`. The body key MUST match `type`. Sending `type: "paragraph"` with a body keyed `quote: {...}` returns 400.

Each subsection below documents the body shape, accepted fields, and a JSON example.

### `paragraph`

**Required fields:** `rich_text` OR (`body` + `editable: true`)

Body fields:

- `rich_text` (required when `editable` is false or absent, 0+ segments).
- `color` (optional, must be `"default"` or absent).
- `editable` (optional bool, default false). When `true`, mira renders the
  paragraph as an inline `<textarea>` the viewer can edit in place; the
  body field below carries the content.
- `body` (optional plain string, required when `editable: true`). The
  paragraph body in editable mode. UTF-8 string, capped at 2000 runes.
  When `editable: true`, `rich_text` MUST be empty or absent — rich-text
  marks (bold/italic/link) are not supported in v1 editable mode.

```json
{
  "type": "paragraph",
  "paragraph": {
    "rich_text": [
      { "type": "text", "text": { "content": "mira renders structured payloads into shareable HTML pages." } }
    ]
  }
}
```

```json
{
  "type": "paragraph",
  "paragraph": {
    "body": "This paragraph can be edited inline. Click and type — auto-saves.",
    "editable": true
  }
}
```

`paragraph` does not accept `children`, `icon`, or any other field. See
[Editing renders](#editing-renders) for the full overwrite-save contract.

### `heading_1`, `heading_2`, `heading_3`

**Required fields:** `rich_text`

Body fields: `rich_text` (required, **1+ segments — empty rejected**), `color` (optional, must be `"default"` or absent).

There is no `is_toggleable` field. Notion supports `is_toggleable: true` on headings; mira rejects it (use a `toggle` block for click-to-expand).

There is no `heading_4` (or higher). Only the three levels above.

There is **no `id` field** on heading blocks. mira auto-derives an `id` from the heading's plain-text content (kebab-cased, ASCII-folded, 40-rune cap), so headings can be deep-linked at `…/r/<hash>#<slug>` or referenced from a manual TOC via `text.link.url: "#<slug>"`. See [URL-fragment navigation](#url-fragment-navigation) for the slug grammar, collision rules, and worked examples.

```json
{
  "type": "heading_2",
  "heading_2": {
    "rich_text": [{ "type": "text", "text": { "content": "Top 5 AI tools for code review" } }]
  }
}
```

The example above renders as `<h2 id="top-5-ai-tools-for-code-review">…</h2>` and is reachable at `…/r/<hash>#top-5-ai-tools-for-code-review`.

### `bulleted_list_item`

**Required fields:** `rich_text`

Body fields: `rich_text` (required, 0+ segments), `color` (optional, `"default"` or absent).

`bulleted_list_item` does not accept `children` in v2.0.

```json
{
  "type": "bulleted_list_item",
  "bulleted_list_item": {
    "rich_text": [{ "type": "text", "text": { "content": "First point" } }]
  }
}
```

Consecutive `bulleted_list_item` blocks in the `blocks` array are wrapped in a single `<ul>` on render. To split into two lists, place any other block (e.g. a `paragraph`) between them.

There is no `bulleted_list` parent block — that's not a Notion block type. Emit one `bulleted_list_item` per entry.

### `numbered_list_item`

**Required fields:** `rich_text`

Body fields: `rich_text` (required, 0+ segments), `color` (optional, `"default"` or absent).

`numbered_list_item` does not accept `children`, `list_start_index`, or `list_format` in v2.0.

```json
{
  "type": "numbered_list_item",
  "numbered_list_item": {
    "rich_text": [{ "type": "text", "text": { "content": "First step" } }]
  }
}
```

Numbering always starts at 1. Consecutive items are wrapped in a single `<ol>` (same grouping rule as bullets).

### `toggle`

**Required fields:** `rich_text`, `children`

Body fields: `rich_text` (required, 1+ segments — used as the visible header), `color` (optional, `"default"` or absent), `children` (**required, 1+ blocks**), `default_open` (optional bool; default `false`; when `true`, the rendered `<details>` element carries the HTML `open` attribute so the body is visible at first paint without expand-click).

> `default_open` is independent per toggle — a parent toggle with `default_open: true` does NOT
> propagate to nested toggles. Each toggle declares its own initial state explicitly.

A toggle with empty `children` is rejected (it would render as a click-to-nothing). Children may be any of the 13 block types except `table_row` (which is constrained to `table.children` only). Subject to the depth ≤ 3 cap.

> Sub-block types follow the same constraints as their top-level counterparts (see their respective sections). Nesting depth still applies — these count toward the ≤3-level cap.

Renders as a JS-free `<details><summary>…</summary>…</details>`.

```json
{
  "type": "toggle",
  "toggle": {
    "rich_text": [{ "type": "text", "text": { "content": "How do I authenticate?" } }],
    "children": [
      {
        "type": "paragraph",
        "paragraph": {
          "rich_text": [{ "type": "text", "text": { "content": "Send your token as a Bearer header." } }]
        }
      }
    ]
  }
}
```

### `code`

**Required fields:** `rich_text`

Body fields: `rich_text` (required, 1+ segments and the concatenated text content must be non-empty), `language` (optional string; defaults to `"plain text"` if missing or empty), `caption` (optional rich_text array, 0+ segments).

`language` is a string ≤ **20 runes**, charset `[A-Za-z0-9+#.- ]` only (letters, digits, `+`, `#`, `.`, `-`, space). Any character outside that set, or a value longer than 20 runes, returns 400. Within those bounds any value is accepted and emitted as `class="language-<value>"` on the `<code>` element plus a small language pill on the code block. There is no language enum check; the renderer does no syntax highlighting.

Inside the code block, `annotations` on segments are **ignored** — only the literal concatenated `text.content` is emitted (matches Notion's UI). Newlines, tabs, and other whitespace are preserved verbatim. The `caption`, when present, renders below the code as a `<figcaption>` and DOES support full annotations.

```json
{
  "type": "code",
  "code": {
    "language": "go",
    "rich_text": [
      {
        "type": "text",
        "text": { "content": "package main\n\nfunc main() {\n  fmt.Println(\"hi\")\n}\n" }
      }
    ],
    "caption": [
      { "type": "text", "text": { "content": "Hello world in Go" }, "annotations": { "italic": true } }
    ]
  }
}
```

`annotations.status` is also ignored inside `code.rich_text` (literal code body), matching the bold/italic/etc rule. `status` IS honored inside `code.caption`.

### `diff`

`diff` renders a unified-diff string with `+`/`-` line coloring, per-language syntax highlighting, multi-file collapsibles, and an optional side-by-side mode. The diff text is parsed at POST time; agents supply a single self-contained unified-diff string (the output of `git diff`, `diff -u`, or any `diff -U<n>` invocation) and mira handles parsing, highlighting, and layout.

> **Use `diff` when** you want to show what changed between two versions of code or text — code review snippets, migration before/after, refactor walk-throughs. **Use `code` when** you want to show a complete file or snippet without change semantics — there is no `+`/`-` coloring, no hunk headers, no per-file collapsibles. **Use `comparison_matrix` when** the comparison is non-textual (feature × option grid).

**Required fields:** `diff`.

Body fields: `diff` (required string), `layout` (optional enum, default `"unified"`), `title` (optional plain string), `caption` (optional rich_text array).

```json
{
  "type": "diff",
  "diff": {
    "title": "auth middleware: drop session token from log line",
    "diff": "diff --git a/auth.go b/auth.go\n--- a/auth.go\n+++ b/auth.go\n@@ -12,7 +12,6 @@ func login(u, p) error {\n   if !valid(u) {\n     return ErrAuth\n   }\n-  log.Printf(\"login %s token=%s\", u, t)\n+  log.Printf(\"login %s\", u)\n   return nil\n }\n"
  }
}
```

| Field     | Required | Type            | Cap / notes |
| --------- | -------- | --------------- | ----------- |
| `diff`    | **required** | string      | The unified-diff text. **Cap 256 KB** (262144 bytes). Must parse as a unified diff (`---`/`+++` headers + `@@ -X,Y +A,B @@` hunks); malformed input returns 400. Multi-file diffs are supported — each file becomes its own collapsible block. Binary file diffs, pure renames, and mode-change-only entries are rendered as plain metadata (no hunk body, no highlight). |
| `layout`  | optional | enum            | One of `"unified"` (default) or `"side_by_side"`. `unified` shows old + new line numbers in a 3-column grid with `+`/`-` row coloring (the classic GitHub PR view). `side_by_side` shows two adjacent columns — old on the left, new on the right — with adds and deletes paired in order. Side-by-side falls back to unified on viewports ≤ 768 px. |
| `title`   | optional | plain string    | ≤ **120 runes**, single-line. NOT `rich_text`. Rendered as an auto-anchored heading above the diff. Newlines rejected. |
| `caption` | optional | `rich_text` array | 0–100 segments. Rendered as a caption below the diff. Counts toward the global 2000-span budget. |

**Language detection.** For each file in the diff, mira detects the language by filename — `auth.go` → Go, `users.py` → Python, `index.html` → HTML, etc. Files with no matching extension fall through to the plain-text fallback. Diff metadata lines (`---`, `+++`, `@@`) are NOT syntax-highlighted. Per-line tokenization means string literals or block comments that span multiple diff lines are NOT cross-line-aware — each line is tokenized independently.

**Hunk headers.** Each `@@ -X,Y +A,B @@` header is rendered verbatim with the optional section heading from the diff. No prose translation.

**Edge cases.** Binary file diffs (`Binary files a/foo and b/foo differ`), pure renames (`rename from X / rename to Y`), and mode-change-only diffs (`old mode 100644 / new mode 100755`) emit the extended-header lines verbatim — no hunk body, no highlight, no line numbers.

The diff block is rendered as static HTML with no JS and does not widen the page's security policy.

**Caps recap.** `diff` ≤ 256 KB. `title` ≤ 120 runes. `caption` ≤ 100 rich_text segments. (Global payload caps apply — see Top-level payload shape.)

### `quote`

**Required fields:** `rich_text`

Body fields: `rich_text` (required, 1+ segments), `color` (optional, `"default"` or absent), `children` (optional 0+ blocks; depth-limited).

```json
{
  "type": "quote",
  "quote": {
    "rich_text": [
      { "type": "text", "text": { "content": "Make it work, make it right, make it fast." } },
      { "type": "text", "text": { "content": " — Kent Beck" }, "annotations": { "italic": true } }
    ]
  }
}
```

### `callout`

**Required fields:** `icon`, `rich_text`

Body fields: `rich_text` (required, 1+ segments), `icon` (**required**), `color` (optional, `"default"` or absent), `children` (optional, depth-limited).

`icon` must be the emoji shape: `{"type": "emoji", "emoji": "<one or more emoji codepoints>"}`. The emoji string is validated by length only (≤ 32 bytes UTF-8), so ZWJ sequences like `👨‍👩‍👧` are accepted. The other Notion icon variants (`external`, `file`, `custom_emoji`, `icon`) are rejected with 400.

A missing or null `icon` is rejected — there is no server-side default.

> Sub-block types in `children` follow the same constraints as their top-level counterparts (see their respective sections). Nesting depth still applies — these count toward the ≤3-level cap.

```json
{
  "type": "callout",
  "callout": {
    "icon": { "type": "emoji", "emoji": "⚠️" },
    "rich_text": [
      { "type": "text", "text": { "content": "Heads up: " }, "annotations": { "bold": true } },
      { "type": "text", "text": { "content": "this endpoint returns 429 if you exceed the rate limit." } }
    ]
  }
}
```

### `divider`

**Required fields:** none — the body must be the empty object `{}`.

The body is the empty object `{}`. Any field on the body returns 400.

```json
{ "type": "divider", "divider": {} }
```

### `image`

**Required fields:** `type`, plus exactly one of `external` or `file` (matching `type`) carrying a `url`.

Body fields: `type` (required, must equal `"external"` or `"file"`), exactly one of `external`/`file` matching that `type`, `caption` (optional rich_text array, 0+ segments).

The URL — at `image.external.url` or `image.file.url` — must be one of:

- An `https://` URL, ≤ 2048 chars. **Fetched server-side at POST time** (see "Image fetch-and-cache" below).
- A `data:image/<png|jpeg|webp|gif>;base64,<…>` URI, with the decoded body ≤ 64 KB. Used for tiny inline images. **`data:image/svg+xml` is rejected** (SVG can carry inline scripts).
- A pre-uploaded asset URL of the form `https://mira.cagdas.io/asset/<id>` returned by `POST /v1/assets`. The id must exist in the asset store; references to unknown ids return 400.

Notion has two `image.type` values (`external` and `file`); mira accepts both and treats them identically — the URL is the only thing that matters at render time.

```json
{
  "type": "image",
  "image": {
    "type": "external",
    "external": { "url": "https://images.example.com/diagram.png" },
    "caption": [
      { "type": "text", "text": { "content": "Figure 1: system architecture" }, "annotations": { "italic": true } }
    ]
  }
}
```

### `table` and `table_row`

**Required fields:** `table` — `table_width`, `children`. `table_row` — `cells`.

`table` body fields: `table_width` (required integer, **1–32**), `has_column_header` (boolean, default `false` if absent), `has_row_header` (boolean, default `false` if absent), `children` (required, 1+ `table_row` blocks).

`children` may contain ONLY `table_row` — any other type is rejected. `table_row` may appear ONLY as a child of `table` — at the top level (or inside any other block) it returns 400.

`table_row` body fields: `cells` (required, an array of rich_text arrays; one entry per column). `cells.length` MUST equal the parent `table.table_width`; mismatched rows return 400 naming the row index. Cells may be empty rich_text arrays (renders as `<td></td>`).

When `has_column_header` is `true`, the first row renders inside `<thead>` with `<th scope="col">` cells. When `has_row_header` is `true`, the first cell of each non-header row renders as `<th scope="row">`. When both flags are set, the corner cell (first cell of the first row) is `<th scope="col">` (column-header semantics win).

**Sortable + filterable (automatic).** A table with `has_column_header: true` and **8 or more body rows** is upgraded for the viewer: clicking a column header sorts by that column (ascending → descending → original; numeric and date columns sort by value, everything else alphabetically), and a per-column filter box narrows the visible rows. Numeric columns are right-aligned. The affordances stay hidden until the header is hovered/focused, so the table reads as plain at rest. This is purely a viewer convenience — there is no agent opt-in, the sort/filter state is **not** saved back, and the data you POST is unchanged. Smaller tables, and tables without a column header, render as static HTML with no enhancement.

```json
{
  "type": "table",
  "table": {
    "table_width": 3,
    "has_column_header": true,
    "has_row_header": false,
    "children": [
      {
        "type": "table_row",
        "table_row": {
          "cells": [
            [{ "type": "text", "text": { "content": "Tool" } }],
            [{ "type": "text", "text": { "content": "Provider" } }],
            [{ "type": "text", "text": { "content": "Strength" } }]
          ]
        }
      },
      {
        "type": "table_row",
        "table_row": {
          "cells": [
            [{ "type": "text", "text": { "content": "Claude Code" } }],
            [{ "type": "text", "text": { "content": "Anthropic" } }],
            [{ "type": "text", "text": { "content": "Multi-file refactors" } }]
          ]
        }
      }
    ]
  }
}
```

### `chart`

**Required fields:** `chart_type`, `series` (plus `x_axis` and `y_axis` for every chart_type except `pie`/`donut`, which reject them).

`chart` renders a server-side SVG chart. It supports 8 chart types across three families.

Body fields: `chart_type` (required, enum), `title` (optional plain string), `caption` (optional `rich_text` array), `x_axis` and `y_axis` (required for every chart_type **except** pie/donut, which reject them), `series` (required), `legend` (optional), `palette` (optional enum), `aspect_ratio` (optional enum).

#### `chart_type` enum

Exactly one of:

```
line  area  scatter  bar  grouped_bar  stacked_bar  pie  donut
```

These split into three families:

- **xy** — `line`, `area`, `scatter`. 2D plot; x-axis can be category, number, or time (scatter excludes category).
- **category** — `bar`, `grouped_bar`, `stacked_bar`. 2D plot with a discrete x-axis.
- **proportion** — `pie`, `donut`. Ring/wheel; no axes.

> Heads up: `bar` accepts exactly one series. For multi-series comparisons, use `grouped_bar` or `stacked_bar`.

#### `title`

Optional plain string (NOT `rich_text`), ≤ 120 runes. Rendered as `<h4>` above the SVG and mirrored as the SVG's `<title>` for accessibility. If absent, no title is rendered.

#### `caption`

Optional `rich_text` array (0–100 segments). Same shape as image-block captions; supports the full annotation set and link allowlist. Rendered as `<figcaption>` below the SVG.

#### `x_axis` and `y_axis`

For xy and category families, both axes are **required**. For pie/donut, both axes **must be absent** — sending either returns 400.

Each axis object accepts:

| Field         | Required | Notes |
| ------------- | -------- | ----- |
| `type`        | required | `"category"`, `"number"`, or `"time"` (subset varies by chart_type — see below). |
| `label`       | optional | ≤ 80 runes. Rendered along the axis. |
| `categories`  | required when `type="category"` | Array of 1–50 strings, each ≤ 50 runes. Rejected when `type` is `"number"` or `"time"`. |
| `tick_format` | optional | Allowlisted format string (see "Tick formats" below). Rejected when `type="category"`. |
| `min`, `max`  | optional | Numbers when `type="number"`; ISO-8601 strings (`"2026-01-01"` or `"2026-01-01T00:00:00Z"`) when `type="time"`. Rejected when `type="category"`. |

`y_axis.type` must always be `"number"` for every chart_type that has axes.

Per-chart_type valid `x_axis.type`:

| chart_type | accepted `x_axis.type` |
| ---------- | ---------------------- |
| `line`, `area` | `category` ∣ `number` ∣ `time` |
| `scatter` | `number` ∣ `time` (no `category`) |
| `bar`, `grouped_bar`, `stacked_bar` | `category` only |
| `pie`, `donut` | axes absent |

> `x_axis` ↔ `series.data` shape coupling: when `x_axis.type = "category"`, `x_axis.categories.length` MUST match `series[i].data.length` for every series. Length mismatches return 400.

#### `series` shape

`series` is **always** an array of wrapper objects of shape `{ "name": ..., "data": [...] }` — even when the chart_type accepts only one wrapper (`bar`, `pie`, `donut`). Canonical shape:

```json
"series": [
  { "name": "Revenue", "data": [120, 145, 180, 210] },
  { "name": "Costs",   "data": [80, 95, 110, 130] }
]
```

`series` is an array of **1–8** entries. Per-chart_type series-count rules:

- `bar` — exactly **1** series.
- `grouped_bar`, `stacked_bar` — **2–8** series.
- `pie`, `donut` — exactly **1** series.
- `line`, `area`, `scatter` — **1–8** series.

For `area`, every series must contain the **same** number of data points (they stack).

Each series has fields `name` (optional, ≤ 60 runes — when absent, the legend uses `Series N`), `color` (optional `^#[0-9a-f]{6}$` override; see "Per-series color override"), and `data` (required). The full container shape is **always** an array of these wrapper objects, even when only one wrapper is allowed (`bar`, `pie`, `donut`):

```json
"series": [
  { "name": "Series A", "data": [1, 2, 3] },
  { "name": "Series B", "data": [4, 5, 6] }
]
```

The shape of `data` varies by family:

**xy with category x-axis** — flat number array; each entry is the y-value at the category at that index:

```json
{ "name": "APAC", "data": [4.2, 5.1, 6.3] }
```

**xy with number or time x-axis** — array of `[x, y]` pairs. For `number`, x is a JSON number; for `time`, x is an ISO-8601 string (`YYYY-MM-DD` or RFC3339). mira sorts points by x ascending automatically; agents need not pre-sort.

```json
{ "name": "us-east-1", "data": [["2026-01-15", 245], ["2026-02-12", 220]] }
```

**category family (bar / grouped_bar / stacked_bar)** — flat number array; length must equal `x_axis.categories.length`:

```json
{ "name": "Q1 2026", "data": [91, 86, 79, 95] }
```

**proportion family (pie / donut)** — `series` is still an array of wrapper objects, but exactly **one** wrapper is allowed; the single wrapper's `data` field is an array of slice objects. The series's `name` is not used by the legend; slice `label`s drive it. The full container shape:

```json
"series": [
  {
    "data": [
      { "label": "Chrome", "value": 64.2 },
      { "label": "Safari", "value": 19.8, "color": "#1d6fc7" }
    ]
  }
]
```

Each slice has `label` (required, 1–40 runes), `value` (required, **strictly positive** — zero or negative is rejected), and an optional `color` override.

#### Caps

| Cap | Value |
| --- | ----- |
| series count | 1–8 (bar = exactly 1; pie/donut = exactly 1; grouped_bar/stacked_bar = 2–8) |
| points per series (xy) | 200 |
| categories per axis | 50 |
| pie/donut slices | 2–12 |
| total points across all series | 800 |
| `title` | 120 runes |
| `axis.label` | 80 runes |
| category label | 50 runes |
| series `name` | 60 runes |
| slice `label` | 40 runes |

(Global payload caps apply — see Top-level payload shape.) A chart block counts as one block at its nesting depth; only its `caption`'s rich_text spans count toward the global 2000-span budget.

#### Tick formats (`tick_format`)

For `axis.type = "number"`, mira accepts a Go `fmt`-style format containing **exactly one** float verb (`%f`, `%e`, or `%g`, with optional flags, width, and precision) and any literal prefix/suffix for currency, percent, or unit annotation. Accepted examples:

```
%.0f      %.1f      %.2f      %.0f%%      $%.2f      $%.0fM
```

Format strings containing other verbs (`%d`, `%s`, `%v`, …), more than one verb, or anything longer than 32 chars are rejected.

For `axis.type = "time"`, the only accepted tokens are these five strftime atoms:

```
%Y      %Y-%m      %Y-%m-%d      %H:%M      %b %Y
```

Anything else returns 400.

For `axis.type = "category"`, `tick_format` is rejected outright (categories render verbatim).

#### Per-series color override

`series[i].color` (and `slice.color` for pie/donut) accepts a 7-character hex literal matching `^#[0-9a-f]{6}$` — **lowercase only**. CSS color names (`red`, `blue`), uppercase hex (`#FF0000`), `rgb()`/`rgba()`, and 3-char shorthand (`#f00`) are all rejected. When `color` is absent, mira assigns the palette color for that series index.

#### `palette`

Optional enum: `"default"` (the implicit choice — categorical, distinct hues), `"sequential"` (single-hue ramp for ordinal data), or `"diverging"` (two-hue ramp for signed data). Per-series `color` overrides take precedence over the palette assignment.

#### `legend`

Optional object `{ "visible"?: bool, "position"?: "top" | "bottom" | "left" | "right" }`. Defaults vary by chart family:

- Multi-series xy/category: `{ "visible": true, "position": "bottom" }`.
- 1-series xy/bar: `{ "visible": false }` (the chart title already names the data).
- pie/donut: `{ "visible": true, "position": "right" }` (the legend is the readout).

#### `aspect_ratio`

Optional enum: `"16:9"` (default), `"4:3"`, `"1:1"`. The chart fills its container width and uses CSS `aspect-ratio` to size its height. There are no portrait ratios.

#### Domain rules

- `stacked_bar` rejects negative values (mixed-sign stacks are not supported). Use `grouped_bar` if you need signed data.
- pie/donut slice `value` must be > 0 (zero-value slices are rejected).
- For `time` x-axes, every x-string must parse as ISO-8601 (`2026-01-01` or `2026-01-01T00:00:00Z`); mira sorts by x ascending automatically if the agent doesn't.

#### Mobile

Charts scale fluidly via the SVG `viewBox`. Note that `legend.position: "left"` and `"right"` may compress the plot to under 50% of available width on narrow viewports; if mobile rendering matters, prefer `"bottom"` (the multi-series default) or `"top"`.

#### Common rejections

Most chart 400s in practice fall into one of these categories:

- **Color override format** — `color` (series or slice) must match `^#[0-9a-f]{6}$` exactly. Uppercase hex (`#FF0000`), 3-char shorthand (`#f00`), `rgba(...)`, and CSS color names are rejected.
- **`bar` with multiple series** — `bar` accepts exactly 1 series. Use `grouped_bar` or `stacked_bar` for multi-series comparisons.
- **Axes on `pie` / `donut`** — proportion charts reject `x_axis` and `y_axis` entirely. Sending either returns 400.
- **`scatter` with `x_axis.type: "category"`** — scatter requires a `"number"` or `"time"` x-axis. Use `bar` for category-axis comparisons.
- **Negative values in `stacked_bar`** — `stacked_bar` rejects negative values. Use `grouped_bar` if you need signed data.
- **`tick_format` shape** — number axes accept exactly one float verb (`%.0f`, `%.1f%%`, `$%.0fM`, …); `%d`, `%s`, multi-arg formats, and anything > 32 chars are rejected.

### `stat_grid`

**Required fields:** `tiles` (each tile requires `label` and `value`).

`stat_grid` renders a responsive grid of labeled big-number tiles with optional trend indicators. It is the right block for KPI scorecards, status dashboards, and "at-a-glance" headline numbers — layouts that previously had to be flattened into a vertical stack of `callout`s.

Body fields: `title` (optional plain string), `caption` (optional `rich_text` array), `columns` (optional, `"auto"` or 1–6), `tiles` (required, **2–12** entries).

| Field      | Required | Type                       | Cap / notes |
| ---------- | -------- | -------------------------- | ----------- |
| `title`    | optional | plain string               | ≤ 120 runes. Rendered as `<h4>` above the grid. |
| `caption`  | optional | `rich_text` array          | 0–100 segments. Rendered as `<figcaption>` below the grid. |
| `columns`  | optional | `"auto"` or integer 1–6    | Default `"auto"` → `repeat(auto-fit, minmax(180px, 1fr))`. Integer N → up to N across, wraps when narrower. |
| `tiles`    | required | array of tile objects      | **2–12** entries. A single-tile grid is rejected (use `callout`). |

> Heads up: `stat_grid` requires **2–12 tiles**. For a single fact, use `callout` — the grid layout has no value with one tile.

#### Tile object

Each entry of `tiles` is an object with these fields:

| Field         | Required | Type                       | Cap / notes |
| ------------- | -------- | -------------------------- | ----------- |
| `label`       | required | plain string               | 1–60 runes. The "what" — e.g. "Quarterly revenue", "Active users". |
| `value`       | required | string **or** number       | String: 1–24 runes, no `\n`/`\r`. Number: any finite float (NaN/Infinity rejected). Strings render verbatim; numbers render with no thousands separators. |
| `unit`        | optional | plain string               | ≤ 8 runes. Rendered as a suffix after the value in muted/smaller type. For prefix currency, embed it in `value` (e.g. `"$4.2M"`). |
| `trend`       | optional | trend object               | See below. |
| `accent`      | optional | enum                       | One of `"default"` / `"positive"` / `"negative"` / `"warning"` / `"info"`. Drives a 2px top-border color on the tile. Default is `"default"` (no accent). See "Accent enum" below. |
| `description` | optional | `rich_text` array          | 0–100 segments. Rendered below the trend in muted small text. Counts toward the global 2000-span budget. |

#### Trend object

`tile.trend` is an optional object describing a delta annotation. When present, `direction` is required.

| Field       | Required | Type                       | Cap / notes |
| ----------- | -------- | -------------------------- | ----------- |
| `direction` | **required (when `trend` is present)** | enum | One of `"up"` / `"down"` / `"flat"`. Drives the rendered arrow glyph (`↑` / `↓` / `→`). |
| `magnitude` | optional | string **or** number       | String: ≤ 24 runes (e.g. `"18%"`, `"+1,200"`). Number: any finite float. |
| `period`    | optional | plain string               | ≤ 16 runes. Free string — e.g. `"QoQ"`, `"WoW"`, `"YoY"`, `"since launch"`. |
| `semantic`  | optional | enum                       | One of `"positive"` / `"negative"` / `"neutral"`. Overrides the default `direction → color` mapping. Useful for inverted metrics (e.g. "open bugs went down" → `direction:"down"`, `semantic:"positive"`). When absent, default mapping is `up→positive`, `down→negative`, `flat→neutral`. |

#### Accent enum

`tile.accent` is a small semantic enum (NOT a hex color, NOT a palette index). The five accepted values map to the same chart-palette CSS variables used by the `chart` block, so accents stay visually consistent across blocks.

| Value        | Visual                              |
| ------------ | ----------------------------------- |
| `"default"`  | No top border (or transparent).     |
| `"positive"` | Emerald top border (`--chart-c2`).  |
| `"negative"` | Rose top border (`--chart-c0`).     |
| `"warning"`  | Amber top border (`--chart-c1`).    |
| `"info"`     | Blue top border (`--chart-c3`).     |

Hex values, CSS color names, and palette indexes are all rejected. The same applies to `trend.semantic` — only the three semantic enum values are accepted.

#### Caps

| Cap | Value |
| --- | ----- |
| `tiles` count                              | 2–12 (inclusive) |
| `label` length                             | 1–60 runes |
| `value` length (when string)               | 1–24 runes |
| `value` (when number)                      | any finite float64 (NaN/Infinity rejected) |
| `unit` length                              | 0–8 runes |
| `trend.magnitude` length (when string)     | 0–24 runes |
| `trend.period` length                      | 0–16 runes |
| `description` rich_text segments           | 0–100 (counts toward global 2000-span budget) |
| `title` length                             | 0–120 runes |
| `columns` (when integer)                   | 1–6 |
| `caption` rich_text segments               | 0–100 (counts toward global 2000-span budget) |

(Global payload caps apply — see Top-level payload shape.)

#### Common rejections

Most stat_grid 400s in practice fall into one of these categories:

- **Single-tile grid** — `tiles.length < 2` returns 400. Use `callout` for a single fact; the grid layout has no value with one tile.
- **Newline in `value`** — `value: "$4.2M\nQ1 2026"` returns 400. Move the secondary line into `description`.
- **Hex / palette-index in `accent`** — only the 5-value semantic enum is accepted. `"#10b981"`, `"emerald"`, `2` are all rejected.
- **Hex / palette-index in `trend.semantic`** — only `"positive"` / `"negative"` / `"neutral"` is accepted.
- **`trend` with no `direction`** — `trend: { "magnitude": 18 }` returns 400. `direction` is required when the trend object is present.
- **`columns` outside 1–6** — `columns: 0`, `columns: 7`, `columns: -1` are rejected. Use `"auto"` (the default) or an integer 1–6. The string form must equal `"auto"` exactly.
- **`unit` longer than 8 runes** — `"million USD"` returns 400. Embed long units in the `value` string instead (e.g. `value: "$4.2 million USD"`).
- **NaN / Infinity in number values** — `value: NaN` and `value: Infinity` are rejected (Go's JSON decoder rejects them, and the validator double-checks finite-ness on numbers).
- **Notion-wrapper fields** — `id`, `object`, `parent`, `created_time`, etc. on the body or any tile return 400 (closed schema).

### `mermaid`

The `mermaid` block — referred to as the Mermaid Block — embeds a Mermaid diagram. The agent supplies the diagram source as text; the diagram is rendered in the viewer's browser via a bundled mermaid.js v11 (the same library that powers GitHub, GitLab, and Notion mermaid blocks). It is the right block for flowcharts, sequence diagrams, ER schemas, gantt charts, and the rest of mermaid's diagram catalogue. Supported diagram-type keywords (the source's first non-blank, non-`%%`-comment line must begin with one of these):

```
flowchart            graph              sequenceDiagram
stateDiagram-v2      classDiagram       erDiagram
gantt                pie                journey
mindmap              timeline           gitGraph
quadrantChart        requirementDiagram C4Context
C4Container          C4Component        C4Dynamic
sankey-beta          xychart-beta       block-beta
kanban
```

**Required fields:** `source`

Body fields: `source` (required), `title` (optional plain string), `caption` (optional `rich_text` array), `aspect_ratio` (optional enum), `accessibility` (optional object holding a single `description` field).

| Field                       | Required     | Type                | Cap / notes |
| --------------------------- | ------------ | ------------------- | ----------- |
| `source`                    | **required** | string              | 1–6000 runes, ≤ 200 lines. First non-blank, non-`%%`-comment line MUST begin with one of the supported diagram-type keywords above (case-sensitive). mira does not parse the diagram body — mermaid handles per-type validity in the browser. |
| `title`                     | optional     | plain string        | ≤ 120 runes. Rendered above the diagram. NOT `rich_text`. |
| `caption`                   | optional     | `rich_text` array   | 0–100 segments. Rendered below the diagram. Counts toward the global 2000-span budget. |
| `aspect_ratio`              | optional     | enum                | One of `"auto"` (default) / `"16:9"` / `"4:3"` / `"1:1"`. Defaults to `"auto"` — unlike `chart`'s `"16:9"` default, because flowcharts can be tall or wide and forcing a fixed ratio letterboxes most diagrams. |
| `accessibility`             | optional     | object              | Currently holds one field, `description`. Unknown keys return 400. |
| `accessibility.description` | optional     | plain string        | ≤ 500 runes. Overrides the default `aria-label` on the rendered `<pre class="mermaid">` element. Always set this for non-trivial diagrams — screen readers cannot meaningfully read the rendered SVG. |

> Heads up: pages containing a `mermaid` block load a bundled mermaid library (~3 MB) and run it in the viewer's browser.
>
> **Security model.** A rendered page runs no JavaScript and makes no outbound requests unless it contains a `mermaid` block, an `editable` block, or an interactive (sortable) table — which load same-origin JS — or a `video` / `network` block, which embed a sandboxed iframe. Otherwise the page is fully static.

#### Restrictions

Mermaid's syntax accepts a few directives that mira rejects up-front:

- **`click X foo()`, `callback X fn`, and `href "..."`** — mermaid's binding syntax for node click handlers and link targets. Any line matching these returns 400 with an error naming the matched directive (`'click'`, `'callback'`, or `'href'`). `callback` is matched only at the start of a line, so arrow-message text containing the word `callback` (e.g. `A->>B: GET /callback?code=abc`) is fine. Use a `mermaid.caption` rich_text segment with a `text.link` for outbound links instead.
- **`%%{init: ...}%%`** — only `{theme: "default"}` or `{theme: "dark"}` is allowed; everything else (`themeCSS`, `themeVariables`, `fontFamily`, `fontSize`, `flowchart.*`, etc.) returns 400 with the offending key named. The successfully-validated directive is **stripped** from the stored source — mira's stylesheet, not the directive, drives the actual look.
- **Native mermaid `theme` directive** (`forest`, `neutral`, etc.) — accepted only inside the `%%{init: {theme: ...}}%%` form above, and even then only `"default"` and `"dark"` are valid theme values. The directive is stripped before storage.
- **`classDef` and per-node `:::class` directives** — flow through to the browser unmodified. Class names other than `:::primary` / `:::warning` / `:::positive` / `:::info` are accepted but have no mira-provided styling.
- **Unknown top-level keys** on the `mermaid` body (e.g. `mermaid.diagram_type`, `mermaid.theme`, `mermaid.width`) — rejected (closed schema). The diagram type is derived from the first source line, never from a separate JSON field.

#### Accessibility

- The rendered figure is labelled by the `title` (when set) and described by the `caption` (when set).
- The diagram carries an `aria-label`. When `accessibility.description` is set, that text becomes the label; otherwise a default `"mermaid diagram"` label is used.
- Provide `accessibility.description` for non-trivial diagrams — screen readers cannot meaningfully read the rendered SVG.

#### Caps

| Cap                                       | Value |
| ----------------------------------------- | ----- |
| `source` length                           | 1–6000 runes |
| `source` lines                            | 1–200 |
| `title` length                            | 0–120 runes |
| `accessibility.description` length        | 0–500 runes |
| `caption` rich_text segments              | 0–100 (counts toward global 2000-span budget) |

(Global payload caps apply — see Top-level payload shape.)

#### Common rejections

Most `mermaid` 400s in practice fall into one of these categories:

- **Source doesn't start with a supported diagram-type keyword** — the first non-blank, non-`%%`-comment line must begin (case-sensitive) with one of the keywords listed at the top of this section. Common typos: `flowChart` (capital C), `Flowchart`, `flow chart`, `sequencediagram` (lowercase d). The error names the first 30 chars of what you sent.
- **`click` / `callback` / `href "..."` directives** — rejected with an error naming which directive matched. `callback` is matched only at line-start, so the substring `callback` (or `/callback` in a URL path inside an arrow message label) is fine. Use `mermaid.caption` with a rich_text link instead.
- **`%%{init: ...}%%` with anything other than `{theme: "default"|"dark"}`** — the error names the offending key (e.g. `themeVariables`, `fontSize`).
- **`source` longer than 6000 runes or longer than 200 lines** — split into multiple `mermaid` blocks or simplify the diagram.
- **Unknown top-level keys in the `mermaid` object** — closed schema; there is no escape hatch.
- **`aspect_ratio` outside the 4-value enum** — `"portrait"`, `"3:2"`, `"21:9"`, integers, etc. all return 400.
- **Non-string value in `source`, `title`, `aspect_ratio`, or `accessibility.description`** — sending a number / boolean / array returns 400 with `<field>: must be a string`.
- **Non-object value in `accessibility`** — sending a number / string / array returns 400 with `accessibility: must be an object`.

### `timeline`

**Required fields:** `events` (each event requires `date` and `label`).

`timeline` renders a time-ordered sequence of events as a vertical or horizontal rail of dated cards with status dots, accent borders, optional icons, optional "now" marker, and optional year/month group headers. It is the right block for release histories, project roadmaps, biographical timelines, incident post-mortems, and any "here is a chronology" payload that previously had to be flattened into a numbered list. Pure HTML + CSS, no JS, no SVG.

Body fields: `orientation` (optional enum, defaults to `"vertical"`), `events` (required, **1–50** entries), `title` (optional plain string), `caption` (optional `rich_text` array), `now_marker` (optional, boolean or ISO date string), `group_by` (optional enum), `density` (optional enum).

| Field         | Required | Type                        | Cap / notes |
| ------------- | -------- | --------------------------- | ----------- |
| `orientation` | optional | enum                        | One of `"vertical"` (default) / `"vertical-alternating"` / `"horizontal"`. Vertical is a single rail; vertical-alternating puts cards on alternating sides of a center rail (collapses to single-rail below 600 px); horizontal scrolls left-to-right with `tabindex="0"` on the scroll region for keyboard access. |
| `events`      | **required** | array of event objects  | **1–50** entries. Empty or oversized returns 400. |
| `title`       | optional | plain string                | ≤ 120 runes. Rendered as `<h4>` above the rail. NOT `rich_text`. Newlines rejected. |
| `caption`     | optional | `rich_text` array           | 0–100 segments. Rendered as `<figcaption>` below the rail. Counts toward the global 2000-span budget. |
| `now_marker`  | optional | boolean **or** ISO date string | `true` → draw a "Now" line at `time.Now()` UTC at render time. ISO date string (`"2026-03-15"`, `"2026-03"`, `"2026"`, or RFC3339) → pin the line at that date. `false` / absent / `null` → no marker. If no event has a parseable date, the marker is silently dropped (an HTML comment is emitted in its place). Other types or unparseable strings return 400. |
| `group_by`    | optional | enum                        | One of `"none"` (default) / `"year"` / `"month"`. When set, mira inserts a group-header `<li>` before the first event of each bucket. Year buckets render as `2026`; month buckets render as `January 2026`. Events with unparseable sort dates land in a trailing `Other` bucket. **For quarter-level grouping**, encode the quarter in the date `display` field (e.g. `{ "sort": "2026-04-01", "display": "Q2 2026" }`) and use `group_by: "year"` (or `"none"`) — there is no `"quarter"` enum value. |
| `density`     | optional | enum                        | One of `"comfortable"` (default) / `"compact"`. `compact` reduces vertical spacing — useful for 20+-event lists. |

#### Event object

Each entry of `events` is an object with these fields:

| Field         | Required | Type                       | Cap / notes |
| ------------- | -------- | -------------------------- | ----------- |
| `date`        | **required** | string **or** object   | See "Date — string or object" below. Required on every event; mira does NOT default to render-order. |
| `label`       | **required** | plain string           | **1–120 runes**. The headline of the event (e.g. `"v1.0 shipped"`, `"Marries William King-Noel"`). **`label` is a plain string, NOT `rich_text`** — sending a `rich_text` array (or any non-string) returns 400. For marked-up event text (bold, links), put it in `description` instead. Newlines rejected. |
| `description` | optional | `rich_text` array          | 0–100 segments. Rendered below the label in muted text. Counts toward the global 2000-span budget. |
| `status`      | optional | enum                       | One of `"shipped"` / `"in-progress"` / `"planned"` / `"skipped"`. Drives the marker-dot color: shipped → emerald (`--chart-c2`), in-progress → amber (`--chart-c3`), planned → outline (`--mira-rule`), skipped → muted (`--mira-muted`). |
| `accent`      | optional | enum                       | One of `"default"` / `"positive"` / `"negative"` / `"warning"` / `"info"` — the same 5-value enum `stat_grid.tile.accent` uses. Drives a 2px left-border accent on the card. |
| `icon`        | optional | string                     | A **single grapheme** (e.g. `"🚀"`, `"v"`), ≤ 4 bytes UTF-8. When set, overrides the default dot marker. Multi-grapheme strings (`"AB"`, `"v1"`) are rejected. |

`status` and `accent` are independent. `status` colors the dot (chronological state); `accent` colors the card border (semantic flag). Setting both is fine — the dot turns green-ish for a shipped event, the border turns whatever `accent` says.

#### Date — string or object

`event.date` is dual-shape: a plain ISO string for the common case, an object for messy real-world dates.

**String form.** A plain string, 1–32 runes, no newlines. mira tries to parse it in this exact order; first match wins:

```
RFC3339         e.g. "2026-01-15T09:30:00Z"
RFC3339Nano     e.g. "2026-01-15T09:30:00.123456789Z"
2006-01-02      e.g. "2026-01-15"
2006-01         e.g. "2026-01"
2006            e.g. "2026"
```

If the string matches one of these, it drives both the chronological sort key and the displayed text. If it matches none, the string still renders verbatim (so `"2026-Q4"`, `"Spring 2024"`, `"Sprint 14"` all work as display) but the event sorts at the **end** of the list in agent order, after all events with parseable dates.

Other date formats — `"MM/DD/YYYY"`, `"DD/MM/YYYY"`, `"Jan 15, 2024"`, ISO week `"2026-W02"`, non-ASCII month names — are NOT parsed. They render verbatim but always sort at the end. To guarantee chronological placement, use the object form below.

**Object form 1 — `{ sort, display }`.** Lets the agent supply a parseable `sort` key separately from a free-form `display` string:

```json
{ "sort": "1820-01-01", "display": "circa 1820" }
```

Both fields required when this shape is used; `sort` must parse against the ISO list above (otherwise 400); `display` renders verbatim. Use this when the displayed date is approximate or non-standard (`"circa 1820"`, `"1980s"`, `"Q1 2026"`, `"Spring 2024"`).

**Object form 2 — `{ start, end }`.** Lets the agent express a date range:

```json
{ "start": "2024-01-15", "end": "2024-06-30" }
{ "start": "2025-09-01", "end": null }
```

`start` and `end` both required keys. `start` must parse against the ISO list. `end` may be either a parseable ISO string OR JSON `null` (meaning "ongoing"). Omitting `end` returns 400 (`use null for ongoing`). `start > end` returns 400. The range sorts by `start`; the displayed text is `"<start> – <end>"` or `"<start> – ongoing"`.

**Mixing forms.** Sending both `sort`/`display` and `start`/`end` keys in the same object returns 400 (`pick either {sort, display} or {start, end}`). The object must commit to one shape. Unknown keys (`{ "foo": "bar" }`, `{ "sort": "...", "label": "..." }`) are rejected (closed schema) with an error naming the offending key and the allowed set: `unknown field "<key>" (allowed: sort, display, start, end)`.

#### Sort order

Events are auto-sorted chronologically ascending by the parsed sort key. Events with no parseable sort (string form that doesn't match the ISO list) preserve their **agent order** and sit at the end of the list. There is no `sort_order` field — mira owns the ordering. If you want a specific order for unparseable dates, sort the array yourself before POSTing.

For ties (two events with the same sort key), the relative order is the agent's array order (stable sort).

#### `now_marker` placement

When `now_marker` is `true`, mira computes the target as `time.Now()` UTC at render time. When `now_marker` is an ISO date string, mira parses it (same layouts as `date` strings; year-only, year-month, date-only, RFC3339 all accepted) and pins the line at that date.

The marker `<li>` is inserted into the sorted list at the position where it should sit chronologically — between the last event with `sort ≤ target` and the first event with `sort > target`. If `target` is before every parseable event, the marker is the first item; if after, the last. Events with no parseable date are not considered for positioning (they live at the tail).

If no event has a parseable date, the marker is dropped silently — an HTML comment `<!-- now_marker requested but no parseable dates -->` is emitted instead. This avoids a meaningless dangling "Now" line.

#### Caps

| Cap                                       | Value |
| ----------------------------------------- | ----- |
| `events` count                            | 1–50 |
| `label` length                            | 1–120 runes |
| `title` length                            | 0–120 runes |
| `date` string length                      | 1–32 runes |
| `date` object: `sort` / `display` / `start` / `end` (when string) | 1–32 runes |
| `icon` length                             | exactly 1 grapheme, ≤ 4 bytes UTF-8 |
| `description` rich_text segments          | 0–100 (counts toward global 2000-span budget) |
| `caption` rich_text segments              | 0–100 (counts toward global 2000-span budget) |
| automatic skip-link                       | rendered when `events.length ≥ 20` |
| vertical-alternating breakpoint           | collapses to single-rail below 600 px |

(Global payload caps apply — see Top-level payload shape.)

#### Accessibility

- The rendered `<figure class="timeline ...">` carries `aria-labelledby` (pointing at the `<h4>` title when `title` is set) or `aria-label` (`"Timeline with N events"` when no title). When `caption` is present, `aria-describedby` points at the `<figcaption>`.
- The event list is a semantic `<ol role="list">`. Each `<li>` carries an `aria-label` of the form `"Event {i} of {N}: {date}. {label}. {description snippet}"` (≤ 200 runes, truncated with `…`).
- When a date is parseable, the date column renders as `<time datetime="...">`. Year-only → `datetime="2026"`, year-month → `datetime="2026-01"`, date → `datetime="2026-01-15"`, datetime → RFC3339. Unparseable dates render as `<span>` with no machine-readable datetime.
- `orientation: "horizontal"` puts `tabindex="0"` on the scrollable `<ol>` so keyboard users can scroll the strip with arrow keys.
- When `events.length ≥ 20`, a `<a class="timeline-skip-link" href="#after-timeline-{seq}">Skip timeline</a>` is rendered before the list and a matching anchor after, so screen-reader users can jump past long rails.
- Marker dots and the now-line are `aria-hidden="true"` — they're decorative; the status is already conveyed by the `aria-label` and the displayed text.

#### Common rejections

Most timeline 400s in practice fall into one of these categories:

- **`orientation` outside the 3-value enum** — `"diagonal"`, `"vert"`, `"vertical-flip"` all return 400 with an error listing `"vertical"`, `"vertical-alternating"`, `"horizontal"`.
- **`events` empty or > 50** — `events: []` → 400 (`must contain at least 1 event`); `events: [<51 entries>]` → 400 (`events length 51 exceeds limit of 50`). For more than 50 events, split across multiple timelines or summarize.
- **`status` / `accent` outside the enum** — `"done"`, `"chartreuse"` return canonical `"<value>" not supported; must be one of ...` errors.
- **Non-string value in `status` / `accent` / `icon` / `label` / `title` / `orientation` / `group_by` / `density`** — sending a number / boolean / array returns 400 with `<field>: must be a string`. The validator never leaks Go struct names.
- **`date` wrong type** — `date: 2024` (number) or `date: ["2024-01-01"]` (array) → 400 (`date: must be string or object`). `date` is required on every event; absent → 400.
- **`date` object with no shape match** — `date: {}` (empty object) → 400 (`object must contain {sort, display} or {start, end}`); `date: { "label": "..." }` (no recognized keys) → 400 (`unknown field "label" ...`).
- **`date` object mixing shapes** — `date: { "sort": "2026-01-01", "start": "2026-01-01" }` → 400 (`pick either {sort, display} or {start, end}`).
- **`date` range with omitted `end`** — `date: { "start": "2026-01-01" }` → 400 (`end required (use null for ongoing)`). For ongoing ranges, send `end: null` explicitly.
- **`date` range with `start > end`** — `date: { "start": "2026-12-01", "end": "2026-01-01" }` → 400 (`start must be ≤ end`).
- **`date` object with an unparseable `sort` / `start` / `end`** — `{ "sort": "Q1 2026", "display": "..." }` → 400 (`sort: "Q1 2026" is not a recognized ISO date (allowed: RFC3339, YYYY-MM-DD, YYYY-MM, YYYY)`). The object form requires a machine-parseable sort key; if you need a non-ISO display, use the object form with a real ISO `sort`.
- **`icon` not a single grapheme** — `"AB"`, `"v1"`, `"🚀🔥"` → 400 (`icon must be a single grapheme`). Longer-than-4-bytes single emoji ZWJ sequences are also rejected by the byte cap (use a simpler glyph or drop the icon).
- **`label` empty or > 120 runes** — `label: ""` → 400 (`label required`); `label: "<121+ runes>"` → 400 (`label must be 1-120 runes`). `label` and `title` reject newlines (`\r`/`\n`).
- **`now_marker` wrong type** — `now_marker: 42`, `now_marker: ["2026-01-15"]` → 400 (`must be boolean or ISO date string`); `now_marker: "not-a-date"` → 400 (string must parse against the ISO layouts).
- **`group_by` outside the enum** — `"week"`, `"day"`, `"decade"` → 400 (`group_by "..." not supported; must be one of "none", "year", "month"`).
- **Unknown top-level keys on the `timeline` body or any event** — closed schema. Extra fields like `sort_order`, `palette`, `aspect_ratio`, `link` on an event are rejected.

#### Worked examples (inline)

The "Worked examples — end to end" section below has full timeline payloads wrapped in a `template: "page"` envelope (Example 17 release history; Example 18 roadmap with `now_marker`; Example 19 biography with alternating sides). The two snippets below are timeline-body-only — copy them straight into the `blocks` array.

**Year-grouped multi-decade history** — `group_by: "year"` with ISO date strings and mixed `status` values. The year bucket headers (`2006`, `2009`, …) are auto-inserted by mira; events within each year are sorted chronologically. Use this shape for company histories, founder bios, project retrospectives that span many years.

```json
{
  "type": "timeline",
  "timeline": {
    "title": "Two decades at Helix Co.",
    "orientation": "vertical-alternating",
    "group_by": "year",
    "events": [
      { "date": "2006-08-12", "label": "Helix Co. incorporated", "status": "shipped" },
      { "date": "2009-04-30", "label": "Series A — $4M", "status": "shipped", "accent": "positive" },
      { "date": "2014-11-08", "label": "Headquarters relocated to Berlin", "status": "shipped" },
      { "date": "2020-06-15", "label": "Pivot to platform business", "status": "shipped", "accent": "positive" },
      { "date": "2024-09-22", "label": "Series C — $48M", "status": "shipped", "accent": "positive" },
      { "date": "2026-01-10", "label": "EU GDPR compliance audit", "status": "in-progress", "accent": "info" },
      { "date": "2026-09-01", "label": "Tokyo office opens", "status": "planned" }
    ]
  }
}
```

**In-flight roadmap with `now_marker`** — the shape an agent reaches for when one event is actively happening "right now". `status: "in-progress"` colors the marker dot amber; `now_marker: true` draws a horizontal "Now" line at `time.Now()` UTC at render time, slotted chronologically between past and future events. The final event uses object-form date with a display string (`"Q4 2026"`) — quarter-level grouping is not built in, so encoding the quarter in `display` is the documented workaround.

```json
{
  "type": "timeline",
  "timeline": {
    "title": "2026 H2 roadmap",
    "now_marker": true,
    "events": [
      { "date": "2026-04-15", "label": "Auth rewrite shipped", "status": "shipped", "accent": "positive" },
      { "date": "2026-05-30", "label": "Audit log streaming", "status": "in-progress", "icon": "⚙",
        "description": [{ "type": "text", "text": { "content": "Kafka → ClickHouse, partial rollout to 3 design partners." } }] },
      { "date": "2026-07-10", "label": "Self-serve billing", "status": "planned" },
      { "date": { "sort": "2026-10-01", "display": "Q4 2026" }, "label": "SOC 2 Type II", "status": "planned", "accent": "info" }
    ]
  }
}
```

### `calendar`

`calendar` renders a single month as a 7-column grid of all-day events. Use it for launch calendars, content schedules, conference programs, sprint timelines — anywhere a month-at-a-glance view of dated items is the narrative. The grid is a real `<table>` (Sunday-start, 5–6 rows of `<tr>`); the renderer expands the month from the Sunday before the 1st through the Saturday after the last day, with out-of-month cells visually muted. Static render only — no JS, no time-of-day, no multi-day events, no recurrence.

> **Use `calendar` when** the reader needs to see events laid out by date in a familiar month grid. **Use `timeline` when** events are ordered chronologically and exact-date placement is less important than sequence. **Use `kanban` when** items are grouped by workflow phase rather than by date. **Use `slides` when** the narrative is a sequenced set of framed sections rather than a calendar of dated items.

**Required fields:** `month` (`YYYY-MM`), `events` (array, may be empty).

Body fields: `month` (required, strict `YYYY-MM`), `events` (required, 0–80 entries), `title` (optional plain string), `today` (optional `YYYY-MM-DD` highlighting one day cell), `caption` (optional `rich_text` array).

```json
{
  "type": "calendar",
  "calendar": {
    "title": "Q2 launch calendar",
    "month": "2026-05",
    "today": "2026-05-11",
    "events": [
      { "date": "2026-05-04", "title": "Spec freeze", "accent": "info" },
      { "date": "2026-05-11", "title": "Beta cohort opens", "accent": "warning" },
      { "date": "2026-05-18", "title": "Dogfood week begins" },
      { "date": "2026-05-28", "title": "GA launch", "accent": "positive" }
    ],
    "caption": [{ "type": "text", "text": { "content": "As of Friday standup." } }]
  }
}
```

| Field     | Required     | Type                   | Cap / notes |
| --------- | ------------ | ---------------------- | ----------- |
| `month`   | **required** | plain string           | Strict `YYYY-MM`, matches `^\d{4}-(0[1-9]\|1[0-2])$`. Single month per block — for spans, emit multiple `calendar` blocks. |
| `events`  | **required** | array of event objects | **0–80** entries (`[]` is legal — renders an empty grid). **Max 6 events per day**. Events render in the order supplied; the renderer groups them by `date` into the matching day cell. |
| `title`   | optional     | plain string           | ≤ **120 runes**. Rendered as `<h3 class="calendar-title">` above the month-name header. NOT `rich_text`. Newlines rejected. |
| `today`   | optional     | plain string           | Strict `YYYY-MM-DD`. Highlights the matching day cell with class `calendar-day-today`. MUST fall inside `month`. Payload-passed only — mira NEVER reads the server clock; if you want "today," supply it. |
| `caption` | optional     | `rich_text` array      | 0–100 segments. Rendered as `<figcaption class="calendar-caption">` below the grid. Counts toward the global 2000-span budget. |

> Note: `today` MUST be within the month being rendered. `{month: "2026-06", today: "2026-05-11"}` is a 400 — drop `today` if you don't have one for that month.

#### Event object

Each entry of `events` is an object with these fields (and nothing else — closed schema):

| Field         | Required     | Type              | Cap / notes |
| ------------- | ------------ | ----------------- | ----------- |
| `date`        | **required** | plain string      | Strict `YYYY-MM-DD`, matches `^\d{4}-\d{2}-\d{2}$`. Must be a real calendar date (Feb 30 / non-leap Feb 29 rejected by `time.Parse`). Must fall inside `month`. |
| `title`       | **required** | plain string      | **1–80 runes**, single-line. Rendered as `<strong class="calendar-event-title">`. NOT `rich_text`. Newlines rejected. |
| `description` | optional     | `rich_text` array | **1–3 segments**, ≤ **140 runes** total content. Rendered as `<div class="calendar-event-desc">`. Counts toward the global 2000-span budget. Empty array `[]` rejected (omit the field instead). Hidden at narrow viewports to keep cells legible. |
| `accent`      | optional     | enum              | One of `"default"` / `"positive"` / `"negative"` / `"warning"` / `"info"`. Paints a 3px left border-stripe + bg-tint on the event chip via class `calendar-event-accent-<value>`. **Absent → `default` (no palette cycling — the day is already the categorical axis)**. Same 5-value enum as `kanban.columns[].accent`, `stat_grid`, `comparison_matrix`, `timeline`. |

#### Caps

| Cap                                 | Value |
| ----------------------------------- | ----- |
| `events` count                      | 0–80 |
| `events` per day                    | **6** (validation hard-rejects more) |
| `title` length                      | 0–120 runes |
| `events[i].title` length            | 1–80 runes |
| `events[i].description` segments    | 1–3 (counts toward global 2000-span budget) |
| `events[i].description` total runes | ≤ 140 |
| `caption` segments                  | 0–100 (counts toward global 2000-span budget) |
| nesting depth                       | ≤ 3 (same as every other block) |

(Global payload caps apply — see Top-level payload shape.)

#### Rendering

- When `title` is supplied it renders as a heading above the grid; an optional `caption` renders below. A "Month YYYY" header is always shown — a chronological cue even when `title` is absent.
- The grid is a real `<table>`: a Sunday-start weekday header row (`Sun … Sat`) and 5–6 week rows. Cells outside the month show the real adjacent-month day number, muted; weekend cells are styled distinctly.
- The cell matching `today` (if supplied) gets an outlined ring.
- Events on a day render as chips inside that day cell. An empty day shows no events. Out-of-month cells never carry events.

#### Accents

`event.accent` reuses the universal 5-value enum `default | positive | negative | warning | info` (same as `kanban`, `stat_grid`, `comparison_matrix`, `timeline`). Absent → `default` styling (no palette cycling). The four non-default values paint a left border-stripe and a subtle background tint on the event chip that harmonizes with the other accented blocks on the same page.

#### What `calendar` is NOT in v1

- **Not multi-month.** One `month` per block. For a quarterly view, emit three `calendar` blocks. The `months` plural is caught at decode time.
- **Not timed.** All events are all-day. To indicate a time, put it inside `event.title` (e.g. `"10:00 — Standup"`). `start_time` / `end_time` / `time` / `start` / `end` are caught at decode time.
- **Not multi-day.** `event.date` is a single date. For a 3-day span, emit three events with the same `title` (or three different titles). `end_date` / `until` / `duration` are caught at decode time.
- **Not recurring.** No `RRULE`. Expand recurrences client-side into individual events. `recurring` / `rrule` / `repeat` are caught at decode time.
- **No click-through.** No `event.link` field — calendar chips are content, not anchors. To make an event linkable, put the link inside `description` rich_text (same pattern as `kanban`). `link` / `url` / `href` are caught at decode time.
- **No tags / assignee.** Calendar chips are intentionally lean — for urgency cues use `accent`. `tags` / `labels` / `assignee` / `owner` / `attendees` are caught at decode time.
- **No JS, no drag-drop, no click-to-edit.** Static render only.
- **No server clock.** `today` is payload-passed. If you want "today" highlighted, supply `today: "YYYY-MM-DD"` — mira never reads the system clock.

#### Common rejections

Most calendar 400s in practice fall into one of these categories (verbatim error strings the validator emits — agents can pattern-match on them):

- **`month` missing / empty / non-string / wrong shape** — `""` or omitted → 400 (`calendar.month: required`); non-string → 400 (`calendar.month: must be a string`); `"2026-5"` or any non-`YYYY-MM` → 400 (`calendar.month: must match YYYY-MM (got "...")`).
- **`events` too many** — 81+ entries → 400 (`calendar.events: total event count N exceeds limit of 80 per month`).
- **`events` per-day cap exceeded** — 7+ events on a single date → 400 (`calendar.events: 7 events on YYYY-MM-DD exceeds limit of 6 events per day`).
- **`title` too long / contains newlines / non-string** — > 120 runes → 400 (`calendar.title exceeds 120 runes`); `\r` or `\n` → 400 (`calendar.title: must not contain newlines`); non-string → 400 (`calendar.title: must be a string`).
- **`today` outside the month** — `today` not matching `month` prefix → 400 (`calendar.today "2026-06-15" is not within month 2026-05`).
- **`today` wrong shape** — non-`YYYY-MM-DD` → 400 (`calendar.today: must match YYYY-MM-DD (got "...")`); semantically invalid (e.g. `2025-02-29`) → 400 (`calendar.today: "..." is not a valid calendar date`).
- **`events[i].date` missing / wrong shape / outside the month** — `""` or omitted → 400 (`calendar.events[N].date: required`); wrong shape → 400 (`calendar.events[N].date: must match YYYY-MM-DD (got "...")`); semantically invalid → 400 (`calendar.events[N].date: "..." is not a valid calendar date`); not within month → 400 (`calendar.events[N].date "..." is not within month YYYY-MM`).
- **`events[i].title` missing / oversized / non-string / contains newline** — empty or omitted → 400 (`calendar.events[N].title: required`); > 80 runes → 400 (`calendar.events[N].title exceeds 80 runes`); newline → 400 (`calendar.events[N].title: must not contain newlines`); non-string → 400 (`calendar.events[N].title: must be a string`).
- **`events[i].description` over segment cap / rune cap / non-array / empty array** — 4+ segments → 400 (`calendar.events[N].description exceeds 3 segments`); > 140 runes total → 400 (`calendar.events[N].description: total content exceeds 140 runes`); plain string `"hello"` → 400 (`calendar.events[N].description: must be a rich_text array`); `[]` → 400 (`calendar.events[N].description: rich_text array cannot be empty`). Omit the field instead.
- **`events[i].accent` outside the enum** — `"red"`, `"highlight"`, `"urgent"` → 400 (`calendar.events[N].accent "..." not supported; must be one of default, positive, negative, warning, info`).
- **Plain-string caption** — `"caption": "hello"` → 400 (`calendar.caption: must be a rich_text array`).
- **Unknown fields on the body** — closed schema. Extra keys like `view`, `default_view`, `start_day`, `week_start`, `holidays`, `legend` are rejected as `unknown field "..."`. Several common confusions are rewritten into actionable hints (see below).

#### Hint rewrites the decoder emits

When the closed-schema decoder encounters a known-confusion field name, the stock `json: unknown field "X"` error is rewritten into an actionable hint pointing at the correct field. Read the response body verbatim — agents should *not* paraphrase these.

On the body (`calendar: { … }`):

- `items` → 400 (`items: unknown field 'items' (did you mean 'events'?)`)
- `entries` → 400 (`entries: unknown field 'entries' (did you mean 'events'?)`)
- `dates` → 400 (`dates: unknown field 'dates' (did you mean 'events'?)`)
- `months` → 400 (`months: unknown field 'months' (single month per block in v1)`)
- `today_date` → 400 (`today_date: unknown field 'today_date' (did you mean 'today'?)`)
- `now` → 400 (`now: unknown field 'now' (did you mean 'today'?)`)

On an event object (`events[i]`):

- `end_date` → 400 (`end_date: unknown field 'end_date' (calendar events are single-date in v1)`)
- `until` → 400 (`until: unknown field 'until' (calendar events are single-date in v1)`)
- `duration` → 400 (`duration: unknown field 'duration' (calendar events are single-date in v1)`)
- `start_time` → 400 (`start_time: unknown field 'start_time' (calendar events are all-day in v1)`)
- `end_time` → 400 (`end_time: unknown field 'end_time' (calendar events are all-day in v1)`)
- `time` → 400 (`time: unknown field 'time' (calendar events are all-day in v1)`)
- `start` → 400 (`start: unknown field 'start' (calendar events are all-day in v1)`)
- `end` → 400 (`end: unknown field 'end' (calendar events are all-day in v1)`)
- `day` → 400 (`day: unknown field 'day' (did you mean 'date'?)`)
- `when` → 400 (`when: unknown field 'when' (did you mean 'date'?)`)
- `summary` → 400 (`summary: unknown field 'summary' (did you mean 'title' or 'description'?)`)
- `name` → 400 (`name: unknown field 'name' (did you mean 'title'?)`)
- `tags` → 400 (`tags: unknown field 'tags' (not supported on calendar events in v1)`)
- `labels` → 400 (`labels: unknown field 'labels' (not supported on calendar events in v1)`)
- `assignee` → 400 (`assignee: unknown field 'assignee' (not supported on calendar events in v1)`)
- `owner` → 400 (`owner: unknown field 'owner' (not supported on calendar events in v1)`)
- `attendees` → 400 (`attendees: unknown field 'attendees' (not supported on calendar events in v1)`)
- `link` → 400 (`link: unknown field 'link' (use description rich_text + link mark)`)
- `url` → 400 (`url: unknown field 'url' (use description rich_text + link mark)`)
- `href` → 400 (`href: unknown field 'href' (use description rich_text + link mark)`)
- `recurring` → 400 (`recurring: unknown field 'recurring' (recurring events not supported in v1)`)
- `rrule` → 400 (`rrule: unknown field 'rrule' (recurring events not supported in v1)`)
- `repeat` → 400 (`repeat: unknown field 'repeat' (recurring events not supported in v1)`)

### `slides`

`slides` renders a vertical stack of slide-flavored content sections — each slide is a framed `<section>` with its own title, optional subtitle, optional accent stripe, and a small body of sub-blocks. Static long-scroll render only — no carousel, no autoplay, no transitions, no JS. The reader scrolls top to bottom through every slide; deep-links land on a specific slide via its auto-derived title anchor.

> **Use `slides` when** the narrative is a sequenced set of framed sections — a board deck, a quarterly review, an onboarding doc with discrete chapters. **Use `tabs` when** the sections are mutually-exclusive and only one panel should be visible at a time. **Use `timeline` when** the sections are chronological events on one axis. **Use `calendar` when** the narrative is a month grid of dated items. For plain prose runs without per-section visual containers and "N / M" cues, use `heading_2` + paragraphs instead.

**Required fields:** `slides` (array, 1–30 entries; each slide requires `title` and `blocks`).

Body fields: `slides` (required, **1–30** entries), `title` (optional plain string), `caption` (optional `rich_text` array).

```json
{
  "type": "slides",
  "slides": {
    "title": "Q3 board deck",
    "slides": [
      {
        "title": "TAM and positioning",
        "subtitle": "Where we play and why",
        "accent": "info",
        "blocks": [
          { "type": "paragraph", "paragraph": { "rich_text": [{ "type": "text", "text": { "content": "$8B addressable, $1.2B serviceable in year 1." } }] } }
        ]
      },
      {
        "title": "Q3 ask",
        "accent": "warning",
        "blocks": [
          { "type": "paragraph", "paragraph": { "rich_text": [{ "type": "text", "text": { "content": "Approve $4M hiring budget." } }] } }
        ]
      }
    ],
    "caption": [{ "type": "text", "text": { "content": "Drafted 2026-05-11." } }]
  }
}
```

| Field     | Required     | Type                   | Cap / notes |
| --------- | ------------ | ---------------------- | ----------- |
| `slides`  | **required** | array of slide objects | **1–30** entries. Empty `[]` rejected (`slides.slides: at least 1 slide required`). Decks longer than 30 slides should be split across multiple `slides` blocks with a `heading_2` between them. |
| `title`   | optional     | plain string           | ≤ **120 runes**. Rendered as `<h3 class="slides-title">` above the deck. NOT `rich_text`. Newlines rejected. |
| `caption` | optional     | `rich_text` array      | 0–100 segments. Rendered as `<figcaption class="slides-caption">` below the deck. Counts toward the global 2000-span budget. |

#### Slide object

Each entry of `slides` is an object with these fields (and nothing else — closed schema):

| Field      | Required     | Type                | Cap / notes |
| ---------- | ------------ | ------------------- | ----------- |
| `title`    | **required** | plain string        | **1–120 runes**, single-line. Rendered as the slide heading with a hover-anchor copy-link. NOT `rich_text`. Newlines rejected. The slug is auto-derived from the title; same-titled slides get a `-2`, `-3`, … suffix on later occurrences. See [URL-fragment navigation](#url-fragment-navigation) for the slug grammar and cross-feature collision rules. |
| `subtitle` | optional     | plain string        | **0–160 runes**, single-line. Rendered as `<p class="slide-subtitle">` below the slide title. NOT `rich_text`. Newlines rejected. |
| `accent`   | optional     | enum                | One of `"default"` / `"positive"` / `"negative"` / `"warning"` / `"info"`. Paints a 4px left-border stripe + 2px top-bar tint on the slide via class `slide-accent-<value>`. **Absent → `default` (no palette cycling — the slide order is the categorical axis already)**. Same 5-value enum as `calendar`, `kanban.columns[].accent`, `stat_grid`, `comparison_matrix`, `timeline`. |
| `is_cover` | optional     | bool                | Default `false`. When `true`, the slide renders with a gradient backdrop and additional vertical padding (class `slide-cover`) — meant for the opening / hero slide of a deck. Cosmetic only; does not change layout rules or sub-block whitelist. Omit or set `false` for ordinary slides. |
| `blocks`   | **required** | array of sub-blocks | **0–12** entries. **Empty array allowed** — a `[]` slide renders as a title-only section-divider. Sub-blocks must be drawn from the 8-type whitelist below. Omit the field → 400 (`slides.slides[i].blocks: required (use [] for a section-divider slide)`). |

#### Allowed sub-block types

`slide.blocks[]` accepts only these 8 sub-block types:

- `paragraph`
- `heading_3`
- `bulleted_list_item`
- `numbered_list_item`
- `quote`
- `callout` — note: `callout.icon` is REQUIRED (see [`callout`](#callout) section).
- `image`
- `code`

> Sub-block types follow the same constraints as their top-level counterparts (see their respective sections). Nesting depth still applies — these count toward the ≤3-level cap.

Everything else is rejected (`block type "<x>" is not allowed inside a slide; allowed types are paragraph, heading_3, bulleted_list_item, numbered_list_item, quote, callout, image, code`). Notable exclusions:

- **`heading_2` excluded** — the slide title is already `<h2>`.
- **`heading_1` excluded** — the deck's block-level `title` is the page-section heading.
- **`slides` excluded (nested)** — rejected with a dedicated message before recursion (`slides.slides[i].blocks[j]: nested slides blocks are not allowed`).
- **`tabs` excluded.**
- **`chart` / `mermaid` / `stat_grid` / `timeline` / `gallery` / `comparison_matrix` / `kanban` / `calendar` excluded** — if you need one of those, emit it at the page level outside the `slides` block with a `heading_2` above it.
- **`table` / `toggle` / `divider` excluded.**

Sub-blocks recurse at depth+1, so the page-wide nesting cap (≤3) still applies. A list with two levels of nesting inside a slide will hit the cap.

#### Caps

| Cap                              | Value |
| -------------------------------- | ----- |
| `slides` count                   | **1–30** per block |
| `slides[i].blocks` count         | **0–12** per slide |
| `title` (block) length           | 0–120 runes |
| `slides[i].title` length         | 1–120 runes |
| `slides[i].subtitle` length      | 0–160 runes |
| `caption` segments               | 0–100 (counts toward global 2000-span budget) |
| nesting depth                    | ≤ 3 (same as every other block) |

(Global payload caps apply — see Top-level payload shape.)

#### Rendering

- When the block-level `title` is supplied it renders as a heading above the slide stack; an optional `caption` renders below the stack.
- Each slide is a framed section showing a position indicator ("N / M") at the top-right, then the slide title, then the subtitle (only when supplied), then the slide body (only when `blocks` is non-empty). A slide with `blocks: []` is a title-only section divider with no body.
- Slide titles are auto-anchored and deep-linkable (hover reveals a `#` copy-link; `/r/<hash>#<slug>` jumps to that slide).

Slide-title slugs share the page-wide anchor namespace with headings and tab-panel ids; collisions get a `-2`, `-3`, … suffix. See [URL-fragment navigation](#url-fragment-navigation) for the slug grammar.

#### Accents

`slide.accent` reuses the universal 5-value enum `default | positive | negative | warning | info` (same as `calendar`, `kanban`, `stat_grid`, `comparison_matrix`, `timeline`). Absent → `default` styling (no palette cycling — the slide index is already a categorical axis). The renderer emits a `slide-accent-<value>` class on the `<section>` for the four non-default values; CSS paints a 4px left-border stripe and a 2px top-bar tint that harmonizes with `chart`, `stat_grid`, `comparison_matrix`, `timeline`, `kanban`, and `calendar` palettes on the same page.

The block-level `title` does NOT carry an accent — only individual slides do.

#### What `slides` is NOT in v1

- **Not a carousel.** No auto-advance, no slide-by-slide switching, no JS. Reader scrolls top-to-bottom through every slide. CSS `:target`-driven panel selection is the `tabs` block, not `slides`.
- **Not nestable.** A `slides` block inside `slides[i].blocks` is rejected (`slides.slides[i].blocks[j]: nested slides blocks are not allowed`). One deck per block.
- **No speaker notes.** `notes` / `speaker_notes` are caught at decode time.
- **No slide backgrounds.** `background` / `background_image` / `bg` / `bg_color` are caught at decode time. For visual emphasis use `accent`; for a header image emit an `image` sub-block as the first entry of `slide.blocks`.
- **No layout switching.** `layout` / `template` / `style` are caught at decode time. Every slide uses the same header + body layout.
- **No transitions or animations.** `transitions` / `transition` / `animation` / `effects` are caught at decode time.
- **No agent-supplied slide IDs or numbers.** Both are auto-derived (title slug + array order). `id` / `slug` / `number` / `index` / `order` are caught at decode time.
- **No interactive containers nested inside slides.** `tabs` and nested `slides` are rejected; other visual blocks (chart, mermaid, stat_grid, timeline, gallery, comparison_matrix, kanban, calendar) are also rejected as sub-blocks — emit them outside the `slides` block.
- **No JS at all.** Static render only.

#### Common rejections

Most slides 400s in practice fall into one of these categories (verbatim error strings the validator emits — agents can pattern-match on them):

- **`slides.slides` missing / empty** — omitted or `[]` → 400 (`slides.slides: at least 1 slide required (got empty array)`).
- **`slides.slides` over the cap** — 31+ slides → 400 (`slides.slides: count N exceeds limit of 30 slides per block`).
- **`title` (block) too long / contains newlines / non-string** — > 120 runes → 400 (`slides.title exceeds 120 runes`); `\r` or `\n` → 400 (`slides.title: must not contain newlines`); non-string → 400 (`slides.title: must be a string`).
- **`slides[i].title` missing / oversized / non-string / contains newline** — empty or omitted → 400 (`slides.slides[N].title: required`); > 120 runes → 400 (`slides.slides[N].title exceeds 120 runes`); newline → 400 (`slides.slides[N].title: must not contain newlines`); non-string → 400 (`slides.slides[N].title: must be a string`).
- **`slides[i].subtitle` oversized / non-string / contains newline** — > 160 runes → 400 (`slides.slides[N].subtitle exceeds 160 runes`); non-string → 400 (`slides.slides[N].subtitle: must be a string`); newline → 400 (`slides.slides[N].subtitle: must not contain newlines`).
- **`slides[i].accent` outside the enum** — `"red"`, `"highlight"`, `"urgent"` → 400 (`slides.slides[N].accent "..." not supported; must be one of default, positive, negative, warning, info`); non-string → 400 (`slides.slides[N].accent: must be a string`).
- **`slides[i].blocks` missing / non-array** — omitted → 400 (`slides.slides[N].blocks: required (use [] for a section-divider slide)`); non-array → 400 (`slides.slides[N].blocks: must be an array`).
- **`slides[i].blocks` over the cap** — 13+ sub-blocks → 400 (`slides.slides[N].blocks: count M exceeds limit of 12 sub-blocks per slide`).
- **Disallowed sub-block type** — anything outside the 8-type whitelist → 400 (`slides.slides[N].blocks[K]: block type "<x>" is not allowed inside a slide; allowed types are paragraph, heading_3, bulleted_list_item, numbered_list_item, quote, callout, image, code`).
- **Nested slides** — a `slides` sub-block inside `slides[i].blocks` → 400 (`slides.slides[N].blocks[K]: nested slides blocks are not allowed`).
- **Plain-string caption** — `"caption": "hello"` → 400 (`slides.caption: must be a rich_text array`).
- **Caption over segment cap** — 101+ segments → 400 (`slides.caption: rich_text array exceeds 100 segments`).
- **Unknown fields on the body or a slide** — closed schema. Several common confusions are rewritten into actionable hints (see below).

#### Hint rewrites the decoder emits

When the closed-schema decoder encounters a known-confusion field name, the stock `json: unknown field "X"` error is rewritten into an actionable hint pointing at the correct field. Read the response body verbatim — agents should *not* paraphrase these.

On the body (`slides: { … }`):

- `items` → 400 (`slides: unknown field "items" (did you mean "slides"?)`)
- `pages` → 400 (`slides: unknown field "pages" (did you mean "slides"?)`)
- `entries` → 400 (`slides: unknown field "entries" (did you mean "slides"?)`)
- `slide_list` → 400 (`slides: unknown field "slide_list" (did you mean "slides"?)`)
- `slide_array` → 400 (`slides: unknown field "slide_array" (did you mean "slides"?)`)
- `slide` → 400 (`slides: unknown field "slide" (did you mean "slides" (plural)? slides is an array of slide objects)`)

On a slide object (`slides[i]`):

- `heading` → 400 (`slides.slides[i]: unknown field "heading" (did you mean "title"?)`)
- `header` → 400 (`slides.slides[i]: unknown field "header" (did you mean "title"?)`)
- `name` → 400 (`slides.slides[i]: unknown field "name" (did you mean "title"?)`)
- `label` → 400 (`slides.slides[i]: unknown field "label" (did you mean "title"?)`)
- `body` → 400 (`slides.slides[i]: unknown field "body" (did you mean "blocks"? slides[i].blocks is an array of sub-blocks)`)
- `content` → 400 (`slides.slides[i]: unknown field "content" (did you mean "blocks"? slides[i].blocks is an array of sub-blocks)`)
- `panels` → 400 (`slides.slides[i]: unknown field "panels" (did you mean "blocks"? slides[i].blocks is an array of sub-blocks)`)
- `children` → 400 (`slides.slides[i]: unknown field "children" (did you mean "blocks"? slides[i].blocks is an array of sub-blocks)`)
- `notes` → 400 (`slides.slides[i]: unknown field "notes" (slides v1 has no notes field; speaker notes are deferred)`)
- `speaker_notes` → 400 (`slides.slides[i]: unknown field "speaker_notes" (slides v1 has no notes field; speaker notes are deferred)`)
- `background` → 400 (`slides.slides[i]: unknown field "background" (slides v1 has no slide.background field; use the accent enum, or put an image block as the first slide.blocks entry)`)
- `background_image` / `bg` / `bg_color` — same `background`-family rewrite as above.
- `layout` → 400 (`slides.slides[i]: unknown field "layout" (slides v1 has no layout field; every slide uses header + body layout)`)
- `template` / `style` — same `layout`-family rewrite as above.
- `transitions` → 400 (`slides.slides[i]: unknown field "transitions" (slides v1 is static; no transitions or animations)`)
- `transition` / `animation` / `effects` — same `transitions`-family rewrite as above.
- `id` → 400 (`slides.slides[i]: unknown field "id" (slide anchors are auto-derived from the title; agent-supplied ids are not accepted)`)
- `slug` — same `id`-family rewrite as above.
- `number` → 400 (`slides.slides[i]: unknown field "number" (slide numbers are auto-derived from array order)`)
- `index` / `order` — same `number`-family rewrite as above.

For a side-by-side grid layout of arbitrary sub-blocks — pricing comparisons, team handbook sections, dashboard panels — use `columns` (the layout-only neighbour of `slides`). For a static world map with lat/lng-positioned marker pins, use `map` — the geographic-narrative neighbour of `slides`.

### `columns`

`columns` renders a side-by-side CSS Grid of 2–4 equal-width columns. Each column holds 0–12 sub-blocks drawn from a 12-type whitelist. Layout primitive only — no per-column title, accent, width, or color in v1. Static long-scroll render — no JS, no resize handles, no agent-controlled gutter or width. On viewports ≤ 600 px the grid collapses to a single column in source order.

> **Use `columns` when** the narrative wants two-to-four arbitrary stacks of content side-by-side — pricing tiers, team-handbook lanes, "engineering / product / design" panels, quarter-summary boards. **Use `comparison_matrix` when** the same fields are compared across same-shape entries (row-major, fixed cell vocabulary — text / number / check / cross / dash). **Use `slides` when** the narrative is a sequenced stack of framed `<section>` units with per-section title and optional accent. **Use `tabs` when** the panels are mutually-exclusive and only one should be visible at a time. **Use `stat_grid` when** the cells are metric tiles (label + value + trend) rather than arbitrary content.

**Required fields:** `columns` (array of column objects, 2–4 entries; each column requires `blocks`).

Body fields: `columns` (required, **2–4** entries), `title` (optional plain string), `caption` (optional `rich_text` array).

```json
{
  "type": "columns",
  "columns": {
    "title": "Plans",
    "columns": [
      {
        "blocks": [
          { "type": "heading_3", "heading_3": { "rich_text": [{ "type": "text", "text": { "content": "Free" } }] } },
          { "type": "paragraph", "paragraph": { "rich_text": [{ "type": "text", "text": { "content": "Try every block type at 60 renders/hour." } }] } }
        ]
      },
      {
        "blocks": [
          { "type": "heading_3", "heading_3": { "rich_text": [{ "type": "text", "text": { "content": "Paid" } }] } },
          { "type": "paragraph", "paragraph": { "rich_text": [{ "type": "text", "text": { "content": "Password-protected slugs, persistent /p/ URLs." } }] } }
        ]
      }
    ],
    "caption": [{ "type": "text", "text": { "content": "Pricing snapshot — see docs for full terms." } }]
  }
}
```

| Field     | Required     | Type                    | Cap / notes |
| --------- | ------------ | ----------------------- | ----------- |
| `columns` | **required** | array of column objects | **2–4** entries. Fewer than 2 rejected (`columns.columns: at least 2 columns required …`); more than 4 rejected (`columns.columns: count <n> exceeds limit of 4 columns per block`). For more variation along an axis, use `comparison_matrix` (row-major) or split across multiple `columns` blocks with a `heading_2` between them. |
| `title`   | optional     | plain string            | ≤ **120 runes**, single-line. Rendered as `<h3 class="columns-title">` above the grid. NOT `rich_text`. Newlines rejected (`columns.title: must not contain newlines`). Picked up by `blockTitle` for OG-image title derivation when this is the only block on the page. |
| `caption` | optional     | `rich_text` array       | 0–100 segments. Rendered as `<figcaption class="columns-caption">` below the grid. Counts toward the global 2000-span budget. |

#### Column object

Each entry of `columns` is an object with exactly one field (closed schema):

| Field    | Required     | Type                | Cap / notes |
| -------- | ------------ | ------------------- | ----------- |
| `blocks` | **required** | array of sub-blocks | **0–12** entries. **Empty `[]` is legal** — renders as an empty `<div class="columns-column"></div>` for layout staging (a one-third gutter column next to a two-thirds content column, breathing-room layouts). Omit the field → 400 (`columns.columns[i].blocks: required (use [] for an empty layout column)`). Non-array → 400 (`columns.columns[i].blocks: must be an array`). Sub-blocks must be drawn from the 12-type whitelist below. |

No per-column `title`, `accent`, `width`, `flex`, `color`, `id`, or `align` field in v1 — columns is a **layout primitive**. To label a column, emit a `heading_3` as the first sub-block. To draw the reader's eye to one column's content, use a `callout` or `stat_grid` sub-block inside it. For asymmetric widths, use `tabs` (single-panel selection) or `comparison_matrix` (row-major same-shape grid) instead.

#### Allowed sub-block types

`columns.columns[i].blocks[]` accepts only these 12 sub-block types:

- `paragraph`
- `heading_2`
- `heading_3`
- `bulleted_list_item`
- `numbered_list_item`
- `quote`
- `callout` — note: `callout.icon` is REQUIRED (see [`callout`](#callout) section).
- `image` — note: `image.url` accepts both external `https://` URLs and `asset_id` values (no special restriction for column placement; see [`image`](#image) for the universal SSRF rules).
- `code`
- `divider`
- `stat_grid` — the inner `repeat(auto-fit, minmax(180px, 1fr))` grid already collapses gracefully inside a narrow column.
- `toggle` — `default_open: true` honored as on a top-level toggle.

> Sub-block types follow the same constraints as their top-level counterparts (see their respective sections). Nesting depth still applies — these count toward the ≤ 3-level cap.

Everything else is rejected with the canonical hint (`block type "<x>" is not allowed inside a column; allowed types are paragraph, heading_2, heading_3, bulleted_list_item, numbered_list_item, quote, callout, image, code, divider, stat_grid, toggle`). Notable exclusions:

- **`columns` excluded (nested)** — rejected with a dedicated message BEFORE the whitelist check (`columns.columns[i].blocks[j]: nested columns blocks are not allowed`). One columns wrapper per nesting level.
- **`heading_1` excluded** — too loud inside a column; would visually dwarf the columns wrapper's `<h3>` title.
- **`tabs` / `slides` excluded** — interactive container metaphors and narrative containers don't compose inside a layout primitive.
- **`chart` / `mermaid` / `gallery` / `comparison_matrix` / `kanban` / `calendar` / `map` / `timeline` excluded** — minimum-width concerns. Charts have fixed aspect ratios; mermaid SVGs scale poorly in narrow columns; gallery / comparison_matrix / kanban / calendar / map / timeline all carry their own grid or fixed-width primitives that overflow inside a 250 px-wide column. If you need one of those, emit it at the page level outside the `columns` wrapper (optionally with a `heading_2` above it).
- **`table` excluded** — tables are already `display: block; overflow-x: auto`; a horizontally-scrolling table inside a narrow column is confusing UX.

Sub-blocks recurse at depth+1, so the page-wide nesting cap (≤ 3) still applies. A `toggle` whose `children` contain another nested container inside a column hits the cap.

#### Caps

| Cap                                    | Value |
| -------------------------------------- | ----- |
| `columns` count                        | **2–4** per block |
| `columns[i].blocks` count              | **0–12** per column |
| `title` (block) length                 | 0–120 runes |
| `caption` segments                     | 0–100 (counts toward global 2000-span budget) |
| nesting depth                          | ≤ 3 (same as every other block; nested `columns` rejected before this rule fires) |
| gutter / column width                  | not agent-controllable — 1 rem gutter, equal `1fr` widths |

(Global payload caps apply — see Top-level payload shape.)

#### Rendering

- When `title` is supplied it renders as an auto-anchored heading above the grid; an optional `caption` renders below.
- The columns render as an equal-width grid (2, 3, or 4 columns). There is no per-column id, accent, or width.
- Sub-blocks render inside each column exactly as they would at the page level: a `heading_3` is auto-anchored; a `callout` carries its required `icon`; an `image` participates in the universal SSRF + size policies.

On viewports ≤ 600 px all columns stack in source order — column[0] on top, column[N-1] on bottom. The reading order at any viewport (including for screen readers) is column[0]'s sub-blocks top-to-bottom, then column[1]'s, etc.

#### What `columns` is NOT in v1

- **No agent-controlled widths.** All columns are equal-width `1fr`. `width` / `flex` / `weight` / `span` / `size` / `colspan` on a column object are caught at decode time. For asymmetric layout use `tabs` (single-panel selection) or `comparison_matrix` (row-major same-shape grid).
- **No per-column accent.** `accent` / `color` / `tone` / `highlight` are caught at decode time. The 5-value accent enum (`default | positive | negative | warning | info`) lives at the sub-block level — to tint a column, put an accented `callout`, `stat_grid`, or `comparison_matrix` inside it.
- **No per-column title.** `title` / `name` / `label` / `header` / `heading` on a column object are caught at decode time. Emit a `heading_3` as the first entry of `columns[i].blocks` instead.
- **Not nestable.** A `columns` block inside `columns[i].blocks` is rejected with a dedicated message before the whitelist check.
- **No agent-controlled gutter.** The 1 rem gutter matches `stat_grid`, `gallery-grid`, `kanban`, and `tabs-strip` for visual consistency across mixed-block pages. `gap` / `gutter` / `spacing` on the body or column are caught at decode time.
- **No alignment / vertical centering.** `align` / `justify` / `vertical_align` on a column object are caught at decode time. Columns stretch to the height of the tallest column (CSS Grid `auto` row height).
- **No agent-supplied column ids.** `id` / `slug` on a column object are caught at decode time. Per-column sub-block `heading_2` / `heading_3` continue to auto-anchor normally.
- **No JS, no transitions, no animations, no drag-drop, no resize handles.** Static render only.

#### Common rejections

Most `columns` 400s in practice fall into one of these categories (verbatim error strings the validator emits — agents can pattern-match on them):

- **`columns.columns` missing / under-cap / over-cap** — fewer than 2 entries → 400 (`columns.columns: at least 2 columns required (got <n>)`); more than 4 → 400 (`columns.columns: count <n> exceeds limit of 4 columns per block`).
- **`columns[i].blocks` missing / non-array / over-cap** — omitted → 400 (`columns.columns[i].blocks: required (use [] for an empty layout column)`); non-array → 400 (`columns.columns[i].blocks: must be an array`); 13+ sub-blocks → 400 (`columns.columns[i].blocks: count <n> exceeds limit of 12 sub-blocks per column`).
- **`title` (block) too long / contains newlines / non-string** — > 120 runes → 400 (`columns.title exceeds 120 runes`); `\r` or `\n` → 400 (`columns.title: must not contain newlines`); non-string → 400 (`columns.title: must be a string`).
- **`caption` non-array / over-cap** — `"caption": "hello"` → 400 (`columns.caption: must be a rich_text array`); 101+ segments → 400 (`columns.caption: rich_text array exceeds 100 segments`).
- **Disallowed sub-block type** — anything outside the 12-type whitelist → 400 (`columns.columns[i].blocks[j]: block type "<x>" is not allowed inside a column; allowed types are paragraph, heading_2, heading_3, bulleted_list_item, numbered_list_item, quote, callout, image, code, divider, stat_grid, toggle`).
- **Nested columns** — a `columns` sub-block inside `columns[i].blocks` → 400 (`columns.columns[i].blocks[j]: nested columns blocks are not allowed`).
- **Unknown fields on the body or a column** — closed schema. Several common confusions are rewritten into actionable hints (see below).
- **Universal sub-block failures** — `callout` missing `icon`, `image` failing SSRF/size, `code` exceeding rune cap, etc. — surface their canonical errors prefixed with the `columns.columns[i].blocks[j]:` path.

#### Hint rewrites the decoder emits

When the closed-schema decoder encounters a known-confusion field name, the stock `json: unknown field "X"` error is rewritten into an actionable hint pointing at the correct field. Read the response body verbatim — agents should *not* paraphrase these.

On the body (`columns: { … }`):

- `cols` → 400 (`columns: unknown field "cols" (did you mean "columns"?)`)
- `column` → 400 (`columns: unknown field "column" (did you mean "columns" (plural)?)`)
- `grid` → 400 (`columns: unknown field "grid" (did you mean "columns"?)`)
- `rows` → 400 (`columns: unknown field "rows" (did you mean "columns"? this block lays out side-by-side columns, not rows)`)
- `layout` → 400 (`columns: unknown field "layout" (columns v1 has no layout field; the array name is "columns")`)
- `panes` → 400 (`columns: unknown field "panes" (did you mean "columns"?)`)
- `splits` → 400 (`columns: unknown field "splits" (did you mean "columns"?)`)
- `flex` → 400 (`columns: unknown field "flex" (columns v1 are equal-width and use CSS Grid; the array name is "columns")`)
- `slots` → 400 (`columns: unknown field "slots" (did you mean "columns"?)`)
- `sections` → 400 (`columns: unknown field "sections" (did you mean "columns"? or use slides for sectioned narrative)`)
- `items` / `content` / `children` → 400 (`columns: unknown field "<name>" (did you mean "columns"?)`)

On a column object (`columns[i]`):

- `width` / `flex` / `weight` / `span` / `size` / `colspan` → 400 (`columns.columns[i]: unknown field "<name>" (columns v1 are equal-width; agent-supplied widths are not accepted — for asymmetric layout use tabs or comparison_matrix)`)
- `accent` / `color` / `tone` / `highlight` → 400 (`columns.columns[i]: unknown field "<name>" (columns v1 has no per-column accent (layout-only); use a callout or stat_grid sub-block to highlight content)`)
- `title` / `name` / `label` / `header` / `heading` → 400 (`columns.columns[i]: unknown field "<name>" (columns v1 has no per-column title; emit a heading_3 sub-block as the first entry of blocks instead)`)
- `content` / `children` / `body` / `panel` / `items` → 400 (`columns.columns[i]: unknown field "<name>" (did you mean "blocks"? columns[i].blocks is an array of sub-blocks)`)
- `align` / `justify` / `vertical_align` → 400 (`columns.columns[i]: unknown field "<name>" (columns v1 has no alignment field; columns stretch to equal height)`)
- `id` → 400 (`columns.columns[i]: unknown field "id" (columns v1 has no per-column id; sub-block headings auto-derive anchors)`)

For a static world map with lat/lng-positioned marker pins (engineering offices, customer cities, conference locations, trip itineraries), use `map` — the geographic-narrative neighbour of `columns`.

### `map`

`map` renders a static world map with lat/lng-positioned marker pins. The base map is bundled (equirectangular projection); there is NO live tile fetching, NO call to MapBox / OpenStreetMap / Google Maps, NO geocoding endpoint, NO JS. Static long-scroll render only — no zoom, no pan, no hover state, no click handlers.

> **Use `map` when** the narrative is geographic — engineering offices, customer cities, supplier locations, trip itinerary, conference cities, country-of-origin breakdown. **Use `image` / `gallery` when** the visual is a single static map image without lat/lng pin positioning. **Use `chart` with `chart_type: "scatter"` when** the axes are arbitrary numbers, not lat/lng. **Use `timeline` when** the axis is chronological rather than spatial.

**Required fields:** `markers` (array, **1–50** entries; each marker requires `lat`, `lng`, `label`).

Body fields: `markers` (required, 1–50 entries), `title` (optional plain string), `caption` (optional `rich_text` array).

```json
{
  "type": "map",
  "map": {
    "title": "Engineering offices",
    "markers": [
      { "lat": 37.7749, "lng": -122.4194, "label": "San Francisco HQ" },
      { "lat": 52.5200, "lng": 13.4050,   "label": "Berlin office" },
      { "lat": 35.6762, "lng": 139.6503,  "label": "Tokyo office" }
    ],
    "caption": [{ "type": "text", "text": { "content": "Drafted 2026-05-12." } }]
  }
}
```

| Field     | Required     | Type                    | Cap / notes |
| --------- | ------------ | ----------------------- | ----------- |
| `markers` | **required** | array of marker objects | **1–50** entries. Empty `[]` rejected (`map.markers: at least 1 marker required (got empty array)`). Decks needing more than 50 markers should be split across multiple `map` blocks with a `heading_2` between them. |
| `title`   | optional     | plain string            | ≤ **120 runes**. Rendered as `<h3 class="map-title">` above the map, with a phase-6.1 hover-anchor link. NOT `rich_text`. Newlines rejected. |
| `caption` | optional     | `rich_text` array       | 0–100 segments. Rendered as `<figcaption class="map-caption">` below the map. Counts toward the global 2000-span budget. |

#### Marker object

Each entry of `markers` is an object with these three required fields (and nothing else — closed schema):

| Field   | Required     | Type         | Cap / notes |
| ------- | ------------ | ------------ | ----------- |
| `lat`   | **required** | number       | Latitude in degrees, WGS84 (positive = north). **`-90` ≤ `lat` ≤ `90`** inclusive. NaN / ±Inf rejected. The integer `0` is legal (the equator passes through it). |
| `lng`   | **required** | number       | Longitude in degrees, WGS84 (positive = east). **`-180` ≤ `lng` ≤ `180`** inclusive. NaN / ±Inf rejected. The integer `0` is legal (the prime meridian passes through it). |
| `label` | **required** | plain string | **1–80 runes**, single-line. Rendered as one `<li>` in the `<ol class="map-markers-list">` below the SVG and as one of the comma-separated labels in the SVG `<desc>`. NOT `rich_text`. Newlines rejected. Empty string rejected. |

#### Caps

| Cap                              | Value |
| -------------------------------- | ----- |
| `markers` count                  | **1–50** per block |
| `markers[i].lat`                 | **−90 to 90** inclusive (NaN / ±Inf rejected) |
| `markers[i].lng`                 | **−180 to 180** inclusive (NaN / ±Inf rejected) |
| `markers[i].label` length        | **1–80** runes, single-line |
| `title` (block) length           | 0–120 runes, single-line |
| `caption` segments               | 0–100 (counts toward global 2000-span budget) |

(Global payload caps apply — see Top-level payload shape.)

#### Projection

The map uses a fixed equirectangular projection that distorts area near the poles (Greenland and Antarctica look larger than they are). There is no projection-override field in v1.

#### Rendering

- The block renders as a responsive world map. When `title` is present it renders as an auto-anchored heading above the map; an optional `caption` renders below.
- Markers render as identical pins at their projected lat/lng positions, in agent order.
- An auto-generated screen-reader description names the markers (capped at 30; past that it reads `… (K markers total)`).
- Below the map, a decimal-numbered `<ol>` lists one entry per marker, in agent order (2-column on desktop, 1-column on mobile). This list is the authoritative reading order for screen readers and text-only browsers.

There is NO in-SVG text rendering — **labels never appear inside the SVG; the only way to learn what a marker means is to read the numbered list below it.**

#### What `map` is NOT in v1

- **Not interactive.** No zoom, no pan, no hover-state tooltips, no click handlers, no JS. The image is fixed; the reader's only verb is "scroll".
- **No live tile fetching.** mira does not connect to MapBox, OpenStreetMap, Google Maps, or any tile server. The bundled `world.svg` is the entire base layer.
- **No marker variation.** `marker.accent` / `marker.color` / `marker.icon` / `marker.asset_id` / `marker.description` / `marker.title` / `marker.note` are all caught at decode time with hint rewrites. All markers render identically — the same `--chart-c0` concentric-circle pin. For multi-category visuals, split the markers across multiple `map` blocks (one per category) and surround each with a `heading_3`.
- **No region focus.** `region` / `bounds` / `zoom` / `center` / `projection` are caught at decode time. The view is always whole-world equirectangular. If markers cluster in one region, that's already legible from the marker positions in the bundled-world view.
- **No size override.** `width` / `height` / `aspect` are caught at decode time. The map has a fixed 2:1 aspect and scales responsively to the page width.
- **No polylines or routes.** A `map` block is markers only, not arrows-between-markers. For a chronological-ordered itinerary, surround the `map` with a `timeline` (the `timeline` carries the order; the `map` carries the geography).
- **No clustering, jittering, deduping, or rounding.** Overlapping markers render at the same projected pixel in agent order. NaN / ±Inf are rejected before the range check; any other valid float in range is accepted as-is.
- **No agent-supplied marker IDs or numbers.** Marker order is the array order; there are no per-marker anchors in v1. `id` / `slug` on a marker are caught at decode time.

#### Failure modes to avoid

Most map 400s in practice fall into one of these categories (verbatim error strings the validator emits — agents can pattern-match on them):

- **`map.markers` missing / empty** — omitted or `[]` → 400 (`map.markers: at least 1 marker required (got empty array)`).
- **`map.markers` over the cap** — 51+ markers → 400 (`map.markers: count N exceeds limit of 50 markers per block`).
- **`title` (block) too long / contains newlines / non-string** — > 120 runes → 400 (`map.title exceeds 120 runes`); `\r` or `\n` → 400 (`map.title: must not contain newlines`); non-string → 400 (`map.title: must be a string`).
- **`markers[i].lat` missing / out of range / non-number** — omitted → 400 (`map.markers[N].lat: required`); non-number → 400 (`map.markers[N].lat: must be a number`); `NaN` / `Inf` → 400 (`map.markers[N].lat: must be a finite number`); `95` → 400 (`map.markers[N].lat: must be between -90 and 90 (got 95)`); `-91` → 400 (`map.markers[N].lat: must be between -90 and 90 (got -91)`).
- **`markers[i].lng` missing / out of range / non-number** — omitted → 400 (`map.markers[N].lng: required`); non-number → 400 (`map.markers[N].lng: must be a number`); `NaN` / `Inf` → 400 (`map.markers[N].lng: must be a finite number`); `200` → 400 (`map.markers[N].lng: must be between -180 and 180 (got 200)`); `-200` → 400 (`map.markers[N].lng: must be between -180 and 180 (got -200)`).
- **`markers[i].label` missing / empty / oversized / contains newline / non-string** — omitted → 400 (`map.markers[N].label: required`); empty `""` → 400 (`map.markers[N].label: must not be empty`); > 80 runes → 400 (`map.markers[N].label exceeds 80 runes`); newline → 400 (`map.markers[N].label: must not contain newlines`); non-string → 400 (`map.markers[N].label: must be a string`).
- **Plain-string caption** — `"caption": "hello"` → 400 (`map.caption: must be a rich_text array`).
- **Caption over segment cap** — 101+ segments → 400 (`map.caption: rich_text array exceeds 100 segments`).
- **Unknown fields on the body or a marker** — closed schema. Several common confusions are rewritten into actionable hints (see below).

#### Hint rewrites the decoder emits

When the closed-schema decoder encounters a known-confusion field name, the stock `json: unknown field "X"` error is rewritten into an actionable hint pointing at the correct field. Read the response body verbatim — agents should *not* paraphrase these.

On the body (`map: { … }`):

- `items` → 400 (`map: unknown field "items" (did you mean "markers"?)`)
- `pins` → 400 (`map: unknown field "pins" (did you mean "markers"?)`)
- `points` → 400 (`map: unknown field "points" (did you mean "markers"?)`)
- `locations` → 400 (`map: unknown field "locations" (did you mean "markers"?)`)
- `coordinates` → 400 (`map: unknown field "coordinates" (did you mean "markers"?)`)
- `region` / `bounds` / `zoom` / `center` / `projection` → 400 (`map: unknown field "<x>" (map v1 has no region / bounds / zoom / center / projection fields; the map is always whole-world equirectangular)`)
- `width` / `height` / `aspect` → 400 (`map: unknown field "<x>" (map v1 has no width / height / aspect fields; the map is fixed 1200x600 with responsive scaling via CSS)`)

On a marker object (`markers[i]`):

- `title` → 400 (`map.markers[i]: unknown field "title" (did you mean "label"?)`)
- `name` → 400 (`map.markers[i]: unknown field "name" (did you mean "label"?)`)
- `latitude` → 400 (`map.markers[i]: unknown field "latitude" (did you mean "lat"? the short form is required)`)
- `longitude` → 400 (`map.markers[i]: unknown field "longitude" (did you mean "lng"? the short form is required)`)
- `coords` / `lat_lng` / `location` → 400 (`map.markers[i]: unknown field "<x>" (coordinates are top-level fields lat and lng on the marker, not nested under <x>)`)
- `accent` / `color` → 400 (`map.markers[i]: unknown field "<x>" (map v1 has no marker.accent field; all markers render in the same color)`)
- `description` / `note` → 400 (`map.markers[i]: unknown field "<x>" (map v1 has no marker.description field; emit a surrounding paragraph block for context)`)
- `icon` / `asset_id` → 400 (`map.markers[i]: unknown field "<x>" (map v1 has no marker.icon field; all markers render as a circle pin)`)
- `id` / `slug` → 400 (`map.markers[i]: unknown field "<x>" (marker anchors are not supported in v1)`)

#### Accessibility

`map` carries four redundant a11y channels so screen readers, text-only browsers, and search engines all get the data:

1. **`role="img"`** on the `<svg>` — names it as an image rather than a generic SVG fragment.
2. **`aria-labelledby`** on the `<svg>` (and the wrapping `<figure>`) — points at the block-level `<h3>` title slug when a title is present.
3. **`<desc>`** child of the `<svg>` — short auto-generated text "World map with K markers: Label 1, Label 2, …" capped at 30 labels.
4. **`<ol class="map-markers-list">`** below the SVG — the authoritative reading channel, decimal-numbered, in agent order, never truncated.

When `title` is absent, channels 2 collapses (no `aria-labelledby`); channels 1, 3, 4 still carry the description. For non-trivial maps, provide a `title` so screen-reader users get a human-named anchor.

### `gallery`

`gallery` renders a responsive collection of images as a grid or masonry layout with optional captions, optional CSS-only fullscreen lightbox, and optional per-image outbound links. It is the right block for product galleries, photo essays, before/after comparisons, portfolios, and any "here is a collection of images" payload. For a single hero image with a caption, use the `image` block — a one-image gallery is degenerate. For a carousel or slideshow, mira has no JS-driven equivalent; stack multiple images or queue a future `slides` block.

> **Asset-id only.** Unlike the `image` block, gallery accepts ONLY `asset_id` values returned by `POST /v1/assets` — external `https://` URLs are rejected. Upload each photo first, collect the returned ids, then POST the gallery payload with those ids. For external https images use the `image` block (one block per image).

**Required fields:** `images` (each image requires `asset_id` and `alt`).

Body fields: `images` (required, **1–50** entries), `title` (optional plain string), `caption` (optional `rich_text` array), `layout` (optional enum), `aspect_ratio` (optional enum), `density` (optional enum), `lightbox` (optional boolean), `accessibility` (optional object).

| Field           | Required | Type                | Cap / notes |
| --------------- | -------- | ------------------- | ----------- |
| `images`        | **required** | array of image objects | **1–50** entries. Empty or oversized returns 400. |
| `title`         | optional | plain string        | ≤ 120 runes. Rendered as `<h4>` above the grid. NOT `rich_text`. Newlines rejected. |
| `caption`       | optional | `rich_text` array   | 0–100 segments. Rendered as `<figcaption>` below the grid. Counts toward the global 2000-span budget. |
| `layout`        | optional | enum                | One of `"grid"` (default) / `"masonry"`. `grid` lays out uniform tiles in a responsive grid (240 px minimum tile width). `masonry` is a multi-column layout that preserves native aspect ratios. |
| `aspect_ratio`  | optional | enum                | One of `"auto"` (default) / `"16:9"` / `"4:3"` / `"1:1"`. Body-level only — there is no per-image override. `auto` lets each tile render at its native image aspect (best for photo essays + masonry). The three fixed ratios crop tiles via `object-fit: cover`. |
| `density`       | optional | enum                | One of `"comfortable"` (default) / `"compact"`. `compact` tightens the inter-tile gap — useful for >12-image grids. |
| `lightbox`      | optional | boolean             | `false` (default) → each tile is a passive `<figure>` (or an `<a href="...">` if the image carries a `link`). `true` → each tile becomes an `<a href="#g{seq}-img-{i}">` that opens a fullscreen CSS-only overlay. Mutually exclusive with `link.url` (see below). |
| `accessibility.description` | optional | plain string | ≤ 500 runes. Becomes the `aria-label` on the outer `<figure class="gallery ...">`. Use this for non-trivial galleries — a one-sentence hand-authored summary names the gallery's purpose better than the auto-generated `"Gallery with N images"` fallback. |

> `layout` × `images.length` coupling: `masonry` renders best with N≥3 (column-count layout needs at least 3 tiles to read as masonry); `grid` reads cleanly with N≥1.

#### Image object

Each entry of `images` is an object with these fields:

| Field          | Required | Type                | Cap / notes |
| -------------- | -------- | ------------------- | ----------- |
| `asset_id`     | **required** | string          | Crockford base32 lowercase, **20–32 chars**, matching the `id` returned by `POST /v1/assets`. Mira does NOT fetch external `https://` URLs from a gallery block — every image must be a pre-uploaded asset. See "Asset reference contract" below. |
| `alt`          | **required** | plain string    | **1–250 runes**. Newlines rejected. Required on every image (empty `""` rejected) — gallery has no notion of "decorative" images, so screen-reader users always get the description. For decorative images, use the `image` block, not gallery. |
| `caption`      | optional | `rich_text` array   | 0–100 segments. Rendered as `<figcaption>` directly below the image. Counts toward the global 2000-span budget. |
| `link.url`     | optional | string              | `https://` or `mailto:` only, ≤ 2048 chars — same allowlist as `rich_text` links. When set, the tile is wrapped in an `<a href="...">` opening the link in the same tab. Mutually exclusive with `lightbox: true` on the body (sending both returns 400). |

#### Caps

| Cap                                       | Value |
| ----------------------------------------- | ----- |
| `images` count                            | 1–50 |
| `alt` length (per image, required)        | 1–250 runes |
| `asset_id` length                         | 20–32 chars (Crockford base32 lowercase) |
| `title` length                            | 0–120 runes |
| `accessibility.description` length        | 0–500 runes |
| per-image `caption` segments              | 0–100 (counts toward global 2000-span budget) |
| body-level `caption` segments             | 0–100 (counts toward global 2000-span budget) |
| `link.url` length                         | ≤ 2048 chars (https or mailto only) |
| automatic skip-link                       | rendered when `images.length ≥ 20` |
| minimum tile width (grid layout)          | 240 px (responsive auto-fill columns) |

(Global payload caps apply — see Top-level payload shape.)

#### Constraints at a glance

Three rules collide for big galleries — plan up front, because the validator surfaces them piecemeal:

- **50-image hard cap.** `images` is 1–50; the 51st returns 400. For larger collections, split across multiple gallery blocks with a `heading_2` between them.
- **Asset-id only — no external URLs.** Every `asset_id` must be a hash returned by a prior `POST /v1/assets` (subject to the **100/h per-IP** assets rate limit). A 50-image gallery means 50 asset uploads before the render POST is reachable; budget half your hourly upload quota per render.
- **`link.url` and `lightbox: true` are mutually exclusive.** Per gallery, you pick navigation OR fullscreen view, never both. Setting `lightbox: true` while any image carries `link.url` returns 400. If you want both behaviours in one page, emit two adjacent gallery blocks.

#### Asset reference contract

Gallery references images by mira-hosted **asset id**, not by external URL. Before POSTing a gallery payload, upload each image to `POST /v1/assets` and collect the returned `id` values. The id is a 20–32-char Crockford base32 lowercase hash (e.g. `j0a4z3rp...`); it is the same id format used by the `image` block's `mira.cagdas.io/asset/<id>` URLs.

**Mira does NOT fetch external `https://` URLs from a gallery block.** This is intentional — the `image` block's SSRF-safe fetch pipeline is per-image and serial; running it across 50 gallery images would balloon both the per-payload (20 MB) and per-host (10/min) budgets. Gallery is asset-id-only.

For external images, use the `image` block instead — one image per block, fetched at POST time, then referenced as a same-origin `https://mira.cagdas.io/asset/<id>` URL in the stored render.

Common rejections in this space (verbatim error strings from the validator):

- Unknown asset id: `gallery.images[i].asset_id: no such asset (use the id returned by POST /v1/assets)`
- External `https://` URL in `asset_id`: `gallery.images[i].asset_id: invalid asset id (gallery accepts only ids returned by POST /v1/assets — for external images use the image block)`
- Malformed asset id (wrong charset, wrong length): `gallery.images[i].asset_id: invalid asset id`

#### Layout modes

- **`grid` (default).** Uniform tiles (240 px minimum width) in a responsive grid; tile count per row scales with viewport. Reading order is left-to-right, top-to-bottom — natural for sequential collections.
- **`masonry`.** A multi-column layout that preserves native aspect ratios (best paired with `aspect_ratio: "auto"`). **Reading order is column-by-column — top-down within each column, then the next column.** This surprises agents writing chronological photo essays; if the order matters left-to-right by row, use `grid` instead.

#### Aspect ratio

The four values and what they mean visually:

- `auto` (default). Each tile renders at its native image aspect ratio. Best for photo essays and masonry — the photographer's framing is preserved.
- `16:9`. Tiles cropped to 16:9 landscape via `object-fit: cover`. Best for video stills, app screenshots, hero shots.
- `4:3`. Classic frame ratio. Best for product photography and editorial shots.
- `1:1`. Square. Best for Instagram-grid aesthetics, before/after pairs, headshot collections.

Cropping is `object-fit: cover` — the center of the image is kept, the edges are cropped. There is no `object-position` knob; if a fixed ratio crops important content, use `auto` or supply a pre-cropped asset.

#### Lightbox

`lightbox: true` turns each tile into a click-target that opens a fullscreen overlay. The overlay is **pure CSS** via `:target` — no JS:

- Click a tile → URL fragment becomes `#g<seq>-img-<i>`, the matching `<figure id="g<seq>-img-<i>">` overlay activates.
- Click the dark backdrop or the `✕` close button → URL fragment becomes `#`, overlay deactivates.
- Click the `‹` prev or `›` next chevron → fragment advances to the prev/next image. **Wraps around** at the first/last image (image 1's prev goes to image N; image N's next goes to image 1).

Lightbox behaviour:

- **No Esc-to-close.** Touch/click the backdrop or close button.
- **No swipe gestures.** Mobile users tap chevrons.
- **No focus trap.** The overlay's anchors are reachable by Tab and are not auto-focused. Screen-reader users navigating with arrow keys can still reach the rest of the page; the lightbox is an enhancement, not a modal.

`link.url` on an image and `lightbox: true` on the body are mutually exclusive — sending both returns 400 with `gallery.images[i]: link.url and lightbox are mutually exclusive`. Pick one: a tile is either a lightbox toggle OR an outbound link, never both.

#### Accessibility

- **`alt` is required on every image** — 1–250 runes, empty `""` rejected. Gallery has no "decorative image" lane; for decorative images, use the `image` block (which accepts empty `alt`).
- `accessibility.description` (≤ 500 runes) becomes the `aria-label` on the outer `<figure class="gallery ...">`. When absent but `title` is set, the label falls back to `"Gallery — <title>"`; otherwise, `"Gallery with N images"`.
- The rendered `<div class="gallery-grid-wrap">` carries `role="list"` and each tile carries `role="listitem"`, so AT can announce "list with N items" and step through them predictably.
- Every `<img>` carries `loading="lazy"` so 50-image galleries don't bottleneck above-the-fold paint. No `width`/`height` attributes — mira does not sniff image dimensions in v1.
- When `images.length ≥ 20`, a `<a class="skip-gallery sr-only-focusable" href="#after-gallery-{seq}">Skip past gallery</a>` is rendered before the figure and a matching anchor after, so screen-reader users can jump past long galleries.
- The lightbox overlays' navigation anchors (`prev`/`next`/`close`) carry `aria-label` text describing each action; the backdrop and chevron arrows themselves are not focus traps.

#### Common rejections

Most gallery 400s in practice fall into one of these categories:

- **`images` empty or > 50** — `images: []` → 400 (`gallery.images: must contain at least 1 image`); 51+ entries → 400 (`gallery.images: too many images (cap is 50)`). For more than 50 images, split across multiple gallery blocks with a `heading_2` between them.
- **`layout` outside the 2-value enum** — `"carousel"`, `"slideshow"`, `"mosaic"` → 400 (`gallery.layout: must be one of [grid, masonry]`). mira has no carousel block; queue future `slides` block for deck-style content.
- **`aspect_ratio` outside the 4-value enum** — `"3:2"`, `"21:9"`, `"portrait"` → 400 (`gallery.aspect_ratio: must be one of [auto, 16:9, 4:3, 1:1]`).
- **`density` outside the 2-value enum** — `"tight"`, `"spacious"` → 400 (`gallery.density: must be one of [comfortable, compact]`).
- **`asset_id` not a hash** — `"not-a-hash"`, `"ABCDEF"` (uppercase rejected), too short or too long → 400 (`gallery.images[i].asset_id: invalid asset id`).
- **`asset_id` valid format but unknown** — id matches the hash regex but has not been uploaded → 400 (`gallery.images[i].asset_id: no such asset (use the id returned by POST /v1/assets)`).
- **`asset_id` is an external URL** — `"https://example.com/photo.jpg"` → 400 (`gallery.images[i].asset_id: invalid asset id (gallery accepts only ids returned by POST /v1/assets — for external images use the image block)`).
- **`alt` empty or whitespace-only** — `"alt": ""`, `"alt": "   "` → 400 (`gallery.images[i].alt: alt text is required`). Empty alt is intentionally rejected on gallery; use the `image` block for decorative images.
- **`alt` exceeds 250 runes** → 400 (`gallery.images[i].alt exceeds 250 runes`).
- **`link.url` + `lightbox: true` on the same gallery** → 400 (`gallery.images[i]: link.url and lightbox are mutually exclusive`). Pick one.
- **`link.url` scheme not in allowlist** — `http://`, `javascript:`, `file://`, custom schemes → 400 (`gallery.images[i].link.url: scheme "..." not allowed; only https and mailto`).
- **Plain string caption** (per image or body) — `"caption": "hello"` → 400 (`gallery.caption: must be a rich_text array` or `gallery.images[i].caption: must be a rich_text array`). Captions are `rich_text` arrays everywhere in mira, not bare strings.
- **`accessibility` as a string** — `"accessibility": "an accessible gallery"` → 400 (`gallery.accessibility: must be an object`). The field is the object `{ "description": "..." }`, not a bare string.
- **Non-string value in any string-typed field** — `title: 42`, `layout: ["x"]`, `asset_id: 42`, `alt: {object}` → 400 (`<field>: must be a string`). Non-boolean for `lightbox` → 400 (`gallery.lightbox: must be a boolean`). The validator never leaks Go struct names.
- **Notion wrapper fields** on the body or any image — `"object": "block"`, `"id": "..."`, `"parent": {...}` → 400 (closed schema). Strip them before POSTing.
- **Unknown top-level keys on the `gallery` body or any image** — closed schema. Extra fields like `width`, `height`, `link` on the body, `lightbox` on an image, `overlay`, `caption_position` are rejected.

#### What `gallery` is NOT

Documenting scope boundaries:

- **NOT a slideshow.** Deck-style, one-image-per-panel rendering with auto-advance or arrow-key navigation needs JS; queued as a future `slides` block.
- **NOT a single hero image.** Use the `image` block — a one-image gallery is degenerate.
- **NOT an infinite-scroll feed.** No infinite scroll, no "load more" button, no JS-driven progressive loading. The cap is 50; for more, emit multiple gallery blocks.
- **NOT a carousel.** Carousels need JS to be useful (auto-advance, dot indicators, swipe). CSS-only `scroll-snap` carousels are jank on desktop; skipped.
- **NOT a fetcher of external URLs.** Gallery references only mira-hosted asset ids. For external images, use the `image` block.
- **NOT user-uploadable from the rendered page.** Read-only — mira has no client-side upload affordance.
- **NOT a way to embed videos.** Gallery only renders the four image content types accepted by `POST /v1/assets` (png/jpeg/webp/gif). For video, use the dedicated [`video`](#video) block.

### `video`

`video` renders a single embedded video from a whitelisted provider (YouTube or Vimeo) as a privacy-respecting `<iframe>` inside a responsive aspect-ratio frame. The base embed URL is provider-pinned (`youtube-nocookie.com` for YouTube, `player.vimeo.com` for Vimeo) so cookies are deferred until the user presses Play. Static long-scroll render only — there is no autoplay, no playlist, no live, no self-host, no custom poster, no JS, no agent-controlled playback knobs. Mira passes through a single optional `?start=<sec>` (parsed from the agent's `?t=42` / `?t=42s` / `?start=42` query) and nothing else.

> **Use `video` when** you have a single hosted clip on YouTube or Vimeo and want it inline in the page narrative — a product demo, a recorded talk, a tutorial step. **Use `gallery` when** the content is a collection of still images, not motion. **Use a plain link** (a `rich_text` `link.url`) when the user should leave mira to watch on the source platform — `video` is for in-page playback, not external referrals.

**Required fields:** `url` (https, YouTube or Vimeo, one of the accepted forms below).

Body fields: `url` (required), `title` (optional plain string), `caption` (optional `rich_text` array), `aspect_ratio` (optional enum, default `"16:9"`).

```json
{
  "type": "video",
  "video": {
    "url": "https://www.youtube.com/watch?v=dQw4w9WgXcQ",
    "title": "Quarterly all-hands recording",
    "caption": [{ "type": "text", "text": { "content": "Recorded 2026-05-12 — internal use only." } }],
    "aspect_ratio": "16:9"
  }
}
```

| Field          | Required     | Type              | Cap / notes |
| -------------- | ------------ | ----------------- | ----------- |
| `url`          | **required** | string            | ≤ **2048 chars**. Must be `https://`. Host must be one of the 5 whitelisted hosts (see "Accepted URL forms" below). EXACT host match — `www.youtube.com.evil.com` and other suffix-prefixed hostnames are rejected. Mira parses the provider video id out of the URL and constructs the privacy-pinned embed URL — agents do NOT supply the embed URL directly. |
| `title`        | optional     | plain string      | ≤ **120 runes**, single-line. NOT `rich_text`. Newlines rejected (`video.title: must not contain newlines`). When supplied: rendered as an auto-anchored heading above the frame. Always used as the iframe `title="…"` attribute regardless of whether the heading renders. |
| `caption`      | optional     | `rich_text` array | 0–100 segments. Rendered as `<figcaption class="video-caption">` below the frame. Counts toward the global 2000-span budget. |
| `aspect_ratio` | optional     | enum              | One of `"16:9"` (default) / `"4:3"` / `"9:16"`. Drives the CSS `aspect-ratio` property on the inner `.video-frame` wrapper. `16:9` is the standard YouTube / Vimeo landscape ratio. `4:3` is the classic pre-widescreen ratio (older talks, conference recordings). `9:16` is mobile-portrait (YouTube Shorts, TikTok-style clips); the frame is capped at a `360px` max-width and centered so a phone-shaped video doesn't dominate the desktop column. |

#### Provider whitelist

Only YouTube and Vimeo are accepted in v1. Other providers (Loom, Wistia, Dailymotion, Twitch, archive.org, self-hosted MP4s, IPFS, etc.) are rejected with `provider "<host>" not in v1 whitelist; supported providers are YouTube (youtu.be, www.youtube.com, m.youtube.com) and Vimeo (vimeo.com, player.vimeo.com)`. The provider whitelist is intentionally narrow. If you need a provider that's not on this list, file feedback via `POST /v1/feedback`.

#### Accepted URL forms

The agent supplies the canonical "share" URL — the same URL the user would paste from the browser address bar. Mira extracts the video id and constructs the privacy-pinned embed URL internally. The host MUST match one of these exactly:

| Provider | Accepted URL form                                | Notes |
| -------- | ------------------------------------------------ | ----- |
| YouTube  | `https://youtu.be/<id>`                          | The short share URL. `<id>` is 11 chars matching `[A-Za-z0-9_-]`. |
| YouTube  | `https://www.youtube.com/watch?v=<id>`           | The standard watch URL. |
| YouTube  | `https://www.youtube.com/embed/<id>`             | The embed URL — mira re-pins to `youtube-nocookie.com`. |
| YouTube  | `https://www.youtube.com/shorts/<id>`            | YouTube Shorts. Pair with `aspect_ratio: "9:16"` for the natural portrait frame. |
| YouTube  | `https://www.youtube.com/v/<id>`                 | Legacy embed path; accepted for compatibility. |
| YouTube  | `https://m.youtube.com/watch?v=<id>`             | Mobile-host variant of `watch`. |
| YouTube  | `https://m.youtube.com/v/<id>`                   | Mobile-host variant of `/v/`. |
| Vimeo    | `https://vimeo.com/<id>`                         | The standard Vimeo URL. `<id>` is 6–12 digits. |
| Vimeo    | `https://player.vimeo.com/video/<id>`            | The player URL. |

`http://` (non-TLS) → 400 (`video.url: scheme "http" not allowed; video URLs must be https`). Bare `youtube.com` (no `www.`) → 400 with a `did-you-mean "www.youtube.com"` hint. Playlist URLs (`/playlist?list=…`) → 400 (`youtube playlist URLs are not supported; pass the watch URL of a single video instead`). Live URLs (`/live/…`) → 400 (`youtube live URLs are not supported in v1; use a regular watch URL`). Bare video ids (no scheme/host) → 400 via the `id`-family hint rewrite (see below).

**Start-time passthrough.** Mira honours an optional start offset encoded in the URL query — `?t=42`, `?t=42s` (YouTube convention with trailing `s`), or `?start=42` (numeric). Values must be non-negative integers ≤ **86400** seconds (24 h). The validated offset is re-emitted onto the embed URL as `?start=<int>`. Out-of-range or non-integer values → 400 (`start time query t=…` / `start=…` not recognized or out of range). Everything else on the query (`feature=`, `ab_channel=`, `fbclid=`, `mute=`, `loop=`, etc.) is dropped — only `t` / `start` survive to the embed URL.

#### Caps

| Cap                             | Value |
| ------------------------------- | ----- |
| iframes per `video` block       | **1** |
| `url` length                    | ≤ **2048 chars** (https only) |
| `title` length                  | 0–120 runes |
| `caption` segments              | 0–100 (counts toward global 2000-span budget) |
| `aspect_ratio` enum             | `"16:9"` (default) / `"4:3"` / `"9:16"` |
| start-time passthrough          | 0–86400 integer seconds (24 h cap) |

(Global payload caps apply — see Top-level payload shape.)

#### Rendering

- When `title` is supplied, it renders as an auto-anchored heading above the frame (so `/r/<hash>#<slug>` deep-links to the video heading).
- The frame scales proportionally to the available width per the `aspect_ratio`. The `9:16` (portrait) variant is capped at 360 px wide and centred so a phone-shaped video doesn't dominate the desktop column.
- The embedded `<iframe>` is lazy-loaded and permits fullscreen / picture-in-picture (those affordances come from the provider's player).
- An optional `caption` renders below the frame; rich-text marks render exactly as elsewhere.

The iframe `title` is required by WCAG 2.4.1 (every iframe MUST carry a non-empty `title`). When `video.title` is absent, mira falls back to the provider-specific synthetic `"YouTube video"` or `"Vimeo video"` so the attribute is never empty.

Only `youtube-nocookie.com` and `player.vimeo.com` frames load, and only on pages that legitimately contain a `video` block — an agent cannot smuggle an arbitrary `<iframe src="…">` onto a mira page.

#### What `video` is NOT in v1

- **NOT a generic iframe.** Only the two whitelisted providers (YouTube + Vimeo) render. Loom, Wistia, Dailymotion, Twitch, archive.org, self-hosted MP4s, IPFS, peertube — all rejected with `provider "<host>" not in v1 whitelist`.
- **NOT autoplay.** Autoplay is forced off; playback is user-initiated. `autoplay: true` on the body is caught at decode time.
- **NOT a playlist.** YouTube `/playlist?list=…` URLs are rejected; pass the watch URL of a single video instead. For a "watch these in order" experience, emit multiple `video` blocks with `heading_2` between them.
- **NOT a livestream.** YouTube `/live/<id>` URLs are rejected — VOD watch URLs only.
- **NOT a self-hosted MP4.** Mira does not accept `.mp4` / `.webm` / `.mov` URLs — there is no `<video src="…">` codepath.
- **NOT a custom poster image.** v1 uses the provider's native thumbnail. `poster` / `thumbnail` fields on the body are caught at decode time.
- **NOT a picture-in-picture toggle.** The PiP affordance is provided by the embedded player itself (via the `allow="picture-in-picture"` permission); mira does not surface an agent-controlled toggle. `pip` / `picture_in_picture` fields on the body are not accepted.
- **NOT agent-controllable playback knobs.** `mute`, `loop`, `controls`, `playsinline`, `cc_load_policy`, `quality`, `volume` — all caught at decode time. Mira lets the provider's player handle defaults; the only state-bearing passthrough is the `?start=<sec>` offset.

#### Common rejections

Most `video` 400s in practice fall into one of these categories (verbatim error strings the validator emits — agents can pattern-match on them):

- **`url` missing or non-string** — omitted → 400 (`video.url: required`); non-string → 400 (`video.url: must be a string`).
- **`url` too long** — > 2048 chars → 400 (`video.url exceeds 2048 chars`).
- **`url` scheme not https** — `http://…` → 400 (`video.url: scheme "http" not allowed; video URLs must be https`); custom schemes (`ftp://`, `data:`, `javascript:`) → same shape.
- **`url` host not whitelisted** — `loom.com/share/…`, `wistia.com/…`, etc. → 400 (`video.url: provider "<host>" not in v1 whitelist; supported providers are YouTube (youtu.be, www.youtube.com, m.youtube.com) and Vimeo (vimeo.com, player.vimeo.com)`).
- **Bare `youtube.com`** (no `www.`) → 400 (`video.url: host "youtube.com" not allowed; use "www.youtube.com" (e.g. https://www.youtube.com/watch?v=<id>)`).
- **YouTube playlist URL** — `/playlist?list=…` → 400 (`video.url: youtube playlist URLs are not supported; pass the watch URL of a single video instead`).
- **YouTube live URL** — `/live/…` → 400 (`video.url: youtube live URLs are not supported in v1; use a regular watch URL`).
- **YouTube path not recognised** — paths outside `/watch`, `/embed/<id>`, `/shorts/<id>`, `/v/<id>` → 400 (`video.url: youtube URL path "<path>" not accepted; supported paths: /watch?v=<id>, /embed/<id>, /shorts/<id>, /v/<id> (or youtu.be/<id>)`).
- **YouTube `watch` missing `?v=`** → 400 (`video.url: youtube watch URL missing ?v=<id> query parameter`).
- **Malformed YouTube id** (10 chars, 12 chars, non-charset character) → 400 (`video.url: youtube provider-id "<id>" malformed; expected 11 chars matching [A-Za-z0-9_-]`).
- **Malformed Vimeo id** (fewer than 6 or more than 12 digits, non-digit characters) → 400 (`video.url: vimeo provider-id "<id>" malformed; expected 6-12 digits`).
- **Vimeo player path not `/video/`** → 400 (`video.url: vimeo player URL path "<path>" not accepted; supported path: /video/<id>`).
- **`title` non-string / too long / contains newlines** → 400 (`video.title: must be a string` / `video.title exceeds 120 runes` / `video.title: must not contain newlines`).
- **`aspect_ratio` outside the 3-value enum** — `"1:1"`, `"21:9"`, `"4:5"` → 400 (`video.aspect_ratio "<value>" not supported; must be one of "16:9", "4:3", "9:16"`).
- **`aspect_ratio` colon-vs-x format slip** — `"16x9"`, `"4x3"`, `"9x16"` → 400 (`video.aspect_ratio "<value>" not supported; use "16:9" with a colon (one of "16:9", "4:3", "9:16")`).
- **`caption` non-array** — `"caption": "hello"` → 400 (`video.caption: must be a rich_text array`).
- **`caption` over-cap** — 101+ segments → 400 (`video.caption: rich_text array exceeds 100 segments`).
- **Start-time out of range** — `?t=99999` → 400 (`video.url: start time query t="99999" out of range; must be 0-86400 seconds`); `?start=foo` → 400 (`start=…` not recognized).
- **Unknown body keys** — closed schema. Several common confusions are rewritten into actionable hints (see below).

#### Hint rewrites the decoder emits

When the closed-schema decoder encounters a known-confusion body-level field name, the stock `json: unknown field "X"` error is rewritten into an actionable hint pointing at the correct field. Read the response body verbatim — agents should *not* paraphrase these.

- `src` → 400 (`video: unknown field "src" (did you mean "url"?)`)
- `embed` → 400 (`video: unknown field "embed" (did you mean "url"? mira builds the embed URL from the watch URL)`)
- `href` → 400 (`video: unknown field "href" (did you mean "url"?)`)
- `autoplay` / `mute` / `loop` / `controls` / `playsinline` → 400 (`video: unknown field "<name>" (playback knobs are not agent-controlled in v1)`)
- `width` / `height` → 400 (`video: unknown field "<name>" (use aspect_ratio enum "16:9" | "4:3" | "9:16")`)
- `provider` → 400 (`video: unknown field "provider" (provider is inferred from the url host)`)
- `source` → 400 (`video: unknown field "source" (provider is inferred from the url host; did you mean "url"?)`)
- `host` → 400 (`video: unknown field "host" (provider is inferred from the url host)`)
- `id` / `youtube_id` / `vimeo_id` / `video_id` → 400 (`video: unknown field "<name>" (use the full url; mira extracts the id)`)
- `ratio` / `aspectRatio` → 400 (`video: unknown field "<name>" (did you mean "aspect_ratio"?)`)
- `poster` → 400 (`video: unknown field "poster" (v1 does not support custom poster images)`)
- `thumbnail` → 400 (`video: unknown field "thumbnail" (v1 does not support custom thumbnail images)`)
- `start` → 400 (`video: unknown field "start" (encode start time in the url as ?t=<sec> or ?start=<sec>)`)

#### Privacy notes

Mira pins both providers' embed URLs to their privacy-respecting origins:

- **YouTube → `youtube-nocookie.com`**. The "privacy-enhanced" mode YouTube documents — no tracking cookies are set on the user's browser until they press Play. The video metadata (title, thumbnail) loads cookie-free.
- **Vimeo → `player.vimeo.com`**. The player origin Vimeo recommends for embeds. Vimeo's analytics cookies are deferred until the user interacts with the player.

This means a user opening a mira page with an unwatched video does NOT have YouTube / Vimeo tracking cookies set on their browser. Only these two privacy origins can load as the embed; any attempt to point the iframe at a non-privacy host is blocked.

### `network`

`network` renders a node-and-edge graph (org chart, dependency tree, telecom topology, service map, etc.) by delegating to **topokit.io** — a sister product purpose-built for interactive graph visualisation. Mira accepts a topokit JSON config inside a wrapper, proxies it to topokit's API to mint a content-addressed hash, then renders either a static PNG (default) or an opt-in interactive iframe at `/r/<hash>`. The topokit hash is stored alongside the mira hash and reused on every subsequent read — there is no per-read upstream call.

> **Use `network` when** you need to show relationships between named entities: services, people, files, hosts, accounts, sessions. **Use `chart` when** the data is quantitative (bars, lines, pie). **Use `mermaid` when** you want a simple flowchart or sequence diagram described in ~10 lines of text (mermaid is text-authored; network is structurally-authored).

**Required fields:** `topokit_config` (object, opaque to mira).

Body fields: `topokit_config` (required), `interactive` (optional bool, default `false`), `title` (optional plain string), `caption` (optional `rich_text` array), `alt` (optional plain string — mira generates a default when absent).

```json
{
  "type": "network",
  "network": {
    "topokit_config": {
      "nodes": [ { "id": "a", "data": {}, "style": { "label": { "text": "A" } } } ],
      "edges": [],
      "layout": "force",
      "theme": "dark"
    },
    "interactive": false,
    "title": "Service map",
    "alt": "Force-directed graph of 1 node."
  }
}
```

| Field | Required | Type | Cap / notes |
|---|---|---|---|
| `topokit_config` | **required** | object | The full topokit topo config. Mira does NOT validate the inner shape — topokit's living-schema policy means unknown fields are silently ignored. **Read https://topokit.io/docs/data-format for the canonical schema** (required `nodes` + `edges`; optional `layout`, `theme`, UI toggles). **Read https://topokit.io/docs/ai-instructions for the agent step-by-step.** Sample configs at https://topokit.io/samples/{org-chart,network,tree,dependency,telecom}.json. Mira POSTs this object verbatim to `https://topokit.io/api/topos`; the returned hash is stored as `topokit_hash` on the render. |
| `interactive` | optional | bool, default `false` | `false` → mira renders `<img src="https://topokit.io/t/<topokit_hash>.png">` (1200×630, immutable, no UI chrome). `true` → mira renders `<iframe src="https://topokit.io/t/<topokit_hash>?embed">` (pan, zoom, hover, minimap, search — see topokit docs for the full UI surface). |
| `title` | optional | plain string | ≤ **120 runes**, single-line. NOT `rich_text`. When supplied: rendered as an auto-anchored heading above the figure. Always used as the iframe `title="…"` attribute when `interactive: true`. |
| `caption` | optional | `rich_text` array | 0–100 segments. Rendered as a caption below the figure. Counts toward the global 2000-span budget. |
| `alt` | optional | plain string | ≤ **240 runes**. Used as `<img alt="…">` in static mode (WCAG 1.1.1) and as the iframe `title=` fallback when `title` is absent (WCAG 2.4.1). When absent, mira emits the default fallback `"Network graph (TopoKit)"` so screen readers always get a content-bearing label — agent-supplied values always win. |

`topokit_hash` appears in the render JSON on read but **must not be set by the agent on POST**. Mira assigns it from the topokit upstream response and rejects agent-supplied values (400, `network.topokit_hash: must not be set by agent`).

**Upstream errors** map to: 400 (topokit rejected the config) → mira 400 with topokit's error message prefixed; topokit 5xx or 401/403/429 → mira 502; mira's 10 s upstream timeout → mira 504.

When `interactive: true`, the graph renders inside a sandboxed iframe that loads same-origin topokit.io content; it cannot escape its frame, open popups, submit forms, or trigger downloads.

(Global payload caps apply — see Top-level payload shape.)

### `comparison_matrix`

`comparison_matrix` renders a feature × option grid as a semantic `<table>` with sticky first column, themed check / cross / dash glyphs, a 5-value accent enum on individual cells AND on rows, optional featured-column emphasis, and an optional caption. It is the right block for pricing-tier comparisons, framework feature matrices, vendor RFP evaluations, before/after migration tables, and anywhere a 2-D side-by-side comparison reads more cleanly than a flat `table` of cells. The DOM is a real `<table>` — screen readers get grid navigation for free.

> **Use it when** you have **2–8 fixed columns of comparable options** (plans, vendors, frameworks, before/after) and want symbolic glyphs, per-cell semantic accent, an optional featured column, and pinned row labels. For general tabular data with no comparison semantics, use the `table` block. For an at-a-glance row of KPI numbers without comparison axes, use `stat_grid`.

**Required fields:** `columns`, `rows` (each column requires `label`; each row requires `label` and `cells`).

Body fields: `columns` (required, **2–8** entries), `rows` (required, **1–30** entries), `title` (optional plain string), `row_label_header` (optional plain string), `caption` (optional `rich_text` array), `density` (optional enum), `accessibility` (optional object).

| Field           | Required | Type                | Cap / notes |
| --------------- | -------- | ------------------- | ----------- |
| `columns`       | **required** | array of column objects | **2–8** entries. Defines the comparison axes (typically products, plans, vendors). A 1-column "comparison" is degenerate — for a single-value display use `callout` or `stat_grid`. |
| `rows`          | **required** | array of row objects    | **1–30** entries. Each row has a `label` (the feature being compared) and `cells` matching `columns.length` one-to-one. For >30 rows, the comparison has crossed into data-exploration territory — use the `table` block. |
| `title`         | optional | plain string        | ≤ 120 runes. Rendered as `<h4>` above the table. NOT `rich_text`. Newlines rejected. |
| `row_label_header` | optional | plain string     | ≤ 60 runes. Rendered as the `<th class="comparison-corner">` in the top-left "corner" cell above the row labels. Typical values: `"Feature"`, `"Criterion"`, `"Service"`. Empty / absent = corner cell is empty. Newlines rejected. |
| `caption`       | optional | `rich_text` array   | 0–100 segments. Rendered as `<figcaption>` below the table. Counts toward the global 2000-span budget. |
| `density`       | optional | enum                | One of `"comfortable"` (default) / `"compact"`. `compact` tightens row padding — choose it for matrices with >20 rows or limited vertical real estate. |
| `accessibility.description` | optional | plain string | ≤ 500 runes. Becomes the `aria-label` on the outer `<figure class="comparison-matrix ...">`. Use this for non-trivial matrices — a one-sentence hand-authored summary names what the comparison is about better than the auto-generated `<caption>` fallback. |

#### Column object

Each entry of `columns` is an object with these fields:

| Field      | Required | Type           | Cap / notes |
| ---------- | -------- | -------------- | ----------- |
| `label`    | **required** | plain string | **1–60 runes**. Rendered in the `<th scope="col">`. NOT `rich_text`. Newlines rejected. |
| `subtitle` | optional | plain string   | 0–80 runes. Rendered as a secondary line under `label` in the header — useful for the `"$29/mo"` or `"v3.45"` qualifier. NOT `rich_text`. Newlines rejected. |
| `featured` | optional | boolean        | Default `false`. When `true`, the column renders a vertical stripe (border + subtle background tint) running the full height of the table. **At most ONE column may be featured per matrix** — multiple → 400. |

#### Row object

Each entry of `rows` is an object with these fields:

| Field    | Required | Type                   | Cap / notes |
| -------- | -------- | ---------------------- | ----------- |
| `label`  | **required** | plain string       | **1–120 runes**. Rendered in the `<th scope="row">` of that row. NOT `rich_text` — row labels are short factual descriptors of the feature being compared. Newlines rejected. |
| `cells`  | **required** | array of cell values | Length **MUST EQUAL** `columns.length` (one-to-one mapping). Each entry follows the cell-shape rules below. |
| `accent` | optional | enum                   | One of `"default"` / `"positive"` / `"negative"` / `"warning"` / `"info"`. Paints a tint across the whole row via a `row-accent-{accent}` class. Useful for "this dimension favors X" or "this row improved end-to-end" signals. Co-exists with per-cell accents (per-cell wins where set). |

#### Cell shape — four forms

Each entry in a row's `cells` array can take one of four shapes — the validator dispatches on the first non-whitespace byte of the cell's JSON:

1. **String shortcut for a glyph** — exact lowercase keywords `"check"`, `"cross"`, `"dash"`. Render as themed glyphs:
   - `"check"` → green check (`--chart-c2`), `aria-label="included"`.
   - `"cross"` → red/warn cross (`--chart-c1`), `aria-label="not included"`.
   - `"dash"` → muted en-dash (`--mira-muted`), `aria-label="not applicable"`.
   The match is **case-sensitive**: `"CHECK"`, `"Yes"`, `"✓"`, `"no"` render as plain text, NOT glyphs. Only the three exact keywords above hit the glyph fast path.
2. **Plain text string** — any other string (e.g. `"5 GB"`, `"$0/mo"`, `"Unlimited"`). Rendered as text inside the `<td>`, HTML-escaped. Empty string `""` allowed — renders a blank cell (no class, no text). Newlines rejected (cell content is single-line).
3. **`rich_text` array** — 1–20 segments. Standard mira `rich_text` shape, supports inline marks and `https`/`mailto` links. Empty array `[]` rejected (use `""` for blank, `"dash"` for N/A).
4. **Object form** — `{"value": rich_text-array, "accent": enum}`. `value` is a 1–20-segment `rich_text` array (required); `accent` is optional and follows the same 5-value enum as row accent. Use the object form when a cell needs its own accent tint — e.g. `{"value": [...$99.95%...], "accent": "positive"}` to paint the SLA win.

`null`, numbers, booleans, and arrays-of-non-segments all reject with `comparison_matrix.rows[i].cells[j]: must be a string, rich_text array, or cell object`.

#### Caps

| Cap                                       | Value |
| ----------------------------------------- | ----- |
| `columns` count                           | 2–8 |
| `rows` count                              | 1–30 |
| `columns[i].label` length                 | 1–60 runes |
| `columns[i].subtitle` length              | 0–80 runes |
| `rows[i].label` length                    | 1–120 runes |
| cell `rich_text` segments (per cell)      | 1–20 segments |
| `title` length                            | 0–120 runes |
| `row_label_header` length                 | 0–60 runes |
| `accessibility.description` length        | 0–500 runes |
| block-level `caption` segments            | 0–100 segments (counts toward global 2000-span budget) |
| at-most-one featured column               | 1 (multiple → 400) |
| automatic skip-link                       | rendered when `rows.length ≥ 10` |
| nesting depth                             | ≤ 3 (same as every other block) |

(Global payload caps apply — see Top-level payload shape.)

#### Featured column

At most **one** column per matrix may have `featured: true`. The featured column renders with:

- A `comparison-featured` class on the `<th scope="col">` header **AND** on every `<td>` at the same column index in every body row (including symbol-cell `<td>`s — the class composes).
- A vertical stripe (border on the left + subtle background tint) painted via CSS, running the full height of the table.

Multiple `featured: true` columns return `comparison_matrix.columns: only one column may be featured (got N)`. If you need to emphasize two columns, pick the most-recommended one as featured and let the rest read naturally.

#### Sticky first column

The first column (the row-label column) is **always sticky**. There is no opt-out — the comparison table triggers horizontal scroll when the viewport drops below ~720 px, and the row label is the only way to know what each cell is comparing.

#### Glyph palette

Reserved cell strings render as themed glyphs:

| Keyword   | Glyph | Color | aria-label         |
| --------- | ----- | ----- | ------------------ |
| `"check"` | ✓     | green | `included`         |
| `"cross"` | ✗     | red   | `not included`     |
| `"dash"`  | –     | gray  | `not applicable`   |

All three colors are theme-aware — they render correctly in light and dark schemes with no schema change required.

#### Accents

In comparison_matrix context, `positive` means "this cell wins this dimension of comparison" (best price, fastest, best support, etc.); `negative` means "this cell loses this dimension." This is distinct from the same enum's meaning in stat_grid, where `positive`/`negative` reflect trend direction (revenue up vs. churn up). When in doubt, pick the meaning that matches your intent — both are valid.

The 5-value accent enum (`default | positive | negative | warning | info`) is the same vocabulary used by `stat_grid` and `timeline`. It paints a subtle tint on the affected element:

- **Per-cell accent.** Only available on cells in **object form**: `{"value": [...], "accent": "positive"}`. `default` (or absent) emits no tint.
- **Per-row accent.** Set on the row: `{"label": "...", "cells": [...], "accent": "warning"}`. `default` (or absent) emits no tint.

If both apply to the same cell, the per-cell tint wins visually. Tints harmonize with chart, stat_grid, and timeline colors on the same page.

#### Density

- **`"comfortable"`** (default). 0.75 rem 1 rem cell padding. Best for matrices with ≤ 20 rows where vertical real estate is generous.
- **`"compact"`**. 0.4 rem 0.6 rem cell padding. Best for >20-row matrices, framework-feature-style comparisons with many short cells, or when paired with a long page where vertical compression matters.

#### Accessibility

- **Semantic `<table>`.** Renders as a real table so screen readers can navigate the grid with their existing table affordances (arrow-key cell-to-cell, jump to header, announce row + column context for each cell).
- **`accessibility.description` ≤ 500 runes** becomes the figure's accessible label. When absent, the label falls back to `title`; when both are absent, no label is emitted.
- **Auto-generated sr-only caption** describes the comparison axes: `"<title> comparison: <col 1>, <col 2>, ... across N features"` (or `"Comparison: ..."` when `title` is absent) — visually hidden, audible to screen readers — providing context before the first row is announced.
- **Symbol cells carry an accessible label** describing their semantic value (`"included"` / `"not included"` / `"not applicable"`) so screen-reader users hear meaningful text instead of an empty cell or a decorative glyph.
- **Color is never the only signal.** Row and cell accents are decorative tints layered on top of the existing text; removing them leaves the content fully legible.
- **Skip-link.** When `rows.length ≥ 10`, a "Skip past comparison table" link is rendered before the figure with a matching anchor after it, so screen-reader users can jump past long matrices.

#### Common rejections

Most comparison_matrix 400s in practice fall into one of these categories (verbatim error strings from the validator):

- **`columns` too small** — 0 or 1 column → 400 (`comparison_matrix.columns must contain at least 2 columns; for a single-value display use the callout or stat_grid block`).
- **`columns` too large** — 9+ columns → 400 (`comparison_matrix.columns length 9 exceeds limit of 8`). Beyond 8 the matrix is unreadable on desktop and unusable on mobile.
- **`rows` empty** — `"rows": []` → 400 (`comparison_matrix.rows must contain at least 1 row`).
- **`rows` too large** — 31+ rows → 400 (`comparison_matrix.rows length 31 exceeds limit of 30; for large data tables use the table block`).
- **`cells.length` doesn't match `columns.length`** — row of 3 cells on a 4-column matrix → 400 (`comparison_matrix.rows[i].cells: length M does not match columns length N`). Row-major coherence is enforced before any cell is decoded.
- **Multiple featured columns** — two columns with `featured: true` → 400 (`comparison_matrix.columns: only one column may be featured (got 2)`).
- **Non-string / non-array / non-object cell** — a number `42`, a boolean `true`, or `null` in any cell → 400 (`comparison_matrix.rows[i].cells[j]: must be a string, rich_text array, or cell object`).
- **Object-form cell missing `value`** — `{"accent": "positive"}` with no `value` → 400 (`comparison_matrix.rows[i].cells[j]: cell object missing required "value" field`).
- **Object-form cell with plain-string `value`** — `{"value": "5 GB", "accent": "positive"}` → 400 (`comparison_matrix.rows[i].cells[j].value: must be a rich_text array`). Object-form cells take `rich_text` arrays, not bare strings.
- **`rich_text` array cell that is empty** — `"cells": [...[]...]` → 400 (`comparison_matrix.rows[i].cells[j]: rich_text array cannot be empty (use "" for blank cell or "dash" for N/A)`).
- **`rich_text` array cell over 20 segments** → 400 (`comparison_matrix.rows[i].cells[j]: rich_text array exceeds 20 segments`). Cells are short by design.
- **`column.label` missing / empty / oversized** — `""` or omitted → 400 (`comparison_matrix.columns[i].label: required`); >60 runes → 400 (`comparison_matrix.columns[i].label exceeds 60 runes`).
- **`column.subtitle` oversized** — >80 runes → 400 (`comparison_matrix.columns[i].subtitle exceeds 80 runes`).
- **`row.label` missing / empty / oversized** — `""` or omitted → 400 (`comparison_matrix.rows[i].label: required`); >120 runes → 400 (`comparison_matrix.rows[i].label exceeds 120 runes`).
- **`row.accent` outside the enum** — `"red"`, `"good"`, `"highlight"` → 400 (`comparison_matrix.rows[i].accent "..." not supported; must be one of "default", "positive", "negative", "warning", "info"`).
- **Object-form cell `accent` outside the enum** — same shape → 400 (`comparison_matrix.rows[i].cells[j].accent "..." not supported; must be one of "default", "positive", "negative", "warning", "info"`).
- **`density` outside the enum** — `"normal"`, `"dense"`, `"loose"` → 400 (`comparison_matrix.density "..." not supported; must be one of "comfortable", "compact"`).
- **Non-string in any plain-string field** — `title: 42`, `row.label: ["x"]`, `column.label: {object}`, `accessibility.description: true` → 400 (`<field>: must be a string`). Non-boolean for `column.featured` → 400 (`comparison_matrix.columns[i].featured: must be a boolean`). The validator never leaks Go struct names.
- **Plain-string caption** — `"caption": "hello"` → 400 (`comparison_matrix.caption: must be a rich_text array`). Captions are `rich_text` arrays everywhere in mira, not bare strings.
- **`accessibility` as a string** — `"accessibility": "an accessible matrix"` → 400 (`comparison_matrix.accessibility: must be an object`). The field is the object `{"description": "..."}`, not a bare string.
- **Newlines in any plain-string field** — `title`, `row_label_header`, `column.label`, `column.subtitle`, `row.label`, or any plain-text cell containing `\r` or `\n` → 400 (`<field>: must not contain newlines`). Cell content is single-line.
- **Unknown top-level keys on the body, any column, any row, or any cell object** — closed schema. Extra fields like `sort_order`, `palette`, `aspect_ratio`, `link` on a row, `width` on a column, `tooltip` on a cell are rejected.

#### Order preservation

`comparison_matrix` **does not sort**. Rows render in the order the agent emitted them; columns render in the order they appear in `columns`. There is no `sort_order` field, no auto-alphabetization, no "highlight first / featured first" reordering. If the comparison's narrative depends on order (e.g. "Free → Pro → Team → Enterprise" reading left-to-right), the agent owns that ordering. Same guarantee as `stat_grid`, `gallery`, and pie/donut chart slices.

#### What `comparison_matrix` is NOT

Documenting scope boundaries:

- **NOT a sortable / filterable data grid.** No `sort_order`, no per-column sort directives, no user-clickable sort headers. For sortable views of bulk data, use the `table` block.
- **NOT a fluid masonry layout.** A comparison matrix is a fixed grid with N columns × M rows. For collections of mixed-shape items, use `gallery`.
- **NOT a single-axis ranking.** A 1-column "comparison" is degenerate (it's just a list of values). Use `stat_grid` for a row of KPI numbers, or `callout` for a single emphasized value.
- **NOT a spreadsheet.** No formulas, no merged cells, no rowspan/colspan, no inline editing on the rendered page. Above 30 rows the matrix has crossed into data-territory; use `table`.
- **NOT a mobile-stacked-card-collapse layout in v1.** On narrow viewports the matrix horizontally scrolls with a sticky first column — the row label stays visible while the user scans columns. Stacked-card responsive collapse may land in a future minor; sticky-first-column covers the common case cleanly.

### `kanban`

`kanban` renders 2–6 named columns of same-shape cards. Use it for sprint-status boards, hiring funnels, feature-rollout phases, or anywhere a workflow snapshot is the right visual. Two modes:

- **Read-only** (default, `editable: false` or absent) — static HTML+CSS render with no JS, no interactivity beyond what plain CSS gives.
- **Editable** (`editable: true`) — viewers can rename cards inline, add cards per column, remove cards, and drag-reorder cards within and across columns. Auto-saves via the [overwrite-hash](#editing-renders) mechanism.

It is the right block when the narrative is *cards grouped by phase* (read-only) or *cards the user is moving through phases* (editable).

> **Use `kanban` when** the same items take well-defined buckets and the reader needs to scan column-by-column. **Use `comparison_matrix` when** the items take a fixed *row × column* grid (one cell per intersection). **Use `timeline` when** events are ordered by time and the chronology is the point. **Use `stat_grid` when** the items are independent KPI numbers without a shared grouping axis.

**Required fields:** `columns` (each column requires `name` and `cards`; each card requires `title`).

Body fields: `columns` (required, **2–6** entries), `title` (optional plain string), `caption` (optional `rich_text` array), `editable` (optional bool, default `false`).

```json
{
  "type": "kanban",
  "kanban": {
    "title": "Sprint 24 status",
    "columns": [
      {
        "name": "Todo",
        "cards": [
          { "title": "Stripe webhook signature verification", "tags": ["backend"], "assignee": "Ada" }
        ]
      },
      {
        "name": "Doing",
        "accent": "info",
        "cards": [
          { "title": "Onboarding checklist v2", "description": [{ "type": "text", "text": { "content": "Five-step flow; copy approved." } }], "tags": ["frontend", "design"], "assignee": "Ben" }
        ]
      },
      {
        "name": "Done",
        "accent": "positive",
        "cards": [
          { "title": "Postgres 16 upgrade", "tags": ["infra"], "assignee": "Cleo" }
        ]
      }
    ]
  }
}
```

| Field     | Required     | Type                 | Cap / notes |
| --------- | ------------ | -------------------- | ----------- |
| `columns` | **required** | array of column objects | **2–6** entries. A 1-column kanban is a bulleted list — for a single phase use `bulleted_list_item`. Past 6 the strip overflows the desktop viewport; for more lanes, drop down to `comparison_matrix`. |
| `title`   | optional     | plain string         | ≤ **120 runes**. Rendered as `<h3 class="kanban-title">` above the board. NOT `rich_text`. Newlines rejected. |
| `caption` | optional     | `rich_text` array    | 0–100 segments. Rendered as `<figcaption>` below the columns. Counts toward the global 2000-span budget. |
| `editable` | optional    | bool                 | Default `false`. When `true`, the board opts into the [editable-blocks](#editing-renders) protocol: each card renders as plain text with hover-revealed pencil + trash buttons (the pencil swaps the title to an inline `<input>`), each column gains a tiny `+` button in its header for appending cards, and cards are draggable. The viewer's edits auto-save through `overwrite_hash`. See [Editable kanban](#editable-kanban) below for the full client-protocol surface. |

#### Column object

Each entry of `columns` is an object with these fields (and nothing else — closed schema):

| Field    | Required     | Type                  | Cap / notes |
| -------- | ------------ | --------------------- | ----------- |
| `name`   | **required** | plain string          | **1–40 runes**. Rendered as the column header `<h4 class="kanban-column-name">`. NOT `rich_text` — bold/italic in a column header is visual clutter. Newlines rejected. |
| `accent` | optional     | enum                  | One of `"default"` / `"positive"` / `"negative"` / `"warning"` / `"info"`. Paints a 3px top border-stripe on the column. **Absent → palette-cycles by column index** (see [Accents](#accents-1) below). Same enum as `stat_grid`, `comparison_matrix`, and `timeline`. |
| `cards`  | **required** | array of card objects | **0–20** per column. **Empty array allowed** — `"Done"` legitimately starts empty. The global per-board cap is 60 cards across all columns. |

#### Card object

Each entry of `cards` is an object with these fields (and nothing else — closed schema):

| Field         | Required     | Type              | Cap / notes |
| ------------- | ------------ | ----------------- | ----------- |
| `title`       | **required** | plain string      | **1–120 runes**, single-line. Rendered as `<p class="kanban-card-title">`. NOT `rich_text` — card titles are short factual labels. Newlines rejected. |
| `description` | optional     | `rich_text` array | **1–4 segments**, ≤ **200 runes** total content. Rendered as `<div class="kanban-card-description">`. Counts toward the global 2000-span budget. Empty array `[]` rejected (omit the field instead). |
| `tags`        | optional     | array of strings  | **0–3** tags. Each tag is **1–20 chars** matching `^[A-Za-z0-9 _-]+$`. Rendered as a `<ul class="kanban-card-tags" aria-hidden="true">` strip — visual taxonomy only, not announced to screen readers. |
| `assignee`    | optional     | plain string      | **1–40 chars**, single-line. Rendered as `<p class="kanban-card-assignee" aria-label="assignee">`. Free-form text — does not have to be a person; `"Vendor B"`, `"infra-bot"`, and `"unassigned"` are all valid. Newlines rejected. |

#### Caps

| Cap                              | Value |
| -------------------------------- | ----- |
| `columns` count                  | 2–6 |
| `cards` per column               | 0–20 |
| Total cards (all columns summed) | **60** |
| `title` length                   | 0–120 runes |
| `columns[i].name` length         | 1–40 runes |
| `columns[i].cards[j].title` length | 1–120 runes |
| `columns[i].cards[j].description` segments | 1–4 (counts toward global 2000-span budget) |
| `columns[i].cards[j].description` total runes | ≤ 200 |
| `columns[i].cards[j].tags` count | 0–3 |
| `columns[i].cards[j].tags[k]` length | 1–20 chars (regex `^[A-Za-z0-9 _-]+$`) |
| `columns[i].cards[j].assignee` length | 0–40 chars |
| `caption` segments               | 0–100 (counts toward global 2000-span budget) |
| nesting depth                    | ≤ 3 (same as every other block) |

(Global payload caps apply — see Top-level payload shape.)

#### Accents

The 5-value accent enum (`default | positive | negative | warning | info`) is the same vocabulary used by `stat_grid`, `comparison_matrix`, and `timeline`. On `kanban` it paints a 3px top border-stripe on the column header via a `kanban-accent-{accent}` class, plus a tinted column-count badge.

**Palette cycling on absent.** When `accent` is omitted on one or more columns, the renderer cycles through the palette by **column index**, deterministically. The cycle order is:

| Column index | Cycled accent |
| ------------ | ------------- |
| 0            | `positive`    |
| 1            | `info`        |
| 2            | `warning`     |
| 3            | `negative`    |
| 4            | `default`     |
| 5            | `positive` (wraps mod 5) |

So a 5-column board with every `accent` omitted reads as positive → info → warning → negative → default left-to-right. Mixing explicit and absent values is allowed: explicit `accent` always wins for that column; cycle indices are still counted from the column's position in the array (the cycle is not "skip explicit columns").

Tints are painted via `color-mix()` against `--chart-c2` (positive), `--chart-c1` (negative), `--chart-c3` (warning), `--chart-c0` (info) so they harmonize with `chart`, `stat_grid`, `comparison_matrix`, and `timeline` colors on the same page.

There is **no per-card accent**. Cards inherit no tint from the column; if a card needs emphasis, use `description` rich_text marks (bold, code) or a tag.

#### Read-only — what `kanban` does NOT do (even when `editable: true`)

The schema is intentionally narrow regardless of `editable`. The block:

- has **no clickable card wrappers** — `<li class="kanban-card">` is plain content, no `<a>` envelope;
- has **no per-card `due_date`, `priority`, `status_changed_at`, `link`, `id`, `image`, or `attachment` field** — these are explicitly rejected;
- has **no swimlanes / nested groupings** — one flat column row;
- has **no WIP-limit annotation, no card move history, no comments thread** — those belong in your tracker of record, not in a render snapshot;
- in editable mode, has **no column-level edits** — column names, accents, ordering, addition, and removal are agent-only; the viewer can only move cards. Reframing the board structurally is the agent's job;
- in editable mode, has **no edits on `description`, `tags`, or `assignee`** — only `title` is viewer-editable. Those metadata fields are agent-controlled snapshot context.

If your workflow needs richer card metadata, point the viewer at your tracker; use `kanban` to share a moment-in-time view that the viewer can either snapshot (read-only) or reshuffle (editable).

#### Editable kanban

When `editable: true`, the board renders with four interactive affordances on top of the read-only HTML:

1. **Inline card-title edit (pencil-on-hover).** Each card shows its title as plain text at rest. On hover/focus a pencil button surfaces; clicking it reveals an input pre-focused with the title selected. Typing commits the new title after a short debounce. Enter or blur exits edit mode; Esc restores the pre-edit value and exits. Blur with an empty value also reverts (a blank title would 400).
2. **Add card (per column).** Each column header carries a hover-revealed `+` button. Click pushes `{"title":"New card"}` onto that column's `cards` array; the page reloads so the new card is visible, then the viewer can click the pencil to rename.
3. **Remove card.** Each card carries a hover-revealed `×` button. First click flips it to a `Delete?` pill; second click within 2 s removes the card and reloads; clicking elsewhere cancels.
4. **Drag-reorder cards.** Cards are draggable and keyboard-focusable. Mouse drag shows a drop-zone line between cards and on drop moves the card; the page reloads. Keyboard: focus a card, Space picks up, ↑/↓ moves the indicator within the column, ←/→ moves it to the adjacent column (drops at end), Space drops, Esc cancels. Cross-column drag works on both input modalities.

Schema invariants are unchanged when editable: cards still need a title between 1 and 120 runes, columns still need 0–20 cards each, the board still has the 60-card aggregate cap. Saves that violate the schema 400 with the same error messages a fresh render would (e.g. `kanban.columns[i].cards[j].title: required` on an empty title).


##### Limitations

- **Desktop drag only.** HTML5 drag-and-drop has poor touch support; on touch devices the add-card button + trash glyph + inline title editor all work, but drag-reorder does not. A touch-drag polyfill is a separate follow-up.
- **No column-level edits.** Adding/removing columns, renaming them, recoloring their accent, or reordering them are agent-only operations. The viewer cannot reshape the board's column set.
- **Concurrency is last-write-wins.** Two viewers reordering cards on the same board both succeed; the later POST wins byte-by-byte. There is no merge.

#### Common rejections

Most kanban 400s in practice fall into one of these categories (verbatim error strings from the validator):

- **Flat `cards` at the top level (F1 hint)** — `{"kanban": {"cards": [...]}}` → 400 (`kanban: unknown field "cards" (did you mean "columns"? cards belong inside columns[i].cards)`). Cards always nest inside a column.
- **`title` on a column (F3 hint)** — `{"name": "Todo", "title": "Todo"}` on a column → 400 (`kanban.columns: unknown field "title" (did you mean "name"? the kanban block's title field lives on the block body, not on columns)`). Block has `title`; columns have `name`. The shortcut `label` is also caught: → 400 (`kanban.columns: unknown field "label" (did you mean "name"?)`).
- **`columns` too few** — 0 or 1 column → 400 (`kanban.columns: must contain at least 2 columns; for a single phase use a bulleted_list_item list instead`).
- **`columns` too many** — 7+ columns → 400 (`kanban.columns length 7 exceeds limit of 6`).
- **Total card count over the global cap** — > 60 cards across all columns → 400 (`kanban: total card count N exceeds limit of 60 across all columns`).
- **Per-column card count over the cap** — 21+ cards in one column → 400 (`kanban.columns[i].cards length N exceeds limit of 20 per column`).
- **`column.name` missing / empty / oversized / non-string / contains newline** — `""` or omitted → 400 (`kanban.columns[i].name: required`); non-string → 400 (`kanban.columns[i].name: must be a string`); >40 runes → 400 (`kanban.columns[i].name exceeds 40 runes`); contains `\r` or `\n` → 400 (`kanban.columns[i].name: must not contain newlines`).
- **`column.accent` outside the enum** — `"red"`, `"good"`, `"highlight"` → 400 (`kanban.columns[i].accent "..." not supported; must be one of default, positive, negative, warning, info`).
- **`card.title` missing / empty / oversized / non-string / contains newline** — `""` or omitted → 400 (`kanban.columns[i].cards[j].title: required`); non-string → 400 (`kanban.columns[i].cards[j].title: must be a string`); >120 runes → 400 (`kanban.columns[i].cards[j].title exceeds 120 runes`); contains newline → 400 (`kanban.columns[i].cards[j].title: must not contain newlines`).
- **`card.description` over segment cap** — 5+ segments → 400 (`kanban.columns[i].cards[j].description exceeds 4 segments`).
- **`card.description` over rune cap** — total content > 200 runes → 400 (`kanban.columns[i].cards[j].description: total content exceeds 200 runes`).
- **Plain-string description** — `"description": "hello"` → 400 (`kanban.columns[i].cards[j].description: must be a rich_text array`). Descriptions are `rich_text` arrays, not bare strings.
- **Empty description array** — `"description": []` → 400 (`kanban.columns[i].cards[j].description: rich_text array cannot be empty`). Omit the field instead.
- **Too many tags** — 4+ tags on a card → 400 (`kanban.columns[i].cards[j].tags exceeds 3 tags`).
- **Tag failing the regex / over the length cap** — empty string, > 20 chars, or any character outside `[A-Za-z0-9 _-]` → 400 (`kanban.columns[i].cards[j].tags[k]: must be 1-20 chars matching ^[A-Za-z0-9 _-]+$`). No `@`, no `/`, no `#`, no emoji.
- **Non-string-array tags** — `"tags": "backend, frontend"` (a plain string), `"tags": [1, 2, 3]` → 400 (`kanban.columns[i].cards[j].tags: must be an array of strings`).
- **`assignee` over the length cap / contains newline / non-string** — > 40 chars → 400 (`kanban.columns[i].cards[j].assignee exceeds 40 chars`); newline → 400 (`kanban.columns[i].cards[j].assignee: must not contain newlines`); non-string → 400 (`kanban.columns[i].cards[j].assignee: must be a string`).
- **Plain-string caption** — `"caption": "hello"` → 400 (`kanban.caption: must be a rich_text array`).
- **Unknown top-level keys on the body, any column, or any card** — closed schema. Extra fields like `swimlanes`, `wip_limit`, `default_column` on the body; `wip_limit`, `cards_count` on a column; `due_date`, `priority`, `status`, `id`, `image`, `link`, `priority_score` on a card are rejected.

#### What `kanban` is NOT

- **NOT interactive by default.** A plain `kanban` is render-only (no JS) — no drag-drop, no reordering, no inline editing. Those become available only when you set `editable: true` on the block (drag cards across columns, reorder, inline-edit titles, add/remove cards), which auto-saves and lets the agent read the result back. See the `editable` field above and [Editing renders](#editing-renders).
- **NOT a clickable card layout.** Card wrappers are not `<a>` tags. If you need a card to deep-link out, put a link in the `description` rich_text — same `rich_text` link allowlist as everywhere else.
- **NOT per-card-accent-capable.** Only columns carry accent. To single out a card, write a tag (`"hot"`, `"urgent"`) or use a bold/code mark in the description.
- **NOT a multi-board layout.** Want multiple boards? Emit multiple `kanban` blocks on the page; each gets its own per-page `id="kanban-N-title"` sequence.
- **NOT swimlane-capable.** A workflow with rows × columns is a `comparison_matrix`, not a kanban with swimlanes — swimlanes are explicitly rejected.
- **NOT a card-image surface.** No image inside cards in v1 — keeps the card height predictable and the print stylesheet clean. Put images in a `gallery` block alongside.
- **NOT a due-date / priority tracker.** No `due_date`, `priority`, `status_changed_at`, or `assignee_history` fields — those are tracker concerns, not snapshot concerns.

### `tabs`

`tabs` renders 2–8 labeled panels with a clickable tab strip. Selection is driven entirely by the URL fragment via CSS `:target` — no JavaScript. The first panel is visible by default; clicking a strip link (or opening `…/r/<hash>#tab-<slug>` directly) selects that panel, populates browser history, and lets the back button cycle previously-selected tabs. It is the right block for FAQ groupings by category, multi-language code snippets ("Python / Go / JavaScript"), tabbed product copy (Overview / Pricing / FAQ), or any place a small number of related views compete for the same vertical real estate.

> **Use it when** the same page would otherwise repeat headings + scroll for closely-related variants. For unrelated sections of one document, use `heading_2` + a manual TOC of fragment links instead.

**Required fields:** `panels` (each panel requires `label` and `blocks`).

Body fields: `panels` (required, **2–8** entries), `title` (optional plain string), `caption` (optional `rich_text` array).

```json
{
  "type": "tabs",
  "tabs": {
    "title": "Plans",
    "panels": [
      {
        "label": "Overview",
        "blocks": [
          { "type": "paragraph", "paragraph": { "rich_text": [{ "type": "text", "text": { "content": "Caldera is a CI runner with deterministic builds." } }] } }
        ]
      },
      {
        "label": "Pricing",
        "blocks": [
          { "type": "paragraph", "paragraph": { "rich_text": [{ "type": "text", "text": { "content": "Free for OSS; $29/mo Pro tier; Team and Enterprise above." } }] } }
        ]
      },
      {
        "label": "FAQ",
        "blocks": [
          { "type": "heading_3", "heading_3": { "rich_text": [{ "type": "text", "text": { "content": "Does it support GitLab?" } }] } },
          { "type": "paragraph", "paragraph": { "rich_text": [{ "type": "text", "text": { "content": "Yes — same runner binary, separate setup guide." } }] } }
        ]
      }
    ]
  }
}
```

| Field     | Required     | Type                 | Cap / notes |
| --------- | ------------ | -------------------- | ----------- |
| `panels`  | **required** | array of panel objects | **2–8** entries. A 1-panel "tabs" is degenerate — use the panel's blocks directly. Beyond 8 the strip overflows on any sane viewport; the page is better served by a heading-anchored TOC. |
| `title`   | optional     | plain string         | ≤ **120 runes**. Rendered as `<h4 class="tabs-title">` above the strip. NOT `rich_text`. Newlines rejected. |
| `caption` | optional     | `rich_text` array    | 0–100 segments. Rendered as `<figcaption>` below the panels. Counts toward the global 2000-span budget. |

#### Panel object

Each entry of `panels` is an object with these fields (and nothing else — closed schema):

| Field    | Required     | Type             | Cap / notes |
| -------- | ------------ | ---------------- | ----------- |
| `label`  | **required** | plain string     | **1–50 runes**. Rendered as the tab-strip link text AND used to derive the panel's auto-id (`tab-<Slugify(label)>`). NOT `rich_text` — bold/italic in a tab strip is visual clutter. Newlines rejected. |
| `blocks` | **required** | array of blocks  | **≥ 1 block**. The panel's content. Validated at the page-wide depth+1, so the global ≤ 3 nesting cap still applies. **Nested `tabs` blocks are rejected** (`tabs.panels[i].blocks[j]: nested tabs blocks are not allowed`).<br/><br/>Sub-block types follow the same constraints as their top-level counterparts (see their respective sections). Nesting depth still applies — these count toward the ≤3-level cap. |

#### Caps

| Cap                                  | Value |
| ------------------------------------ | ----- |
| `panels` count                       | 2–8 |
| `panels[i].label` length             | 1–50 runes |
| `panels[i].blocks` count             | ≥ 1 |
| `title` length                       | 0–120 runes |
| `caption` segments                   | 0–100 (counts toward global 2000-span budget) |
| nesting depth                        | ≤ 3 (same as every other block) |
| nested `tabs` inside panel blocks    | **rejected** |

(Global payload caps apply — see Top-level payload shape.)

#### Panel ids and deep-linking

Each panel is rendered as `<section class="tab-panel" id="tab-<slug>">` where `<slug>` is `Slugify(label)`. The tab-strip link is `<a href="#tab-<slug>">`. Anyone can share `…/r/<hash>#tab-pricing` and the link lands on that tab.

Panel slugs share the **page-wide slug namespace** with heading auto-ids. If a `heading_2` with text `"tab pricing"` and a tabs block with a `"Pricing"` panel both want the slug `tab-pricing`, the **first writer (document order) wins** and the second gets a `-2` suffix. See [URL-fragment navigation](#url-fragment-navigation) for the slug rules.

If a label's slug is empty (all-emoji, all-whitespace, all-punctuation), the panel falls back to a synthetic id `tab-panel-N` where N is the panel's 1-based index within the block.

#### Default panel and selection

- **No fragment in the URL** → the first panel is visible.
- **`#tab-<slug>` matches a panel** → that panel is visible.
- **`#tab-<slug>` matches no panel** (typo, stale link) → the first panel is visible.

Browser back/forward cycles previously-selected panels because each click adds a history entry — same behavior as a regular fragment link.

#### Browser support

Tab selection is pure CSS, no JS. On modern browsers (Safari 15.4+, Chrome 105+, Firefox 121+) the first panel shows when nothing is targeted and the targeted panel shows when a tab is selected. On older browsers it degrades gracefully — the first panel is always visible AND the targeted panel is also visible (readable, not pretty).

#### Narrow viewport

On viewports too narrow to fit all tabs, the strip becomes horizontally scrollable and each tab snaps into view as the user swipes. Panels themselves are full-width.

#### Accessibility

- The tab strip is a list of anchor links (not an ARIA `role="tablist"`); it is navigable with the keyboard's native Tab key.
- Panels are semantic sections, navigable by screen-reader landmark.

#### Common rejections

Most tabs 400s in practice fall into one of these categories (verbatim error strings from the validator):

- **`panels` too few** — 0 or 1 panel → 400 (`tabs.panels: must contain at least 2 panels`).
- **`panels` too many** — 9+ panels → 400 (`tabs.panels: too many panels (cap is 8)`).
- **`panels[i].label` missing / empty / oversized / non-string** — `""` or omitted → 400 (`tabs.panels[i].label: required`); non-string → 400 (`tabs.panels[i].label: must be a string`); >50 runes → 400 (`tabs.panels[i].label exceeds 50 runes`); contains `\r` or `\n` → 400 (`tabs.panels[i].label: must not contain newlines`).
- **`panels[i].blocks` empty** — `"blocks": []` → 400 (`tabs.panels[i].blocks: must contain at least 1 block`).
- **Nested `tabs`** — a `tabs` block inside `panels[i].blocks[j]` → 400 (`tabs.panels[i].blocks[j]: nested tabs blocks are not allowed`).
- **Per-block validation bubbles up** — any invalid block inside `panels[i].blocks[j]` returns 400 prefixed with `tabs.panels[i].blocks[j]:`.
- **`title` non-string / oversized / contains newline** — 400 `tabs.title: must be a string` / `tabs.title exceeds 120 runes` / `tabs.title: must not contain newlines`.
- **`caption` non-array** — `"caption": "hello"` → 400 (`tabs.caption: must be a rich_text array`). Captions are `rich_text` arrays everywhere in mira, not bare strings.
- **Unknown top-level keys on the body or any panel** — closed schema. Extra fields like `default_panel`, `id`, `name` on a panel, `orientation`, `style` on the body are rejected.

#### Order preservation

`tabs` **does not sort**. Panels render in the order the agent emitted them; the first panel is always the default. There is no `default_panel` index, no `featured` flag, no auto-reorder. If the narrative depends on order (`Overview → Pricing → FAQ`), the agent owns that ordering.

#### What `tabs` is NOT

- **NOT a wizard / multi-step form.** No "next" buttons, no validation gates between panels. Panels are peers.
- **NOT a carousel / slideshow.** No autoplay, no transitions, no JS-driven panel cycling.
- **NOT recursive.** `panels[i].blocks` rejects nested `tabs` blocks — a tabbed page inside a tab inside a tab is not a reasonable reading experience. If you need true hierarchy, use heading anchors + a TOC.
- **NOT a `role="tablist"` ARIA implementation.**

### `choice`

**Required fields:** `prompt`, `options`

The `choice` block is an input affordance: a labelled radio (single-select)
or checkbox group (multi-select) bound to a prompt. When `editable: true`
the rendered controls are interactive — the viewer's selection is written
back to the canonical render JSON by `/static/editable.js` on change. When
`editable: false` (or absent) the block renders as a read-only labelled
list with ●/○ (single) or ☑/☐ (multi) marks showing the current selection.

**`choice` doubles as the canonical checklist primitive.** For a checkable
list of items, use a `choice` block with `multi: true`. There is no
separate `to_do` or `checklist` block in mira; do not reach for one.

Body fields:

- `prompt` (required plain string, ≤200 runes) — the question or instruction shown above the options.
- `options` (required array, 2..20 entries) — the choices. Each entry is `{"id": "<slug>", "label": "<plain text>"}`.
  - `id` is a slug matching `^[a-z0-9_-]{1,40}$`. Ids must be unique within the block; they're what `selected[]` references and what the JS protocol writes back into the JSON.
  - `label` is a plain string ≤120 runes — the visible text next to the radio/checkbox.
- `multi` (optional bool, default `false`). `false` → single-select radio group; `true` → multi-select checkbox group.
- `selected` (optional array of option ids, default `[]`) — the currently-checked options. Every id MUST exist in `options[]`. When `multi: false`, `selected.length` ≤ 1.
- `editable` (optional bool, default `false`). When `true`, the block renders as interactive radios/checkboxes wired to the auto-save client (see [Editing renders](#editing-renders)). When `false`, the same selection state renders as a read-only labelled list.

```json
{
  "type": "choice",
  "choice": {
    "prompt": "Pick which features to ship in v2",
    "multi": false,
    "options": [
      { "id": "auth",     "label": "OAuth" },
      { "id": "billing",  "label": "Billing" },
      { "id": "webhooks", "label": "Webhooks" }
    ],
    "selected": ["auth"],
    "editable": true
  }
}
```

```json
{
  "type": "choice",
  "choice": {
    "prompt": "Sprint checklist",
    "multi": true,
    "options": [
      { "id": "docs",   "label": "Update docs" },
      { "id": "tests",  "label": "Write tests" },
      { "id": "deploy", "label": "Deploy to prod" }
    ],
    "selected": ["docs", "tests"]
  }
}
```

**Common rejections**

- `prompt` empty or missing → 400 (`choice.prompt required`).
- Fewer than 2 or more than 20 options → 400 (`choice.options must contain at least 2 entries` / `choice.options exceeds 20 entries`).
- Option `id` outside `[a-z0-9_-]{1,40}` → 400 (`choice.options[i].id "X" must match [a-z0-9_-]{1,40}`).
- Duplicate option ids → 400 (`choice.options[i].id "X": duplicate`).
- `selected` entry not present in `options` → 400 (`choice.selected[i] "X": not present in options`).
- `selected.length > 1` while `multi: false` → 400 (`choice.selected length N invalid when multi is false (max 1)`).
- Label or prompt past their rune caps → 400 with `exceeds … runes`.

### `approve`

**Required fields:** `prompt`

The `approve` block is a single reversible affirm button — a checkbox-
flavoured signal the agent can read back to confirm a yes/no decision.
When `editable: true` the block renders as a prominent accent-coloured
button; clicking flips `approved` between `true` and `false` (reversible,
NOT a one-way commit). When `editable: false` the block renders as a
static pill — `"Approved"` (green tint) when `approved: true`, `"Pending"`
otherwise.

Body fields:

- `prompt` (required plain string, ≤240 runes) — the question or proposal shown above the button.
- `approved` (optional bool, default `false`) — the current decision state.
- `editable` (optional bool, default `false`). `true` → interactive button wired to the auto-save client; `false` → read-only status pill.

```json
{
  "type": "approve",
  "approve": {
    "prompt": "Approve this proposal for board review?",
    "approved": false,
    "editable": true
  }
}
```

```json
{
  "type": "approve",
  "approve": {
    "prompt": "Snapshot status — Q3 OKRs sign-off",
    "approved": true
  }
}
```

**Common rejections**

- `prompt` empty or missing → 400 (`approve.prompt required`).
- `prompt` over 240 runes → 400 (`approve.prompt exceeds 240 runes`).

## URL-fragment navigation

mira renders are **deep-linkable**: every `heading_1`/`heading_2`/`heading_3` auto-emits an `id` attribute, every `tabs` panel emits an `id`, and `rich_text` links may target same-page fragments via `text.link.url: "#<slug>"`. The three features share one slug engine and one allowlist regex, so an agent that learns the slug rules can author manual TOCs, FAQ cross-references, and tabbed sections without re-checking the contract.

### Heading anchors

Every `heading_1`, `heading_2`, and `heading_3` block is auto-anchored for deep-linking: it renders with an `id` derived from its slug and a hover-revealed `#` copy-link. No JS. There is no agent-supplied `id` field — the slug is **always** derived from the heading's plain-text content. (Block-level and slide titles are auto-anchored the same way.)

**Slug grammar:** `^[a-z0-9](?:-?[a-z0-9])+$`, max **40 runes**. The emitted form never starts or ends with `-` and never contains consecutive `-`.

The slug is derived from the heading's plain text: accents are folded to ASCII (`"Café Society"` → `cafe-society`), text is lowercased, runs of non-`[a-z0-9]` become a single `-`, leading/trailing `-` are trimmed, and the result is capped at 40 runes (snapping back to the last `-` boundary).

**Examples:**

| Input heading text                                  | Slug emitted |
| --------------------------------------------------- | ------------ |
| `"Pricing details"`                                 | `pricing-details` |
| `"Café Society"`                                    | `cafe-society` |
| `"Top 5 AI tools for code review"`                  | `top-5-ai-tools-for-code-review` |
| `"How does it work?!"`                              | `how-does-it-work` |
| `"日本語"` (CJK with no NFKD ASCII fallback)         | synthetic `heading-N` |
| `"🔥"` (emoji only)                                  | synthetic `heading-N` |
| `"   "` (whitespace only — rejected at validate)    | n/a (heading rejects empty rich_text) |

**Collision suffix:** two `heading_2`s with text `"Overview"` produce slugs `overview` and `overview-2` (document order wins). A third becomes `overview-3`.

**Cross-feature collisions:** heading auto-ids and `tabs` panel ids share **one page-wide slug namespace**. A `heading_2 "tab pricing"` and a tabs block with a `"Pricing"` panel both want `tab-pricing`; the first writer in document order wins, the second gets `-2`. Mixing the features on the same page is supported — the namespace is just shared.

**Synthetic fallback:** when slugify produces an empty string (e.g., all-CJK, all-emoji, all-punctuation), the slug becomes `heading-N` where `N` is the heading's 1-based ordinal in the page (heading sequence, not block sequence). Tab panels under the same rule fall back to `tab-panel-N`.

**Heading-text edits break existing deep-links.** A future rename from `"Pricing"` to `"Plans & pricing"` changes the slug from `pricing` to `plans-pricing`; any external bookmark to `…/#pricing` becomes a stale anchor. mira does not pin a stable id across renames in v1.

### Fragment links in `rich_text`

`text.link.url` accepts same-page fragment URLs matching:

```
^#[a-z0-9][a-z0-9-]{0,40}$
```

That regex mirrors the slug grammar one-for-one — the leading `#` followed by 1–41 runes of lowercase ASCII, digits, and hyphens. Fragments are valid in any `rich_text` context: `paragraph`, `bulleted_list_item`, `numbered_list_item`, `quote`, `callout`, `toggle`, `code.caption`, `image.caption`, `table` cells — and any other rich_text-bearing field.

**Accepted:** `#pricing`, `#tab-faq`, `#heading-2`, `#top-5-ai-tools-for-code-review`.

**Rejected** (each returns 400 with the canonical error `rich_text[i].<field>: fragment link "<url>" does not match required pattern ^#[a-z0-9][a-z0-9-]{0,40}$`):

- `#FOO` — uppercase rejected.
- `#foo bar` — space rejected.
- `#foo#bar` — multiple `#` rejected.
- `#` — empty fragment rejected.
- `#-foo` — must start with `[a-z0-9]`.
- `#foo-` — emitted slugs never end with `-`, so the allowlist matches.
- `#javascript:alert(1)` — colon rejected; defence-in-depth on top of the existing scheme check.

**Cross-page fragment links** (`https://mira.cagdas.io/r/<other-hash>#section`) flow through the existing `https:` allowlist; the fragment is preserved. The spec does not encourage cross-render TOCs in v1 — they go stale fast — but the URL is legal.

### Worked example 1 — Manual TOC with anchor links

A simple long-form page with a table of contents at the top linking to subsequent headings:

```json
{
  "template": "page",
  "blocks": [
    { "type": "heading_1", "heading_1": { "rich_text": [{ "type": "text", "text": { "content": "Lumen handbook" } }] } },
    { "type": "paragraph", "paragraph": { "rich_text": [
      { "type": "text", "text": { "content": "Sections: " } },
      { "type": "text", "text": { "content": "Setup",      "link": { "url": "#setup" } } },
      { "type": "text", "text": { "content": " · " } },
      { "type": "text", "text": { "content": "Architecture","link": { "url": "#architecture" } } },
      { "type": "text", "text": { "content": " · " } },
      { "type": "text", "text": { "content": "FAQ",        "link": { "url": "#faq" } } }
    ] } },
    { "type": "heading_2", "heading_2": { "rich_text": [{ "type": "text", "text": { "content": "Setup" } }] } },
    { "type": "paragraph", "paragraph": { "rich_text": [{ "type": "text", "text": { "content": "Install the CLI and authenticate." } }] } },
    { "type": "heading_2", "heading_2": { "rich_text": [{ "type": "text", "text": { "content": "Architecture" } }] } },
    { "type": "paragraph", "paragraph": { "rich_text": [{ "type": "text", "text": { "content": "Single Go binary, stdlib only." } }] } },
    { "type": "heading_2", "heading_2": { "rich_text": [{ "type": "text", "text": { "content": "FAQ" } }] } },
    { "type": "paragraph", "paragraph": { "rich_text": [{ "type": "text", "text": { "content": "Common questions." } }] } }
  ]
}
```

Rendered HTML carries `<h2 id="setup">`, `<h2 id="architecture">`, `<h2 id="faq">`. Each TOC link scrolls smoothly to its target.

### Worked example 2 — Tabbed FAQ with direct deep-link

A page that announces "see the pricing tab" via a prose cross-reference, then renders the tabs:

```json
{
  "template": "page",
  "blocks": [
    { "type": "paragraph", "paragraph": { "rich_text": [
      { "type": "text", "text": { "content": "For pricing details, see " } },
      { "type": "text", "text": { "content": "the Pricing tab", "link": { "url": "#tab-pricing" } } },
      { "type": "text", "text": { "content": " below." } }
    ] } },
    { "type": "tabs", "tabs": {
      "title": "Caldera",
      "panels": [
        { "label": "Overview", "blocks": [
          { "type": "paragraph", "paragraph": { "rich_text": [{ "type": "text", "text": { "content": "A deterministic CI runner." } }] } }
        ] },
        { "label": "Pricing", "blocks": [
          { "type": "paragraph", "paragraph": { "rich_text": [{ "type": "text", "text": { "content": "Free for OSS; $29/mo Pro." } }] } }
        ] },
        { "label": "FAQ", "blocks": [
          { "type": "paragraph", "paragraph": { "rich_text": [{ "type": "text", "text": { "content": "What CI providers does it integrate with?" } }] } }
        ] }
      ]
    } }
  ]
}
```

The link `#tab-pricing` resolves to the `<section id="tab-pricing">` and CSS `:target` makes that panel visible. Sharing `…/r/<hash>#tab-pricing` lands a fresh visitor on the Pricing tab directly.

### Worked example 3 — Inline cross-reference between sections

A long article where one paragraph references another section by anchor:

```json
{
  "template": "page",
  "blocks": [
    { "type": "heading_2", "heading_2": { "rich_text": [{ "type": "text", "text": { "content": "Setup" } }] } },
    { "type": "paragraph", "paragraph": { "rich_text": [
      { "type": "text", "text": { "content": "First install the CLI. For payment details see " } },
      { "type": "text", "text": { "content": "the pricing section", "link": { "url": "#pricing" } } },
      { "type": "text", "text": { "content": "." } }
    ] } },
    { "type": "heading_2", "heading_2": { "rich_text": [{ "type": "text", "text": { "content": "Pricing" } }] } },
    { "type": "paragraph", "paragraph": { "rich_text": [{ "type": "text", "text": { "content": "Free for OSS; $29/mo Pro." } }] } }
  ]
}
```

The inline link `#pricing` matches the auto-id of the second `heading_2` (slug `pricing`). The visitor scrolls smoothly to that section. Renaming the heading to "Plans & pricing" would change the slug to `plans-pricing` and break the inline link — the agent owns slug stability across edits.

## How to give mira feedback

mira accepts anonymous feedback at `POST /v1/feedback`. Use this when you hit a real obstacle: a spec ambiguity that made you guess, a JSON shape that surprised you, a block type that would have made the user's request easier, or a bug in the rendered output. Feedback goes into a local file that the maintainer reads between phases.

### What to send

| ✅ DO send                                                                                                                                                  | ❌ DON'T send                                                                                                                                                                                                                       |
| --------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Factual spec errors.** Example: "the `chart.series` field is described in prose but never shown in a JSON example, and I guessed the wrapper shape wrong." | **Speculative features.** "You should add an AI assistant that summarises pages." We aren't taking design proposals via this channel.                                                                                              |
| **Concrete unmet needs.** "A `calendar` block with multi-day events would have helped me render the user's launch schedule. I had to fall back to a bulleted list." | **UX opinions.** "The font is too small on mobile." Use a real bug report channel for UX work.                                                                                                                                       |
| **Reproducible bugs.** "POST with `chart.series: [...]` returned 400 saying X but the spec says Y. The exact payload was [...]."                            | **Requests to change the locked schema.** The schema is locked in `internal/spec/spec.md`. Friction with the locked rules is interesting, but proposed counter-rules go to the project maintainer, not this endpoint.                |

### Endpoint

```
POST https://mira.cagdas.io/v1/feedback
Content-Type: application/json
```

Body:

```json
{
  "message": "<10-4096 bytes>",
  "context": {
    "path": "/r/<hash>",
    "error": "<the error string mira returned, optional>",
    "hash": "<the render hash you were looking at, optional>",
    "ua": "<your tool identifier, optional>"
  }
}
```

`message` is required. `context` is optional and all of its inner fields are optional. Unknown fields anywhere return 400.

Response on success: `200 {"id": "<16-char hex>", "ok": true}`. The `id` is the row id in the maintainer's log; quote it back if a back-channel reply ever happens.

Rate limit: 30 requests/hour per IP. Separate counter from `/v1/render`.

### Worked example

A cold agent that hit a spec ambiguity:

```bash
curl https://mira.cagdas.io/v1/feedback \
  -H 'Content-Type: application/json' \
  -d '{
    "message": "The chart spec describes the series field as an array of wrapper objects, but the JSON example shows the data array directly. I guessed wrong and got 400. A second JSON example showing the wrapper layer would unblock cold runs.",
    "context": {
      "path": "/v1/spec.md",
      "error": "chart.series[0]: must be an object with name and data",
      "ua": "claude-code-agent"
    }
  }'
```

Response:

```json
{ "id": "a1b2c3d4e5f60718", "ok": true }
```

### What this is not

- Not a support channel. Mira has no SLA, no on-call, no inbox.
- Not a feature request board. Concrete unmet needs are welcome; speculative proposals are not.
- Not a public stream. The file is local-only; nobody else reads what you submit (until the admin panel ships later).

## Explicitly rejected block types

The following Notion block types are NOT supported in v2.0. Sending any of them returns 400 with a clear "unsupported block type" error message:

```
to_do            bookmark         embed
link_preview     file             video
audio            pdf              child_page
child_database   synced_block     template
link_to_page     equation         column
column_list      breadcrumb       table_of_contents
unsupported
```

The `mention` and `equation` `rich_text` variants are also rejected with 400. Only `type: "text"` is accepted on `rich_text` segments.

## Assets

mira hosts small images so payloads can reference them without sending bytes inside the JSON envelope. Two endpoints make this work.

### `POST /v1/assets`

```
POST https://mira.cagdas.io/v1/assets
Content-Type: multipart/form-data
```

The request body must be a multipart form with **one** part named `file` containing the image bytes. The part's `Content-Type` header is required and must be one of:

- `image/png`
- `image/jpeg`
- `image/webp`
- `image/gif`

`image/svg+xml` is **not** accepted (SVG can carry inline scripts). Other content types are rejected with 415 (request) or 400 (part).

Constraints:

- Total request body capped at 1 MB. Larger bodies return 413.
- The declared part `Content-Type` must agree with the type sniffed from the bytes. Mismatches return 400.
- Per-IP rate limit: 100 uploads per hour. Excess returns 429 with a `Retry-After` header. (Ample for a 50-image gallery, which needs 50 prior asset uploads plus one `/v1/render`, well under one hour.)

#### Response 200

```json
{
  "id": "<crockford-base32 hash>",
  "url": "https://mira.cagdas.io/asset/<id>",
  "content_type": "image/png",
  "size": 12345
}
```

The `url` is the public, immutable address for the asset. Use it verbatim in the `url` field of any later `image` block.

### `GET /asset/<id>`

Returns the raw image bytes with the original `Content-Type` and a `Cache-Control: public, max-age=31536000, immutable` header. Asset ids are random 128-bit values — the same id is never re-issued, so the bytes at a given URL never change. Unknown ids return 404.

This endpoint never returns JSON; it always serves the image bytes (or a 404 error page).

### Lifecycle

Assets and renders are linked **only by reference**. Deleting a render does not delete the assets it referenced, and assets uploaded but never referenced in a render are still served. Asset garbage collection is not implemented in v1; uploaded assets are kept indefinitely.

## Worked examples — end to end

Each example below is a full POST body for `https://mira.cagdas.io/v1/render` and a 1-line description of what the rendered page shows.

### Example 1 — Comparison table with callout

A table comparing three options, with a divider and a callout flagging the recommendation.

```json
{
  "template": "page",
  "blocks": [
    {
      "type": "heading_1",
      "heading_1": {
        "rich_text": [{ "type": "text", "text": { "content": "Database backup strategies" } }]
      }
    },
    {
      "type": "table",
      "table": {
        "table_width": 3,
        "has_column_header": true,
        "has_row_header": true,
        "children": [
          {
            "type": "table_row",
            "table_row": {
              "cells": [
                [{ "type": "text", "text": { "content": "Strategy" } }],
                [{ "type": "text", "text": { "content": "RPO" } }],
                [{ "type": "text", "text": { "content": "Cost" } }]
              ]
            }
          },
          {
            "type": "table_row",
            "table_row": {
              "cells": [
                [{ "type": "text", "text": { "content": "Daily snapshot" } }],
                [{ "type": "text", "text": { "content": "24h" } }],
                [{ "type": "text", "text": { "content": "$" } }]
              ]
            }
          },
          {
            "type": "table_row",
            "table_row": {
              "cells": [
                [{ "type": "text", "text": { "content": "Continuous WAL" } }],
                [{ "type": "text", "text": { "content": "<1m" } }],
                [{ "type": "text", "text": { "content": "$$$" } }]
              ]
            }
          }
        ]
      }
    },
    { "type": "divider", "divider": {} },
    {
      "type": "callout",
      "callout": {
        "icon": { "type": "emoji", "emoji": "💡" },
        "rich_text": [
          { "type": "text", "text": { "content": "For most teams, " } },
          { "type": "text", "text": { "content": "daily snapshot + WAL shipping" }, "annotations": { "bold": true } },
          { "type": "text", "text": { "content": " hits a good cost/RPO balance." } }
        ]
      }
    }
  ]
}
```

### Example 2 — Mixed report with code, quote, and inline marks

A short report combining a heading, paragraph with inline code and link, a quote, and a code block with caption.

```json
{
  "template": "page",
  "blocks": [
    {
      "type": "heading_1",
      "heading_1": {
        "rich_text": [{ "type": "text", "text": { "content": "Why we cap nesting at depth 3" } }]
      }
    },
    {
      "type": "paragraph",
      "paragraph": {
        "rich_text": [
          { "type": "text", "text": { "content": "Notion's API allows arbitrary depth, but mira's renderer caps " } },
          { "type": "text", "text": { "content": "blocks[].children" }, "annotations": { "code": true } },
          { "type": "text", "text": { "content": " at depth 3 — root, child, grandchild." } }
        ]
      }
    },
    {
      "type": "quote",
      "quote": {
        "rich_text": [
          { "type": "text", "text": { "content": "Make it work, make it right, make it fast." } },
          { "type": "text", "text": { "content": " — Kent Beck" }, "annotations": { "italic": true } }
        ]
      }
    },
    {
      "type": "heading_2",
      "heading_2": {
        "rich_text": [{ "type": "text", "text": { "content": "Validator excerpt" } }]
      }
    },
    {
      "type": "code",
      "code": {
        "language": "go",
        "rich_text": [
          {
            "type": "text",
            "text": { "content": "if depth > maxNestingDepth {\n  return fmt.Errorf(\"nesting depth %d exceeds limit of %d\", depth, maxNestingDepth)\n}\n" }
          }
        ],
        "caption": [
          { "type": "text", "text": { "content": "internal/blocks/validate.go" }, "annotations": { "code": true } }
        ]
      }
    }
  ]
}
```

### Example 3 — Investor pitch deck (5 slides, mixed accents)

A Q3 board deck rendered as a single shareable page. Five slides — TAM, Product, Team, Traction, Ask — each framed as its own `<section>`. Accents code section sentiment: `info` for context, `positive` for traction, `warning` for the ask. Slide titles `<h2>` auto-anchor so anyone can deep-link `/r/<hash>#traction` straight to the traction slide.

```json
{
  "template": "page",
  "blocks": [
    {
      "type": "heading_2",
      "heading_2": {
        "rich_text": [{ "type": "text", "text": { "content": "Caldera — Q3 board deck" } }]
      }
    },
    {
      "type": "slides",
      "slides": {
        "title": "Q3 board deck",
        "slides": [
          {
            "title": "TAM and positioning",
            "subtitle": "Where we play and why",
            "accent": "info",
            "blocks": [
              { "type": "paragraph", "paragraph": { "rich_text": [{ "type": "text", "text": { "content": "$8B addressable, $1.2B serviceable in year 1." } }] } },
              { "type": "bulleted_list_item", "bulleted_list_item": { "rich_text": [{ "type": "text", "text": { "content": "Mid-market SaaS analytics, $50M–$500M ARR target." } }] } },
              { "type": "bulleted_list_item", "bulleted_list_item": { "rich_text": [{ "type": "text", "text": { "content": "Wedge: Slack-native event narration." } }] } }
            ]
          },
          {
            "title": "Product",
            "blocks": [
              { "type": "paragraph", "paragraph": { "rich_text": [{ "type": "text", "text": { "content": "Event-stream summarization for revenue and ops teams." } }] } },
              { "type": "callout", "callout": { "icon": { "type": "emoji", "emoji": "💡" }, "rich_text": [{ "type": "text", "text": { "content": "We turn 10k raw events into one paragraph the on-call can read in 5 seconds." } }] } }
            ]
          },
          {
            "title": "Team",
            "blocks": [
              { "type": "paragraph", "paragraph": { "rich_text": [{ "type": "text", "text": { "content": "7 engineers, 2 GTM, 1 designer. Hiring 4 more by EOY." } }] } }
            ]
          },
          {
            "title": "Traction",
            "subtitle": "YoY revenue growth",
            "accent": "positive",
            "blocks": [
              { "type": "paragraph", "paragraph": { "rich_text": [{ "type": "text", "text": { "content": "ARR up 4.2× year over year; gross retention 96%." } }] } },
              { "type": "bulleted_list_item", "bulleted_list_item": { "rich_text": [{ "type": "text", "text": { "content": "118 paid teams, up from 28 a year ago." } }] } },
              { "type": "bulleted_list_item", "bulleted_list_item": { "rich_text": [{ "type": "text", "text": { "content": "Net new logos: 14 in Q2 alone." } }] } }
            ]
          },
          {
            "title": "Q3 ask",
            "accent": "warning",
            "blocks": [
              { "type": "paragraph", "paragraph": { "rich_text": [{ "type": "text", "text": { "content": "Approve $4M hiring budget across eng + GTM." } }] } },
              { "type": "numbered_list_item", "numbered_list_item": { "rich_text": [{ "type": "text", "text": { "content": "Two senior backend engineers (data platform)." } }] } },
              { "type": "numbered_list_item", "numbered_list_item": { "rich_text": [{ "type": "text", "text": { "content": "One enterprise AE + one SE." } }] } }
            ]
          }
        ],
        "caption": [{ "type": "text", "text": { "content": "Drafted 2026-05-11." } }]
      }
    }
  ]
}
```

### Example 4 — Pricing comparison (2-column free vs paid)

A pricing page rendered as a `columns` block with two equal-width columns. Each column carries a `heading_3` label, an intro `paragraph`, three `bulleted_list_item` rows, and the paid column closes with a `callout` highlighting support tier. The block-level `title` ("Plans") sits above the grid; the block-level `caption` carries a one-line footnote below. On viewports ≤ 600 px the two columns stack in source order — Free on top, Paid below.

```json
{
  "template": "page",
  "blocks": [
    {
      "type": "heading_1",
      "heading_1": {
        "rich_text": [{ "type": "text", "text": { "content": "mira pricing" } }]
      }
    },
    {
      "type": "columns",
      "columns": {
        "title": "Plans",
        "columns": [
          {
            "blocks": [
              { "type": "heading_3", "heading_3": { "rich_text": [{ "type": "text", "text": { "content": "Free" } }] } },
              { "type": "paragraph", "paragraph": { "rich_text": [{ "type": "text", "text": { "content": "Try every block type at a generous 60-render/hour cap." } }] } },
              { "type": "bulleted_list_item", "bulleted_list_item": { "rich_text": [{ "type": "text", "text": { "content": "Public renders only." } }] } },
              { "type": "bulleted_list_item", "bulleted_list_item": { "rich_text": [{ "type": "text", "text": { "content": "30-day retention." } }] } },
              { "type": "bulleted_list_item", "bulleted_list_item": { "rich_text": [{ "type": "text", "text": { "content": "Community support." } }] } }
            ]
          },
          {
            "blocks": [
              { "type": "heading_3", "heading_3": { "rich_text": [{ "type": "text", "text": { "content": "Paid" } }] } },
              { "type": "paragraph", "paragraph": { "rich_text": [{ "type": "text", "text": { "content": "Production-ready. Password-protected slugs, custom themes." } }] } },
              { "type": "bulleted_list_item", "bulleted_list_item": { "rich_text": [{ "type": "text", "text": { "content": "Private renders + password gating." } }] } },
              { "type": "bulleted_list_item", "bulleted_list_item": { "rich_text": [{ "type": "text", "text": { "content": "Persistent /p/ URLs (10 version history)." } }] } },
              { "type": "callout", "callout": { "icon": { "type": "emoji", "emoji": "⭐" }, "rich_text": [{ "type": "text", "text": { "content": "Email support, SLA, 5000/h cap." } }] } }
            ]
          }
        ],
        "caption": [{ "type": "text", "text": { "content": "Pricing snapshot — see docs for full terms." } }]
      }
    }
  ]
}
```

## Persistent URLs

mira supports two URL flavours from the same `POST /v1/render` endpoint:

- **One-shot** (default): the payload omits `persistent`. The response is `/r/<hash>` — immutable, content-addressed.
- **Persistent**: the payload includes `"persistent": "<slug>"`. The response is `/p/<slug>` — a stable URL the agent can keep updating with new versions.

Pick `/r/<hash>` for one-off shares. Pick `/p/<slug>` when the URL needs to outlive a single render — *the latest status of project X*, *this week's tracker*, *live release notes*.

### Slug grammar

- Regex: `^[a-z0-9][a-z0-9-]{1,40}$` — lowercase ASCII, alphanumeric or hyphens, must start with `[a-z0-9]`, length 2–41.
- Slug matching is **case-insensitive**: a payload sent with `"persistent": "MySlug"` is normalised to `myslug` on write. Subsequent `GET /p/MySlug` 301-redirects to `/p/myslug`.
- The slug must not collide with the reserved-word list (see the bottom of this section).
- First-come-first-served. The first POST to claim the slug owns it. Subsequent updates are open by default — anyone who knows the slug can POST a new version. If you set a password on the underlying render, **updates require the matching password as authentication** (see [Updating a persistent URL](#updating-a-persistent-url) and [Password protection](#password-protection)).

### Creating a persistent URL

```
POST https://mira.cagdas.io/v1/render
Content-Type: application/json

{
  "template": "page",
  "persistent": "my-project",
  "blocks": [ /* … */ ]
}
```

#### Response 200

```json
{
  "url": "https://mira.cagdas.io/p/my-project",
  "version": 1,
  "version_url": "https://mira.cagdas.io/p/my-project/v/1"
}
```

If the payload also carried a top-level `password` field, the response includes `"protected": true`. The render hash is now password-protected; viewing `/p/my-project` requires the password. See [Password protection](#password-protection).

### Updating a persistent URL

When the latest version of the slug is **unprotected**, updates are open — anyone who knows the slug can POST a new version with no auth.

```
POST https://mira.cagdas.io/v1/render
Content-Type: application/json

{
  "template": "page",
  "persistent": "my-project",
  "blocks": [ /* … updated blocks … */ ]
}
```

#### Response 200

```json
{
  "url": "https://mira.cagdas.io/p/my-project",
  "version": 2,
  "version_url": "https://mira.cagdas.io/p/my-project/v/2"
}
```

#### Update gate when the latest version is password-protected

When the latest version of the slug **is** password-protected, updates require authentication via the same body field. The `password` field is overloaded by context:

- **On a protected slug**: `password` is the AUTH credential and must match the latest version. Missing or wrong → **403**. The OPTIONAL `new_password` field controls protection on the new version after AUTH passes (absent → inherit; string → rotate; `null` → drop).
- **On an unprotected slug**: `password` (when present) becomes the initial password on the new version. `new_password` is invalid here — it returns **400** with `new_password: only valid when updating a password-protected persistent slug`.

| Latest version | Body              | Outcome                                                                 |
|---|---|---|
| unprotected    | no fields         | anonymous update; new version public.                                   |
| unprotected    | `password:"x"`    | anonymous update; new version protected with `"x"`.                     |
| protected      | (no `password`)   | **403** `persistent: latest version is password-protected; …`           |
| protected      | wrong `password`  | **403** `persistent: password does not match latest version`            |
| protected      | correct `password`| 200; new version **inherits** the same password.                        |
| protected      | correct `password` + `new_password:"y"` | 200; new version **rotated** to `"y"`.       |
| protected      | correct `password` + `new_password:null` | 200; new version **drops** protection (public). |

Older versions are unaffected by rotation or drop — each version is an independent hash with its own sidecar.

##### Example — update on a protected slug

```bash
curl https://mira.cagdas.io/v1/render \
  -H 'Content-Type: application/json' \
  -d '{
    "template": "page",
    "persistent": "my-project",
    "password": "current-correct-pass",
    "blocks": [ /* … updated blocks … */ ]
  }'
```

The new version inherits the same password by default. Add `"new_password": "<replacement>"` alongside `password` to rotate, or `"new_password": null` to drop protection on the new version.

### Version history

- Up to **10** most recent versions are addressable at `/p/<slug>/v/<N>` (1-indexed, monotonically increasing).
- `/p/<slug>` always serves the latest version.
- Older versions (beyond the 10-kept window) return **410 Gone** with a pointer back to `/p/<slug>`.
- The underlying `/r/<hash>` URLs for **every** version stay valid forever — `/r/<hash>` is content-addressed and unaffected by the rolling 10-version window.

### Error responses

| Status | Error string | When |
|---|---|---|
| 400 | `persistent: must be a string slug or omitted` | `persistent` is present but not a string (number, bool, array, object). |
| 400 | `persistent: slug "<x>" does not match required pattern ^[a-z0-9][a-z0-9-]{1,40}$` | Slug fails the grammar regex. |
| 400 | `persistent: slug "<x>" is reserved` | Slug is in the reserved-word list. |
| 400 | `new_password: only valid when updating a password-protected persistent slug` | `new_password` was sent on a one-shot render, a create, or an update where the latest version is unprotected. |
| 403 | `persistent: latest version is password-protected; supply matching "password" in body to update` | Update path: latest version protected and `password` field absent (or `null`). |
| 403 | `persistent: password does not match latest version` | Update path: latest version protected and supplied `password` does not match. |
| 404 | (HTML "Not found") | `GET /p/<slug>` for an unknown slug, or `/p/<slug>/v/<N>` for `N > latest`, or `/v/0`/`/v/-1`/non-numeric. |
| 409 | `persistent: slug "<x>" already exists` | Concurrent create-create race for the same slug; the loser gets 409. To update the slug, POST without expecting the create response shape — the same endpoint accepts updates from any caller. |
| 410 | `persistent: version <N> of slug "<x>" is archived; latest at /p/<x>` | `GET /p/<slug>/v/<N>` for an evicted version (N < oldest-kept). |
| 429 | `persistent: slug creation rate limit exceeded (60 per hour per IP)` | More than 60 distinct slug creations from the same IP in one hour. Carries `Retry-After`. |

The standard `/v1/render` 120/h rate limit and 5 MB body cap apply to persistent renders as well, unchanged.

### Rate limits

- `POST /v1/render`: **60 requests / hour / IP** (covers both one-shot and persistent renders).
- Slug **creation**: **10 new slugs / hour / IP** (additive, applies only to the create path; updates to existing slugs do not count). The 11th new slug in the same hour returns 429 with `Retry-After`.

### Caching

- `GET /p/<slug>`: `Cache-Control: private, max-age=0, must-revalidate`. The latest is revalidation-checked every load via a weak `ETag`; pair with `If-None-Match` for 304 responses.
- `GET /p/<slug>/v/<N>`: `Cache-Control: public, max-age=31536000, immutable`. Version-pinned URLs are content-stable forever.

### Reserved word list

The following slugs are reserved and rejected at create time with `persistent: slug "<x>" is reserved`. The list is closed; new entries are added by mira release, not by config:

```
account     accounts    admin       api         apple-touch-icon
about       asset       assets      auth        browserconfig
callback    contact     docs        embed       error
errors      false       favicon.ico forge       health
help        humans.txt  infinity    llms        llms.txt
login       logout      manifest.json metrics    mira
mira-cagdas mirahq      nan         notfound    null
oauth       og          oops        p           pricing
privacy     profile     r           robots      search
security.txt settings   share       signup      sitemap
spec        static      subscribe   support     tag
tags        terms       tos         true        undefined
user        users       v           v1          void
well-known  404         500
```

### Worked examples

**Example 1 — first create.** Agent claims `acme-q3-tracker`:

```bash
curl https://mira.cagdas.io/v1/render \
  -H 'Content-Type: application/json' \
  -d '{
    "template": "page",
    "persistent": "acme-q3-tracker",
    "blocks": [
      { "type": "heading_1", "heading_1": { "rich_text": [{"type":"text","text":{"content":"Acme Q3 — week 1"}}] } }
    ]
  }'
```

```json
{
  "url": "https://mira.cagdas.io/p/acme-q3-tracker",
  "version": 1,
  "version_url": "https://mira.cagdas.io/p/acme-q3-tracker/v/1"
}
```

**Example 2 — update.** Any agent who knows the slug posts the next version. No `Authorization` header. If the latest version is password-protected, supply the matching `password` in the body for AUTH (see [Update gate when the latest version is password-protected](#update-gate-when-the-latest-version-is-password-protected)).

```bash
curl https://mira.cagdas.io/v1/render \
  -H 'Content-Type: application/json' \
  -d '{
    "template": "page",
    "persistent": "acme-q3-tracker",
    "blocks": [
      { "type": "heading_1", "heading_1": { "rich_text": [{"type":"text","text":{"content":"Acme Q3 — week 2"}}] } }
    ]
  }'
```

```json
{
  "url": "https://mira.cagdas.io/p/acme-q3-tracker",
  "version": 2,
  "version_url": "https://mira.cagdas.io/p/acme-q3-tracker/v/2"
}
```

**Example 3 — race on create.** Two agents both POST the same slug simultaneously. One wins, the other gets 409:

```bash
curl -i https://mira.cagdas.io/v1/render \
  -H 'Content-Type: application/json' \
  -d '{"template":"page","persistent":"acme-q3-tracker","blocks":[…]}'
```

```
HTTP/1.1 409 Conflict
{"error":"persistent: slug \"acme-q3-tracker\" already exists"}
```

After the slug is claimed, subsequent POSTs to that slug are *updates*, not collisions — they always succeed with a new version number.

**Example 4 — version drilldown.** A user compares v3 against the latest:

```
GET https://mira.cagdas.io/p/acme-q3-tracker/v/3   → renders v3 (immutable cache)
GET https://mira.cagdas.io/p/acme-q3-tracker       → renders latest (must-revalidate)
```

### What persistent URLs are NOT in v1

- **Not renameable, transferable, or deletable.** A slug is permanent once claimed.
- **Not login-gated by default.** Anyone who knows the slug name can POST a new version on an unprotected slug. To gate updates, set a password on the underlying render — updates then require the matching `password` in the body (see [Update gate when the latest version is password-protected](#update-gate-when-the-latest-version-is-password-protected) and [Password protection](#password-protection)).
- **Not a webhook surface.** Updates are POST-only; readers poll.
- **No compare-and-swap.** Two concurrent updates follow last-writer-wins; an `If-Match` header is not honoured in v1.

## Password protection

mira renders are public by default. To gate a render behind a password, set the top-level `password` field on the POST `/v1/render` body. The password is stored hashed; visiting the resulting URL serves a JS-free unlock prompt page until the viewer enters the password. The password is the credential — there is no separate token issued.

> **URL knowledge equals control.** Anyone who knows the URL of an unprotected render can set a password on it. Anyone who knows the URL AND the current password of a protected render can change or remove the password. Treat the URL itself as a low-trust identifier — for stronger guarantees, share the URL only with intended viewers, AND set a password they don't already have.

### Setting a password at create time

Pass `password` alongside the normal render body:

```
POST https://mira.cagdas.io/v1/render
Content-Type: application/json

{
  "template": "page",
  "password": "<8-256 byte string>",
  "blocks": [ /* … */ ]
}
```

#### Response 200

```json
{
  "url": "https://mira.cagdas.io/r/<hash>",
  "protected": true
}
```

The same field works on persistent renders — pass `"persistent": "<slug>"` alongside `"password": "<plaintext>"` and the resulting `/p/<slug>` is password-protected.

Once a persistent slug's latest version is password-protected, **subsequent updates require the matching `password` in the body** as authentication. The `new_password` field (string or `null`) on the same body controls whether the new version inherits, rotates, or drops protection. See [Update gate when the latest version is password-protected](#update-gate-when-the-latest-version-is-password-protected).

### Password constraints

- 8–256 UTF-8 bytes after NFC normalisation.
- No NUL byte or other C0 control characters (U+0000 through U+001F, plus U+007F).
- No leading or trailing whitespace.
- Must be a JSON string. Empty string `""` at create time is rejected with 400 — empty is the *remove* signal on the change endpoint, not a valid initial value.

### Viewing a password-protected render

`GET /r/<hash>` (or `/p/<slug>`, or `/p/<slug>/v/<N>`) on a protected render returns the JS-free unlock prompt page:

- `Content-Type: text/html; charset=utf-8`
- A `<form action="/r/<hash>/unlock" method="post">` with a single `password` input.

Submitting the form:

```
POST /r/<hash>/unlock
Content-Type: application/x-www-form-urlencoded

password=<plaintext>
```

- **Correct** → 302 redirect to `/r/<hash>` plus a signed `HttpOnly` session cookie bound to *this* hash (not to the slug or the password), with a 24h TTL.
- **Incorrect** → 200 + unlock prompt page with an "Incorrect password." error region.
- **Rate-limited** → 429 with `Retry-After`. Per-IP burst of 5 attempts/min with a 1/min refill; 30 failures inside a rolling 30-minute window trip a 60-minute lockout.

When the underlying hash differs across slug versions (e.g. `/p/<slug>` vs `/p/<slug>/v/2` after a content change), the cookie does NOT carry over — each hash has its own cookie. Re-enter the password to view the other version.

### Changing or removing a password

```
POST /v1/renders/<hash>/password
Content-Type: application/json

{ "current": "<existing plaintext>", "new": "<replacement plaintext>" }
```

- **On an unprotected render**: `current` is optional/ignored. `new` becomes the initial password. Response `200 {"ok": true}`.
- **On a protected render**: `current` is REQUIRED and must match. `new` becomes the rotated password. Response `200 {"ok": true}`. Without `current`, or with a wrong `current`, the response is `403 {"error": "password: current password does not match"}`.
- **To remove protection**: send `"new": ""` (empty string). On a protected render with a valid `current`, protection is removed and the response is `200 {"ok": true, "removed": true}`. On an unprotected render, the response is `404 {"error": "password: no password to remove"}`.

Rate limits: 10 requests/hour per IP on the endpoint itself. Wrong-`current` attempts ALSO consume the per-IP unlock-prompt counter — the same 30/30min lockout applies.

### Social-share unfurl on protected pages

`GET /og/<hash>.png` on a protected hash returns a bundled, byte-stable "Password-protected page" PNG instead of the per-render card. The image bytes do NOT depend on the underlying render — pasting a protected URL into Slack/Discord/Twitter unfurls to the generic protected card with no content leak.

### Errors

| Status | Error string | When |
|---|---|---|
| 400 | `password: must be a string` | The `password` field on `/v1/render` is present but not a JSON string. |
| 400 | `password: must not be empty` | `"password": ""` on `/v1/render` (empty is invalid at create; use `/v1/renders/<hash>/password` with `"new": ""` to remove). |
| 400 | `password: must be at least 8 bytes after NFC normalization` | Too short. |
| 400 | `password: must be at most 256 bytes after NFC normalization` | Too long. |
| 400 | `password: must not contain control characters` | NUL byte or other C0 control. |
| 400 | `password: must not have leading or trailing whitespace` | Trim before sending. |
| 400 | `password: 'new' is required (use empty string "" to remove)` | Change endpoint without a `new` field. |
| 400 | `password: 'new' must be a string` | Change endpoint `new` is the wrong JSON type. |
| 403 | `password: current password does not match` | Change/remove on a protected render with wrong, missing, or empty `current`. |
| 404 | `password: no password to remove` | Change endpoint with `"new": ""` on an already-public render. |
| 404 | `not found` | Change endpoint hash matches no stored render. |
| 429 | `password: change rate limit exceeded (10 per hour per IP)` | Throttled. Carries `Retry-After`. |
| 429 | `password: too many failed attempts; try again later` | Per-IP lockout: 30 wrong-current attempts inside 30 min triggers a 60-minute cooldown. Carries `Retry-After`. |

### Worked examples

#### Example PP-1 — protect at create

```bash
curl https://mira.cagdas.io/v1/render \
  -H 'Content-Type: application/json' \
  -d '{
    "template": "page",
    "password": "first-attempt-9k",
    "blocks": [
      { "type": "heading_1", "heading_1": { "rich_text": [{"type":"text","text":{"content":"Internal — Q3 launch plan"}}] } }
    ]
  }'
```

Response:

```json
{ "url": "https://mira.cagdas.io/r/<hash>", "protected": true }
```

A subsequent `GET https://mira.cagdas.io/r/<hash>` with no cookie serves the unlock prompt page.

#### Example PP-2 — change the password

The original creator (or anyone with the URL AND the current password) rotates:

```bash
curl https://mira.cagdas.io/v1/renders/<hash>/password \
  -H 'Content-Type: application/json' \
  -d '{"current":"first-attempt-9k","new":"new-pass-8k"}'
```

Response:

```json
{ "ok": true }
```

The previous unlock cookies are still valid for their 24-hour lifetime (cookies are bound to the hash, not the password). The OLD password no longer works for new unlock attempts.

#### Example PP-3 — remove protection

```bash
curl https://mira.cagdas.io/v1/renders/<hash>/password \
  -H 'Content-Type: application/json' \
  -d '{"current":"new-pass-8k","new":""}'
```

Response:

```json
{ "ok": true, "removed": true }
```

The render is now publicly viewable. The OG card flips back to the per-render image on the next `/og/<hash>.png` fetch.

#### Example PP-4 — wrong current password

```bash
curl -i https://mira.cagdas.io/v1/renders/<hash>/password \
  -H 'Content-Type: application/json' \
  -d '{"current":"wrong-guess","new":"replacement-7k"}'
```

Response:

```
HTTP/1.1 403 Forbidden
{"error":"password: current password does not match"}
```

The unlock-attempt counter consumes one slot for this IP. Repeated wrong-current attempts can trip the 30/30min lockout.

## Round-trip your render

Fetch any rendered page as JSON by appending `.json` to its URL. The response body is the validated, canonical block payload mira stored when you POSTed `/v1/render` — no envelope, no metadata, no wrapping. Agents that want to read back what they (or another agent) published can do so without parsing HTML.

### Routes

| Route | Returns |
|---|---|
| `GET /r/<hash>.json` | Block payload for the one-shot hash. |
| `GET /p/<slug>.json` | Block payload of the **latest** version of the persistent slug. |
| `GET /p/<slug>/v/<N>.json` | Block payload of version `<N>` of the persistent slug. |

`Content-Type: application/json; charset=utf-8` on every successful response. These are API responses, not pages.

### Cache headers

The `.json` shuttle mirrors the cache policy of its HTML sibling:

- `/r/<hash>.json` — no `Cache-Control` (same as `/r/<hash>`).
- `/p/<slug>.json` — `Cache-Control: private, max-age=0, must-revalidate`.
- `/p/<slug>/v/<N>.json` — `Cache-Control: public, max-age=31536000, immutable`.

### Worked example

POST a render:

```bash
curl https://mira.cagdas.io/v1/render \
  -H 'Content-Type: application/json' \
  -d '{
    "template": "page",
    "blocks": [
      { "type": "heading_2", "heading_2": { "rich_text": [{"type":"text","text":{"content":"Q3 launch checklist"}}] } },
      { "type": "paragraph", "paragraph": { "rich_text": [{"type":"text","text":{"content":"Three items left before we ship."}}] } }
    ]
  }'
```

Response:

```json
{ "url": "https://mira.cagdas.io/r/<hash>" }
```

Read it back as JSON:

```bash
curl https://mira.cagdas.io/r/<hash>.json
```

```json
{"template":"page","blocks":[{"type":"heading_2","heading_2":{"rich_text":[{"type":"text","text":{"content":"Q3 launch checklist"}}]}},{"type":"paragraph","paragraph":{"rich_text":[{"type":"text","text":{"content":"Three items left before we ship."}}]}}]}
```

POSTing that exact body back to `/v1/render` produces an equivalent render — the `.json` payload is round-trip safe.

### Password-protected pages

A protected `/r/<hash>` or `/p/<slug>` without a valid unlock cookie returns **401** with a JSON error so the agent has a parseable signal instead of an HTML prompt page:

```
HTTP/1.1 401 Unauthorized
Content-Type: application/json; charset=utf-8

{"error":"password_required","unlock":"https://mira.cagdas.io/r/<hash>"}
```

Send a user to the `unlock` URL to enter the password; the resulting cookie also unlocks subsequent `.json` reads.

### Discovery

HTML responses for `/r/<hash>`, `/p/<slug>`, and `/p/<slug>/v/<N>` advertise the JSON sibling via an `alternate` Link rel, alongside the existing `llms` and `feedback` rels:

```
Link: </v1/spec.md>; rel="llms", </v1/feedback>; rel="feedback", </r/<hash>.json>; rel="alternate"; type="application/json"
```

An agent crawling response headers can sniff the alternate without prior knowledge of the `.json` convention.

### Errors

| Status | Body | When |
|---|---|---|
| 401 | `{"error":"password_required","unlock":"<html-url>"}` | Password-protected page; no valid unlock cookie. |
| 404 | `{"error":"not_found"}` | Hash, slug, or version does not exist. |
| 410 | `{"error":"archived","latest":"/p/<slug>.json"}` | Version was evicted by the retention window. |
| 410 | `{"error":"gone"}` | Pointer references a hash that no longer exists on disk. |

## Export your render as PDF or PNG

Fetch any rendered page as a PDF or PNG by appending `.pdf` or `.png` to its `/r/<hash>` URL — or, equivalently, to a `/p/<slug>` persistent URL. Useful when the user wants to attach the render to a message, print it, or drop a screenshot into a deck.

### Routes

| Route | Returns |
|---|---|
| `GET /r/<hash>.pdf` | PDF of the rendered page. Auto-fits content to a single tall page (no page breaks). `Content-Type: application/pdf`. |
| `GET /r/<hash>.png` | PNG screenshot of the rendered page at 1120 px wide. `Content-Type: image/png`. |
| `GET /p/<slug>.pdf` | PDF of the latest version of `<slug>`. Equivalent to `/r/<hash>.pdf` where `<hash>` is the slug's latest underlying render. |
| `GET /p/<slug>.png` | PNG of the latest version of `<slug>`. |
| `GET /p/<slug>/v/<N>.pdf` | PDF of version `N` of `<slug>` (historic snapshot). |
| `GET /p/<slug>/v/<N>.png` | PNG of version `N` of `<slug>`. |

The slug routes resolve the slug (+ optional version) to its underlying `/r/<hash>` render and serve the same bytes — so `/p/team-roadmap.pdf` and `/r/<latest-hash>.pdf` for that slug share one cached file on disk. `/p/<slug>.{pdf,png}` direct-serves (no redirect), matching the `/p/<slug>.json` shape.

Both responses default to **dark theme** (the same theme served to a user landing on `/r/<hash>` without toggling). To export a specific look, append the same `?theme=<name>` and optional `?mode=light|dark` query params you'd use on the live page — e.g. `/r/<hash>.pdf?theme=editorial&mode=light` — and the export captures that themed render. Each theme/mode (and PNG height) combination is cached independently, so the default and themed exports never clobber each other. Both formats are sized to the rendered content: the PNG is captured at the page's full content height (no trailing whitespace), and the PDF is a single page sized to the same content height (no page breaks, no clipping).

### How it works

The first export request for a given hash takes a few seconds; subsequent requests for the same hash are served from cache and complete in milliseconds. Editing the render through `overwrite_hash` invalidates the cached export, so the next export request re-renders against the new content.

### PNG height

By default the PNG is captured at the page's **full content height** — the image is exactly as tall as the rendered page, with no trailing whitespace. To pin a fixed capture height instead, pass `?h=<px>`:

```
GET /r/<hash>.png?h=20000
```

`h` must be an integer in `[1, 30000]`. A fixed height shorter than the content clips it; taller pads with whitespace. Each distinct height is cached under its own slot (it does not clobber the default full-page capture). The PDF route always sizes itself to the content.

### Mermaid blocks

The export captures the page at first paint and does not run JavaScript. For most blocks this is the final visual; for `mermaid` blocks (which render client-side), the exported PDF/PNG shows the raw mermaid source text rather than the rendered diagram. Use the live `/r/<hash>` page when you need the diagram visual.

### Password-protected renders

Password-protected `/r/<hash>` returns **404** on both `.pdf` and `.png`. The slug routes follow the same rule: a `/p/<slug>.{pdf,png}` that resolves to a protected underlying hash also returns 404. No password-prompt page, no error envelope — just a clean 404. Matches the v1 stance: exports of protected content are not supported. (A future revision may add a `?pass=<password>` query-param escape hatch.)

### Errors

| Status | When |
|---|---|
| 400 | `?h=` is non-integer or outside `[1, 30000]`. |
| 404 | Hash does not exist, hash shape invalid, or render is password-protected. |
| 502 | The export renderer returned an error. |
| 504 | The export renderer timed out (>30 s). |

### Worked example

```bash
# Render a payload.
curl -s https://mira.cagdas.io/v1/render \
  -H 'Content-Type: application/json' \
  -d '{"template":"page","blocks":[{"type":"heading_1","heading_1":{"rich_text":[{"type":"text","text":{"content":"Q3 roadmap"}}]}}]}'
# → {"url":"https://mira.cagdas.io/r/abc123"}

# Fetch a PDF of that render.
curl -L -o roadmap.pdf https://mira.cagdas.io/r/abc123.pdf

# Fetch a PNG screenshot.
curl -L -o roadmap.png https://mira.cagdas.io/r/abc123.png

# Or fetch the latest version of a persistent slug as a PDF / PNG.
curl -L -o roadmap.pdf https://mira.cagdas.io/p/team-roadmap.pdf
curl -L -o roadmap.png https://mira.cagdas.io/p/team-roadmap.png

# A historic version of the same slug.
curl -L -o roadmap-v2.pdf https://mira.cagdas.io/p/team-roadmap/v/2.pdf
```

## Editing renders

Renders are editable in place: a viewer can change the content of any block
flagged `editable: true` and the page auto-saves through the same
`POST /v1/render` endpoint that created the render.

### Per-block opt-in

A block opts into inline editing by setting `editable: true` on its body
sub-object. The currently supported editable surfaces are:

- [`paragraph`](#paragraph) — `body` (plain string) rendered as a
  `<textarea>` the viewer can type into. Rich-text marks are not
  supported in this mode.
- [`choice`](#choice) — radio (single-select) or checkbox group
  (multi-select). The viewer picks an option or set of options; the
  selection writes back to `selected[]`.
- [`approve`](#approve) — a reversible affirm button. The viewer clicks
  to flip the `approved` boolean between `true` and `false`.
- [`kanban`](#kanban) — structural board edits. The viewer renames cards
  inline (hover-reveal), adds cards per column, removes cards (hover-
  reveal confirm pill), and drag-reorders cards within and across
  columns (mouse + keyboard Space/arrow/Space/Esc). Column-level edits
  remain agent-only.

When `editable: true`, the renderer emits native form controls (`<textarea>`, radios, checkboxes, button) and loads a small same-origin client script that auto-saves viewer edits. Each change is debounced, then the page fetches `/r/<hash>.json`, applies the edit, and POSTs the mutated payload back to `/v1/render` with `overwrite_hash`. Edits that add, remove, or reorder array elements reload the page; text and toggle edits update in place.

### Save mechanism — `overwrite_hash`

There is no separate edit endpoint and no patch grammar. Saves re-POST
the full Page payload to `POST /v1/render` with one extra top-level
field:

```json
{
  "template": "page",
  "overwrite_hash": "<existing-render-hash>",
  "blocks": [ ... ]
}
```

The server validates the new payload against the same schema as a fresh
render, then replaces the JSON stored at `<existing-render-hash>` with the
new payload. The response body has the same shape as a fresh render —
`{"url":"https://mira.cagdas.io/r/<hash>"}` — with the same hash echoed
back, so subsequent saves can keep using it.

`overwrite_hash` also accepts a **persistent slug** instead of a bare
hash. When the value is a slug (e.g. `"overwrite_hash": "my-doc"`), the
save targets that slug's **latest version** and edits its render in
place — letting you update a persistent page without tracking the
underlying hash. No new version is appended (use a normal `persistent`
render for that); the slug keeps pointing where it did and `/p/<slug>`
reflects the edit immediately. The response echoes the
`{"url":"https://mira.cagdas.io/p/<slug>"}` URL. If the value is both a
valid hash and a valid slug, an existing render at that hash wins; the
slug is only consulted when no render exists at the hash.

Constraints:

- The full payload must validate. Partial updates are not supported.
- The 5 MB body cap applies as it does to fresh renders.
- `persistent`, `new_password`, and `network` blocks are not supported on
  the overwrite path. POST a fresh render for those.
- The standard 60-per-hour-per-IP render rate limit is **bypassed** on
  overwrite saves — the gating is "have the URL" (or "have the password"),
  which already constrains the abuse surface.

### Trust model

mira treats edits wiki-style:

- **Open renders** (no password): anyone with the URL can save.
- **Password-protected renders**: a viewer with a valid
  unlock cookie can both view AND save — the cookie is the edit
  credential, no second password prompt. Non-browser callers can include
  `password` in the body of the overwrite request instead.

An editor who can save can rewrite **any** field — title, caption, blocks,
including blocks not marked `editable`. The server validates the schema
but does not restrict which fields differ from the previous version. The
audit log at `renders.jsonl` records each save with timestamp and source
IP, so the creator can review who has been writing.

### Concurrency

Last-write-wins, byte-level. Two tabs editing the same hash both succeed;
the later save's bytes are the persisted state. There is no version
token, no merge, no diff. Build clients accordingly.

### Failure modes

| Status | Body | When |
|---|---|---|
| 400 | `{"error":"overwrite_hash: invalid hash characters"}` | Hash fails the shape check (length / Crockford alphabet). |
| 400 | `{"error":"paragraph.editable: rich_text not allowed when editable is true (use body string instead)"}` | Validation: an editable paragraph carried `rich_text`. |
| 400 | `{"error":"persistent: not allowed with overwrite_hash ..."}` | `persistent` or `new_password` was set alongside `overwrite_hash`. |
| 403 | `{"error":"overwrite_hash: render is password-protected; supply matching \"password\" in body or unlock first"}` | The render is protected and neither a valid cookie nor a body `password` was supplied. |
| 403 | `{"error":"overwrite_hash: password does not match"}` | The body `password` doesn't match the stored hash. |
| 404 | `{"error":"overwrite_hash: no render or slug exists at \"<value>\""}` | The value is a valid hash or slug in shape, but no render exists at that hash and no pointer exists for that slug. |
| 413 | `{"error":"request body exceeds 5MB limit"}` | Overwrite body exceeded the universal cap. |

## Versioning

All endpoints live under `/v1/`. Breaking changes — new required fields, removed block types, renamed keys — go to `/v2/`. Additive changes (new optional fields, new block types) may land in `/v1/` and will be documented here.
