mdd
DocsUse CasesGitHubOpen App

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

FieldMeaning
versionFormat version integer, currently 1. Required on every sidecar.
kindOptional. Omitted means a box diagram; "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

FieldMeaning
participantsArray of participant objects; array order is left-to-right column order.
messagesArray of message objects; array order is top-to-bottom time order.

Participant

FieldMeaning
idUnique string identifier, required.
labelOptional single-line text; falls back to id.
actorOptional true — renders a stick-figure header instead of a box.
styleOptional header border weight 1–4 (default 2); ignored on an actor.

Message

FieldMeaning
from / toParticipant ids. from === to is a legal self-message (drawn as a loop back to the same lifeline).
labelOptional single-line text; a bare arrow with no label is legal.
replyOptional 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

FieldMeaning
boxesArray of box objects, in left-to-right order within each row.
clustersOptional array of cluster objects — groups of boxes drawn under a shared outline. See the Clusters section.
box-paddingOptional Spacing (default 1). Interior padding for every box; overridable per box.
box-marginOptional Spacing (default 0). Minimum gap between boxes and rows; overridable per box.

Box

FieldMeaning
idUnique string identifier.
labelFree text. A \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.
rowInteger layer, 0 = top. A box is centered over the span of its children (barycenter layout).
styleOptional border weight 1–4 (default 2).
shapeOptional "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.
box-paddingOptional Spacing override of the diagram default for this box.
box-marginOptional Spacing override of the diagram default for this box.
connectionsOptional array of connection objects.
tableOptional Table. When present the box renders as a grid of rows and cells instead of a centered label — see the Tables section.

Connection

FieldMeaning
toid of another box in the same diagram; the line runs from the owning box to it.
styleOptional line weight 1–4 (default 2).
arrowOptional "to" | "from" | "both" | "none" (default "to").
labelOptional non-empty text drawn on the connector line.
fromRow / toRowOptional row 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 }
FieldMeaning
columnsWidth of the column grid, integer ≥ 1. Every row's cell spans must sum to exactly columns (invalid-cell-span otherwise). Column widths auto-size to the widest cell — there is no width field.
rowsAt least one TableRow (invalid-table if empty).
row.idStable string, unique within the table (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.
row.cellsLeft to right, one per grid column unless spanning. \n in cell text is a hard line break; the row grows to fit its tallest cell.
row.headerMarks the row as a header: text is centered and a solid rule is drawn below it. Conventionally one cell spanning all columns.
cell.spanColspan, integer ≥ 1 (default 1).

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.
  • fromRow and toRow are 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-table otherwise) and the id must name one of its rows (unknown-anchor-row otherwise).
  • Self-connections are valid only with fromRow and toRow set to two distinct rows — e.g. employees.manager_id → employees.id. Anything else on a self-connection is invalid-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. \n in 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? }
FieldMeaning
idUnique across boxes and clusters (one shared namespace) — sharing it is what lets a connection target a cluster.
labelOptional. When present: single-line and non-empty, woven into the outline's top border (┌┄ DMZ ┄┄┄┐) instead of drawn inside or above it — zero extra height. Omit it for an unbroken border (┌┄┄┄┄┄┄┄┄┐) and no reserved label width.
membersArray of box ids, at least one. Nesting is never declared, only derived: if cluster A's members are a strict superset of cluster B's, A's outline encloses B's automatically.
styleOptional border weight 1–4. Cluster default is 1 (dotted), matching the trust-boundary convention; boxes keep their own default of 2.
cluster-paddingOptional 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.
connectionsOptional array of connection objects — a cluster can be a connection source, and any connection's to may name a cluster instead of a box.

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-overlap error — 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-rectangular render 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 a cluster-label-unplaced warning. Labels are never clipped and never overwrite another outline's border or label.

Line weights

WeightName — sample
1dotted — ┌┄┄┐ ┆
2light (default) — ┌──┐ │
3heavy — ┏━━┓ ┃
4double (max) — ╔══╗ ║

Shared by both kinds: box borders (box.style), connectors (connection.style), cluster outlines (cluster.style, default 1), and sequence participant headers (participant.style).