AI-Native Interaction Protocol for markdown — Official Specification
The authoritative protocol document — for humans and AI to read. After reading you should know how to write a
.mdfile that, when opened in a conforming reader, renders as an interactive document with charts, animations, sliders, draggable scenes, and inline media.File-name shorthand
AINP_*is used in this document tree only. The body of this document refers to the protocol by its full name or simply as the protocol — never by abbreviation.
| Field | Value |
|---|---|
| Protocol | AI-Native Interaction Protocol for markdown |
| Status | Official Specification — v0 |
| Version | 0.7 (adds table / media / quiz / branch) |
| Editor | scos-lab |
| License | CC BY 4.0 |
| Reference implementation | Rho MD (rho.md) |
| Date | 2026-06-14 |
Note on examples: each example below is shown as its source only — a code block you (human or AI) can read and copy. The rendered, interactive version of each lives as a one-fence file under
docs/demo/examples/, linked from the example (open it in a conforming reader to see it run). The protocol text teaches syntax; the example files show the live result.
Table of Contents
Part I — Foundations
- About this document
- Quick Start
- Core principles
Part II — The markdown carrier 4. Markdown superset (incl. 4.4 static structural blocks: callout / layout+card / timeline / annotate / stl) 5. Fallback in non-conforming readers
Part III — Capability surface (v0)
6. chart — declarative data visualization
7. interact — interactive scenes
8. Direct manipulation — :drag and pick
9. image — inline raster
10. Interactive structural & content blocks (tabs / stepper / modal · table / media / quiz / branch — v0.7 · graph — v0.8)
Part IV — Composition 11. Composing primitives 12. Namespaces and shared state
Part V — Authoring guide 13. Decision tree 14. Common patterns 15. Anti-patterns to avoid 16. For LLM authors
Part VI — Safety and compliance 17. The safety model 18. Failure mode 19. Conformance levels
Part VII — Reference 20. Mini-DSL grammar 21. Cheat sheet 22. Versioning and roadmap
Appendices
- A. Full grammar (BNF-style)
- B. Built-in function library
- C. Carrier abstraction
- D. Related documents
Part I — Foundations
1. About this document
This is the official specification of the AI-Native Interaction Protocol for markdown — a declarative writing protocol that lets AI agents and humans express interactive content (charts, animations, simulations, draggable scenes) directly inside a .md file. A conforming reader compiles the file into an interactive document. No code runs from the document; only declarative primitives.
Who this document is for
- Human authors who want to write
.mdfiles that render as interactive documents - AI agents (LLMs, autonomous systems) generating interactive content as their output medium
- Renderer implementers building conforming readers for this protocol
- Tooling authors (editors, linters, validators) supporting the protocol surface
What this document is not
- Not a marketing or positioning piece — see
AINP_POSITIONING.mdfor the strategic framing - Not a methodology paper — see
AINP_DESIGN_PURPOSE.mdfor the rationale - Not a tutorial-only document — examples are included, but this is the authoritative reference
How to read this document
If you are an author or AI agent: read Part I, skim Part III to learn the capability surface, then keep Part VII (Reference) open while writing.
If you are an implementer: read everything; pay close attention to Parts II, VI, and the appendices.
If you are an LLM consuming this as a system prompt: focus on Part III + Part V + Part VII. Sections are numbered for direct reference (e.g., "per §6.2 chart types").
2. Quick Start
Here is a complete .md file that uses three protocol features. Save it as hello.md and open it in a conforming reader (e.g., Rho MD):
# Hello, interactive markdown
This is a normal paragraph in plain markdown.
```chart line
y = sin(t)
range = [0, 6.28]
title = a sine wave
```
A slider that controls a circle:
```interact
slider r range=10..80 step=5 default=40 label="radius"
template svg: <svg width="200" height="120"><circle cx="100" cy="60" r="{r}" fill="#3b82f6"/></svg>
```
What renders:
- The text "Hello, interactive markdown" as a heading
- A standard paragraph
- A live function-plot of
sin(t)from 0 to 2π - A slider labeled "radius" controlling a blue circle that resizes as the slider moves
What the AI agent or author had to do:
- Write markdown
- Wrap interactive features in fenced code blocks tagged with the protocol's DSL names (
chart,interact) - Declare what should be true (e.g., "y equals sin of t"), not how to render it
That's the entire programming model.
3. Core principles
The protocol is built on six commitments. Implementers and authors should know these by heart.
3.1 Markdown is the carrier
The protocol is a legitimate superset of markdown. Every feature is wrapped in a fenced code block (```chart, ```interact, ```image, etc.). A .md file using the protocol opens and reads as standard markdown in any markdown tool. Conforming readers render the fenced blocks as interactive content; non-conforming readers display them as code blocks.
Hard constraint: any feature that would break this property is rejected from the protocol.
3.2 Declarative, not imperative
Authors describe what should be true. The renderer determines how to make it true. There are no loops, no callbacks, no event handlers in protocol source. There is no programming language to learn — only a small vocabulary of named primitives.
3.3 Safe by construction
Protocol source contains no executable code. Expressions inside {...} placeholders are evaluated by a sandboxed mini-language with a fixed list of built-in functions (see Appendix B). No JavaScript, no network requests, no DOM access, no file system access. Rendering protocol source is as safe as opening a PNG.
3.4 Composition over configuration
A small set of primitives composes freely. Chart inside a section. Interact block driving a chart. Image annotated by an interact overlay. Multiple interact blocks sharing state through a namespace. There is no "extension API" — composition is the API.
3.5 AI-generation reliability is the primary KPI
Every feature is designed to be reliably written by an LLM in a single pass. The benchmark target is ≥ 99% syntactic validity in default LLM generation (measured at the v1 milestone; v0 baseline is ≥ 95%). Features that LLMs cannot reliably write are not promoted into the L1 surface, regardless of how useful they might appear.
3.6 Graceful failure
Any source that cannot be parsed or rendered degrades to a visible, readable code block showing the original source with an error annotation. There is no silent corruption, no "broken document," no white screen. The .md file always opens; the failed block always shows its source.
Part II — The markdown carrier
4. Markdown superset
The protocol uses the markdown fenced code block syntax as its container:
```<dsl_name> [<args>]
<body>
```
Where <dsl_name> is one of the protocol's DSLs (chart, interact, image), <args> are optional space-separated modifiers, and <body> is the DSL-specific content (line-based; see §6, §7, §9 for each DSL).
4.1 Standard markdown still works
Everything that is valid markdown remains valid. Headings, paragraphs, lists, links, tables, blockquotes, math ($...$ and $$...$$), images via standard  syntax, code blocks for source code in any language — all unchanged. Protocol fences live alongside normal markdown, not in place of it.
Math, inline vs block. $...$ is inline. $$...$$ is a centred block whenever it stands alone as its own paragraph — both the one-liner and the fenced form mean the same thing:
$$ \int_{-\infty}^{\infty} e^{-x^2} dx = \sqrt{\pi} $$
$$
\int_{-\infty}^{\infty} e^{-x^2} dx = \sqrt{\pi}
$$
$$...$$ in the middle of a sentence stays inline, and $$ inside code is never math.
Line breaks. By default a single newline inside a paragraph renders as a line break (the Obsidian/Typora convention), not the CommonMark space. A blank line still separates paragraphs; two-trailing-space and backslash hard breaks still work and mean the same thing. Practical consequences for generators: don't hard-wrap prose at a fixed column (each source line would render as its own line — write one paragraph per line and let the reader wrap it); when you want a line break, just use a newline. Two exemptions: readers may offer a strict-CommonMark toggle, and book-layout documents (kind: book / layout: book) are always strict so hard-wrapped book sources reflow into paragraphs. Code fences, inline code, and math are untouched either way.
Native HTML passthrough (zero-cost extras). A conforming reader passes through a safe subset of inline HTML, so three useful affordances need no protocol DSL:
<details>/<summary>— a native collapsible section. Use it for "click to expand" without themodalblock (§10.3):<details><summary>Click to expand</summary> Folded markdown content. </details>- Inline
<svg>— a static SVG drawn directly in the body (not insideinteract/image) renders as a figure. It is sanitized like any SVG (§17.3): no<script>, noon*handlers, no externalxlink:href. For animated or data-driven SVG, useinteract+template svg:(§7) instead. - SVG
<title>— a<title>child of any SVG element gives a free browser-native hover tooltip (plain text), inside both inline SVG andtemplate svg:output.
4.2 Body line syntax (inside a protocol fence)
Inside any protocol fence, the body is parsed line-by-line. The conventions:
key = value— assignment. Whitespace around=is ignored.<primitive> <var>: <body>— primitive declaration (see §6, §7).# ...or// ...— comment, ignored.- Blank lines — ignored.
Value types:
| Type | Form | Example |
|---|---|---|
| Number | Plain literal | 10, 0.5, -3.14 |
| String | Bare or quoted | Apple, red, "hello, world" (quote only when needed) |
| List | Square brackets, paren depth respected | [1, 2, 3], [(1, 2), (3, 4)], [atan2(a, b)] |
| Tuple | Parenthesized | (label, v0, v1, ...) |
4.3 Naming
- Identifiers:
[a-zA-Z_][a-zA-Z0-9_]*— letters, digits, underscores; cannot start with a digit - Reserved keywords at the start of a spec line (recognized by the interact compiler):
slider,timer,input,chip,select,toggle,computed,template,interpolate,step,rank,appear-at,reveal,trail,morph,drag,pick - Magic identifiers inside
trailexpressions:_lag,_i(see §7.4.6)
4.4 Static structural blocks
Beyond the dynamic DSLs of Part III, the carrier provides a set of static structural blocks — document-structure primitives that render richer than plain markdown but need no interactivity or animation. They are part of the markdown carrier (this Part) because they are pure layout/semantics. All degrade gracefully (§5): a callout falls back to a blockquote; the fenced blocks fall back to readable code blocks.
These are L1 capabilities (static; see §19).
4.4.1 callout — semantic highlight
A blockquote whose first line is [!TYPE] becomes a colored callout. An optional title may follow the type.
> [!INFO] Optional title
> Body markdown — supports **bold**, links, lists.
> [!WARN]
> A pitfall or warning.
> [!ZEN]
> A core insight / takeaway.
Native types: INFO · WARN · ZEN. GitHub-compatible types also recognized: NOTE · TIP · IMPORTANT · WARNING · CAUTION. A conforming reader MUST recognize all of these; it MAY add custom types. Fallback: a standard blockquote with the literal [!TYPE] first line.
4.4.2 layout grid + :::card — multi-column cards
A fenced layout grid cols=N block contains one or more :::card blocks. Use it for side-by-side comparison, dashboards, multi-column notes. Card bodies are full carrier markdown — protocol fences nest and stay live: a ```chart inside a card compiles and renders exactly like a top-level one (write the outer fence with more backticks, §10.1's nesting rule), which is what makes card-grid dashboards possible.
```layout grid cols=3
:::card accent=blue title="A"
Content A — any markdown.
:::
:::card accent=green title="B"
Content B
:::
:::card accent=purple title="C"
Content C
:::
```
cols: 1–6 (collapses to a single column on narrow screens, < 720px). Card accent: blue · yellow · purple · green · red · gray (default gray). Card title optional. Fallback: the :::card markers appear as literal text and each card's markdown still renders.
4.4.3 timeline — chronological events
```timeline
:::event date="2026-04-01" title="Kickoff" accent=blue
What happened.
:::
:::event date="2026-05-13" title="v0.6 shipped"
Body markdown.
:::
```
Event date and title required; accent optional (6-color, default blue). Block-level order=asc|desc optional (default = document order). Fallback: readable code block.
4.4.4 annotate — inline multi-color annotation
Marks substrings of a single body line with colored highlights + hover labels. The body comes first, then ---, then one START-END|COLOR|LABEL line per annotation.
```annotate
[Heavy_Rain] → [Flooding] ::mod(action="triggers")
---
0-12|blue|Source anchor
13-14|yellow|Directional edge
15-25|blue|Target anchor
```
Ranges are 0-based, half-open [start, end), counted in Unicode codepoints. Colors: the 6-color set. Fallback: code block showing body + the annotation table.
4.4.5 stl — Semantic Tension Language highlighting
A fenced stl code block is auto-tokenized into 6 colors — no options needed.
```stl
[Source] → [Target] ::mod(action="triggers", strength=0.85, confidence=0.95)
# comment
```
Token coloring: anchor [X], arrow →/->, modifier key, string value, number value, comment. Fallback: standard code block (no coloring, content still readable).
5. Fallback in non-conforming readers
When a .md file using this protocol is opened in a reader that does not support the protocol (Obsidian, Typora, GitHub web view, pandoc, plain text editors), the fenced blocks degrade to opaque code blocks. The reader displays the source text inside the block, surrounded by code-block styling. The document continues to open. Headings, lists, links, paragraphs all render normally.
This is structural fallback, not best-effort. The protocol is designed so that:
- Chart specs read as data tables in their source form (the reader can manually plot if needed)
- Interact bodies read as declarative spec source (a human can understand intent)
- Image blocks degrade to opaque base64 text (visible as a code block; not pretending to be something else)
A worst-case experience in a non-conforming reader is: "I cannot render this interactive chart, but I can read what the chart's data is and what the author intended." This is acceptable and intentional.
Part III — Capability surface (v0–v0.8)
The v0 protocol defines four DSLs as fenced blocks. Each section below covers one.
| DSL | Fence | Purpose |
|---|---|---|
chart |
```chart <type> |
Declarative data visualization (static + animated) |
interact |
```interact |
Interactive scenes with sliders, timers, custom SVG/Vega templates |
:drag |
spec line inside interact |
Direct manipulation of SVG elements by pointer |
image |
```image |
Inline base64-encoded raster (gif, jpeg, png, webp) |
6. chart — declarative data visualization
6.1 Fence form
```chart <type>
<assignments> # add `animate = <mode>` as a body line for animation (§6.3)
```
Where <type> is one of line, bar, scatter, area, pie, heatmap, treemap. An optional animate = <mode> body line (§6.3) turns the chart into a time-driven animation — the fence line carries only the type.
6.2 Static charts
6.2.1 line / area
A line plot of a function or explicit data. Areas behave the same but fill the region under the line.
Example — ▶ renders live in example01_chart_line.md
```chart line
y = sin(t)
range = [0, 6.28]
title = sin wave
```
| Field | Required | Notes |
|---|---|---|
y |
required (as function) or data |
Scalar expression OR y = [a(x), b(x)] for multi-series |
range |
required when y is set |
[a, b] — sampled at samples points |
x |
optional | Default t; the sampling variable |
data |
alternative to function | [(x0, y0), (x1, y1), ...] explicit data |
series |
required for multi-series | [name1, name2, ...] labels |
samples |
optional | Default 200 |
xlabel / ylabel / title |
optional | Strings |
yscale |
optional | linear (default) / log |
ylim |
optional | [lo, hi] clamp |
xtype |
optional | quantitative (default) / time (ISO-date strings) / nominal |
color / colors |
optional | Named (accent, primary, …) or #hex; colors=[a,b,...] per series |
6.2.2 bar
Like line but mark is a bar. xtype defaults to nominal for explicit data.
Example — ▶ renders live in example02_chart_bar.md
```chart bar
data = [(jan, 5), (feb, 8), (mar, 6), (apr, 9), (may, 11)]
xlabel = month
ylabel = signups
```
6.2.3 scatter
Like line but mark is a point.
Example — ▶ renders live in example03_chart_scatter.md
```chart scatter
data = [(1, 2.1), (2, 3.9), (3, 6.0), (4, 8.2)]
title = noisy linear
```
6.2.4 pie
Requires data (slices). Optional donut=true (sets innerRadius=60). Optional colors=[...].
Example — ▶ renders live in example04_chart_pie.md
```chart pie
data = [(Apples, 30), (Bananas, 25), (Cherries, 15), (Dates, 30)]
title = fruit basket
```
6.2.5 heatmap
data rows are 2D-grid rows: (rowLabel, c0, c1, ..., cN). Each value within a row becomes one cell with opacity proportional to value / max_value.
Example — ▶ renders live in example05_chart_heatmap.md
```chart heatmap
data = [(Mon, 3, 5, 8, 2), (Tue, 6, 9, 4, 1), (Wed, 2, 7, 3, 9), (Thu, 8, 1, 6, 4)]
xlabel = hour bucket
ylabel = day
title = activity heatmap
```
6.2.6 treemap
data rows are tiles whose proportional widths interpolate when animated. Tile order is fixed in v0 (squarified layout deferred to v1).
Example — ▶ renders live in example06_chart_treemap.md
```chart treemap
data = [(Engineering, 45), (Sales, 25), (Marketing, 15), (Ops, 10), (HR, 5)]
title = team headcount
```
6.3 Animated charts — the animate = body line
When the body contains an animate = <mode> line, the chart compiles to an interact block under the hood. The body uses wide-format data:
Example — ▶ renders live in example07_chart_bar_animate_race.md
```chart bar
animate = race
data = [(Apple, 10, 12, 15, 14, 18), (Banana, 8, 11, 13, 17, 15)]
frame_duration = 0.8
title = Q1 Sales
```
Each row: (label, v_at_t0, v_at_t1, ..., v_at_tN). All rows must have the same number of values.
| Modifier | Required field | Description |
|---|---|---|
frame_duration |
optional, default 0.8 |
Seconds per time-step |
title / xlabel / ylabel |
optional | Strings |
Allowed animate values per chart type
| Type | Animate values | Visual story |
|---|---|---|
bar |
race |
Bars reorder by rank, widen to current value |
line |
race |
Polylines drawn left-to-right via clip-mask |
area |
reveal |
Stacked area shapes revealed via clip-mask |
scatter |
trail |
Current dot + N fading historical positions |
heatmap |
reveal |
Grid cells static, clip-mask sweeps columns |
treemap |
evolve |
Tile widths interpolate (fixed order) |
pie |
— | (no animate in v0) |
6.3.1 Scatter trail extras (Gapminder pattern)
Example — ▶ renders live in example08_chart_scatter_animate_trail.md
```chart scatter
animate = trail
data_x = [(USA, 5000, 8000, 12000, 18000, 25000), (China, 200, 400, 800, 2000, 6000)]
data_y = [(USA, 70, 71, 73, 76, 79), (China, 50, 55, 60, 65, 72)]
data_size = [(USA, 200, 240, 280, 310, 330), (China, 700, 850, 1050, 1250, 1400)]
trail_length = 4
title = Gapminder
```
- Separate
data_xanddata_y(labels must match in order, same step count) - Optional
data_sizemakes it a bubble chart (sqrt-scaled radius) trail_length(default 4) controls how many past positions remain visible
6.4 Quick reference
The chart DSL collapses the 13 demo reference catalog into one-liners. Most narrative-style data visualizations should use chart rather than dropping to the lower-level interact block.
| Demo pattern | v0 form |
|---|---|
| Bar chart race | chart bar animate=race |
| Bubble (Gapminder) | chart scatter animate=trail data_size=[...] |
| Line chart race | chart line animate=race |
| Heatmap reveal | chart heatmap animate=reveal |
| Stacked area reveal | chart area animate=reveal |
| Treemap evolution | chart treemap animate=evolve |
| Connected scatter trail | chart scatter animate=trail |
7. interact — interactive scenes
The interact block is the lower-level interactive primitive. Use it when chart is not expressive enough — custom SVG scenes, physics simulations, algorithm visualizations, network growth animations, and so on.
7.1 Fence form
```interact
<spec lines>
template <fmt>: <body>
```
The body is a sequence of spec lines followed by exactly one template line. Everything after template <fmt>: is the template body (free-form SVG, a vega-lite spec, or text in any language — see §7.2; can span multiple lines).
7.2 Base spec lines
| Line | Form | Purpose |
|---|---|---|
| slider | slider <var> range=A..B step=S default=V label="..." |
Numeric scalar input (drag handle) |
| timer | timer <var> range=A..B step=S speed=FPS loop=true label="..." [playlabel="..."] [autoplay=true] |
Auto-advancing numeric scalar (Play/Pause/Reset). Optional playlabel renames the Play button (e.g. playlabel="Fire" → ▶ Fire); Pause/Reset are unaffected. A non-looping timer that has finished restarts from its default when Play is pressed again. Optional autoplay=true starts the timer running as soon as the document opens — use for showcase/welcome documents that should be alive without a click; readers can still Pause. Example: timer t range=0..6.28 step=0.05 speed=25 loop=true autoplay=true label="Time" |
| input | input <var> default="..." [placeholder="..."] label="..." |
Free-text string input |
| chip | chip <var> options=a,b,c default=a label="..." |
Single-select from chip buttons; value is the chosen string |
| select | select <var> options=a,b,c default=a label="..." |
Single-select dropdown; value is the chosen string |
| toggle | toggle <var> default=true label="..." |
Boolean toggle; value is the string "true" or "false" |
| computed | computed <var> = <expression> |
Derived scalar (re-evaluated per frame) |
| template | template <fmt>: <body> |
Render target. <fmt> is svg, vega-lite, or any text language (stl / json / python / js / …). Body can span multiple lines. |
A spec line declares a variable in the namespace (controls + computeds) or the render target (template). The body of the template references namespace variables through {...} placeholders.
Two families of variable:
- Numeric —
slider,timer,computed. Feed arithmetic in{...}expressions (positions, sizes, opacities). This is the reactive/animation family used by §7.3–7.5. - String —
input,chip,select,toggle. Their value is substituted verbatim into the template as text. Use them to let the reader pick options / type text that appears in a text-output template (see §7.2a).toggleyields the literal"true"/"false"; compare it in expressions as{<var> == "true" ? ... : ...}.
7.2a Text-output templates
When <fmt> is a text language (not svg/vega-lite), the conforming reader renders the substituted body as a syntax-highlighted code block (highlight.js language <fmt>) that updates live as controls change. This is the "editable source" idiom — the reader drags a chip/slider and watches STL / JSON / code regenerate:
Example — ▶ renders live in example29_interact_controls_text_template.md
```interact
slider conf range=0..1 step=0.05 default=0.95 label="Confidence"
chip rule options=causal,logical,empirical default=causal label="Rule"
input src default="A" label="Source"
template stl: [{src}] → [B] ::mod(rule="{rule}", confidence={conf})
```
A text-output template performs string substitution only — {...} placeholders are replaced and the result is highlighted. No SVG sanitization or chart compilation applies. For a vega-lite template the substituted result must be valid JSON (see §7.3); for svg, see §7.3 and §17.3.
7.3 Template expressions (the {...} placeholder)
Inside the template body, {<expression>} placeholders are native in v0. The compiler auto-hoists each unique expression to a computed _aN = <expr> line and rewrites the placeholder to {_aN}:
Example — ▶ renders live in example10_interact_timer_template_expr.md
```interact
timer t range=0..10 step=0.1 speed=30 loop=true
template svg: <svg width="200" height="120"><circle cx="{100 + 30 * sin(t)}" cy="60" r="{round(5 + 3 * sin(t), 2)}" fill="#3b82f6"/></svg>
```
- Pure identifiers (
{r}) are not hoisted — they pass through to the renderer as-is - JSON-shape placeholders (
{"key": value}) inside vega-lite blocks are not hoisted - Repeated expressions de-duplicate to a single computed
- Allowed inside
{}: arithmetic, function calls, ternary? :, comparisons. No statements. See Appendix B for the function library.
7.4 High-level primitives
Each primitive is a single-line declaration that lowers to one or more computed lines. Use them to keep authoring concise.
7.4.1 interpolate — piecewise-linear
interpolate <var> [over <input>]: (t0, v0) -> (t1, v1) -> ...
Linear ramp between anchors. After the last anchor, value clamps to the last v. Default input variable is t.
Example — ▶ renders live in example11_interpolate.md
```interact
timer t range=0..6 step=0.05 speed=30 loop=true
interpolate cx: (0, 30) -> (2, 170) -> (4, 100) -> (6, 30)
template svg: <svg width="200" height="80"><circle id="dot" cx="{cx}" cy="40" r="12" fill="#10b981"/></svg>
```
7.4.2 step — piecewise-constant
step <var> [over <input>]: (t0, v0) -> (t1, v1) -> ...
Same shape as interpolate but no blending — each segment holds its value as a constant. Values may be numeric or quoted strings (used for color state machines).
Example — ▶ renders live in example12_step.md
```interact
timer t range=0..6 step=0.05 speed=30 loop=true
step phase: (0, "idle") -> (2, "running") -> (4, "stopped")
step fill: (0, "#94a3b8") -> (2, "#10b981") -> (4, "#ef4444")
template svg: <svg width="220" height="60"><rect id="box" x="10" y="10" width="200" height="40" fill="{fill}"/><text x="110" y="36" text-anchor="middle" font-size="14" fill="white">{phase}</text></svg>
```
7.4.3 rank — sort-position by comparison
rank [r1, r2, ..., rN] from [v1, v2, ..., vN]
Emits N computed lines. Each r_i is the count of v_j > v_i for j ≠ i. Rank 0 means highest. Ties share a rank.
Example — ▶ renders live in example13_rank.md
```interact
timer t range=0..6 step=0.05 speed=20 loop=true
interpolate va: (0, 20) -> (3, 80) -> (6, 50)
interpolate vb: (0, 50) -> (3, 30) -> (6, 80)
interpolate vc: (0, 70) -> (3, 60) -> (6, 25)
rank [ra, rb, rc] from [va, vb, vc]
template svg: <svg width="220" height="120"><rect id="bar_a" x="10" y="{10 + ra * 35}" width="{va * 2}" height="25" fill="#3b82f6"/><rect id="bar_b" x="10" y="{10 + rb * 35}" width="{vb * 2}" height="25" fill="#10b981"/><rect id="bar_c" x="10" y="{10 + rc * 35}" width="{vc * 2}" height="25" fill="#ef4444"/></svg>
```
7.4.4 appear-at — opacity step / pulse
appear-at <var> [over <input>] at <t0> [until <t1>]
Without until: computed <var> = <input> >= <t0> ? 1 : 0 (one-shot reveal).
With until: pulse window — value is 1 only in t0, t1).
Example — ▶ renders live in [example14_appear_at.md
```interact
timer t range=0..5 step=0.05 speed=20 loop=true
appear-at node1 at 0.5
appear-at node2 at 1.5
appear-at node3 at 2.5
appear-at node4 at 3.5
template svg: <svg width="220" height="80"><circle id="n1" cx="30" cy="40" r="14" fill="#3b82f6" opacity="{node1}"/><circle id="n2" cx="80" cy="40" r="14" fill="#10b981" opacity="{node2}"/><circle id="n3" cx="130" cy="40" r="14" fill="#f59e0b" opacity="{node3}"/><circle id="n4" cx="180" cy="40" r="14" fill="#ef4444" opacity="{node4}"/></svg>
```
7.4.5 reveal — clamp-both-ends linear
reveal <var> [over <input>]: <t0>..<t1> [from=<v0>] to=<v1>
Before t0 stays at from (default 0). After t1 stays at to. Linear in between. Designed for clip-path widths and opacity ramps.
Example — ▶ renders live in example15_reveal.md
```interact
timer t range=0..5 step=0.05 speed=20 loop=true
reveal w: 0.5..4 from=0 to=200
template svg: <svg width="220" height="60"><rect x="10" y="20" width="200" height="20" fill="#e5e7eb"/><rect id="bar" x="10" y="20" width="{w}" height="20" fill="#3b82f6"/></svg>
```
7.4.6 trail — lagged samples
trail <var> lags=[l0, l1, ..., lN] [over <input>] = <expression>
Emits N computed lines named <var>_0, <var>_1, …. Inside <expression>, _lag is replaced with each lag literal and _i with the sample index. Use for trailing dots (Pendulum, Solar System Moon), echo effects, lagged history.
Example — ▶ renders live in example16_trail.md
```interact
timer t range=0..6.28 step=0.05 speed=30 loop=true
trail cx lags=[0, 0.15, 0.3, 0.45, 0.6] = 110 + 70 * cos(t - _lag)
trail cy lags=[0, 0.15, 0.3, 0.45, 0.6] = 60 + 40 * sin(t - _lag)
template svg: <svg width="220" height="120"><circle id="t0" cx="{cx_0}" cy="{cy_0}" r="10" fill="#3b82f6"/><circle id="t1" cx="{cx_1}" cy="{cy_1}" r="8" fill="#3b82f6" opacity="0.7"/><circle id="t2" cx="{cx_2}" cy="{cy_2}" r="6" fill="#3b82f6" opacity="0.5"/><circle id="t3" cx="{cx_3}" cy="{cy_3}" r="4" fill="#3b82f6" opacity="0.3"/><circle id="t4" cx="{cx_4}" cy="{cy_4}" r="3" fill="#3b82f6" opacity="0.15"/></svg>
```
7.4.7 morph — path d interpolation
morph <var> phase=<expr> paths=["<path_a>", "<path_b>", ...]
paths is a JSON list of strings (SVG path data). Emits computed <var> as a nested ternary over phase. The browser interpolates d natively if all paths share the same M/L/C/Z command sequence (the author's responsibility to keep them aligned).
Example — ▶ renders live in example17_morph.md
```interact
timer t range=0..2 step=0.02 speed=30 loop=true
morph d phase=t paths=["M50,30 L150,30 L150,90 L50,90 Z", "M100,20 L160,60 L100,100 L40,60 Z", "M50,30 L150,30 L150,90 L50,90 Z"]
template svg: <svg width="220" height="120"><path id="shape" d="{d}" fill="#8b5cf6"/></svg>
```
7.5 Diff-and-animate — stable IDs for smooth motion
When you give any SVG element a stable id attribute, the conforming reader keeps that DOM node alive across re-renders. The browser's CSS transition engine then automatically interpolates attribute changes between frames. The author writes no animation code; they just give elements identities and let attribute changes happen naturally.
Example — ▶ renders live in example18_interact_diff_and_animate.md
```interact
timer t range=0..10 step=0.1 speed=30 loop=true
template svg: <svg width="220" height="120"><circle id="ball" cx="{110 + 80 * sin(t)}" cy="60" r="14" fill="#3b82f6"/></svg>
```
The id="ball" is what makes the circle's motion smooth. Without it, the circle jumps from frame to frame; with it, the CSS transition engine fills in the interpolation.
Interpolation applies to discrete changes (a chip click, a step advance, a slow timer). When re-renders stream in faster than a transition could settle — a fast timer or a slider being dragged — the conforming reader suppresses interpolation and tracks the computed values exactly, so an id-bearing shape never lags the rest of the frame (e.g. a dot riding a polyline whose points string re-renders instantly: points is not a CSS-transitionable property, so tweening the dot would float it off the line).
This is the single most distinctive feature of the protocol's animation model. It is what turns 13 different visual patterns (bar chart race, Gapminder bubble, line race, network growth, path morph, algorithm viz, solar system, damped pendulum, etc.) into one declarative pattern: "give the element an id, compute its position from time, repeat."
7.6 Worked example — Gapminder, two ways
The Gapminder demo can be written as either a high-level chart (recommended) or a hand-written interact (escape hatch):
High-level — chart:
Example — ▶ renders live in example08_chart_scatter_animate_trail.md
```chart scatter
animate = trail
data_x = [(USA, 5000, 12000, 25000), (China, 200, 800, 6000)]
data_y = [(USA, 70, 73, 79), (China, 50, 60, 72)]
data_size = [(USA, 200, 280, 330), (China, 700, 1050, 1400)]
trail_length = 4
title = Gapminder
```
Low-level — interact (manual but primitive-driven, sketch only):
Authors should prefer the chart form. Drop to interact only when the higher-level form does not express the intent — for example, custom physics, algorithm visualizations, or scenes that mix non-chart elements.
8. Direct manipulation — :drag and pick
Two spec lines let a reader interact with the rendered SVG itself, not just the control widgets: drag (continuous pointer drag → position) and pick (discrete click/tap → selection). Both write back to a namespace variable, which then drives the template re-render through the normal reactive flow.
The drag spec line lets a reader directly manipulate an SVG element with a pointer (mouse or touch). This is the inverse of the slider: a slider writes a variable from a UI widget; a drag writes the same variable from a touched object inside the rendered SVG. pick (§8.7) is its discrete sibling: clicking an element sets a variable to that element's identity.
Status: Implementation Draft. Reference impl:
src/utils/interact.ts.dragadded in v0.5;pickadded in v0.6.
8.1 Spec line
drag target=<mvid> binds=<var>|<varX,varY> mode=<angular|xy|x|y> [pivot=<mvid>] [min=<n>] [max=<n>]
| Field | Required | Meaning |
|---|---|---|
target |
✓ | Value of an mvid="…" attribute on an element inside the template SVG. The pointerdown is registered here. |
binds |
✓ | Variable name (single) for mode=angular, x, or y. Two comma-separated names for mode=xy (X then Y). The variable(s) must exist in the namespace — typically declared by another slider or computed line so they have an initial value. |
mode |
✓ | Drag interpretation. See §8.2. |
pivot |
required for mode=angular |
The mvid of the rotation center element. The pivot's bbox center is used as the rotation origin. |
min |
optional | Clamp the bound value below. For mode=xy not supported in v0 (use template-side clamp). |
max |
optional | Same, above. |
8.2 Drag modes
mode |
Effect |
|---|---|
angular |
binds=θ is set to atan2(pointer.x − pivot.x, pivot.y − pointer.y) in radians, range (−π, π]. θ=0 is straight down (pendulum convention). pivot=<mvid> is required. |
xy |
binds=x,y are set to pointer position in SVG user-space coordinates (using the SVG's viewBox, not pixels). |
x |
binds=v is set to pointer's SVG x-coordinate. |
y |
binds=v is set to pointer's SVG y-coordinate. |
After computing the new value(s) on each pointermove, the runtime applies min/max clamp (if provided) and writes to the namespace. This triggers the normal update() re-render cycle — the same flow as a slider move.
8.3 The mvid namespace
mvid is a separate namespace from the protocol's namespace id (which names spec variables) and from SVG's native id attribute (which the diff-and-animate pipeline uses for stable-element identity). Authors must add mvid="..." to the elements they want addressable from drag lines:
<svg viewBox="0 0 200 200">
<g mvid="hinge" transform="translate(100, 40)">
<circle r="3" fill="#333"/>
</g>
<circle mvid="ball" cx="..." cy="..." r="10" fill="#3b82f6"/>
</svg>
Why separate from SVG id: the diff-and-animate pipeline uses SVG id for stable-element identity across re-renders. Reusing id for drag-target lookup would conflate two concerns and force authors who do not use diff-animate to still set id. mvid is opt-in and explicit.
8.4 Visual affordance
- An element matched by
target=getscursor: grabautomatically (andcursor: grabbingduring active drag). No author CSS required. - The element is tagged with
data-mv-drag-target="1"at hydration so authors can layer additional styles if desired (for example, a glow on hover).
8.5 Mobile touch behaviour
Drag uses Pointer Events (pointerdown / pointermove / pointerup), which unify mouse + touch. The handler calls event.stopPropagation() on pointerdown so the app's pinch-zoom listener (if any) does not intercept the drag. Scrolling, two-finger pinch, and back-swipe outside the draggable element are unaffected.
8.6 Dual-binding pattern (slider + drag, same variable)
A namespace variable can be written by both a slider and a drag. They share state. This is the canonical "indirect + direct manipulation" pattern:
Example — ▶ renders live in example19_drag_angular_pendulum.md
```interact
namespace pendulum
slider L range=0.5..2.0 step=0.1 default=1.0 label="rope length"
slider θ range=-1.57..1.57 step=0.01 default=0.3 label="angle"
drag target=ball binds=θ mode=angular pivot=hinge min=-1.5 max=1.5
computed cx = 100 + L * 80 * sin(θ)
computed cy = 40 + L * 80 * cos(θ)
template svg:
<svg viewBox="0 0 200 200">
<g mvid="hinge" transform="translate(100, 40)"><circle r="3"/></g>
<line x1="100" y1="40" x2="{cx}" y2="{cy}" stroke="#333"/>
<circle mvid="ball" cx="{cx}" cy="{cy}" r="10" fill="#3b82f6"/>
</svg>
```
Moving the slider updates the ball position; dragging the ball updates the slider position. Same variable. The protocol enforces consistency automatically.
8.7 pick — click/tap to select
The pick spec line makes template SVG elements clickable: clicking (or tapping) an element that carries an mvid attribute sets a namespace variable to that element's mvid value. It is the discrete, click sibling of drag — selection by identity rather than manipulation by position.
Spec line:
pick <var>
<var> is the namespace variable to set. While a pick line is active, any element in the template SVG that carries an mvid="..." becomes clickable; clicking it sets <var> = <that mvid>. (Use the same mvid namespace as drag, §8.3.) Because the value is the clicked element's mvid, one pick line makes an entire grid/map/diagram selectable — there is no per-element declaration.
<var> is typically also a slider (so the reader can either sweep with the slider or click directly — dual selection, both writing the same variable, kept in sync) or seeded by a computed. If <var> is declared only by pick, it is seeded to 0 (= nothing picked yet).
Worked example — a clickable 3-cell strip that updates a card:
Example — ▶ renders live in example30_pick_click_select.md
```interact
slider sel range=0..3 step=1 default=0 label="Selected (0 = none)"
pick sel
step name over sel: (1, "Hydrogen") -> (2, "Helium") -> (3, "Lithium")
template svg: <svg viewBox="0 0 240 90">
<g mvid="1"><rect x="10" y="10" width="60" height="50" rx="6" fill="#A6D8F5" stroke="#2b2b2b" stroke-width="{round(sel)==1 ? 3 : 0.5}"/><text x="40" y="42" text-anchor="middle" font-size="20" font-weight="700">H</text></g>
<g mvid="2"><rect x="90" y="10" width="60" height="50" rx="6" fill="#D4A574" stroke="#2b2b2b" stroke-width="{round(sel)==2 ? 3 : 0.5}"/><text x="120" y="42" text-anchor="middle" font-size="20" font-weight="700">He</text></g>
<g mvid="3"><rect x="170" y="10" width="60" height="50" rx="6" fill="#F7B2A1" stroke="#2b2b2b" stroke-width="{round(sel)==3 ? 3 : 0.5}"/><text x="200" y="42" text-anchor="middle" font-size="20" font-weight="700">Li</text></g>
<text x="120" y="82" text-anchor="middle" font-size="13" opacity="{sel > 0 ? 1 : 0}">{name}</text>
</svg>
```
A "deselect / close" affordance is just another pickable element whose mvid is the sentinel (e.g. mvid="0"): clicking it sets <var> back to 0. Conditional overlays (popups, detail cards) are then driven by opacity="{<var> > 0 ? 1 : 0}".
pick is L4 (it requires click hydration; see §19).
8.8 Out of scope
mode=radial(drag along a radial axis from a pivot)mode=along-path path=<id>(constrain drag to follow a path)- Multi-target drag groups (drag a whole node plus its edges in a graph)
- Keyboard accessibility for draggable / pickable elements
- Velocity / momentum (drag a ball and let it swing freely)
- Per-element distinct
pickvalues independent ofmvid; multiplepickvars disambiguated per element
These are deferred to future versions.
9. image — inline raster
The image block embeds raster images (gif, jpeg, png, webp) directly inside the markdown source as base64-encoded data. The image becomes part of the .md file itself — no external URLs, no separate asset files, no broken links.
Status: Stable in v0. Reference impl:
src/utils/image.ts. Spec:IMAGE_BLOCK_v0.md.
9.1 Fence form
Example — ▶ renders live in example20_image_inline_base64.md
```image
format = png
data = iVBORw0KGgoAAAANSUhEUgAAA... (truncated for documentation; real base64 spans hundreds of chars)
alt = a photo of a cat
```
| Field | Required | Notes |
|---|---|---|
format |
✓ | gif, jpeg, png, or webp |
data |
✓ | Base64-encoded image data. May span multiple lines (whitespace is stripped). |
alt |
recommended | Accessibility text for screen readers and fallback display |
width / height |
optional | Render dimensions in pixels (CSS pixels). If omitted, image is rendered at its natural size, constrained to container width. |
9.2 Why base64 inline
The protocol deliberately does not support external URLs in image blocks. The rationale:
- Portability: a
.mdfile with inline images is a self-contained artifact. Move it to a new machine; share it through any channel; archive it for the long term. No broken links. - Audit: the image is right there in the source. An AI's output is fully inspectable; no hidden network fetches.
- Trust boundary: external URLs would let an AI's output trigger arbitrary network requests at render time. Inline base64 closes that channel by construction.
- Diff-able: the base64 changes when the image changes. Version control sees the difference; you can see when an image was edited.
Trade-off: roughly 33% size overhead vs binary. Accepted in exchange for the properties above. For very large images (> 1 MB encoded), authors should consider whether a .md file is the right carrier; for typical document-scale images, the overhead is negligible.
9.3 Fallback in non-conforming readers
In a reader without the protocol, an image block degrades to an opaque code block displaying the literal base64 text. This is intentional: it is visible (not pretending to be something else), and the source remains audit-able. No image renders, but no information is lost.
9.4 Standard markdown images still work
For images already hosted elsewhere (e.g., on a CDN), standard markdown image syntax  continues to work. The image block is specifically for inline embedded images where portability matters more than file size.
10. Interactive structural & content blocks
These fenced blocks hydrate with a small amount of behavior — click handlers, ARIA state, media players — beyond chart / interact / image, with no animation or expression evaluation. Two families:
- Structural (§10.1–10.3):
tabs/stepper/modal— switch, step, expand. - Content (§10.4–10.7, added in v0.7):
table/media/quiz/branch— data, embeds, assessment, branching narrative. - Visualization (§10.8, added in v0.8):
graph— networks/relationship maps with semantic zoom.
All are L2 capabilities (they need hydration; see §19). Each degrades gracefully in a non-conforming reader (§5): the structural blocks render their panels stacked; the content blocks fall back to their own plain-markdown form — a GFM table, a media source line, a task-list answer key, a numbered choose-your-path page.
10.1 tabs — same-space multi-view switching
```tabs default="STL"
:::tab title="STL"
```stl
[A] → [B] ::mod(action="triggers")
```
:::
:::tab title="JSON"
```json
{"source": "A", "target": "B"}
```
:::
:::tab title="Note"
**A** triggers **B**.
:::
```
Block default (tab title or 0-based index) optional. Each :::tab needs a title. The reader renders a tab bar; clicking a tab shows its panel. Use it for equivalent representations of one thing (STL / JSON / prose). Fallback: all panels render stacked.
Nesting rule — outer fence must be longer. When a panel body itself contains a fenced code block, a bare ``` closing line would terminate the OUTER fence early (CommonMark: any same-character run at least as long as the opener, with no info string, closes the fence). Write the outer fence with more backticks than any inner fence — four or more:
````tabs default="Code"
:::tab title="Code"
```js
console.log('inner fences are safe now');
```
:::
````
Conforming readers accept any fence length (the language word is what triggers the block). The same rule applies to stepper, layout, and every structural fence whose body can contain code blocks.
10.2 stepper — sequential walkthrough with Prev/Next
```stepper linear=true
:::step title="Install"
```bash
npm install rho-md
```
:::
:::step title="Import"
```js
import { render } from 'rho-md';
```
:::
:::step title="Use"
Call `render()` after the DOM is ready.
:::
```
Block linear (default false) — when true, steps must be completed in order. Each :::step needs a title. The reader shows one step at a time with Prev/Next navigation. Fallback: all steps render stacked.
10.3 modal — click-to-expand overlay
```modal trigger="Show full derivation" width=large
**Step 1.** Define V(ρ) = m²ρ·g(ρ)
**Step 2.** Apply the BF bound → a = 1/2
```
trigger (required) is the clickable label. width ∈ small / medium / large / full (default medium). The body is any markdown, shown in a dialog when the trigger is clicked; the reader provides a close affordance. Fallback: the body renders inline below the trigger text.
10.4 table — inline data table
The body is a standard GFM pipe table — that is the entire data model. Optional header assignments (one key = value per line, before the table) add behavior:
```table
columns = task:text, status:select(todo|doing|done), due:date, effort:number, done:checkbox
sort = due asc
filter = status != done
summary = effort:sum, task:count
search = true
| task | status | due | effort | done |
|---------------|--------|------------|--------|------|
| Ship table | doing | 2026-06-13 | 3 | [x] |
| Validate | todo | 2026-06-20 | 5 | [ ] |
```
| Key | Form | Behavior |
|---|---|---|
columns |
name:type, … |
Types: text (default) · number · date (ISO) · select(a|b|c) · checkbox · url. Drive sort comparison + cell rendering. Unlisted columns default to text. |
sort |
<col> asc|desc |
Initial sort. Clicking any header also sorts (type-aware); click again reverses. |
filter |
<col> <op> <value> |
Initial row filter. Ops: == != > < >= <= contains. One expression (no boolean combinators in v0.7). |
summary |
<col>:fn, … |
Footer row, recomputed over visible rows. Fns: sum · avg · min · max · count. |
search |
true |
Render a search box scoped to this table. |
A type mismatch (e.g. text in a number column) renders as text — it never fails the block. Editing cells back to the source is an application concern, not a protocol one (a read-only reader simply omits edit affordances). Fallback: a perfectly readable GFM table. All view state (sort / filter / search) is ephemeral (§17.5).
10.5 media — audio / video embed
Flat key = value lines. src must be the document's own bytes — a data: URI is the only self-contained, conforming form.
```media
title = Pronunciation — "syzygy"
src = data:audio/mpeg;base64,SUQzBAAA…
```
| Key | Required | Notes |
|---|---|---|
src |
✓ | A data: URI. kind is inferred from its MIME type. |
kind |
— | audio / video — overrides MIME inference. |
title |
— | Visible caption + accessible label. |
poster |
— | (video) a data: image shown before play. |
captions |
— | WebVTT data: URI; the reader shows a CC toggle. |
loop / muted / autoplay |
— | autoplay is honored only when muted = true (platform convention). |
Network URLs (http(s)://) never render — the content-security policy refuses remote fetches structurally (§17.4), preserving the no-network guarantee. Bundle-relative paths (./clip.mp4) are reserved for a future folder-bundle form and currently render an honest placeholder. Fallback: a code block showing src / title — the reader of the source knows exactly what media was intended.
10.6 quiz — formative assessment
```quiz
shuffle = true
pass = 70
:::q What does T² ∝ a³ relate?
- [ ] Orbital speed to mass
- [x] Orbital period to semi-major axis
> Kepler's third law.
:::q (multi) Which fences run no code?
- [x] chart
- [x] table
- [ ] script
:::q (text) The data-table fence is called ____
= table
:::q (match) Match each layer to its role
- L1 = public protocol surface
- L2 = Render IR
```
:::q <prompt> opens a question (closing ::: optional, the same lenient container grammar as tabs/stepper). An optional type tag follows the marker:
- single (default) /
(multi)— options are a GFM task list;[x]marks the correct option(s). (text)— accepted answers given as= answerlines (case-insensitive, trimmed).(match)—- left = rightpairs; the reader renders dropdown matching (a drag affordance may come later, with identical grading).
A > blockquote line directly after the options is the post-answer explanation. Header: shuffle (default false), pass (a percentage → enables a final score panel with a pass/fail verdict). Correct answers are visible in the source by design — this targets formative self-checks, not proctoring; high-stakes grading belongs to the application/cloud layer. Scores are ephemeral (§17.5). Fallback: a task list with [x] on the right answers — i.e. a readable answer key.
10.7 branch — choose-your-path scenario
```branch
start = ticket
:::node ticket
The dashboard is down. Logs show nothing unusual.
- Restart the service → restarted
- Check the load balancer first → lb
:::node restarted
It comes back up… for ninety seconds. The pager fires again.
- Check the load balancer → lb
:::node lb (end)
One backend was unhealthy since the cert rotation. Outcome: found it.
```
Header start = <node-id> (required). :::node <id> opens a node (closing ::: optional); (end) after the id marks a terminal node. Choices are list items whose text contains → <node-id> (ASCII -> accepted) — the arrow's left side is the choice label, the right side the destination. The reader shows one node at a time with a breadcrumb trail and a start-over affordance. A branch is a pure directed graph — deliberately no variables, conditions, or counters (stateful scenarios are a separate, later concern, or an application-layer one). An unknown destination renders as a disabled choice with a visible warning (§18); position is ephemeral (§17.5). Fallback: numbered prose with literal "→ go to X" arrows — exactly a printed choose-your-own-adventure page.
10.8 graph — network / starmap visualization
Relationship networks — character maps, concept graphs, citation webs. Every node is a star (color = group, area = weight); every edge is a typed, colored line. The conforming reader owns semantic zoom: zooming keeps node radii and label sizes constant in screen pixels — so it spreads the graph apart instead of magnifying a blur — and label placement re-runs per zoom level, so deeper zoom reveals more names (level-of-detail).
Reader interaction contract (Rho MD's reference behavior; a conforming reader SHOULD match it):
- On hover-capable fine-pointer devices (desktop mouse / trackpad) the map is
live on hover: wheel zooms about the cursor immediately, no arming click.
On touch (and other hover-less) devices the block starts inert — swiping
over it scrolls the page normally (a hint pulses instead); one tap arms it,
and
ctrl+wheel arms directly. This protects reading flow on scroll-past where the trap is real (a finger can't scroll past a full-width live map). - Live/armed: wheel/pinch zooms about the cursor with an eased glide; drag pans (finger-locked, with release inertia); double-click glides back to the overview; a reset button appears whenever the view has moved.
- Hovering a node shows a tooltip (
label · group · w) and lights up that node's edges; clicking a node withurl=opens it. - Rendering is continuous (canvas-style): the view stays crisp at every zoom level and labels cross-fade between placement passes.
Example — ▶ renders live in example39_graph.md
```graph
size 600x400
slider ch range=1..3 step=1 default=3 label="Reveal up to chapter"
group Capulet color=#ef4444
group Montague color=#60a5fa
rel love color=#fb7185
rel feud color=#ef4444 dashed=true
node juliet x=200 y=150 label="Juliet" group=Capulet w=4 ch=1 url="https://example.org/juliet"
node romeo x=400 y=150 label="Romeo" group=Montague w=4 ch=1
edge juliet romeo rel=love ch=1
```
| Line | Form | Purpose |
|---|---|---|
| size | size <W>x<H> |
World coordinate system for x/y (default 1000x760) |
| bg | bg <color> |
Stage background (default #0d1117 — starfield dark) |
| slider | slider <var> range=A..B step=S default=V label="..." |
Optional reveal timeline. An element carrying attribute <var>=N is visible iff N <= value |
| group | group <name> color=#hex |
Node grouping — colors nodes and builds the group legend (declaration order). Quote names containing spaces: group "de Bourgh" color=… |
| rel | rel <name> color=#hex [dashed=true] |
Edge type — colors edges and builds the relation legend |
| node | node <id> x=<n> y=<n> [label="..."] [group=...] [w=<n>] [<var>=<n>] [url=https://…] |
One node. id is a space-free token; label defaults to id; w (weight) drives radius and label priority; url makes the star clickable (http/https only) |
| edge | edge <a> <b> [rel=...] [<var>=<n>] |
One edge between node ids |
Layout is the author's job (coordinates are data, typically baked by an
offline layout pass) — the reader never re-layouts, so a document renders
identically everywhere, and the fence stays purely declarative: no code, no
runtime dependency. The renderer owns zoom, level-of-detail label placement,
tooltips (hover shows label · group · w), legends, and navigation.
Fallback in a non-conforming reader: the fence degrades to a visible code block of node/edge lines — an audit-able adjacency list, no information lost.
Part IV — Composition
11. Composing primitives
The protocol's primitives compose freely. There is no fixed page structure; an author shapes the document arbitrarily by combining primitives.
11.1 Charts and interact blocks side-by-side
A document can mix multiple chart and interact blocks in any order, interleaved with standard markdown content:
# Sales analysis
```chart bar
data = [(jan, 5), (feb, 8), (mar, 6), (apr, 9), (may, 11)]
```
The trend is positive. To explore sensitivity, try the slider below:
```interact
slider growth range=0..2 step=0.1 default=1.0 label="growth multiplier"
template svg: <svg>...projected sales as a function of growth...</svg>
```
11.2 Nesting standard markdown around protocol fences
Headings, paragraphs, lists, blockquotes can surround protocol fences naturally. The renderer processes the markdown normally and inserts the rendered interactive blocks where their fences appear.
11.3 Multiple interact blocks in one document
Each interact block by default has its own isolated namespace. Variables declared in one block are not visible in another. To share state across blocks, use a named namespace (see §12).
12. Namespaces and shared state
12.1 The default namespace
Every interact block has an implicit, anonymous namespace. Slider, timer, and computed declarations live in this namespace and are accessible only within the block's template.
12.2 Named namespaces
To share state between multiple interact blocks, declare a named namespace at the top of each block:
Example — ▶ renders live in example21_namespace_shared_state.md
```interact
namespace pendulum
slider L range=0.5..2.0 step=0.1 default=1.0 label="rope length"
slider θ range=-1.57..1.57 step=0.01 default=0.3 label="angle"
computed cx = 100 + L * 70 * sin(θ)
computed cy = 30 + L * 70 * cos(θ)
template svg:
<svg viewBox="0 0 200 160">
<circle cx="100" cy="30" r="3" fill="#333"/>
<line x1="100" y1="30" x2="{cx}" y2="{cy}" stroke="#333"/>
<circle id="bob" cx="{cx}" cy="{cy}" r="10" fill="#3b82f6"/>
</svg>
```
```interact
namespace pendulum
computed kinetic = 0.5 * L * θ * θ
computed potential = L * (1 - cos(θ))
template svg:
<svg viewBox="0 0 200 60">
<rect id="k" x="10" y="20" width="{kinetic * 80}" height="14" fill="#10b981"/>
<rect id="p" x="10" y="38" width="{potential * 80}" height="14" fill="#f59e0b"/>
<text x="10" y="14" font-size="10">kinetic / potential</text>
</svg>
```
Both blocks now read from the same pendulum namespace. The rope diagram and the energy bars respond to the same sliders.
12.3 Scoping rules
- Variables declared inside a
namespace fooblock are accessible from any other block declaring the same namespace - Variables in the default (anonymous) namespace are scoped strictly to their block
- Two blocks with different named namespaces never see each other's variables
- A
dragspec line writes to its block's namespace; if that namespace is shared, the drag is visible to other blocks
Part V — Authoring guide
13. Decision tree
Use this tree to pick the right fence for a given task:
Is the content a data visualization (chart-shaped)?
├── Yes
│ ├── Animated over time? Yes → chart <type> + body `animate = <mode>` | No → chart <type>
│ └── (See §6 for type / animate compatibility)
└── No
├── Inline raster image? → image (§9)
│
├── Interactive (controls / animation / drag / click)?
│ ├── Direct manipulation (drag an element)? → interact + drag (§8)
│ ├── Click/tap an SVG element to select it? → interact + pick (§8.7)
│ ├── Reader picks/types and output regenerates (STL/JSON/code)?
│ │ → interact + controls + template <lang> (§7.2a)
│ ├── Slider/timer drives an SVG scene or chart? → interact + template svg|vega-lite (§7)
│ └── Just narrative text? → plain markdown
│
├── Tabular data to sort / filter / search? → table (§10.4)
├── Embed audio / video (the document's bytes)? → media (§10.5)
├── Knowledge check / self-test? → quiz (§10.6)
├── Choose-your-path branching scenario? → branch (§10.7)
├── Relationship network / entity map (characters,
│ concepts, citations — many nodes, typed edges)? → graph (§10.8)
│
└── Document structure (no animation)?
├── Highlight a note / warning / insight → callout (§4.4.1)
├── N concepts side-by-side → layout grid + card (§4.4.2)
├── Chronological events → timeline (§4.4.3)
├── Annotate parts of one line → annotate (§4.4.4)
├── Show STL with coloring → stl fenced block (§4.4.5)
├── Equivalent views of one thing (switch) → tabs (§10.1)
├── Sequential walkthrough (Prev/Next) → stepper (§10.2)
├── Click-to-expand overlay → modal (§10.3)
└── Simple collapsible → <details> (§4.1)
13.1 Default to high-level forms
When a task can be expressed with both chart and interact, prefer chart. Reasons:
- Shorter (one-liner vs 10-20 line interact)
- Higher AI generation reliability (the chart compiler validates more)
- Better default styling (axes, legends, colors handled automatically)
- The compiled IR is the same internally — no performance trade-off
Drop to interact only when the chart DSL cannot express the intent.
14. Common patterns
14.1 A simple controllable visualization
Example — ▶ renders live in example22_wavy_line.md
```interact
slider freq range=0.5..3 step=0.1 default=1.0 label="frequency"
slider amp range=10..80 step=5 default=40 label="amplitude"
template svg: <svg width="300" height="120">
<path d="M0,60 Q75,{60 - amp},150,60 T300,60" stroke="#3b82f6" fill="none" stroke-width="2"/>
</svg>
```
A wavy line whose amplitude can be adjusted. (freq is declared but not yet used in the template; an exercise is to involve it in the path expression.)
14.2 A time-driven animation
Example — ▶ renders live in example23_orbiter.md
```interact
timer t range=0..6.28 step=0.05 speed=30 loop=true
template svg: <svg width="220" height="120">
<circle id="orbiter" cx="{110 + 60 * cos(t)}" cy="{60 + 35 * sin(t)}" r="12" fill="#10b981"/>
</svg>
```
A green circle orbiting in an ellipse. The id="orbiter" makes the motion smooth.
14.3 A bar chart race
Example — ▶ renders live in example07_chart_bar_animate_race.md
```chart bar
animate = race
data = [(Apple, 10, 12, 15, 14, 18, 22), (Banana, 8, 11, 13, 17, 15, 16), (Cherry, 5, 8, 10, 12, 18, 20)]
frame_duration = 0.7
title = Fruit popularity over time
```
Three bars that reorder and resize across six time steps.
14.4 A draggable pendulum (slider + drag dual binding)
See §8.6 — the canonical example (source + live link: example19_drag_angular_pendulum.md).
14.5 An inline diagram
Example — ▶ renders live in example20_image_inline_base64.md
```image
format = png
alt = system architecture
data = iVBORw0KGgoAAAANSUhEUgAA... (full base64 omitted for documentation)
```
15. Anti-patterns to avoid
15.1 Putting executable code in a template
The template body is SVG or vega-lite spec, not JavaScript or any other code. There is no onclick, no <script>, no setInterval. If a feature you want requires code, the protocol either does not yet support it or supports it through a declarative primitive instead. Check the decision tree (§13) before reaching for a workaround.
15.2 Using external URLs in image blocks
The image block does not accept URLs. If you need an externally hosted image, use standard markdown  syntax instead. Do not encode a URL as base64 to fit into the image block.
15.3 Reusing SVG id for drag targets
SVG id is for diff-and-animate identity. Drag uses the separate mvid namespace. Reusing id for drag-target lookup will not work and may interfere with the animation pipeline. Always use mvid for drag targets.
15.4 Putting a chart inside an interact template
Chart compilation runs first, then interact compilation. A chart block cannot be nested inside an interact template. If you need a chart-like visualization driven by an interact's variables, use a template vega-lite: block inside the interact (which is what the chart compiler produces internally anyway).
15.5 Expecting standard markdown features to work inside a protocol fence
Inside a chart, interact, or image block, only that DSL's syntax is recognized. Headings, lists, links, blockquotes inside the fence body are ignored or cause a parse error. To embed protocol blocks alongside markdown, place them as separate top-level fences.
15.6 Trying to write a single mega-interact
A 200-line interact block with twenty sliders and a sprawling template is hard to author, hard to debug, and hard for an AI to generate correctly. Split it into multiple smaller blocks that share a named namespace (see §12.2).
15.7 Raw < inside a template SVG body
The template svg: body is markup-parsed before placeholder substitution. A raw < anywhere in the body other than opening a tag — in an attribute value, in text content, including inside {...} placeholders — kills the whole block with an SVG parse error:
fill="{x < 5 ? '#10b981' : '#ef4444'}" ← ✗ raw < in an attribute
<text>five < ten</text> ← ✗ raw < in text content
Two mechanical rewrites always work:
- Flip the comparison so only
>/>=appear in the template:x < 5→5 > x. - Hoist the expression into a
computedline (spec lines are not markup —<and<=are fine there) and reference the result:computed f = x < 5 ? '#10b981' : '#ef4444'… thenfill="{f}". For literal text, write<.
Everything else survives: >, >=, ==, !=, &&, || are all safe inside placeholders (verified against the reference implementation), and a bare & in text is tolerated by the reference parser — though & is the conservative spelling for cross-reader portability. Values substituted into the document at runtime may contain < freely (the runtime sets text through the DOM, not by re-parsing the markup).
16. For LLM authors
This section is written for LLMs consuming this document as a system prompt input.
16.1 Generation discipline
When generating protocol content as part of your output:
- Prefer the high-level form. Use
chartfor any chart-shaped visualization. Drop tointeractonly whenchartis insufficient. - Validate variables before referencing them. Every variable used inside a
templateplaceholder must be declared as aslider,timer, orcomputedin the same block (or in a shared namespace). - One template per interact block. Multiple
templatelines in a single block are a syntax error. - Give SVG elements stable
idattributes when they should animate smoothly. Withoutid, attribute changes between frames will not interpolate. - Use
mvid(notid) for drag targets. These are different namespaces. - Quote strings only when needed.
name = Appleis valid; you only need quotes when the value contains commas, leading/trailing whitespace, or other ambiguous characters. - Match data row lengths. In animated charts with wide-format data, every row must have the same number of values.
16.2 Failure recovery
If a previous attempt at generating a protocol block failed (you can tell because the rendered output showed a [chart compile error] or [interact compile error] block), apply the following recovery:
- Identify which fence failed from the error message
- Check the spec line that the error references (§ numbers map directly to this document)
- Most common causes: missing required field, unknown
animatemode for the chart type, malformed primitive syntax, expression with unknown function or attribute access - Re-emit only the failed block; do not re-emit the entire document
16.3 Decision: when not to use the protocol
Not every output needs interactive content. Plain prose, lists, code blocks, and standard markdown features serve most of an LLM's output. The protocol is for:
- Data visualization
- Demonstration scenes (physics, math, algorithms)
- Sliders / timers / parameter exploration
- Reader-driven exploration (
:dragbased scenes) - Inline media (
imagefor portable embedded raster)
If a task does not benefit from any of the above, generate standard markdown and skip the protocol entirely.
Part VI — Safety and compliance
17. The safety model
The protocol is architecturally safe — not best-effort, but enforced by the structure of the protocol itself.
17.1 No code execution
There is no path from protocol source to arbitrary code execution. The renderer is a parser + sanitizer + CSS layer:
- No
eval()orFunctionconstructor used on author content - No
<script>tags allowed in template SVG bodies - No
onclick/onload/ other event handler attributes accepted from author content - No
javascript:URLs accepted inhref/srcattributes - No network requests initiated by author content
17.2 Sandboxed expression evaluator
Expressions inside {...} placeholders are evaluated by a fixed-function mini-language:
- Arithmetic operators:
+,-,*,/,%,** - Comparison operators:
<,>,<=,>=,==,!= - Ternary:
a ? b : c - Logical:
&&,||,! - Function calls (whitelist only — see Appendix B)
- Identifier references (must be declared namespace variables)
Not allowed: attribute access (obj.field), subscripting (arr[i]), variable assignment, function definition, anything else not on the whitelist. A source that attempts these falls cleanly through to the failure model (§18).
17.3 SVG sanitization
Template SVG bodies are sanitized before insertion into the DOM:
- Disallowed element tags (e.g.,
<script>,<iframe>,<object>) are stripped - Disallowed attributes (e.g.,
onclick) are removed - URL attributes (
href,xlink:href) are validated and stripped ifjavascript:or other unsafe schemes - External URLs in image references inside the SVG are stripped
The sanitizer enforces an allowlist; the default policy is reject what is not explicitly allowed.
17.4 No remote content at render time
Opening a document is not a network event. A conforming reader MUST NOT fetch over http(s) at render time for any document resource — images, audio, video, fonts, frames, stylesheets, or data. The only legal payload sources are inline (data: URIs — the document's own bytes) and, where an implementation supports folder bundles, bundle-relative paths. Elements that point at the internet MUST NOT load; a conforming reader SHOULD render a visible notice in their place explaining why (the reference implementation shows: "This 〈image|audio|video|frame〉 comes from the internet and can't be 〈displayed|played|embedded〉 here — documents only render content embedded in the file."), and SHOULD enforce the guarantee structurally (the reference implementation ships a Content-Security-Policy that makes render-time egress impossible at the engine layer, with the DOM filter demoted to the explanatory notice).
Why this is a feature, not a restriction: no tracking pixels (opening a shared document cannot leak the reader's IP or read-time), no content swap after review (what was moderated is what renders, forever), no link rot (the document renders identically in ten years, offline, on a plane).
Hyperlinks are exempt. Ordinary [text](https://…) links remain present and clickable — following one is an explicit user action handled by the platform (e.g. opening the system browser), not a render-time fetch. Rendering no remote content and containing links to the web are compatible by design.
17.5 No persistent state
Protocol rendering produces no persistent state — no cookies set, no localStorage written, no IndexedDB accessed by the protocol itself. (Hosting applications may add their own storage; that is a layer above the protocol.) A document renders identically across opens, modulo browser fonts and viewport size.
18. Failure mode
Silent corruption is a bug. Clean failure is correct.
When any DSL compiler cannot parse a fence body, it emits a markdown fallback code block showing the original source with an error annotation:
```
[chart compile error] Unknown animate mode: explode (expected: race, trail, reveal, evolve, none)
--- original ---
```chart bar
animate = explode
data = [...]
```
```
18.1 Failure cases the protocol guarantees fallback for
- Unknown function in expression (whitelist evaluator)
- Forbidden AST nodes (attribute access, subscript) — security
- Missing required field (
y,range,data, etc.) - Malformed primitive line (
rank,appear-at,reveal,trail,morph,step,interpolate) - Unknown
animatevalue or incompatible(type, animate)combination - Wide-format data row-length mismatch
- Empty interact body (no recognised spec lines)
- Image block with unsupported format
- Image block with malformed base64
The author's .md file always opens, and the failed block always shows its source — fidelity over rendering.
18.2 What conforming readers must do on failure
A conforming reader must:
- Continue rendering the rest of the document (do not abort)
- Replace the failed fence with a fallback code block in the rendered output
- Include a human-readable error message identifying which validation failed and what the source said
- Preserve the original source inside the fallback block
A reader that aborts the whole document on a single fence failure is non-conforming.
19. Conformance levels
A conforming reader implements one of these levels:
| Level | What it supports |
|---|---|
| L0 — Markdown only | Standard markdown rendering. Protocol fences degrade to code blocks. This is the structural fallback. Any markdown reader is L0-conforming. |
| L1 — Static | All chart types in their static form. image block. Standard markdown. No interactivity. No animation. |
| L2 — Interactive | L1 + interact with controls (slider, input, chip, select, toggle), computed, and template (svg / vega-lite / text-output). Interactive structural & content blocks (§10: tabs / stepper / modal / table / media / quiz / branch / graph). Static charts that are not animated. No timer-driven animation. |
| L3 — Animation | L2 + timer, all high-level primitives (interpolate, step, rank, appear-at, reveal, trail, morph), animated charts, diff-and-animate via stable id. |
| L4 — Full | L3 + direct manipulation: :drag (pointer drag) and pick (click/tap to select). The complete v0 surface. |
A reader must declare its conformance level. Rho MD declares L4. Other readers may choose lower levels (e.g., a static publishing pipeline might be L1; a reading-only embedded view might be L3 without :drag).
A reader must fail gracefully on features above its declared level (the failure model in §18 applies).
Part VII — Reference
20. Mini-DSL grammar (expressions)
This section defines the grammar of expressions allowed inside {...} placeholders in template bodies and inside computed declarations.
20.1 Operators (precedence high to low)
| Operators | Associativity |
|---|---|
** (power) |
Right |
Unary -, ! |
Right |
*, /, % |
Left |
+, - |
Left |
<, >, <=, >= |
Left |
==, != |
Left |
&& |
Left |
|| |
Left |
? : (ternary) |
Right |
20.2 Identifiers
Identifiers reference variables declared in the current namespace. The variable must be declared (as slider, timer, or computed) earlier in the same interact block, or in a shared named namespace.
Magic identifiers (_lag, _i) are only valid inside trail primitive expressions.
20.3 Function calls
Function calls use standard form: name(arg1, arg2, ...). Functions are restricted to a fixed whitelist; see Appendix B.
20.4 Forbidden constructs
The following are not part of the grammar (an expression using them fails cleanly, §18):
- Attribute access:
obj.field - Subscript indexing:
arr[i]. Array literals are allowed, but only as aselect()argument (select(i, [...]), §B) — there is no index operator. - Assignment inside expressions:
a = b(=is reserved for spec-line assignment, not expressions) - Function definition
- Statement-level constructs (no
if/for/whilekeywords — use theif(...)function or the? :ternary)
Strings are first-class. String literals (single- or double-quoted), string-valued ternaries (cond ? 'a' : 'b'), string arrays in select, and the string functions (concat / upper / lower / len) all evaluate normally. Two practical notes: (1) inside an SVG attribute that is itself double-quoted (fill="{...}"), use single-quoted string literals in the placeholder to avoid closing the attribute early; (2) for a color/label state machine driven by an input variable, the step primitive is usually cleaner than a long nested string ternary, and a numeric hue fed to hsl({...}, s%, l%) avoids string handling entirely.
21. Cheat sheet (one-page summary)
# Fences
```chart <type> # type: line/bar/scatter/area/pie/heatmap/treemap; animate = <mode> as body line
# animate: race/trail/reveal/evolve
```interact # spec lines + one template
```image # format, data (base64), alt, width, height
```graph # size/slider/group/rel/node/edge lines — network map,
# semantic zoom + LOD labels owned by the reader
# Interact spec lines
slider <var> range=A..B step=S default=V label="..." # numeric
timer <var> range=A..B step=S speed=FPS loop=true|false label="..." # numeric, auto-advance
input <var> default="..." [placeholder="..."] label="..." # string (free text)
chip <var> options=a,b,c default=a label="..." # string (chip buttons)
select <var> options=a,b,c default=a label="..." # string (dropdown)
toggle <var> default=true|false label="..." # string "true"/"false"
computed <var> = <expression>
template <fmt>: <body> # fmt: svg | vega-lite | any text lang (stl/json/python/...)
# Interact primitives
interpolate <var> [over <input>]: (t0, v0) -> (t1, v1) -> ...
step <var> [over <input>]: (t0, v0) -> (t1, v1) -> ...
rank [r1, r2, ..., rN] from [v1, v2, ..., vN]
appear-at <var> [over <input>] at <t0> [until <t1>]
reveal <var> [over <input>]: <t0>..<t1> [from=<v0>] to=<v1>
trail <var> lags=[l0, l1, ...] [over <input>] = <expression with _lag, _i>
morph <var> phase=<expr> paths=["<path>", "<path>", ...]
drag target=<mvid> binds=<var>|<varX,varY> mode=<angular|xy|x|y> [pivot=<mvid>] [min=<n>] [max=<n>]
pick <var> # click an mvid-bearing SVG element -> sets <var> = that mvid
# Static structural blocks (§4.4 — L1, no interactivity)
> [!INFO|WARN|ZEN|NOTE|TIP|...] title # callout (blockquote first line)
```layout grid cols=N # + :::card accent=blue|.. title=".." ::: ...
```timeline # + :::event date=".." title=".." accent=.. :::
```annotate # body line, then ---, then START-END|COLOR|LABEL
```stl # auto 6-color STL highlighting
# Interactive structural & content blocks (§10 — L2)
```tabs default="T" # + :::tab title="T" ::: ...
```stepper linear=true|false # + :::step title="T" ::: ...
```modal trigger="label" width=medium # body shown in dialog on click
```table # GFM table body + columns=/sort=/filter=/summary=/search=
```media # src=data:URI [kind=audio|video] [title=/poster=/captions=/loop=/muted=]
```quiz # pass=/shuffle= + :::q [(multi|text|match)] task-list options
```branch start=ID # :::node ID [(end)] + "label → target" choices
# Composition
namespace <name> # declare a shared namespace at top of interact block
# Standard markdown features
# All standard markdown (headings, lists, links, tables, math, etc.) continues to work.
22. Versioning and roadmap
22.1 Versioning
The protocol uses pure-numeric semantic versioning: MAJOR.MINOR.
- MAJOR (0 → 1, 1 → 2, etc.): backward-incompatible changes. Spec frozen for at least 6 months before a major bump.
- MINOR (0.0 → 0.1 → 0.5, etc.): backward-compatible feature additions or refinements.
There is no pre-release suffix. The current published version is 0.8 (adds the graph network/starmap block of §10.8; v0.7 added the table / media / quiz / branch content blocks of §10.4–10.7).
22.2 Roadmap to v1.0
Path to v1.0 stable:
| Milestone | Status |
|---|---|
| Core chart DSL (7 types, 4 animate modes) | ✅ shipped (v0) |
| Core interact DSL (slider/timer/input/chip/select/toggle/computed/template) | ✅ shipped (v0) |
| Interact primitives (interpolate, step, rank, appear-at, reveal, trail, morph) | ✅ shipped (v0) |
Diff-and-animate (stable id) |
✅ shipped (v0) |
:drag direct manipulation |
✅ shipped (v0.5) |
image inline base64 raster |
✅ shipped (v0.6) |
pick click/tap-to-select |
✅ shipped (v0.6) |
| Static structural blocks (callout, layout+card, timeline, annotate, stl) | ✅ shipped |
| Interactive structural blocks (tabs, stepper, modal) | ✅ shipped |
table — inline data table (§10.4) |
✅ shipped (v0.7) |
media — audio / video embed (§10.5) |
✅ shipped (v0.7) |
quiz — formative assessment (§10.6) |
✅ shipped (v0.7) |
branch — choose-your-path scenario (§10.7) |
✅ shipped (v0.7) |
| Accessibility (ARIA, keyboard, screen reader) | 🟡 v0.8 |
| Performance baseline (lazy hydration default) | 🟡 v0.9 |
| Stable API + conformance test pack | 🎯 v1.0 |
Media galleries are intentionally not a separate feature — compose multiple
media/imagefences in alayoutgrid (composition over configuration).
Per §3.5 (AI-generation reliability is the primary KPI), no new feature is promoted into the protocol surface without demonstrated ≥ 95% LLM default-write valid rate. The reliability baseline is measured at each minor version. v0.7 status note: the four content blocks are implemented and pass the conformance example pack (table / media / quiz / branch); their formal ≥ 95% default-write trial is the remaining measurement before v0.7 is considered reliability-baselined.
22.3 Extension mechanism
The protocol does not currently support author-defined extensions. The capability surface is exactly what is specified here. This is intentional: a stable, bounded protocol surface is more valuable to AI authors than a general extension mechanism that fragments the ecosystem.
If you need a feature not yet in the protocol, open an issue at the protocol's discussion forum (see Appendix D). The reference implementation team will evaluate against the design principles in §3.
Appendices
Appendix A — Full grammar (BNF-style)
document := (markdown | fence)*
fence := chart_fence | interact_fence | image_fence
| tabs_fence | stepper_fence | modal_fence # §10.1–10.3 (::: container grammar)
| table_fence | media_fence | quiz_fence | branch_fence # §10.4–10.7 (v0.7)
chart_fence := "```chart" type [modifier]* NEWLINE
assignment*
"```"
type := "line" | "bar" | "scatter" | "area" | "pie" | "heatmap" | "treemap"
modifier := "animate=" mode
mode := "race" | "trail" | "reveal" | "evolve"
assignment := identifier "=" value
interact_fence := "```interact" NEWLINE
("namespace" identifier NEWLINE)?
spec_line*
template_line
"```"
spec_line := slider_line | timer_line | input_line | chip_line | select_line
| toggle_line | computed_line | primitive_line | drag_line | pick_line
slider_line := "slider" identifier "range=" range "step=" number ["default=" number] ["label=" string]
timer_line := "timer" identifier "range=" range "step=" number ["speed=" number] ["loop=" boolean] ["label=" string]
input_line := "input" identifier ["default=" string] ["placeholder=" string] ["label=" string]
chip_line := "chip" identifier "options=" option_list ["default=" string] ["label=" string]
select_line := "select" identifier "options=" option_list ["default=" string] ["label=" string]
toggle_line := "toggle" identifier ["default=" boolean] ["label=" string]
option_list := string ("," string)*
computed_line := "computed" identifier "=" expression
primitive_line := interpolate | step | rank | appear_at | reveal | trail | morph
drag_line := "drag target=" mvid_ref "binds=" var_ref ("," var_ref)? "mode=" drag_mode
["pivot=" mvid_ref] ["min=" number] ["max=" number]
pick_line := "pick" identifier
template_line := "template" template_fmt ":" template_body
template_fmt := "svg" | "vega-lite" | text_lang
text_lang := "stl" | "json" | "python" | "js" | <any highlight.js language id>
image_fence := "```image" NEWLINE
"format=" image_format NEWLINE
"data=" base64 NEWLINE
["alt=" string NEWLINE]
["width=" number NEWLINE]
["height=" number NEWLINE]
"```"
image_format := "gif" | "jpeg" | "png" | "webp"
# v0.7 content blocks (§10.4–10.7). Structural fences (tabs/stepper/modal) use the same ::: container grammar.
table_fence := "```table" NEWLINE table_assign* gfm_pipe_table "```"
table_assign := ("columns=" | "sort=" | "filter=" | "summary=" | "search=") value NEWLINE
media_fence := "```media" NEWLINE media_assign* "```"
media_assign := ("src=" | "kind=" | "title=" | "poster=" | "captions=" | "loop=" | "muted=" | "autoplay=") value NEWLINE
quiz_fence := "```quiz" NEWLINE quiz_header* question+ "```"
quiz_header := ("shuffle=" | "pass=") value NEWLINE
question := ":::q" ["(multi)" | "(text)" | "(match)"] prompt NEWLINE answer_body [explanation]
answer_body := task_list | ("=" string NEWLINE)+ | (list_item "=" string NEWLINE)+ # choice | text | match
explanation := ">" markdown_line
branch_fence := "```branch" "start=" identifier NEWLINE node+ "```"
node := ":::node" identifier ["(end)"] NEWLINE markdown choice*
choice := "-" label ("→" | "->") identifier NEWLINE
expression := identifier | number | string | function_call | binary_expr | ternary_expr | array_literal
function_call := identifier "(" expression ("," expression)* ")"
array_literal := "[" (expression ("," expression)*)? "]" # only meaningful as a select() argument
range := number ".." number
value := number | string | list | tuple
(This is a simplified grammar. For the precise grammar including precedence and associativity, refer to the reference implementation at src/utils/exprDSL.ts and src/utils/interact.ts. A formal BNF will be published with v1.0.)
Appendix B — Built-in function library
The mini-language whitelist: 38 built-in functions + 5 constants. All evaluation is pure; no side effects. (Reference implementation: src/utils/exprDSL.ts.)
Math
| Function | Signature | Description |
|---|---|---|
abs(x) |
num → num | Absolute value |
sign(x) |
num → num | Sign: -1, 0, or 1 |
floor(x) |
num → int | Round toward -∞ |
ceil(x) |
num → int | Round toward +∞ |
trunc(x) |
num → int | Round toward zero |
round(x[, d]) |
num [, int] → num | Round half-up, optional decimal places |
min(a, b, ...) |
num... → num | Minimum of arguments |
max(a, b, ...) |
num... → num | Maximum of arguments |
clamp(x, lo, hi) |
num × num × num → num | Clamp x into [lo, hi] |
mod(x, n) |
num × num → num | Modulo (always-positive remainder) |
pow(x, y) |
num × num → num | Power (same as x ** y) |
sqrt(x) |
num → num | Square root |
cbrt(x) |
num → num | Cube root |
exp(x) |
num → num | e^x |
ln(x) |
num → num | Natural logarithm (base e) |
log(x[, base]) |
num [, num] → num | Logarithm; default base 10, or the given base |
log2(x) |
num → num | Base-2 logarithm |
log10(x) |
num → num | Base-10 logarithm |
Trigonometry
| Function | Signature | Description |
|---|---|---|
sin(x) |
num → num | Sine (x in radians) |
cos(x) |
num → num | Cosine |
tan(x) |
num → num | Tangent |
asin(x) |
num → num | Arcsine (returns radians) |
acos(x) |
num → num | Arccosine |
atan(x) |
num → num | Arctangent |
atan2(y, x) |
num × num → num | Two-argument arctangent (-π, π] |
Interpolation and easing
| Function | Signature | Description |
|---|---|---|
lerp(a, b, t) |
num × num × num → num | Linear interpolation: a + (b - a) * t |
mix(a, b, t) |
num × num × num → num | Alias for lerp |
smoothstep(a, b, x) |
num × num × num → num | Smooth Hermite step: 0 below a, 1 above b, eased between |
Aggregate
| Function | Signature | Description |
|---|---|---|
sum(a, b, ...) |
num... → num | Sum of arguments |
avg(a, b, ...) |
num... → num | Mean of arguments |
Conditional and lookup
| Function | Signature | Description |
|---|---|---|
if(cond, a, b) |
bool × any × any → any | Conditional (alternative to cond ? a : b) |
select(x, [v0, v1, ...]) |
num × list → any | Pick the element at integer index round(x); out of range → nan. The list is an array literal — the one place array literals appear (see §20.4). Elements may be numbers or strings, so select covers per-element lookup tables (e.g. select(round(sel)-1, [1.008, 4.0026, …])). |
String
| Function | Signature | Description |
|---|---|---|
concat(a, b, ...) |
any... → string | Concatenate arguments as strings |
len(s) |
any → int | Length of the string form |
upper(s) |
any → string | Uppercase |
lower(s) |
any → string | Lowercase |
Angle conversion
| Function | Signature | Description |
|---|---|---|
deg(r) |
num → num | Radians → degrees |
rad(d) |
num → num | Degrees → radians |
Constants
Bare identifiers (write pi, not pi()):
| Constant | Value |
|---|---|
pi |
π |
e |
Euler's number |
tau |
2π |
inf |
Infinity |
nan |
NaN |
Appendix C — Carrier abstraction
The protocol currently standardizes one carrier: markdown (.md files). The carrier defines how protocol fences are delimited in the source text.
Future versions may add additional carriers:
- HTML carrier: protocol fences encoded as
<script type="x-aip-chart">or similar inert elements; for embedding in non-markdown contexts - JSON-LD carrier: protocol blocks as structured data in a JSON envelope; for machine-to-machine pipelines
- Plain-text carrier: protocol fences delimited by sentinel lines (
---chart---); for environments without markdown fence support
Carriers are pluggable at the outer level. The inner DSL (the body of a fence) is carrier-independent: the same chart spec or interact spec is valid in any carrier.
For v0, only the markdown carrier is specified. Authors should expect markdown to remain the recommended carrier indefinitely; other carriers are for niche cases.
Appendix D — Related documents
Protocol documents (Layer 1 — public, CC BY 4.0)
AINP_POSITIONING.md— Strategic framing: what the protocol is, why it exists, how it relates to adjacent technologiesAINP_DESIGN_PURPOSE.md— Methodology document: the "why" in terms of design principles and goalsAINP_DSL_VALIDATION.md— How protocol capabilities are validated (DSL coverage and LLM reliability)IMAGE_BLOCK_v0.md— Detailed spec for theimagefenced block
Development draft (internal)
AINP_v0_SPEC.md— The internal development draft this official specification was derived from. Authors should reference this document; the draft is retained for development continuity.
Reference implementation
- Rho MD (rho.md) — Reference implementation, conformance level L4 (full)
src/utils/exprDSL.ts— Mini-language parser + 30 built-ins (Appendix B)src/utils/interact.ts— Interact block parser + control builder + render loopsrc/utils/svgDiff.ts— Diff renderer for stable-idanimationsrc/utils/svgSanitizer.ts— Sanitization enforcing the safety model
Discussion and contribution
- Discussion and issue tracker: GitHub Issues — questions, bugs, and ambiguities in the specification
- Contributions to the protocol surface are evaluated against §3 principles before consideration
Built by scos-lab. The protocol is open under CC BY 4.0; the reference implementation is free + closed-source.