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

> ⚠ **Before you POST, get each block's exact field schema.** This contract is large (~277 KB) and the schema is strict — unknown or wrongly-typed fields return 400. If you read this spec through a tool that summarizes pages (e.g. a web-fetch that condenses long content), per-block field *types* are easily dropped, which is the most common cause of a rejected payload. For every block you plan to use, fetch its small, summary-safe schema at **`/v1/spec/<block>.md`** — e.g. `/v1/spec/stat_grid.md`. If that path returns 404, the block's section in this document is authoritative.

### 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 (the edge's HTTP/2 header-list cap is on the order of ~64 KB), with headroom for headers and cookies. Above the wire cap, the edge can silently reject the request (`RST_STREAM`) — 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**.

**New-tab behavior:** `https:` links (off-page destinations) render with `target="_blank" rel="noopener noreferrer"`, so following one keeps the render open in the original tab. `mailto:` links (hand off to a mail client) and same-page `#fragment` links open in place. This is automatic — you don't set `target`/`rel` yourself (those fields are rejected by the closed schema).

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

The default text block; most page copy is paragraphs. An empty `rich_text` is allowed.

| Field | Required | Type | Notes |
| --- | --- | --- | --- |
| `rich_text` | optional | `rich_text` array | The text (omit in editable mode). |
| `color` | optional | enum: `default` | Only `default` (or omit). |
| `editable` | optional | boolean | Inline-edit mode — renders a `<textarea>`; then use `body` and leave `rich_text` empty. |
| `body` | optional | string | Plain text, required when `editable: true` (no marks). See Editing renders. (≤ 2000 runes) |

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

### `heading_1`

**Required fields:** `rich_text`.

A top-level heading. mira auto-derives an `id` (kebab-cased plain text, ≤ 40 runes) so headings deep-link at `…#<slug>` — there is no `id` field, no `is_toggleable`, and no `heading_4`+.

| Field | Required | Type | Notes |
| --- | --- | --- | --- |
| `rich_text` | **required** | `rich_text` array | The heading text (1+ segments; empty rejected). |
| `color` | optional | enum: `default` | Only `default` (or omit). |

```json
{ "type": "heading_1", "heading_1": { "rich_text": [{ "type": "text", "text": { "content": "Quarterly report" } }] } }
```

### `heading_2`

**Required fields:** `rich_text`.

A section heading (level 2). Same shape as `heading_1`; auto-derives a deep-link `id`.

| Field | Required | Type | Notes |
| --- | --- | --- | --- |
| `rich_text` | **required** | `rich_text` array | The heading text (1+ segments; empty rejected). |
| `color` | optional | enum: `default` | Only `default` (or omit). |

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

### `heading_3`

**Required fields:** `rich_text`.

A sub-section heading (level 3). Same shape as `heading_1`; auto-derives a deep-link `id`.

| Field | Required | Type | Notes |
| --- | --- | --- | --- |
| `rich_text` | **required** | `rich_text` array | The heading text (1+ segments; empty rejected). |
| `color` | optional | enum: `default` | Only `default` (or omit). |

```json
{ "type": "heading_3", "heading_3": { "rich_text": [{ "type": "text", "text": { "content": "Implementation notes" } }] } }
```

### `bulleted_list_item`

**Required fields:** `rich_text`.

One bullet. Consecutive `bulleted_list_item` blocks are wrapped in a single `<ul>`; put another block between two runs to split lists. There is no `bulleted_list` parent — emit one item per entry. No `children` in v2.0.

| Field | Required | Type | Notes |
| --- | --- | --- | --- |
| `rich_text` | **required** | `rich_text` array | The item text (0+ segments). |
| `color` | optional | enum: `default` | Only `default` (or omit). |

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

### `numbered_list_item`

**Required fields:** `rich_text`.

One numbered item. Consecutive items are wrapped in a single `<ol>` (numbering always starts at 1). No `children`, `list_start_index`, or `list_format` in v2.0.

| Field | Required | Type | Notes |
| --- | --- | --- | --- |
| `rich_text` | **required** | `rich_text` array | The item text (0+ segments). |
| `color` | optional | enum: `default` | Only `default` (or omit). |

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

### `toggle`

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

A `toggle` is a native, JS-free disclosure widget (`<details><summary>…</summary>…</details>`): a clickable header that expands to reveal nested blocks. Use it for FAQs, progressive disclosure, and collapsing long or secondary content.

| Field | Required | Type | Notes |
| --- | --- | --- | --- |
| `rich_text` | **required** | `rich_text` array | The visible header/summary (1+ segments). |
| `color` | optional | enum: `default` | Only `default` (or omit). |
| `children` | **required** | array of blocks | The collapsible body (**1+ blocks**). Any of the 13 block types except `table_row`; same constraints as their top-level counterparts; counts toward the depth ≤ 3 cap. Empty `children` is rejected. |
| `default_open` | optional | boolean | When `true`, the `<details>` carries the HTML `open` attribute so the body shows at first paint without an expand-click. Defaults to `false`. |

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

#### Common rejections

- **Empty `children`** — a toggle with no body would render as a click-to-nothing and is rejected.
- **`table_row` child** — `table_row` is constrained to `table.children` only and may not appear under a toggle.
- **Nesting past depth 3** — children count toward the ≤ 3-level cap.

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

A `code` block renders a monospaced, preformatted code listing with an optional language pill and caption. Its body text is emitted verbatim — whitespace preserved, annotations ignored — for source snippets, config, and terminal output.

| Field | Required | Type | Notes |
| --- | --- | --- | --- |
| `rich_text` | **required** | `rich_text` array | The code body (1+ segments; the concatenated `text.content` must be non-empty). Only the literal text is emitted — `annotations` (bold/italic/…/`status`) are ignored. Newlines, tabs, and other whitespace are preserved verbatim. |
| `language` | optional | string | Defaults to `"plain text"` if missing or empty. Charset `[A-Za-z0-9+#.- ]` only (letters, digits, `+`, `#`, `.`, `-`, space); any other character returns 400. No language enum and no syntax highlighting — emitted as `class="language-<value>"` plus a language pill. (≤ 20 runes) |
| `caption` | optional | `rich_text` array | Optional caption (0+ segments) rendered below the code as a `<figcaption>`. Unlike the body, the caption DOES support full annotations, including `status`. |

#### Common rejections

- **Empty body** — `rich_text` missing, zero segments, or concatenated `text.content` empty.
- **Illegal `language` character** — anything outside `[A-Za-z0-9+#.- ]` (e.g. `<`, `/`, `"`); there is no language enum.
- **`language` longer than 20 runes** — capped; over-length returns 400.

Inside `code.rich_text`, `annotations` (including `status`) are ignored entirely — only the literal concatenated `text.content` is emitted, matching Notion's UI. The `caption`, when present, honors full annotations and `status`.

```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 } }
    ]
  }
}
```

### `diff`

**Required fields:** `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.

| Field | Required | Type | 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: `unified` / `side_by_side` | Default `unified`. `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 | string | Single-line plain string (NOT `rich_text`). Rendered as an auto-anchored heading above the diff. Newlines rejected. (≤ 120 runes) |
| `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.)

**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).

```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"
  }
}
```

### `quote`

**Required fields:** `rich_text`.

A block quote — set-off quoted text.

| Field | Required | Type | Notes |
| --- | --- | --- | --- |
| `rich_text` | **required** | `rich_text` array | The quoted text (1+ segments). |
| `color` | optional | enum: `default` | Only `default` (or omit). |
| `children` | optional | array of blocks | Optional nested 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`.

A `callout` is an aside — an icon beside body text in a tinted panel — for tips, warnings, and notes set apart from the surrounding prose.

| Field | Required | Type | Notes |
| --- | --- | --- | --- |
| `icon` | **required** | emoji object `{type:"emoji", emoji:…}` | One or more emoji codepoints, ≤ 32 bytes UTF-8 (ZWJ sequences allowed). Other Notion icon variants are rejected; there is no default. |
| `rich_text` | **required** | `rich_text` array | The callout body text (1+ segments). |
| `color` | optional | enum: `default` | Only `default` (or omit). |
| `children` | optional | array of blocks | Optional nested blocks; same constraints as top-level, counts toward the depth ≤ 3 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 `{}`.

A horizontal rule. The body is the empty object `{}`; any field on it returns 400.

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

### `image`

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

An image with an optional caption. 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.

| Field | Required | Type | Notes |
| --- | --- | --- | --- |
| `type` | **required** | enum: `external` / `file` | Selects which URL carrier is present; must equal `"external"` or `"file"`. |
| `external` | optional | object | Required when `type` is `external` (and then `file` must be absent). |
| `file` | optional | object | Required when `type` is `file` (and then `external` must be absent). Same shape as `external`. |
| `caption` | optional | `rich_text` array | Optional caption (0+ segments); also used as the `alt` text. |

#### External object

| Field | Required | Type | Notes |
| --- | --- | --- | --- |
| `url` | **required** | string | An `https://` URL (fetched & cached server-side at POST time), a `data:image/<png|jpeg|webp|gif>;base64,…` URI with decoded body ≤ 64 KB (`data:image/svg+xml` is rejected), or a pre-uploaded asset URL `https://mira.cagdas.io/asset/<id>` from `POST /v1/assets`. (≤ 2048 runes) |

#### File object

| Field | Required | Type | Notes |
| --- | --- | --- | --- |
| `url` | **required** | string | An `https://` URL (fetched & cached server-side at POST time), a `data:image/<png|jpeg|webp|gif>;base64,…` URI with decoded body ≤ 64 KB (`data:image/svg+xml` is rejected), or a pre-uploaded asset URL `https://mira.cagdas.io/asset/<id>` from `POST /v1/assets`. (≤ 2048 runes) |

Provide **exactly one** of `external`/`file`, and it must match `type`: `type:"external"` requires an `external` body (no `file`), `type:"file"` requires a `file` body (no `external`). Both objects have the identical shape (a single `url`).

#### URL rules

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

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

#### Common rejections

- **`type` present but the matching body missing** (`type:"external"` with no `external`, etc.).
- **Both `external` and `file` present** — supply only the one matching `type`.
- **`data:image/svg+xml`** or any non-`png/jpeg/webp/gif` content type — SVG and other types fall through.
- **Non-`https` / non-`data:` scheme** (e.g. `http`, `ftp`).

```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` renders a grid of cells. Its `children` are `table_row` blocks — the only block in v2.0 with a constrained child type — and every row's `cells` count must equal `table_width`. Optional header flags promote the first row and/or the first cell of each row to `<th>`.

| Field | Required | Type | Notes |
| --- | --- | --- | --- |
| `table_width` | **required** | integer | Number of columns; every row's `cells.length` must equal this. |
| `has_column_header` | optional | boolean | Default `false`. When `true`, the first row renders inside `<thead>` with `<th scope="col">` cells. |
| `has_row_header` | optional | boolean | Default `false`. When `true`, the first cell of each non-header row renders as `<th scope="row">`. |
| `children` | **required** | array of blocks | 1+ `table_row` blocks; any other child type returns 400. |

#### `table_row` body

| Field | Required | Type | Notes |
| --- | --- | --- | --- |
| `cells` | **required** | array of `rich_text` arrays (one entry per column) | `cells.length` MUST equal the parent `table.table_width`; a mismatch returns 400 naming the row index. Cells may be empty `rich_text` arrays (renders as `<td></td>`). |

