Markdown Diagram Spec
Overview
A diagram is two files that work together: a Markdown file with an mdd block, and a *.diagram.json sidecar that is the real source of truth. The ASCII inside the block is a generated projection — it is never parsed back. Re-rendering the same JSON produces byte-identical output and leaves the surrounding prose untouched.
Two kinds of diagram share that format, picked by the top-level kind field: box diagrams show structure and flow, sequence diagrams show behavior over time. Each kind gets its own section below.
The mdd block
<!-- mdd:begin id=arch src=arch.diagram.json -->
```
┌───────────────┐
│ Load Balancer │
└───────┬───────┘
┌─────────┴────────┐
│ │
┌───────▼──────┐ ┌───────▼──────┐
│ API Server A │ │ API Server B │
└──────────────┘ └──────────────┘
```
[diagram source](arch.diagram.json)
<!-- mdd:end -->The <!-- mdd:begin id=<name> src=<path> --> and <!-- mdd:end --> comments delimit the block. id names it, so one Markdown file can hold several; src records the sidecar path, relative to the Markdown file's own directory. Between the delimiters sit a fenced code block of ASCII and a [diagram source](…) footer link. Regeneration replaces the block in place, so editing prose around it is always safe.
In source files
// mdd:begin id=flow src=flow.diagram.json // // ┌───────┐ ┌────────┐ // │ lexer ├───▶ parser │ // └───────┘ └────────┘ // // mdd:end
Outside Markdown the same block wears the file's line-comment prefix on every line — begin marker, ASCII, end marker — with no fences and no footer link. The prefix is auto-detected from the file extension (// , # , -- , …) or set with --prefix — e.g. --prefix " * " inside a /* */ comment you opened yourself. A prefix is a verbatim string (indentation included) with no letters or digits and at least one non-whitespace character; blank diagram lines are right-trimmed. Re-rendering reuses the prefix already on the marker, so updates never need the flag. One known limitation: a marker-shaped line inside a string literal is indistinguishable from a real block to mdd check — split such literals ("mdd:" + "begin") in test fixtures.
Top-level fields
Every sidecar carries these two fields, whichever kind it is. kind is the fork between the two sections below: omit it for a box diagram, set it to "sequence" for a sequence diagram. The two kinds' remaining fields cannot mix in one sidecar (mixed-kind).
"sequence" means a sequence diagram. Any other value is invalid-kind.Sequence diagrams
A sequence diagram shows behavior over time: participants with lifelines, messages ordered top to bottom by when they happen. Reach for one for call flows, handshakes, request/response chains, agent orchestration traces, or incident timelines. Participant array order is left-to-right column order; message array order is top-to-bottom time order — there are no other layout decisions to make.
Sequence-specific validation codes: invalid-kind (kind present but not "sequence"), unknown-participant (a message's from/to doesn't match a participant id), mixed-kind (box-diagram and sequence fields on the same sidecar), and empty-message-label (a message label present but blank) — see the Validation reference for full descriptions. duplicate-id, empty-label, and invalid-style are reused on participants.
Top-level fields
Participant
true — renders a stick-figure header instead of a box.Message
from === to is a legal self-message (drawn as a loop back to the same lifeline).true — draws a dashed reply arrow instead of a solid call.Worked example
A login call and its reply, two participants:
{
"version": 1,
"kind": "sequence",
"participants": [
{ "id": "c", "label": "Client" },
{ "id": "s", "label": "Server" }
],
"messages": [
{ "from": "c", "to": "s", "label": "login(creds)" },
{ "from": "s", "to": "c", "label": "session token", "reply": true }
]
} ┌────────┐ ┌────────┐
│ Client │ │ Server │
└────────┘ └────────┘
│ │
│ login(creds) │
│──────────────▶│
│ │
│ session token │
│◀┄┄┄┄┄┄┄┄┄┄┄┄┄┄│
│ │Calls render as solid arrows, replies as dashed (┄) — the same arrowheads either way. The web editor supports sequence diagrams too: the new-tab menu offers Box or Sequence, and a dedicated four-tool rail — Select V, Participant P, Message M, and Reply R — builds participants and messages directly on the canvas; drag an arrow to reorder it in time, or drag a participant header to reorder the columns. Files with kind: "sequence" import and open exactly like box diagrams. The CLI, splice, and mdd check staleness detection all work identically for both kinds.
Box diagrams
A box diagram shows boxes and the connections between them — structure and flow alike: architecture maps, database schemas, trust boundaries, deploy pipelines, decision flowcharts. Omit kind and the sidecar is one of these. Every node is a box: a table is a box with a table property, a decision diamond is a box with shape: "diamond", and a cluster groups boxes under an outline rather than being a node itself.
A Spacing value is either a number (both axes) or { x, y } (per-axis). box-padding is interior space (integer ≥ 1 per axis); box-margin is the minimum gap between boxes (integer ≥ 0 per axis) — the actual gap is max(routing-floor, margin), so it never shrinks below the space the router needs.
Top-level fields
Spacing (default 1). Interior padding for every box; overridable per box.Spacing (default 0). Minimum gap between boxes and rows; overridable per box.Box
\n splits it into centered lines. A blank label is a validation error — unless table is set, in which case label is optional and ignored."diamond" — renders the box as a chamfered decision diamond (flat top/bottom, 45° sides meeting in a mid-height tip). Omit for a rectangle. Invalid combined with table — see the Decision diamonds section.Spacing override of the diagram default for this box.Spacing override of the diagram default for this box.Table. When present the box renders as a grid of rows and cells instead of a centered label — see the Tables section.Connection
"to" | "from" | "both" | "none" (default "to").id anchoring that endpoint to a specific row of a table box — see the Tables section. Independently optional; an un-anchored endpoint uses the ordinary box-level exit.Connections may run downward, sideways, or upward — a box diagram is a general directed graph, not a strictly top-down tree. A connection whose to is the owning box's own id (a self-connection) is only valid when fromRow and toRow name two distinct rows of that box's table.
Worked example
Boxes across three rows, wired with labeled connections — the whole core vocabulary, before the specialized features below:
{
"version": 1,
"boxes": [
{ "id": "lb", "label": "Load Balancer", "row": 0, "style": 2,
"connections": [
{ "to": "api1", "style": 2, "arrow": "to" },
{ "to": "api2", "style": 2, "arrow": "to" }
] },
{ "id": "api1", "label": "API Server A", "row": 1, "style": 2, "connections": [] },
{ "id": "api2", "label": "API Server B", "row": 1, "style": 2, "connections": [] }
]
} ┌───────────────┐
│ Load Balancer │
└───────┬───────┘
┌─────────┴────────┐
│ │
┌───────▼──────┐ ┌───────▼──────┐
│ API Server A │ │ API Server B │
└──────────────┘ └──────────────┘Row order is authoritative and a parent centers over the span of its children, so the layout above is fully determined by row plus array order — there are no coordinates to place.
Tables
A box can render as a table instead of a single centered label: give it a table property and its interior becomes a grid of rows and cells. On the outside it is an ordinary box — same row, style, padding, margin, and connections. The flagship use case is relational-schema planning: one table box per database table, header row = table name, body rows = columns, connections = foreign keys anchored to specific rows.
table: { columns: number, rows: TableRow[] }
TableRow: { id: string, cells: TableCell[], header?: boolean }
TableCell: { text: string, span?: number }columns (invalid-cell-span otherwise). Column widths auto-size to the widest cell — there is no width field.TableRow (invalid-table if empty).duplicate-row-id otherwise). Connections anchor to rows by id, not text or position, so renaming a cell or reordering rows never breaks a connection.Worked example
Two schema tables side by side, with a foreign key from orders.user_id to users.id anchored to both rows:
{
"version": 1,
"boxes": [
{ "id": "users", "row": 0, "style": 2,
"table": {
"columns": 2,
"rows": [
{ "id": "h", "header": true, "cells": [{ "text": "users", "span": 2 }] },
{ "id": "id", "cells": [{ "text": "id" }, { "text": "int" }] },
{ "id": "email", "cells": [{ "text": "email" }, { "text": "text" }] }
]
},
"connections": [] },
{ "id": "orders", "row": 0, "style": 2,
"table": {
"columns": 2,
"rows": [
{ "id": "h", "header": true, "cells": [{ "text": "orders", "span": 2 }] },
{ "id": "id", "cells": [{ "text": "id" }, { "text": "int" }] },
{ "id": "user_id", "cells": [{ "text": "user_id" }, { "text": "int" }] }
]
},
"connections": [
{ "to": "users", "fromRow": "user_id", "toRow": "id", "arrow": "to", "label": "fk" }
] }
]
}┌──────────────┐ ┌───────────────┐ │ users │ │ orders │ ├───────┬──────┤ ├─────────┬─────┤ │ id ┆ int ◀┐ │ id ┆ int │ ├┄┄┄┄┄┄┄┼┄┄┄┄┄┄┤│ ├┄┄┄┄┄┄┄┄┄┼┄┄┄┄┄┤ │ email ┆ text │└fk─┤ user_id ┆ int │ └───────┴──────┘ └─────────┴─────┘
The outer frame renders at the box's style, the header rule solid at weight 2, and every other interior divider dotted at weight 1. Where a dotted divider meets the frame, the junction takes the frame's weight, so the border reads as unbroken. Box padding applies per cell — x spaces around each cell's text — not once around the whole grid.
Row anchors
- A row-anchored endpoint exits its box's left or right edge — whichever faces the other endpoint — at the anchored row's height. If the boxes are stacked vertically, the router bends the line around through the column gap.
fromRowandtoRoware independently optional: anchor both ends, one, or neither. An un-anchored endpoint uses the ordinary box-level exit, even on a table box.- An anchor's box must be a table (
anchor-on-non-tableotherwise) and the id must name one of its rows (unknown-anchor-rowotherwise). - Self-connections are valid only with
fromRowandtoRowset to two distinct rows — e.g.employees.manager_id → employees.id. Anything else on a self-connection isinvalid-self-connection.
Limitations
- Parallel connections between the same two boxes are supported — e.g. two foreign keys into the same table. Edges fan out around the side center, and the router keeps parallel edges from sharing collinear runs wherever space permits; the only restriction is
duplicate-connection: two connections identical in target, anchors, label, and resolved style/arrow are rejected — differentiate them by any field. - No rowspan (vertical cell merging), no per-cell styling beyond the header rule.
\nin cell text is a hard line break.
Decision diamonds
Give a box "shape": "diamond" and it renders as a decision node for flowcharts and runbooks: flat top and bottom runs with diagonal end caps, and 45° chamfered sides meeting in a tip at mid-height. Height matches the equivalent rectangle (a typical one-line diamond is exactly the same size), so decision chains stay compact; taller boxes (multi-line labels, larger box-padding) grow more diamond-like. Everything else about the box is unchanged — same row, style, padding, margin, connections, and cluster membership. Vertical connectors meet the flat runs (and merge into junction glyphs there); horizontal connectors meet the side tips. Label the outgoing connections to make the branches:
{
"version": 1,
"boxes": [
{ "id": "alert", "label": "Disk alert", "row": 0, "style": 2,
"connections": [{ "to": "full", "style": 2, "arrow": "to" }] },
{ "id": "full", "label": "disk full?", "row": 1, "shape": "diamond",
"connections": [
{ "to": "rotate", "arrow": "to", "label": "yes" },
{ "to": "close", "arrow": "to", "label": "no" }
] },
{ "id": "rotate", "label": "Rotate logs", "row": 2, "connections": [] },
{ "id": "close", "label": "Close alert", "row": 2, "connections": [] }
]
} ┌────────────┐
│ Disk alert │
└──────┬─────┘
│
│
╱─────▼────╲
⟨ disk full? ⟩
╲─────┬────╱
┌──yes───┴───no───┐
│ │
┌──────▼──────┐ ┌──────▼──────┐
│ Rotate logs │ │ Close alert │
└─────────────┘ └─────────────┘The flat runs render at the box's style weight; the chamfer characters (╱ ╲ ⟨ ⟩) have no Unicode weight variants and stay the same at every weight. A diamond cannot also be a table (invalid-shape), and a blank label is as invalid as on a rectangle.
Clusters
A clusters array groups a subset of boxes under an outline — optionally labeled — a trust boundary / zone-diagram primitive (VPC, subnet, namespace, security zone). Declaring a cluster never moves a box: row and array order stay authoritative; layout only widens the gaps around member boxes to leave room for the outline. A diagram with no clusters key is fully backward compatible. In the editor, select boxes and press Group (G) to create one.
clusters?: Cluster[]
Cluster: { id, label?, members, style?, "cluster-padding"?, connections? }┌┄ DMZ ┄┄┄┐) instead of drawn inside or above it — zero extra height. Omit it for an unbroken border (┌┄┄┄┄┄┄┄┄┐) and no reserved label width.Spacing; breathing room between the outline and what it contains. Default asymmetric {x:1, y:0} (min 0 per axis, unlike box padding's min 1) — the border itself always occupies one further cell beyond the padding.Worked example
A two-box DMZ cluster, with an audited connection from the cluster itself (not either member) down to a box outside it:
{
"version": 1,
"boxes": [
{ "id": "web", "label": "Web Server", "row": 0, "style": 2, "connections": [] },
{ "id": "api", "label": "API Server", "row": 0, "style": 2, "connections": [] },
{ "id": "db", "label": "Database", "row": 1, "style": 2, "connections": [] }
],
"clusters": [
{
"id": "dmz",
"label": "DMZ",
"members": ["web", "api"],
"connections": [
{ "to": "db", "arrow": "to", "label": "audited" }
]
}
]
} ┌┄ DMZ ┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┐
┆ ┌────────────┐ ┌────────────┐ ┆
┆ │ Web Server │ │ API Server │ ┆
┆ └────────────┘ └────────────┘ ┆
└┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┬┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┘
audited
│
┌─────▼────┐
│ Database │
└──────────┘The outline defaults to weight 1 (dotted) against the members' weight 2 (solid), and the padding is visible above: one blank ┆-lined column separates each box from the left/right border, but the top border sits directly on the boxes' own top row with no blank row above it — the default asymmetric padding.
Connections, nesting, and overlap
- A connection to a cluster terminates on the outline itself, not any particular member. Row anchors are invalid on a cluster endpoint, since a cluster has no rows.
- Degenerate pairs are rejected, not silently allowed: a connection between a cluster and one of its own members, or between two clusters where one's members contain the other's, is a
cluster-connection-overlaperror — created, then flagged, the same as an ordinary box-connection mistake. - Two clusters with identical member sets are a validation error (
identical-cluster-members) — coincident outlines have no defined stacking order. Venn-style overlap (sharing some, not all, members) is supported. - Exact containment is an invariant: an outline never visually contains a non-member. When a rectangle would swallow one, the border detours into a rectilinear polygon that hugs the real member region, with an advisory
cluster-not-rectangularrender warning — the drawing is still correct, just less tidy than a clean box. - A label that won't fit never costs the outline. The engine tries the padded run (
┌┄ DMZ ┄┐) on every qualifying border run, then the unpadded run (┌┄DMZ┄┄┐, two cells shorter), and finally draws the outline with no label at all plus acluster-label-unplacedwarning. Labels are never clipped and never overwrite another outline's border or label.
Line weights
┌┄┄┐ ┆┌──┐ │┏━━┓ ┃╔══╗ ║Shared by both kinds: box borders (box.style), connectors (connection.style), cluster outlines (cluster.style, default 1), and sequence participant headers (participant.style).