`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. When both header 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 across 8 chart types in three families — xy (line/area/scatter), category (bar/grouped_bar/stacked_bar), and proportion (pie/donut). Axes, series shape, and series count rules all key off `chart_type`.

| Field | Required | Type | Notes |
| --- | --- | --- | --- |
| `chart_type` | **required** | enum: `line` / `area` / `scatter` / `bar` / `grouped_bar` / `stacked_bar` / `pie` / `donut` | Selects the family and drives every other constraint (axes presence, series-count, data shape). |
| `title` | optional | string | Plain string (NOT `rich_text`). Rendered as `<h4>` above the SVG and mirrored as the SVG `<title>`. Absent → no title. (≤ 120 runes) |
| `caption` | optional | `rich_text` array | Rendered as `<figcaption>` below the SVG. Same shape as image-block captions (full annotation set + link allowlist); 0–100 segments; counts toward the global 2000-span budget. |
| `x_axis` | optional | object | **Required** for xy and category families; **must be absent** for pie/donut (sending it → 400). See axis type rules below. |
| `y_axis` | optional | object | **Required** for xy and category families; **must be absent** for pie/donut. `y_axis.type` must always be `number`. Same object shape as `x_axis`. |
| `series` | **required** | array of Series object | Always an array of `{name?, color?, data}` wrapper objects, even when only one is allowed. Per-chart_type count: `bar`/`pie`/`donut` = exactly 1; `grouped_bar`/`stacked_bar` = 2–8; `line`/`area`/`scatter` = 1–8. pie/donut wrappers carry 2–12 slice objects in `data`. (1–8 entries) |
| `legend` | optional | object | Optional `{visible?, position?}`. Defaults vary by family (see below). |
| `palette` | optional | enum: `default` / `sequential` / `diverging` | `default` (categorical distinct hues, implicit), `sequential` (single-hue ramp for ordinal data), `diverging` (two-hue ramp for signed data). Per-series/slice `color` overrides take precedence. |
| `aspect_ratio` | optional | enum: `16:9` / `4:3` / `1:1` | Default `16:9`. The chart fills container width and sizes height via CSS `aspect-ratio`. No portrait ratios. |

#### X-axis object

| Field | Required | Type | Notes |
| --- | --- | --- | --- |
| `type` | **required** | enum: `category` / `number` / `time` | `y_axis.type` must always be `number`. `x_axis.type` subset varies by chart_type: line/area accept category|number|time; scatter accepts number|time (no category); bar/grouped_bar/stacked_bar accept category only; pie/donut take no axes at all. |
| `label` | optional | string | Rendered along the axis. (≤ 80 runes) |
| `categories` | optional | array of string | **Required when `type="category"`**; rejected when type is number/time. Each ≤ 50 runes. When present, `categories.length` MUST equal every `series[i].data.length`. (1–50 entries) |
| `tick_format` | optional | string | Allowlisted format. For `type="number"`: a Go-`fmt` string with exactly ONE float verb (`%f`/`%e`/`%g`, optional flags/width/precision) plus literal prefix/suffix — e.g. `%.0f`, `%.1f%%`, `$%.0fM`. For `type="time"`: exactly one of the 5 strftime atoms `%Y`, `%Y-%m`, `%Y-%m-%d`, `%H:%M`, `%b %Y`. Rejected outright when `type="category"`; other verbs / >1 verb / >32 chars are rejected. (≤ 32 runes) |
| `min` | optional | string or number | Number when `type="number"`; ISO-8601 string (`2026-01-01` or `2026-01-01T00:00:00Z`) when `type="time"`. Rejected when `type="category"`. |
| `max` | optional | string or number | Number when `type="number"`; ISO-8601 string when `type="time"`. Rejected when `type="category"`. |

#### Y-axis object

| Field | Required | Type | Notes |
| --- | --- | --- | --- |
| `type` | **required** | enum: `category` / `number` / `time` | `y_axis.type` must always be `number`. `x_axis.type` subset varies by chart_type: line/area accept category|number|time; scatter accepts number|time (no category); bar/grouped_bar/stacked_bar accept category only; pie/donut take no axes at all. |
| `label` | optional | string | Rendered along the axis. (≤ 80 runes) |
| `categories` | optional | array of string | **Required when `type="category"`**; rejected when type is number/time. Each ≤ 50 runes. When present, `categories.length` MUST equal every `series[i].data.length`. (1–50 entries) |
| `tick_format` | optional | string | Allowlisted format. For `type="number"`: a Go-`fmt` string with exactly ONE float verb (`%f`/`%e`/`%g`, optional flags/width/precision) plus literal prefix/suffix — e.g. `%.0f`, `%.1f%%`, `$%.0fM`. For `type="time"`: exactly one of the 5 strftime atoms `%Y`, `%Y-%m`, `%Y-%m-%d`, `%H:%M`, `%b %Y`. Rejected outright when `type="category"`; other verbs / >1 verb / >32 chars are rejected. (≤ 32 runes) |
| `min` | optional | string or number | Number when `type="number"`; ISO-8601 string (`2026-01-01` or `2026-01-01T00:00:00Z`) when `type="time"`. Rejected when `type="category"`. |
| `max` | optional | string or number | Number when `type="number"`; ISO-8601 string when `type="time"`. Rejected when `type="category"`. |

#### Series object

| Field | Required | Type | Notes |
| --- | --- | --- | --- |
| `name` | optional | string | Legend label; when absent the legend uses `Series N`. Unused by pie/donut (slice `label`s drive their legend). (≤ 60 runes) |
| `color` | optional | string | Optional per-series hex override matching `^#[0-9a-f]{6}$` — **lowercase only**. CSS color names, uppercase hex, `rgb()`/`rgba()`, and 3-char shorthand are all rejected. Absent → palette color for the series index. |
| `data` | **required** | array | Shape varies by family (the validator parses the per-family shape): a flat number array for category/bar; `[x, y]` pairs for number/time x-axes; an array of `{label, value, color?}` slice objects for pie/donut (2–12 slices, each `value` > 0). ≤ 200 points/series; ≤ 800 total. (1–200 entries) |

#### Legend object

| Field | Required | Type | Notes |
| --- | --- | --- | --- |
| `visible` | optional | boolean | Show/hide the legend. Defaults vary by family: multi-series xy/category → true; 1-series xy/bar → false; pie/donut → true. |
| `position` | optional | enum: `top` / `bottom` / `left` / `right` | Default `bottom` for xy/category, `right` for pie/donut. On narrow viewports `left`/`right` can compress the plot under 50% width — prefer `bottom`/`top` for mobile. |

#### chart_type families

- **xy** — `line`, `area`, `scatter`. 2D plot; `x_axis.type` can be category, number, or time (scatter excludes category). For `area` every series must have the same number of points (they stack).
- **category** — `bar`, `grouped_bar`, `stacked_bar`. Discrete `x_axis.type="category"` only. `bar` takes exactly 1 series; grouped/stacked take 2–8. `stacked_bar` rejects negative values.
- **proportion** — `pie`, `donut`. No axes. Exactly 1 series wrapper whose `data` is 2–12 slice objects; each slice `value` must be > 0.

#### `series.data` shape (varies by family)

- **xy with category x-axis** / **category family (bar/grouped_bar/stacked_bar)** — flat number array; length MUST equal `x_axis.categories.length`. E.g. `[120, 145, 180, 210]`.
- **xy with number or time x-axis** — array of `[x, y]` pairs. `number` → x is a JSON number; `time` → x is an ISO-8601 string (`YYYY-MM-DD` or RFC3339). mira sorts by x ascending automatically. E.g. `[["2026-01-15", 245], ["2026-02-12", 220]]`.
- **proportion (pie/donut)** — array of slice objects `{label, value, color?}`; `label` 1–40 runes, `value` strictly positive. (This is the shape the schema's `data` element models.)

#### Caps

series count 1–8 (bar/pie/donut = 1; grouped/stacked = 2–8); ≤ 200 points per series (xy); ≤ 50 categories per axis; pie/donut 2–12 slices; ≤ 800 total points across all series; `title` ≤ 120 runes; `axis.label` ≤ 80; category label ≤ 50; series `name` ≤ 60; slice `label` ≤ 40. A chart counts as one block; only its `caption`'s spans count toward the 2000-span budget.

#### `legend` defaults

Multi-series xy/category → `{visible:true, position:"bottom"}`; 1-series xy/bar → `{visible:false}`; pie/donut → `{visible:true, position:"right"}`.

#### Color overrides

`series[i].color` and `slice.color` must match `^#[0-9a-f]{6}$` — lowercase 7-char hex only. CSS color names (`red`), uppercase hex (`#FF0000`), `rgb()`/`rgba()`, and 3-char shorthand (`#f00`) are all rejected. Absent → palette color for that index.

#### Common rejections

- **Color override format** — series/slice `color` not matching `^#[0-9a-f]{6}$` (uppercase, `#f00`, `rgba(...)`, CSS names).
- **`bar` with multiple series** — `bar` accepts exactly 1; use `grouped_bar`/`stacked_bar`.
- **Axes on `pie`/`donut`** — proportion charts reject `x_axis` and `y_axis` entirely.
- **`scatter` with `x_axis.type:"category"`** — scatter needs `number` or `time`; use `bar` for category comparisons.
- **Negative values in `stacked_bar`** — use `grouped_bar` for signed data.
- **`tick_format` shape** — number axes accept exactly one float verb; `%d`/`%s`, multi-verb, and >32 chars are rejected. Category axes reject `tick_format` outright.
- **category/series length mismatch** — when `x_axis.type="category"`, every `series[i].data.length` must equal `categories.length`.

```json
{
  "type": "chart",
  "chart": {
    "chart_type": "bar",
    "x_axis": { "type": "category", "categories": ["Q1", "Q2", "Q3", "Q4"] },
    "y_axis": { "type": "number" },
    "series": [
      { "name": "Revenue", "data": [120, 145, 180, 210] }
    ]
  }
}
```

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

| Field | Required | Type | Notes |
| --- | --- | --- | --- |
| `title` | optional | string | Heading above the grid. (≤ 120 runes) |
| `caption` | optional | `rich_text` array | Caption below the grid. |
| `columns` | optional | "auto" or integer 1–6 | Default "auto" (fits to width). |
| `tiles` | **required** | array of Tile object | A single-tile grid is rejected — use `callout`. (2–12 entries) |

#### Tile object

| Field | Required | Type | Notes |
| --- | --- | --- | --- |
| `label` | **required** | string | The metric name. (1–60 runes) |
| `value` | **required** | string or number | The big number. Strings render verbatim; numbers get no thousands separators. Prefix currency goes in the string (e.g. `$4.2M`). (1–24 runes) |
| `unit` | optional | string | Suffix after the value, in muted type. (≤ 8 runes) |
| `trend` | optional | object | Delta annotation; see below. |
| `accent` | optional | enum: `default` / `positive` / `negative` / `warning` / `info` | Semantic top-border color (see Accent enum). |
| `description` | optional | `rich_text` array | Muted text below the trend. |
| `link` | optional | object | Makes the whole tile a link (mutually exclusive with links inside `description`). |

#### Trend object

| Field | Required | Type | Notes |
| --- | --- | --- | --- |
| `direction` | **required** | enum: `up` / `down` / `flat` | Arrow glyph (↑ ↓ →). |
| `magnitude` | optional | string or number | e.g. "18%", "+1,200". (≤ 24 runes) |
| `period` | optional | string | e.g. "QoQ", "YoY". (≤ 16 runes) |
| `semantic` | optional | enum: `positive` / `negative` / `neutral` | Override the default direction→color mapping. |

#### Accent enum

`accent` (per tile) and `trend.semantic` are **semantic enums, not colors**. `accent`: `default` (none), `positive` (emerald), `negative` (rose), `warning` (amber), `info` (blue). Hex values, CSS color names, and palette indexes are all rejected.

#### Common rejections

- **Single-tile grid** (`tiles` < 2) — use `callout`; a one-tile grid has no value.
- **Newline in `value`** — move the secondary line into `description`.
- **`trend` with no `direction`** — `direction` is required whenever `trend` is present.
- **`unit` longer than 8 runes** — embed long units in the `value` string.
- **Hex / palette index in `accent` or `trend.semantic`** — only the named enum values are accepted.
- **Unknown fields** on the body or any tile (closed schema).

```json
{
  "type": "stat_grid",
  "stat_grid": {
    "title": "Q2 snapshot",
    "tiles": [
      { "label": "MRR", "value": "$4,300", "accent": "positive", "trend": { "direction": "up", "magnitude": "39%", "period": "QoQ" } },
      { "label": "Logo churn", "value": "4.2%", "accent": "negative", "trend": { "direction": "up", "semantic": "negative", "period": "QoQ" } }
    ]
  }
}
```

### `mermaid`

**Required fields:** `source`.

The `mermaid` block embeds a Mermaid diagram. The agent supplies the diagram source as text; it is rendered in the viewer's browser via a bundled mermaid.js v11 (the same library powering GitHub/GitLab/Notion). It is the right block for flowcharts, sequence diagrams, ER schemas, gantt charts, and the rest of mermaid's diagram catalogue.

| Field | Required | Type | Notes |
| --- | --- | --- | --- |
| `source` | **required** | string | The Mermaid diagram source. ≤ 200 lines. The first non-blank, non-`%%`-comment line MUST begin (case-sensitive) with one of the supported diagram-type keywords: `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`. mira does not parse the body — mermaid handles per-type validity in the browser. `click`/`callback`/`href "…"` directives are rejected (would bind JS); a `%%{init:…}%%` directive is allowed only as `{theme:"default"}` or `{theme:"dark"}` and is stripped before storage. (1–6000 runes) |
| `title` | optional | string | Rendered above the diagram. Plain string, NOT `rich_text`. (≤ 120 runes) |
| `caption` | optional | `rich_text` array | Rendered below the diagram. 0–100 segments; counts toward the global 2000-span budget. |
| `aspect_ratio` | optional | enum: `auto` / `16:9` / `4:3` / `1:1` | Default `auto` — unlike `chart`'s `16:9` default, because flowcharts can be tall or wide and forcing a fixed ratio letterboxes most diagrams. Any other value (`portrait`, `3:2`, `21:9`, integers) returns 400. |
| `accessibility` | optional | object | Optional `{ "description": "…" }`. Closed schema — unknown keys return 400; a non-object (number/string/array) returns 400 (`accessibility: must be an object`). |

#### Accessibility object

| Field | Required | Type | Notes |
| --- | --- | --- | --- |
| `description` | optional | string | Overrides the default `aria-label` on the rendered `<pre class="mermaid">` element (default `"mermaid diagram"`). Always set this for non-trivial diagrams — screen readers cannot meaningfully read the rendered SVG. NOT `rich_text`. A non-string value returns 400 (`accessibility.description: must be a string`). (≤ 500 runes) |

#### Restrictions

- **`click X foo()` / `callback X fn` / `href "…"`** — mermaid's JS-handler / link-binding syntax. Any matching line returns 400 naming the directive. `callback` is matched only at line-start, so arrow-message text containing the word (e.g. `A->>B: GET /callback?code=abc`) is fine. Use a `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.*`, …) returns 400 naming the offending key. The validated directive is **stripped** from the stored source — mira's stylesheet drives the look, not the directive.
- **Native `theme` directive** (`forest`, `neutral`, …) — accepted only inside the `%%{init:{theme:…}}%%` form, and even then only `"default"`/`"dark"` are valid. Stripped before storage.
- **`classDef` / per-node `:::class`** — flow through unmodified. Classes other than `:::primary` / `:::warning` / `:::positive` / `:::info` are accepted but have no mira-provided styling.
- **Unknown top-level keys** (e.g. `diagram_type`, `theme`, `width`) — rejected (closed schema). The diagram type is derived from the first source line, never a separate field.

Pages containing a `mermaid` block load a bundled mermaid library (~3 MB) and run it in the viewer's browser (same-origin JS); otherwise pages are fully static.

#### Caps

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

#### Common rejections

- **Source doesn't start with a supported keyword** — first non-blank, non-`%%`-comment line must begin (case-sensitive) with one of the listed keywords. Common typos: `flowChart`, `Flowchart`, `flow chart`, `sequencediagram`. The error names the first 30 chars of what you sent.
- **`click` / `callback` / `href "…"` directives** — error names which matched.
- **`%%{init:…}%%` with anything other than `{theme:"default"|"dark"}`** — error names the offending key.
- **`source` over 6000 runes or 200 lines** — split into multiple `mermaid` blocks or simplify.
- **`aspect_ratio` outside the 4-value enum.**
- **Non-string `source`/`title`/`aspect_ratio`/`accessibility.description`** — `<field>: must be a string`. **Non-object `accessibility`** — `accessibility: must be an object`.
- **Unknown top-level keys** — closed schema, no escape hatch.

```json
{
  "type": "mermaid",
  "mermaid": {
    "source": "flowchart TD\n  A[Start] --> B{Auth?}\n  B -->|yes| C[Dashboard]\n  B -->|no| D[Login]\n",
    "title": "Login flow",
    "accessibility": { "description": "A flowchart: Start branches on Auth into Dashboard or Login." }
  }
}
```

### `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, an 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 would otherwise be flattened into a numbered list.

| Field | Required | Type | Notes |
| --- | --- | --- | --- |
| `orientation` | optional | enum: `vertical` / `vertical-alternating` / `horizontal` | Defaults to `vertical`. `vertical-alternating` puts cards on alternating sides of a center rail (collapses to single-rail below 600px); `horizontal` scrolls left-to-right with a keyboard-focusable scroll region. |
| `title` | optional | string | Plain string (NOT `rich_text`), rendered as `<h4>` above the rail. ≤ 120 runes, no newlines. (≤ 120 runes) |
| `caption` | optional | `rich_text` array | 0–100 segments, rendered as `<figcaption>` below the rail. Counts toward the global span budget. |
| `now_marker` | optional | string or number | Boolean OR ISO date string. `true` → draw a "Now" line at `time.Now()` UTC at render time. An ISO date string (`"2026"`, `"2026-03"`, `"2026-03-15"`, 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. Other types or unparseable strings return 400. (Modeled as string|number; the real accepted shapes are boolean or ISO string.) |
| `group_by` | optional | enum: `none` / `year` / `month` | Defaults to `none`. Inserts a group-header `<li>` before the first event of each bucket — year buckets render `2026`, month buckets `January 2026`. Events with unparseable sort dates land in a trailing `Other` bucket. There is no `quarter` value — encode quarters in the date `display` field instead. |
| `density` | optional | enum: `comfortable` / `compact` | Defaults to `comfortable`. `compact` reduces vertical spacing — useful for 20+-event lists. |
| `events` | **required** | array of Event object | **1–50** entries. Empty or oversized returns 400. Events are auto-sorted chronologically ascending by parsed sort key (stable for ties); unparseable dates preserve agent order at the tail. mira owns ordering — there is no `sort_order` field. (1–50 entries) |

#### Event object

| Field | Required | Type | Notes |
| --- | --- | --- | --- |
| `date` | **required** | object | Required on every event. Dual-shape: a plain ISO **string** (1–32 runes — RFC3339 / YYYY-MM-DD / YYYY-MM / YYYY; non-matching strings render verbatim but sort at the end) OR an **object** in one of two closed shapes — `{sort, display}` or `{start, end}` (see the object table). Mixing the two object shapes, or unknown keys, returns 400. A bare number/array/null returns 400. (Modeled here as an object; the string form is the common case.) |
| `label` | **required** | string | Plain string headline (NOT `rich_text`). 1–120 runes, no newlines. For marked-up event text put it in `description`. (1–120 runes) |
| `description` | optional | `rich_text` array | 0–100 segments, muted text below the label. Counts toward the global span budget. |
| `status` | optional | enum: `shipped` / `in-progress` / `planned` / `skipped` | Colors the marker dot (chronological state): shipped → emerald, in-progress → amber, planned → outline, skipped → muted. Independent of `accent`. |
| `accent` | optional | enum: `default` / `positive` / `negative` / `warning` / `info` | The same 5-value `stat_grid.tile.accent` enum. Drives a 2px left-border accent on the card (semantic flag). Independent of `status`. |
| `icon` | optional | string | A single grapheme (≤ 4 bytes UTF-8, e.g. `"🚀"`, `"v"`). Overrides the default dot marker. Multi-grapheme strings are rejected. (≤ 4 runes) |

#### Event date object

| Field | Required | Type | Notes |
| --- | --- | --- | --- |
| `sort` | optional | string | Object form 1 ({sort, display}): a machine-parseable ISO sort key (RFC3339, YYYY-MM-DD, YYYY-MM, or YYYY). Required alongside `display`. Drives chronological placement; non-ISO `sort` returns 400. (1–32 runes) |
| `display` | optional | string | Object form 1: free-form text rendered verbatim in the date column (e.g. `"circa 1820"`, `"Q4 2026"`). Required alongside `sort`. (1–32 runes) |
| `start` | optional | string | Object form 2 ({start, end}): range start, must parse against the ISO list. Required alongside `end`. The range sorts by `start`. (1–32 runes) |
| `end` | optional | string | Object form 2: range end. Either a parseable ISO string OR JSON `null` (meaning "ongoing"). The key is required when using the range shape — omitting it returns 400 (`use null for ongoing`); `start > end` returns 400. (≤ 32 runes) |

#### `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** — 1–32 runes, no newlines, tried against RFC3339, RFC3339Nano, `YYYY-MM-DD`, `YYYY-MM`, `YYYY` (first match wins). A match drives both sort and display; a non-match still renders verbatim but sorts at the **end**. `MM/DD/YYYY`, `Jan 15, 2024`, ISO-week `2026-W02`, and non-ASCII month names are NOT parsed — use the object form for guaranteed placement.
- **Object form `{sort, display}`** — both required; `sort` must parse against the ISO list, `display` renders verbatim. Use for approximate/non-standard dates (`circa 1820`, `Q1 2026`, `Spring 2024`).
- **Object form `{start, end}`** — both required keys; `start` must parse; `end` is a parseable ISO string OR JSON `null` ("ongoing"). Omitting `end` → 400 (`use null for ongoing`); `start > end` → 400. Displays as `"<start> – <end>"` or `"<start> – ongoing"`.
- Mixing `{sort, display}` with `{start, end}` keys → 400 (`pick either ...`). Unknown keys → 400 (closed schema; error names the offending key, allowed set is `sort, display, start, end`).

#### `now_marker` placement

The marker `<li>` is inserted into the sorted list between the last event with `sort ≤ target` and the first event with `sort > target` (first item if target precedes all, last if after). Events with no parseable date are ignored for positioning. If no event has a parseable date the marker is dropped and an HTML comment (`<!-- now_marker requested but no parseable dates -->`) is emitted instead.

#### `status` vs `accent`

They are independent. `status` colors the dot (chronological state); `accent` colors the card border (semantic flag). Setting both is fine. `accent` is the same 5-value enum `stat_grid.tile.accent` uses: `default` (none), `positive` (emerald), `negative` (rose), `warning` (amber), `info` (blue) — hex/CSS names/palette indexes are rejected.

#### Common rejections

- **`orientation` / `status` / `accent` / `group_by` / `density` outside their enum** — canonical `"<value>" not supported; must be one of ...` error.
- **`events` empty or > 50** — `[]` → 400 (`must contain at least 1 event`); 51+ → 400 (`length 51 exceeds limit of 50`). Split or summarize beyond 50.
- **`label` plain-text only** — sending a `rich_text` array (or any non-string) returns 400; empty or > 120 runes → 400; newlines rejected. `title` same (≤ 120 runes, no newlines).
- **`date` wrong type / no shape match** — `date: 2024` or `date: ["..."]` → 400 (`must be string or object`); `date: {}` → 400; `date` absent → 400.
- **`date` object: unparseable `sort`/`start`/`end`**, mixed shapes, omitted `end` (use `null`), or `start > end` — all 400.
- **`icon` not a single grapheme** — `"AB"`, `"v1"`, multi-emoji, or > 4 bytes → 400.
- **`now_marker` wrong type / unparseable string** — must be boolean or an ISO date string.
- **Unknown keys** on the body or any event (closed schema) — `sort_order`, `palette`, `link`, etc. rejected.

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

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

`calendar` renders a single month as a 7-column grid of all-day events — 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 week rows), expanded 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.

| Field | Required | Type | Notes |
| --- | --- | --- | --- |
| `month` | **required** | string | Strict `YYYY-MM`, matching `^\d{4}-(0[1-9]|1[0-2])$`. Single month per block — for spans, emit multiple `calendar` blocks (the `months` plural is caught at decode time). |
| `events` | **required** | array of Event object | 0–80 entries (`[]` is legal — renders an empty grid). **Max 6 events per day** (the renderer groups events by `date` into the matching day cell; 7+ on one date is hard-rejected). Events render in supplied order. Out-of-month cells never carry events. (0–80 entries) |
| `title` | optional | string | Plain string (NOT `rich_text`). Rendered as `<h3 class="calendar-title">` above the month-name header. Newlines rejected. A "Month YYYY" header is always shown even when `title` is absent. (≤ 120 runes) |
| `today` | optional | string | Strict `YYYY-MM-DD`, must be a real calendar date AND fall inside `month`. Highlights the matching day cell with an outlined ring (`calendar-day-today`). Payload-passed only — mira NEVER reads the server clock; supply `today` if you want it. `{month:"2026-06", today:"2026-05-11"}` is a 400 — drop `today` if you don't have one for that month. |
| `caption` | optional | `rich_text` array | 0–100 segments. Rendered as `<figcaption class="calendar-caption">` below the grid. Counts toward the global 2000-span budget. A plain-string caption is rejected. |

#### Event object

| Field | Required | Type | Notes |
| --- | --- | --- | --- |
| `date` | **required** | string | Strict `YYYY-MM-DD`, matching `^\d{4}-\d{2}-\d{2}$`. Must be a real calendar date (Feb 30 / non-leap Feb 29 rejected by `time.Parse`) AND must fall inside the block's `month`. |
| `title` | **required** | string | Single-line plain string. Rendered as `<strong class="calendar-event-title">`. NOT `rich_text`. Newlines rejected. (1–80 runes) |
| `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 `[]` is rejected (omit the field instead). Hidden at narrow viewports. |
| `accent` | optional | enum: `default` / `positive` / `negative` / `warning` / `info` | Universal 5-value enum. Paints a 3px left border-stripe + bg-tint via `calendar-event-accent-<value>`. Absent → `default` (no palette cycling — the day is already the categorical axis). Same enum as `kanban`, `stat_grid`, `comparison_matrix`, `timeline`, `slides`. |

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

- **Not multi-month** — one `month` per block; for a quarter emit three `calendar` blocks (`months` caught at decode).
- **Not timed** — all events are all-day; put a time inside `event.title` (e.g. `"10:00 — Standup"`). `start_time`/`end_time`/`time`/`start`/`end` caught at decode.
- **Not multi-day** — `event.date` is a single date; for a span emit one event per day. `end_date`/`until`/`duration` caught at decode.
- **Not recurring** — no `RRULE`; expand recurrences into individual events. `recurring`/`rrule`/`repeat` caught at decode.
- **No click-through** — no `event.link`; to link an event put the link inside `description` rich_text. `link`/`url`/`href` caught at decode.
- **No tags / assignee** — chips are lean; use `accent` for urgency cues. `tags`/`labels`/`assignee`/`owner`/`attendees` caught at decode.
- **No JS, no drag-drop, no click-to-edit, no server clock.**

#### Common rejections (verbatim validator strings)

- **`month` missing / non-string / wrong shape** — `calendar.month: required`; `calendar.month: must be a string`; `calendar.month: must match YYYY-MM (got "...")` (e.g. `"2026-5"`).
- **`events` over the cap** — 81+ → `calendar.events: total event count N exceeds limit of 80 per month`.
- **Per-day cap exceeded** — 7+ on one date → `calendar.events: 7 events on YYYY-MM-DD exceeds limit of 6 events per day`.
- **`title` too long / newline / non-string** — `calendar.title exceeds 120 runes`; `calendar.title: must not contain newlines`; `calendar.title: must be a string`.
- **`today` outside month / wrong shape** — `calendar.today "2026-06-15" is not within month 2026-05`; `calendar.today: must match YYYY-MM-DD (got "...")`; `calendar.today: "..." is not a valid calendar date`.
- **`events[i].date` missing / wrong shape / outside month** — `calendar.events[N].date: required`; `...: must match YYYY-MM-DD (got "...")`; `...: "..." is not a valid calendar date`; `calendar.events[N].date "..." is not within month YYYY-MM`.
- **`events[i].title` missing / oversized / non-string / newline** — `calendar.events[N].title: required`; `... exceeds 80 runes`; `...: must not contain newlines`; `...: must be a string`.
- **`events[i].description` over caps / non-array / empty** — `calendar.events[N].description exceeds 3 segments`; `...: total content exceeds 140 runes`; `...: must be a rich_text array`; `...: rich_text array cannot be empty` (omit the field instead).
- **`events[i].accent` outside the enum** — `calendar.events[N].accent "..." not supported; must be one of default, positive, negative, warning, info`.
- **Plain-string caption** — `calendar.caption: must be a rich_text array`.
- **Unknown fields** on the body or an event (closed schema). Common confusions are rewritten into actionable hints — read the response body verbatim: body-level `items`/`entries`/`dates` → "did you mean 'events'?", `months` → "single month per block in v1", `today_date`/`now` → "did you mean 'today'?"; event-level `day`/`when` → "did you mean 'date'?", `summary` → "did you mean 'title' or 'description'?", `name` → "did you mean 'title'?", plus the single-date / all-day / no-link / no-tags hints listed above.

```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." } }]
  }
}
```

### `slides`

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

`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` for a sequenced set of framed sections (a board deck, a quarterly review); use `tabs` for mutually-exclusive panels, `timeline` for chronological events, and `heading_2` + paragraphs for plain prose runs.

| Field | Required | Type | Notes |
| --- | --- | --- | --- |
| `title` | optional | string | Plain string heading rendered as `<h3 class="slides-title">` above the deck. NOT `rich_text`; newlines rejected. The block-level title carries no accent. (≤ 120 runes) |
| `caption` | optional | `rich_text` array | 0–100 segments; rendered as `<figcaption class="slides-caption">` below the deck. Counts toward the global 2000-span budget. A plain-string caption is rejected. |
| `slides` | **required** | array of Slide object | Empty `[]` rejected. Decks longer than 30 slides should be split across multiple `slides` blocks with a `heading_2` between them. Each entry is a closed schema (`title`, `subtitle`, `accent`, `is_cover`, `blocks` — nothing else). (1–30 entries) |

#### Slide object

| Field | Required | Type | Notes |
| --- | --- | --- | --- |
| `title` | **required** | string | Single-line 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. (1–120 runes) |
| `subtitle` | optional | string | Single-line; rendered as `<p class="slide-subtitle">` below the slide title. NOT `rich_text`; newlines rejected. (≤ 160 runes) |
| `accent` | optional | enum: `default` / `positive` / `negative` / `warning` / `info` | Paints a 4px left-border stripe + 2px top-bar tint via `slide-accent-<value>`. Absent → `default` (no palette cycling — slide order is already the categorical axis). Same 5-value enum as `calendar`, `kanban`, `stat_grid`, `comparison_matrix`, `timeline`. |
| `is_cover` | optional | boolean | Default `false`. When `true`, the slide gets a gradient backdrop + extra vertical padding (`slide-cover`) — for the opening/hero slide. Cosmetic only; does not change the layout or sub-block whitelist. |
| `blocks` | **required** | array of blocks | 0–12 sub-blocks. Empty `[]` allowed — renders a title-only section-divider slide. Omitting the field → 400. Only the 8 whitelisted types are allowed: `paragraph`, `heading_3`, `bulleted_list_item`, `numbered_list_item`, `quote`, `callout` (icon required), `image`, `code`. Nested `slides`/`tabs` and other visual blocks are rejected; sub-blocks count toward the depth ≤ 3 cap. |

#### Allowed sub-block types

`slide.blocks[]` accepts only these 8 types: `paragraph`, `heading_3`, `bulleted_list_item`, `numbered_list_item`, `quote`, `callout` (where `callout.icon` is REQUIRED), `image`, `code`. Everything else is rejected. Notable exclusions: `heading_2`/`heading_1` (the slide title is already a heading), `tabs`, nested `slides` (rejected before recursion), and every other visual block (`chart`, `mermaid`, `stat_grid`, `timeline`, `gallery`, `comparison_matrix`, `kanban`, `calendar`, `table`, `toggle`, `divider`) — emit those at the page level outside the `slides` block with a `heading_2` above them. Sub-blocks recurse at depth+1, so the page-wide nesting cap (≤ 3) still applies.

#### Accents

`slide.accent` reuses the universal 5-value enum `default | positive | negative | warning | info`. Absent → `default` (no palette cycling — the slide index is already a categorical axis). The renderer emits `slide-accent-<value>` on the `<section>` for the four non-default values; CSS paints a 4px left-border stripe + 2px top-bar tint that harmonizes with the other accented blocks on the page. The block-level `title` carries no accent — only individual slides do.

#### What `slides` is NOT

Not a carousel (no auto-advance/JS — reader scrolls top-to-bottom). Not nestable (one deck per block). No speaker notes (`notes`/`speaker_notes` caught at decode). No slide backgrounds (`background`/`bg`/`bg_color` — use `accent`, or an `image` sub-block as the first `slide.blocks` entry). No layout switching (`layout`/`template`/`style`). No transitions/animations (`transitions`/`transition`/`animation`/`effects`). No agent-supplied IDs/numbers (`id`/`slug`/`number`/`index`/`order` are auto-derived). All of these are rewritten into actionable hints by the closed-schema decoder.

#### Common rejections

- **`slides.slides` missing / empty** — `slides.slides: at least 1 slide required (got empty array)`.
- **`slides.slides` over the cap** — 31+ → `slides.slides: count N exceeds limit of 30 slides per block`.
- **`title` (block) too long / newline / non-string** — `slides.title exceeds 120 runes` / `slides.title: must not contain newlines` / `slides.title: must be a string`.
- **`slides[i].title` missing / oversized / newline** — `slides.slides[N].title: required` / `slides.slides[N].title exceeds 120 runes` / `slides.slides[N].title: must not contain newlines`.
- **`slides[i].subtitle` oversized / newline** — `slides.slides[N].subtitle exceeds 160 runes` / `slides.slides[N].subtitle: must not contain newlines`.
- **`slides[i].accent` outside the enum** — `slides.slides[N].accent "..." not supported; must be one of default, positive, negative, warning, info`.
- **`slides[i].blocks` missing / non-array** — `slides.slides[N].blocks: required (use [] for a section-divider slide)` / `slides.slides[N].blocks: must be an array`.
- **`slides[i].blocks` over the cap** — 13+ → `slides.slides[N].blocks: count M exceeds limit of 12 sub-blocks per slide`.
- **Disallowed sub-block type** — `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** — `slides.slides[N].blocks[K]: nested slides blocks are not allowed`.
- **Plain-string caption** — `slides.caption: must be a rich_text array`.
- **Unknown fields** on the body or a slide (closed schema) — common confusions (`items`/`pages`/`heading`/`body`/`children`/…) are rewritten into actionable hints; read the response body verbatim.

```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." } }]
  }
}
```

### `columns`

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

`columns` renders a side-by-side CSS Grid of 2–4 equal-width columns, each holding 0–12 sub-blocks from a 12-type whitelist. It is a layout primitive only — no per-column title, accent, width, or color in v1. The render is static long-scroll (no JS); on viewports ≤ 600 px the grid collapses to a single column in source order.

| Field | Required | Type | Notes |
| --- | --- | --- | --- |
| `title` | optional | string | Single-line plain string (NOT `rich_text`). Rendered as `<h3 class="columns-title">` above the grid. Newlines rejected. Picked up by `blockTitle` for OG-image title derivation when this is the only block on the page. (≤ 120 runes) |
| `caption` | optional | `rich_text` array | Rendered as `<figcaption class="columns-caption">` below the grid. 0–100 segments; counts toward the global 2000-span budget. |
| `columns` | **required** | array of Column object | **2–4** entries. Fewer than 2 or more than 4 are rejected. For more variation along an axis, use `comparison_matrix` (row-major) or split across multiple `columns` blocks with a `heading_2` between them. Each entry is a closed-schema object whose only field is `blocks`. (2–4 entries) |

#### Column object

| Field | Required | Type | Notes |
| --- | --- | --- | --- |
| `blocks` | **required** | array of blocks | **0–12** sub-blocks. Empty `[]` is legal — renders an empty layout column for breathing-room / gutter layouts. Omitting the field → 400 (`use [] for an empty layout column`); a non-array → 400. Sub-blocks must be drawn from the 12-type whitelist (`paragraph`, `heading_2`, `heading_3`, `bulleted_list_item`, `numbered_list_item`, `quote`, `callout`, `image`, `code`, `divider`, `stat_grid`, `toggle`). Nested `columns` is rejected before the whitelist check. Recurses at depth+1, so the page-wide ≤ 3 nesting cap applies. |

Use `columns` when the narrative wants 2-to-4 arbitrary stacks of content side-by-side (pricing tiers, team-handbook lanes, "engineering / product / design" panels). Reach for `comparison_matrix` when the same fields are compared across same-shape entries, `slides` for a sequenced stack of framed sections, `tabs` for mutually-exclusive panels, and `stat_grid` when the cells are metric tiles.

#### 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 are caught at decode time — for asymmetric layout use `tabs` or `comparison_matrix`.
- **No per-column accent.** `accent` / `color` / `tone` / `highlight` are caught at decode time; tint a column by putting an accented `callout` or `stat_grid` inside it.
- **No per-column title.** `title` / `name` / `label` / `header` / `heading` on a column are caught at decode time — emit a `heading_3` as the first sub-block instead.
- **Not nestable.** A `columns` block inside `columns[i].blocks` is rejected with a dedicated message before the whitelist check.
- **No agent-controlled gutter** (fixed 1 rem), **no alignment** (`align` / `justify` / `vertical_align` caught at decode time), **no per-column `id`**, and **no JS / transitions / drag-drop**.

#### Common rejections

- **`columns` under/over cap** — < 2 → `columns.columns: at least 2 columns required (got <n>)`; > 4 → `columns.columns: count <n> exceeds limit of 4 columns per block`.
- **`columns[i].blocks` missing / non-array / over-cap** — omitted → `columns.columns[i].blocks: required (use [] for an empty layout column)`; non-array → `must be an array`; 13+ → `count <n> exceeds limit of 12 sub-blocks per column`.
- **`title` too long / newline / non-string** — > 120 runes, `\r`/`\n`, or non-string each 400.
- **`caption` non-array / over-cap** — a bare string → `columns.caption: must be a rich_text array`; 101+ segments → `exceeds 100 segments`.
- **Disallowed sub-block** — outside the 12-type whitelist → `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: `heading_1`, `tabs`, `slides`, `chart`, `mermaid`, `gallery`, `comparison_matrix`, `kanban`, `calendar`, `map`, `timeline`, `table` (min-width / composition concerns).
- **Nested columns** — a `columns` sub-block → `columns.columns[i].blocks[j]: nested columns blocks are not allowed`.
- **Unknown fields** on the body or a column (closed schema) are rewritten into actionable hints (e.g. `cols`/`column`/`grid`/`rows`/`panes` → "did you mean columns"; per-column `width`/`accent`/`title` → the layout-primitive explanation).

```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." } }]
  }
}
```

### `map`

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

`map` renders a static world map with lat/lng-positioned marker pins, for geographic narratives (offices, customer cities, trip itineraries, country-of-origin breakdowns). The base map is a bundled equirectangular `world.svg` — there is NO live tile fetching (MapBox / OSM / Google Maps), NO geocoding, NO JS, no zoom/pan/hover/click. Static long-scroll render only.

| Field | Required | Type | Notes |
| --- | --- | --- | --- |
| `markers` | **required** | array of Marker object | Empty `[]` rejected (`map.markers: at least 1 marker required (got empty array)`); 51+ → `map.markers: count N exceeds limit of 50 markers per block`. Decks needing more than 50 markers should split across multiple `map` blocks with a `heading_2` between them. Each entry is a closed-schema object with only `lat`, `lng`, `label`. Markers render as identical pins (same `--chart-c0` concentric-circle), in agent order — no per-marker accent/color/icon/description. (1–50 entries) |
| `title` | optional | string | Single-line plain string (NOT `rich_text`). Rendered as `<h3 class="map-title">` above the map with a hover-anchor link. Newlines rejected (`map.title: must not contain newlines`); non-string → `map.title: must be a string`; >120 runes → `map.title exceeds 120 runes`. Provide one for non-trivial maps so screen-reader users get a named `aria-labelledby` anchor. (≤ 120 runes) |
| `caption` | optional | `rich_text` array | Rendered as `<figcaption class="map-caption">` below the map. 0–100 segments; counts toward the global 2000-span budget. A plain-string caption → `map.caption: must be a rich_text array`; 101+ segments → `map.caption: rich_text array exceeds 100 segments`. |

#### Marker object

| Field | Required | Type | Notes |
| --- | --- | --- | --- |
| `lat` | **required** | number | Latitude in degrees, WGS84 (positive = north). **−90 ≤ lat ≤ 90** inclusive. NaN / ±Inf rejected (`must be a finite number`). The integer `0` is legal (the equator). Omitted → `map.markers[N].lat: required`; out of range → `map.markers[N].lat: must be between -90 and 90 (got <v>)`; non-number → `must be a number`. |
| `lng` | **required** | number | Longitude in degrees, WGS84 (positive = east). **−180 ≤ lng ≤ 180** inclusive. NaN / ±Inf rejected (`must be a finite number`). The integer `0` is legal (the prime meridian). Omitted → `map.markers[N].lng: required`; out of range → `map.markers[N].lng: must be between -180 and 180 (got <v>)`; non-number → `must be a number`. |
| `label` | **required** | string | Single-line plain string (NOT `rich_text`). Rendered as one `<li>` in the `<ol class="map-markers-list">` below the SVG and as one comma-separated label in the SVG `<desc>`. Empty `""` rejected (`must not be empty`); newlines rejected (`must not contain newlines`); non-string → `must be a string`. NOTE: labels never appear *inside* the SVG — the numbered list below is the only place they read out. (1–80 runes) |

#### Projection

Fixed equirectangular projection that distorts area near the poles (Greenland / Antarctica look oversized). There is no projection-override field in v1; the view is always whole-world.

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

- **Not interactive.** No zoom, pan, hover tooltips, click handlers, or JS — the only verb is "scroll".
- **No live tiles.** No MapBox / OSM / Google Maps / tile server; the bundled `world.svg` is the entire base layer.
- **No marker variation.** `accent` / `color` / `icon` / `asset_id` / `description` / `title` / `name` / `note` on a marker are caught at decode time. For multi-category visuals, split markers across multiple `map` blocks (one per category) with a `heading_3` around each.
- **No region focus.** `region` / `bounds` / `zoom` / `center` / `projection` on the body are caught at decode time (always whole-world equirectangular).
- **No size override.** `width` / `height` / `aspect` caught at decode time; fixed 1200×600 (2:1) with responsive CSS scaling.
- **No polylines/routes.** Markers only. For a chronological itinerary, pair the `map` (geography) with a `timeline` (order).
- **No clustering, jittering, deduping, or rounding.** Overlapping markers render at the same projected pixel in agent order. No agent-supplied marker IDs/numbers (`id` / `slug` caught at decode).

#### Hint rewrites (closed-schema decoder)

Known-confusion field names are rewritten into actionable hints — read the response body verbatim. On the body (`map: { … }`): `items` / `pins` / `points` / `locations` / `coordinates` → `did you mean "markers"?`; `region` / `bounds` / `zoom` / `center` / `projection` → `map v1 has no region / bounds / zoom / center / projection fields`; `width` / `height` / `aspect` → `map v1 has no width / height / aspect fields`. On a marker: `title` / `name` → `did you mean "label"?`; `latitude` / `longitude` → `did you mean "lat"/"lng"? the short form is required`; `coords` / `lat_lng` / `location` → `coordinates are top-level fields lat and lng`; `accent` / `color` → `no marker.accent field`; `description` / `note` → `no marker.description field`; `icon` / `asset_id` → `no marker.icon field`; `id` / `slug` → `marker anchors are not supported in v1`.

#### Accessibility

Four redundant a11y channels: `role="img"` on the `<svg>`; `aria-labelledby` pointing at the `<h3>` title slug (collapses when `title` absent); an auto-generated `<desc>` ("World map with K markers: …", capped at 30 labels, then `… (K markers total)`); and the authoritative `<ol class="map-markers-list">` below the SVG (decimal-numbered, agent order, never truncated, 2-col desktop / 1-col mobile).

```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." } }]
  }
}
```

### `gallery`

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

`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, and portfolios. For a single hero image use the `image` block; gallery accepts ONLY `asset_id` values returned by `POST /v1/assets` — external `https://` URLs are rejected.

| Field | Required | Type | Notes |
| --- | --- | --- | --- |
| `images` | **required** | array of Image object | Empty or oversized returns 400. For >50 images, split across multiple gallery blocks. (1–50 entries) |
| `title` | optional | string | Rendered as `<h4>` above the grid. Plain string, NOT `rich_text`. Newlines rejected. (≤ 120 runes) |
| `caption` | optional | `rich_text` array | Rendered as `<figcaption>` below the grid. 0–100 segments; counts toward the global 2000-span budget. |
| `layout` | optional | enum: `grid` / `masonry` | Default `grid` (uniform 240 px-min responsive tiles). `masonry` is a multi-column layout that preserves native aspect ratios — reading order is column-by-column, best with N≥3. |
| `aspect_ratio` | optional | enum: `auto` / `16:9` / `4:3` / `1:1` | Default `auto` (native aspect, best for photo essays + masonry). Body-level only — no per-image override. Fixed ratios crop via `object-fit: cover` (center kept). |
| `density` | optional | enum: `comfortable` / `compact` | Default `comfortable`. `compact` tightens the inter-tile gap — useful for >12-image grids. |
| `lightbox` | optional | boolean | Default `false` (passive `<figure>`, or `<a>` if the image carries a `link`). `true` opens a fullscreen CSS-only `:target` overlay. Mutually exclusive with any image's `link.url`. |
| `accessibility` | optional | object | Optional `{ "description": "…" }`; a bare string is rejected. |

#### Image object

| Field | Required | Type | Notes |
| --- | --- | --- | --- |
| `asset_id` | **required** | string | Crockford base32 lowercase hash, matching the `id` returned by `POST /v1/assets`. External `https://` URLs are rejected — gallery is asset-id-only. (chars, not runes) (20–32 runes) |
| `alt` | **required** | string | Required on every image (empty `""` rejected — gallery has no decorative lane). Newlines rejected. (1–250 runes) |
| `caption` | optional | `rich_text` array | Rendered as `<figcaption>` directly below the image. 0–100 segments; counts toward the global 2000-span budget. |
| `link` | optional | object | Wraps the tile in an `<a href>` (same tab). Mutually exclusive with body `lightbox: true`. |

#### Accessibility object

| Field | Required | Type | Notes |
| --- | --- | --- | --- |
| `description` | optional | string | Becomes the `aria-label` on the outer `<figure>`. Falls back to `"Gallery — <title>"` or `"Gallery with N images"` when absent. (≤ 500 runes) |

#### Constraints at a glance

Three rules collide for big galleries — plan up front, 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 uploads before the render POST is reachable. For external images, use the `image` block (one per block).
- **`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. For both behaviours, emit two adjacent gallery blocks.

#### Lightbox

`lightbox: true` makes each tile a click-target opening a pure-CSS `:target` overlay (no JS): click a tile → `#g<seq>-img-<i>` activates the overlay; click the backdrop / `✕` → deactivates; `‹`/`›` chevrons step prev/next and **wrap around** at the ends. No Esc-to-close, no swipe gestures, no focus trap.

#### Accessibility

- `alt` is required on every image (1–250 runes; empty `""` rejected). For decorative images use the `image` block.
- `accessibility.description` (≤ 500 runes) becomes the `aria-label`; absent, it falls back to `"Gallery — <title>"` or `"Gallery with N images"`.
- The grid carries `role="list"`, each tile `role="listitem"`. Every `<img>` is `loading="lazy"`. When `images.length ≥ 20`, a skip-link is rendered before/after the figure.

#### Common rejections (verbatim validator strings)

- **`images` empty / > 50** — `gallery.images: must contain at least 1 image`; `gallery.images: too many images (cap is 50)`.
- **`layout` / `aspect_ratio` / `density` outside their enums** — `gallery.layout: must be one of [grid, masonry]`; `gallery.aspect_ratio: must be one of [auto, 16:9, 4:3, 1:1]`; `gallery.density: must be one of [comfortable, compact]`.
- **Bad `asset_id`** — malformed (uppercase / wrong length): `gallery.images[i].asset_id: invalid asset id`; valid format but never uploaded: `gallery.images[i].asset_id: no such asset (use the id returned by POST /v1/assets)`; an external URL: `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 / whitespace-only** — `gallery.images[i].alt: alt text is required`; over 250 runes — `gallery.images[i].alt exceeds 250 runes`.
- **`link.url` + `lightbox: true`** — `gallery.images[i]: link.url and lightbox are mutually exclusive`; bad scheme — `gallery.images[i].link.url: scheme "..." not allowed; only https and mailto`.
- **Plain-string caption** (per image or body) — `gallery.caption: must be a rich_text array` / `gallery.images[i].caption: must be a rich_text array`.
- **`accessibility` as a string** — `gallery.accessibility: must be an object`. **Non-boolean `lightbox`** — `gallery.lightbox: must be a boolean`.
- **Notion wrapper fields or unknown keys** on the body or any image — closed schema (e.g. `object`, `id`, `parent`, `width`, `height`, body-level `link`, per-image `lightbox`).

```json
{
  "type": "gallery",
  "gallery": {
    "title": "Trip highlights",
    "layout": "masonry",
    "aspect_ratio": "auto",
    "lightbox": true,
    "images": [
      { "asset_id": "j0a4z3rpqm1k7w9x2b5n", "alt": "Sunrise over the ridge" },
      { "asset_id": "k1b5w4sqrn2m8x0y3c6p", "alt": "Lake at golden hour", "caption": [{ "type": "text", "text": { "content": "Day 2" } }] }
    ],
    "accessibility": { "description": "Six photos from a three-day hike." }
  }
}
```

### `video`

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

`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 — no autoplay, playlist, live, self-host, custom poster, JS, or agent-controlled playback knobs; mira passes through only an optional `?start=<sec>` parsed from the agent's `?t=42` / `?t=42s` / `?start=42` query.

| Field | Required | Type | Notes |
| --- | --- | --- | --- |
| `url` | **required** | string | Must be `https://`. Host must be one of the 5 whitelisted hosts (exact match — `www.youtube.com.evil.com` and suffix-prefixed hostnames are rejected): YouTube `youtu.be` / `www.youtube.com` / `m.youtube.com`, Vimeo `vimeo.com` / `player.vimeo.com`. Accepted forms: `youtu.be/<id>`, `www.youtube.com/watch?v=<id>`, `/embed/<id>`, `/shorts/<id>`, `/v/<id>` (also `m.youtube.com` watch + `/v/`), `vimeo.com/<id>`, `player.vimeo.com/video/<id>`. YouTube id is 11 chars `[A-Za-z0-9_-]`; Vimeo id is 6–12 digits. Mira parses the id and builds the privacy-pinned embed URL — agents do NOT supply the embed URL. Optional start offset honoured from the query (`?t=42`, `?t=42s`, `?start=42`), 0–86400 integer seconds (24 h cap), re-emitted as `?start=<int>`; everything else on the query is dropped. `http://`, bare `youtube.com`, `/playlist?list=…`, `/live/…`, and bare ids are rejected with specific hints. (chars, not runes) (≤ 2048 runes) |
| `title` | optional | string | Optional plain string (NOT `rich_text`), single-line — newlines rejected (`video.title: must not contain newlines`). When supplied, renders as an auto-anchored `<h3>` heading above the frame. Always used as the iframe `title="…"` attribute; when absent, mira falls back to the synthetic `"YouTube video"` / `"Vimeo video"` so the WCAG-required attribute is never empty. (≤ 120 runes) |
| `caption` | optional | `rich_text` array | Optional caption (0–100 segments) rendered as `<figcaption class="video-caption">` below the frame. Counts toward the global 2000-span budget. A bare string is rejected (`video.caption: must be a rich_text array`). |
| `aspect_ratio` | optional | enum: `16:9` / `4:3` / `9:16` | Default `"16:9"`. Drives the CSS `aspect-ratio` on the inner `.video-frame`. `16:9` = standard landscape; `4:3` = classic pre-widescreen (older talks); `9:16` = mobile-portrait (Shorts/TikTok-style) — the frame is capped at 360px max-width and centered so a phone-shaped video doesn't dominate the desktop column. Colon form only — `"16x9"` etc. are rejected with a colon hint. |

#### Provider whitelist

Only YouTube and Vimeo render in v1. Other providers (Loom, Wistia, Dailymotion, Twitch, archive.org, self-hosted MP4/`.webm`/`.mov`, IPFS, peertube, …) 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)`. For an unlisted provider, file feedback via `POST /v1/feedback`.

#### Start-time passthrough

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

#### Privacy

Embed URLs are pinned to privacy origins: YouTube → `youtube-nocookie.com` (no tracking cookies until Play), Vimeo → `player.vimeo.com`. A user opening a mira page with an unwatched video gets no YouTube/Vimeo cookies. Only these two origins can load; an agent cannot smuggle an arbitrary `<iframe src>` onto a page.

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

Not a generic iframe (only YouTube + Vimeo). Not autoplay (`autoplay: true` caught at decode). Not a playlist (`/playlist?list=…` rejected — for a sequence, emit multiple `video` blocks with `heading_2` between). Not a livestream (`/live/<id>` rejected — VOD only). Not a self-hosted MP4 (no `<video src>` codepath). Not a custom poster/thumbnail (`poster` / `thumbnail` caught at decode — provider native thumbnail only). Not a PiP toggle (the player's own `allow="picture-in-picture"`). No agent-controllable playback knobs (`mute`, `loop`, `controls`, `playsinline`, `cc_load_policy`, `quality`, `volume` all caught at decode) — the only state-bearing passthrough is `?start=<sec>`.

#### Common rejections (verbatim validator strings)

- **`url` missing / non-string** — omitted → `video.url: required`; non-string → `video.url: must be a string`.
- **`url` too long** — > 2048 chars → `video.url exceeds 2048 chars`.
- **`url` scheme not https** — `http://…` → `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/…` → the `provider "<host>" not in v1 whitelist…` message.
- **Bare `youtube.com`** (no `www.`) → `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=…` → `video.url: youtube playlist URLs are not supported; pass the watch URL of a single video instead`.
- **YouTube live URL** — `/live/…` → `video.url: youtube live URLs are not supported in v1; use a regular watch URL`.
- **YouTube path not recognised** — `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=`** → `video.url: youtube watch URL missing ?v=<id> query parameter`.
- **Malformed YouTube id** (not 11 chars / bad charset) → `video.url: youtube provider-id "<id>" malformed; expected 11 chars matching [A-Za-z0-9_-]`.
- **Malformed Vimeo id** (not 6–12 digits) → `video.url: vimeo provider-id "<id>" malformed; expected 6-12 digits`.
- **Vimeo player path not `/video/`** → `video.url: vimeo player URL path "<path>" not accepted; supported path: /video/<id>`.
- **`title` non-string / too long / newline** → `video.title: must be a string` / `video.title exceeds 120 runes` / `video.title: must not contain newlines`.
- **`aspect_ratio` outside the enum** — `"1:1"`, `"21:9"`, `"4:5"` → `video.aspect_ratio "<value>" not supported; must be one of "16:9", "4:3", "9:16"`.
- **`aspect_ratio` colon-vs-x slip** — `"16x9"` → `video.aspect_ratio "16x9" not supported; use "16:9" with a colon (one of "16:9", "4:3", "9:16")`.
- **`caption` non-array** → `video.caption: must be a rich_text array`; over-cap (101+) → `video.caption: rich_text array exceeds 100 segments`.
- **Start-time out of range** — `?t=99999` → `video.url: start time query t="99999" out of range; must be 0-86400 seconds`; `?start=foo` → not recognized.
- **Unknown body keys** — closed schema; known-confusion names are rewritten into actionable hints (read the response body verbatim): `src`/`href` → "did you mean url"; `embed` → "did you mean url? mira builds the embed URL from the watch URL"; `autoplay`/`mute`/`loop`/`controls`/`playsinline` → "playback knobs are not agent-controlled in v1"; `width`/`height` → "use aspect_ratio enum"; `provider`/`source`/`host` → "provider is inferred from the url host"; `id`/`youtube_id`/`vimeo_id`/`video_id` → "use the full url; mira extracts the id"; `ratio`/`aspectRatio` → "did you mean aspect_ratio"; `poster`/`thumbnail` → "v1 does not support custom poster/thumbnail images"; `start` → "encode start time in the url as ?t=<sec> or ?start=<sec>".

```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"
  }
}
```

### `network`

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

`network` renders a node-and-edge graph (org chart, dependency tree, telecom topology, service map) 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.

| Field | Required | Type | Notes |
| --- | --- | --- | --- |
| `topokit_config` | **required** | object | The full topokit topo config; opaque to mira. Mira does NOT validate the inner shape — topokit's living-schema policy means unknown fields are silently ignored. Must be a JSON object (required `nodes` + `edges`; optional `layout`, `theme`, UI toggles). Read https://topokit.io/docs/data-format for the canonical schema and 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 | boolean | 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). The iframe is sandboxed to same-origin topokit.io content — it cannot escape its frame, open popups, submit forms, or trigger downloads. |
| `title` | optional | string | Single-line plain string (NOT `rich_text`); newlines rejected. When supplied: rendered as an auto-anchored heading above the figure, and always used as the iframe `title=` attribute when `interactive: true`. (≤ 120 runes) |
| `caption` | optional | `rich_text` array | 0–100 segments. Rendered as a caption below the figure. Counts toward the global 2000-span budget. A non-array caption is rejected (`network.caption: must be a rich_text array`). |
| `alt` | optional | string | Single-line plain string; newlines rejected. 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. (≤ 240 runes) |

#### `topokit_hash` is mira-assigned, never agent-set

`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 — mira assigns this from upstream`). On read the canonical bytes round-trip the field back to the agent.

#### When to use `network`

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

#### Upstream error mapping

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

#### Common rejections

- **`topokit_config` missing / empty** — `network.topokit_config: required (an opaque topokit topo object — see https://topokit.io/docs/data-format)`.
- **`topokit_config` not a JSON object** — `network.topokit_config: must be a JSON object`.
- **`title` / `alt` over cap or with newlines** — `network.title exceeds 120 runes` / `network.alt exceeds 240 runes` / `… must not contain newlines`; non-string → `… must be a string`.
- **`interactive` non-boolean** — `network.interactive: must be a boolean`.
- **Agent-set `topokit_hash`** — rejected (see above).
- **Unknown fields** (closed schema) — common confusions are rewritten into actionable hints: `config`/`topo`/`topokit`/`graph`/`data` → "did you mean topokit_config"; top-level `nodes`/`edges` → "nodes and edges go inside topokit_config"; `embed`/`iframe` → "did you mean interactive"; `url` → "mira mints the topokit URL; supply topokit_config"; `hash` → "the topokit hash is mira-assigned; do not set this field".

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

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

### `comparison_matrix`

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

`comparison_matrix` renders a feature × option grid as a semantic `<table>` with a sticky first column, themed check/cross/dash glyphs, a 5-value accent enum on individual cells AND on rows, an optional featured column, and an optional caption. Use it for 2–8 fixed columns of comparable options (pricing tiers, framework feature matrices, vendor RFP evaluations, before/after migration tables) — for general tabular data use `table`, for a row of KPI numbers use `stat_grid`.

| Field | Required | Type | Notes |
| --- | --- | --- | --- |
| `columns` | **required** | array of Column object | Defines the comparison axes (products, plans, vendors). A 1-column "comparison" is degenerate — use `callout` or `stat_grid`. Beyond 8 the matrix is unreadable. (2–8 entries) |
| `rows` | **required** | array of Row object | Each row has a `label` (the feature) and `cells` matching `columns.length` one-to-one. For >30 rows use the `table` block. (1–30 entries) |
| `title` | optional | string | Rendered as `<h4>` above the table. NOT `rich_text`. Newlines rejected. (≤ 120 runes) |
| `row_label_header` | optional | string | Rendered as the `<th class="comparison-corner">` in the top-left corner above the row labels. Typical values: `"Feature"`, `"Criterion"`, `"Service"`. Empty/absent = empty corner cell. NOT `rich_text`. Newlines rejected. (≤ 60 runes) |
| `caption` | optional | `rich_text` array | Rendered as `<figcaption>` below the table. 0–100 segments; counts toward the global 2000-span budget. A plain-string caption is rejected. |
| `density` | optional | enum: `comfortable` / `compact` | Default `comfortable` (0.75rem 1rem padding). `compact` (0.4rem 0.6rem) tightens row padding — choose it for matrices with >20 rows or limited vertical real estate. |
| `accessibility` | optional | object | Optional `{ "description": "…" }`; a bare string is rejected (`accessibility: must be an object`). |

#### Column object

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

#### Row object

| Field | Required | Type | Notes |
| --- | --- | --- | --- |
| `label` | **required** | string | Rendered in `<th scope="row">`. NOT `rich_text` — a short factual descriptor of the feature being compared. Newlines rejected. Empty/omitted → 400. (1–120 runes) |
| `cells` | **required** | array of Cell (four shapes) | Length **MUST EQUAL** `columns.length` (one-to-one). Each entry is one of four shapes (validator dispatches on the first non-whitespace byte): (1) glyph keyword — exact lowercase `"check"` / `"cross"` / `"dash"` (case-sensitive: `"Yes"`, `"✓"`, `"no"` render as plain text); (2) plain text string e.g. `"5 GB"`, `"$0/mo"` — `""` allowed (blank cell), newlines rejected; (3) `rich_text` array, **1–20 segments** (empty `[]` rejected — use `""` or `"dash"`); (4) **object form** `{"value": rich_text-array, "accent": enum}` for a cell with its own accent tint. `null`/numbers/booleans/arrays-of-non-segments all reject. (0–0 entries) |
| `accent` | optional | enum: `default` / `positive` / `negative` / `warning` / `info` | Paints a tint across the whole row via `row-accent-{accent}`. `default`/absent emits no tint. Co-exists with per-cell accents (per-cell wins where set). |

#### Cell (four shapes)

| Field | Required | Type | Notes |
| --- | --- | --- | --- |
| `value` | **required** | `rich_text` array | **1–20 segments**. A plain-string `value` is rejected (`value: must be a rich_text array`) — object-form cells take rich_text arrays, not bare strings. |
| `accent` | optional | enum: `default` / `positive` / `negative` / `warning` / `info` | Per-cell semantic tint. `default` (or absent) emits no tint; the per-cell tint wins over the row accent where both apply. Same 5-value enum as `row.accent`. |

#### Accessibility object

| Field | Required | Type | Notes |
| --- | --- | --- | --- |
| `description` | optional | string | Becomes the `aria-label` on the outer `<figure>`. Falls back to `title` when absent; no label when both absent. A one-sentence hand-authored summary of what the comparison is about. (≤ 500 runes) |

#### Cell shapes — four forms

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

1. **Glyph keyword** — exact lowercase `"check"` (green ✓, `aria-label="included"`), `"cross"` (red ✗, `aria-label="not included"`), `"dash"` (muted –, `aria-label="not applicable"`). **Case-sensitive**: `"CHECK"`, `"Yes"`, `"✓"`, `"no"` render as plain text. All three colors are theme-aware (light + dark).
2. **Plain text string** — any other string (`"5 GB"`, `"$0/mo"`, `"Unlimited"`), HTML-escaped. Empty `""` allowed (blank cell). Newlines rejected.
3. **`rich_text` array** — 1–20 segments; inline marks + `https`/`mailto` links. Empty `[]` rejected.
4. **Object form** — `{"value": rich_text-array, "accent": enum}`. `value` required (1–20 segments); `accent` optional. Use it when a cell needs its own accent tint.

#### Accents

The 5-value enum (`default | positive | negative | warning | info`) on row and object-form cell is the same vocabulary as `stat_grid`, `slides`, and `timeline`. Here `positive` means "this cell wins this dimension" (best price, fastest), `negative` "loses it." Per-cell tint wins over the per-row tint where both apply. Color is never the only signal — accents are decorative tints over still-legible text.

#### Featured column

At most **one** column may set `featured: true` (multiple → `comparison_matrix.columns: only one column may be featured (got N)`). It renders a `comparison-featured` class on the header `<th>` and every `<td>` at that index, painting a full-height vertical stripe. The first (row-label) column is **always sticky** — no opt-out; the matrix horizontally scrolls below ~720 px. When `rows.length ≥ 10`, a "Skip past comparison table" link is rendered before/after the figure.

#### Order preservation

`comparison_matrix` does **not** sort — rows render in emitted order, columns in `columns` order. No `sort_order`, no auto-alphabetization, no featured-first reorder. It is NOT a sortable/filterable data grid (use `table`), NOT a single-axis ranking (use `stat_grid`/`callout`), and NOT a spreadsheet (no formulas, merged cells, or rowspan/colspan).

#### Common rejections (verbatim validator strings)

- **`columns` < 2** — `comparison_matrix.columns must contain at least 2 columns; for a single-value display use the callout or stat_grid block`; **> 8** — `comparison_matrix.columns length 9 exceeds limit of 8`.
- **`rows` empty** — `comparison_matrix.rows must contain at least 1 row`; **> 30** — `comparison_matrix.rows length 31 exceeds limit of 30; for large data tables use the table block`.
- **`cells.length` ≠ `columns.length`** — `comparison_matrix.rows[i].cells: length M does not match columns length N` (row-major coherence enforced before any cell decodes).
- **Multiple featured columns** — `comparison_matrix.columns: only one column may be featured (got 2)`.
- **Non-string/non-array/non-object cell** (`42`, `true`, `null`) — `comparison_matrix.rows[i].cells[j]: must be a string, rich_text array, or cell object`.
- **Object-form cell missing `value`** — `comparison_matrix.rows[i].cells[j]: cell object missing required "value" field`; **plain-string `value`** — `comparison_matrix.rows[i].cells[j].value: must be a rich_text array`.
- **Empty `rich_text` cell** — `comparison_matrix.rows[i].cells[j]: rich_text array cannot be empty (use "" for blank cell or "dash" for N/A)`; **over 20 segments** — `... rich_text array exceeds 20 segments`.
- **`column.label` missing/oversized** — `comparison_matrix.columns[i].label: required` / `... exceeds 60 runes`; **`column.subtitle` > 80** — `... exceeds 80 runes`.
- **`row.label` missing/oversized** — `comparison_matrix.rows[i].label: required` / `... exceeds 120 runes`.
- **`row.accent` / object-cell `accent` outside the enum** — `... accent "..." not supported; must be one of "default", "positive", "negative", "warning", "info"`.
- **`density` outside the enum** — `comparison_matrix.density "..." not supported; must be one of "comfortable", "compact"`.
- **Plain-string caption** — `comparison_matrix.caption: must be a rich_text array`; **`accessibility` as a string** — `comparison_matrix.accessibility: must be an object`.
- **Newlines in any plain-string field** (`title`, `row_label_header`, `column.label`, `column.subtitle`, `row.label`, any plain-text cell) — `<field>: must not contain newlines`.
- **Unknown keys** on the body, any column, row, or cell object — closed schema (`sort_order`, `palette`, per-row `link`, per-column `width`, per-cell `tooltip` are rejected).

```json
{
  "type": "comparison_matrix",
  "comparison_matrix": {
    "title": "Plan comparison",
    "row_label_header": "Feature",
    "columns": [
      { "label": "Free" },
      { "label": "Pro", "subtitle": "$29/mo", "featured": true }
    ],
    "rows": [
      { "label": "Seats", "cells": ["1", "Unlimited"] },
      { "label": "SSO", "cells": ["cross", "check"] },
      {
        "label": "SLA",
        "cells": ["dash", { "value": [{ "type": "text", "text": { "content": "99.95%" } }], "accent": "positive" }]
      }
    ]
  }
}
```

### `kanban`

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

`kanban` renders 2–6 named columns of same-shape cards — for sprint-status boards, hiring funnels, feature-rollout phases, or any workflow snapshot. Read-only by default (static HTML+CSS, no JS); with `editable: true` viewers can rename card titles inline, add/remove cards, and drag-reorder cards within and across columns, auto-saving via the overwrite-hash mechanism.

Use `kanban` when the same items take well-defined buckets and the reader scans column-by-column. Use `comparison_matrix` for a fixed row × column grid, `timeline` for time-ordered events, and `stat_grid` for independent KPI numbers without a shared grouping axis.

| Field | Required | Type | Notes |
| --- | --- | --- | --- |
| `columns` | **required** | array of Column object | 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 to `comparison_matrix`. Flat top-level `cards` is rejected (cards nest inside `columns[i].cards`). (2–6 entries) |
| `title` | optional | string | Single-line plain string (NOT `rich_text`). Rendered as `<h3 class="kanban-title">` above the board. Newlines rejected. Lives on the block body, not on a column. (≤ 120 runes) |
| `caption` | optional | `rich_text` array | 0–100 segments. Rendered as `<figcaption>` below the columns. Counts toward the global 2000-span budget. A bare string is rejected (must be a rich_text array). |
| `editable` | optional | boolean | Default `false`. When `true`, the board opts into the editable-blocks protocol: cards get hover-revealed pencil (inline title edit) + trash buttons, each column header gains a `+` button to append cards, and cards are draggable (desktop only). Viewer edits auto-save through `overwrite_hash`. Only card `title` is viewer-editable — `description`/`tags`/`assignee` and all column-level structure (name, accent, order, add/remove) are agent-only. Schema invariants are unchanged when editable. |

#### Column object

| Field | Required | Type | Notes |
| --- | --- | --- | --- |
| `name` | **required** | string | Single-line plain string (NOT `rich_text`). Rendered as the column header `<h4 class="kanban-column-name">`. Newlines rejected. The shortcut `label`/`title` on a column is rejected (use `name`). (1–40 runes) |
| `accent` | optional | enum: `default` / `positive` / `negative` / `warning` / `info` | Paints a 3px top border-stripe + tinted count badge on the column. Absent → palette-cycles by column index (0→positive, 1→info, 2→warning, 3→negative, 4→default, then wraps mod 5). Explicit value always wins for that column; cycle indices still count from array position. Same 5-value enum as `stat_grid`, `comparison_matrix`, `timeline`, `slides`, `calendar`. |
| `cards` | **required** | array of Card object | 0–20 cards per column. Empty array allowed — a `"Done"` column legitimately starts empty. The global per-board cap is 60 cards summed across all columns. Cards may NOT carry `due_date`, `priority`, `status`, `id`, `image`, `link`, etc. — closed schema. (0–20 entries) |

#### Card object

| Field | Required | Type | Notes |
| --- | --- | --- | --- |
| `title` | **required** | string | Single-line plain string (NOT `rich_text`). Rendered as `<p class="kanban-card-title">`. Newlines rejected. In editable mode this is the only viewer-editable field (inline pencil-on-hover). (1–120 runes) |
| `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. A bare string is rejected (must be a rich_text array). |
| `tags` | optional | array | 0–3 tags, each a plain string 1–20 chars matching `^[A-Za-z0-9 _-]+$` (no `@`, `/`, `#`, emoji). Rendered as a `<ul class="kanban-card-tags" aria-hidden="true">` strip — visual taxonomy only, not announced to screen readers. Must be an array of strings (a comma-joined string or `[1,2,3]` is rejected). (0–3 entries) |
| `assignee` | optional | string | 1–40 chars, single-line plain string. Rendered as `<p class="kanban-card-assignee" aria-label="assignee">`. Free-form — does not have to be a person (`"Vendor B"`, `"infra-bot"`, `"unassigned"` all valid). Newlines rejected. (1–40 runes) |

#### Accents

The 5-value accent enum (`default | positive | negative | warning | info`) paints a 3px top border-stripe on the column header (`kanban-accent-<value>`) plus a tinted count badge. **Absent → palette-cycles by column index**, deterministically:

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

Mixing explicit and absent values is allowed: explicit `accent` always wins, but cycle indices still count from the column's array position. Tints are painted via `color-mix()` against `--chart-c*` so they harmonize with `chart`, `stat_grid`, `comparison_matrix`, and `timeline` on the same page. There is **no per-card accent** — to emphasize a card, use a tag or a bold/code mark in its `description`.

#### What `kanban` does NOT do (even when `editable: true`)

- **No clickable card wrappers** — `<li class="kanban-card">` is plain content, no `<a>` envelope. To deep-link out, put a link in the `description` rich_text.
- **No per-card `due_date`, `priority`, `status_changed_at`, `link`, `id`, `image`, or `attachment`** — explicitly rejected.
- **No swimlanes / nested groupings** — one flat column row. A rows × columns workflow is a `comparison_matrix`.
- **No WIP-limit annotation, move history, or comments thread** — tracker concerns, not snapshot concerns.
- **No multi-board layout** — emit multiple `kanban` blocks for multiple boards.
- **No card images** in v1 — put images in an adjacent `gallery` block.
- **In editable mode, no column-level edits and no edits on `description`/`tags`/`assignee`** — only card `title` is viewer-editable; reshaping the board is the agent's job. Drag-reorder is desktop-only (HTML5 DnD); touch devices keep add/trash/inline-edit but not drag. Concurrency is last-write-wins (no merge).

#### Caps

`columns` 2–6; `cards` 0–20 per column; **60 cards total** across all columns; `title` ≤ 120 runes; `name` 1–40 runes; card `title` 1–120 runes; `description` 1–4 segments / ≤ 200 runes; `tags` 0–3, each 1–20 chars (`^[A-Za-z0-9 _-]+$`); `assignee` 1–40 chars; `caption` 0–100 segments (description + caption count toward the global 2000-span budget); nesting depth ≤ 3.

#### Common rejections (verbatim validator strings)

- **Flat top-level `cards`** — `kanban: unknown field "cards" (did you mean "columns"? cards belong inside columns[i].cards)`.
- **`title`/`label` on a column** — `kanban.columns: unknown field "title" (did you mean "name"? the kanban block's title field lives on the block body, not on columns)` / `… unknown field "label" (did you mean "name"?)`.
- **`columns` too few** — 0 or 1 → `kanban.columns: must contain at least 2 columns; for a single phase use a bulleted_list_item list instead`.
- **`columns` too many** — 7+ → `kanban.columns length 7 exceeds limit of 6`.
- **Total cards over cap** — > 60 → `kanban: total card count N exceeds limit of 60 across all columns`.
- **Per-column cards over cap** — 21+ → `kanban.columns[i].cards length N exceeds limit of 20 per column`.
- **`column.name` missing/empty/oversized/non-string/newline** — `kanban.columns[i].name: required` / `: must be a string` / `kanban.columns[i].name exceeds 40 runes` / `: must not contain newlines`.
- **`column.accent` outside the enum** — `kanban.columns[i].accent "..." not supported; must be one of default, positive, negative, warning, info`.
- **`card.title` missing/empty/oversized/non-string/newline** — `kanban.columns[i].cards[j].title: required` / `: must be a string` / `… title exceeds 120 runes` / `: must not contain newlines`.
- **`card.description` over caps / wrong shape** — 5+ segs → `… description exceeds 4 segments`; > 200 runes → `… description: total content exceeds 200 runes`; bare string → `… description: must be a rich_text array`; `[]` → `… description: rich_text array cannot be empty`.
- **`tags` over cap / bad value / wrong type** — 4+ → `… tags exceeds 3 tags`; bad char/length → `… tags[k]: must be 1-20 chars matching ^[A-Za-z0-9 _-]+$`; non-string-array → `… tags: must be an array of strings`.
- **`assignee` oversized/newline/non-string** — `… assignee exceeds 40 chars` / `: must not contain newlines` / `: must be a string`.
- **Plain-string `caption`** — `kanban.caption: must be a rich_text array`.
- **Unknown keys** on the body, any column, or any card (closed schema) — `swimlanes`, `wip_limit`, `default_column`, `cards_count`, `due_date`, `priority`, `status`, `id`, `image`, `link`, `priority_score`, etc. are all rejected.

```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" }
        ]
      }
    ]
  }
}
```

### `tabs`

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

`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. It is the right block for FAQ groupings by category, multi-language code snippets, tabbed product copy (Overview / Pricing / FAQ), or any place a small number of related views compete for the same vertical real estate.

| Field | Required | Type | Notes |
| --- | --- | --- | --- |
| `panels` | **required** | array of Panel object | A 1-panel "tabs" is degenerate — use the panel's blocks directly. Beyond 8 the strip overflows; prefer a heading-anchored TOC. Each entry is a closed-schema object with only `label` and `blocks`. (2–8 entries) |
| `title` | optional | string | Rendered as `<h4 class="tabs-title">` above the strip. Plain string, NOT `rich_text`. Newlines rejected. (≤ 120 runes) |
| `caption` | optional | `rich_text` array | Rendered as `<figcaption>` below the panels. 0–100 segments; counts toward the global 2000-span budget. |

#### Panel object

| Field | Required | Type | Notes |
| --- | --- | --- | --- |
| `label` | **required** | string | Tab-strip link text AND source of the panel auto-id (`tab-<Slugify(label)>`). NOT `rich_text`. Newlines rejected. (1–50 runes) |
| `blocks` | **required** | array of blocks | The panel's content (1+ blocks). Validated at depth+1 (global ≤ 3 nesting cap applies). Nested `tabs` blocks are rejected. |

#### Panel ids and deep-linking

Each panel renders as `<section class="tab-panel" id="tab-<slug>">` where `<slug>` is `Slugify(label)`; the strip link is `<a href="#tab-<slug>">`. Share `…/r/<hash>#tab-pricing` and the link lands on that tab. Panel slugs share the **page-wide slug namespace** with heading auto-ids — first writer (document order) wins, the loser gets a `-2` suffix. If a label's slug is empty (all-emoji / all-whitespace / all-punctuation), the panel falls back to `tab-panel-N` (1-based index).

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

Selection is pure CSS, no JS; browser back/forward cycles previously-selected panels. `tabs` **does not sort** — panels render in emitted order and the first is always the default. There is no `default_panel` index, `featured` flag, or auto-reorder.

#### Common rejections

- **`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** — omitted or `""` → 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`); `\r`/`\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 sub-block returns 400 prefixed with `tabs.panels[i].blocks[j]:`.
- **`title` non-string / oversized / newline** — `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`).
- **Unknown keys on the body or any panel** — closed schema; `default_panel`, `orientation`, `style`, `id`, `name`, etc. are rejected.

```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." } }] } }
        ]
      }
    ]
  }
}
```

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

| Field | Required | Type | Notes |
| --- | --- | --- | --- |
| `prompt` | **required** | string | The question or instruction shown above the options. (1–200 runes) |
| `options` | **required** | array of Option object | The choices. Each entry is `{"id": "<slug>", "label": "<plain text>"}`. (2–20 entries) |
| `multi` | optional | boolean | Default `false`. `false` → single-select radio group; `true` → multi-select checkbox group. |
| `selected` | optional | array | Array of option ids (plain strings) currently checked; default `[]`. Every id MUST exist in `options[]`. When `multi: false`, `selected.length` ≤ 1. (0–20 entries) |
| `editable` | optional | boolean | Default `false`. When `true`, renders as interactive radios/checkboxes wired to the auto-save client (see Editing renders); when `false`, the same selection state renders as a read-only labelled list. |

#### Option object

| Field | Required | Type | Notes |
| --- | --- | --- | --- |
| `id` | **required** | string | Slug matching `^[a-z0-9_-]{1,40}$`. Unique within the block; this is what `selected[]` references and what the JS protocol writes back into the JSON. (1–40 runes) |
| `label` | **required** | string | Plain text shown next to the radio/checkbox. (1–120 runes) |

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

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

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

| Field | Required | Type | Notes |
| --- | --- | --- | --- |
| `prompt` | **required** | string | The question or proposal shown above the button (plain string, no marks). (1–240 runes) |
| `approved` | optional | boolean | The current decision state. Defaults to `false`. |
| `editable` | optional | boolean | `true` → interactive button wired to the auto-save client; `false` (default) → read-only status pill. |

#### Common rejections

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

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

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