mdd
DocsUse CasesGitHubOpen App

API and CLI

Install / build

Requires Node ≥ 20.

npm install
npm run build     # compiles src/ -> dist/ (tsc)
npm test          # runs the vitest suite

The package exposes the mdd CLI binary (dist/cli.js via the bin field) and a programmatic API importable from the package root. npm run typecheck runs tsc --noEmit.

The mdd CLI

mdd <command>

Commands:
  init     --file <path> --id <name> [--json <path>] [--prefix <str>] [--kind sequence]
  render   --json <path> [--file <path> --id <name>] [--prefix <str>] [--format text|json]
  validate --json <path> [--format json]
  check    [paths...] [--format text|json]
  skill    install [--dir <path>]
  • init — scaffolds an empty mdd:begin/mdd:end block plus an empty sidecar JSON, in Markdown or (comment-prefixed) in a source file. --kind sequence scaffolds a sequence sidecar ({ version: 1, kind: "sequence", participants: [], messages: [] }) instead of a box diagram.
  • render — validates and renders. With --file/--id it splices the ASCII into that file's block — comment-prefixed when the file isn't Markdown — and rewrites the sidecar; without them it prints ASCII to stdout. Idempotent.
  • validate — validation only; prints ok or one error per line and exits non-zero on failure.
  • check — sweeps Markdown and source files for mdd blocks and fails (exit 1) if any sidecar is missing, invalid, or its rendered ASCII is stale. Fenced examples are skipped; zero blocks is a pass — safe as a one-line CI step.
  • skill install — copies the packaged Claude Code skill into .claude/skills/mdd/ so coding agents author diagrams with mdd instead of hand-drawing ASCII.

--prefix (on init and render) sets the line-comment prefix used when --file is a source file rather than Markdown: the exact string — indentation included — prepended to every line of the block, so --prefix "// " writes // mdd:begin id=… src=… and comments out each ASCII line the same way. It must contain at least one non-whitespace character and no letters or digits. You rarely need it: known extensions pick their prefix automatically (// for .ts/.go/…, # for .py/.sh/…, -- for .sql/…), and updating an existing block always reuses the prefix already on its marker. Reach for the flag only when scaffolding into an extension mdd doesn't recognize, or to override the default — e.g. --prefix " * " to sit inside a /* */ comment you opened yourself.

Exit codes: 0 success, 1 validation failure, 2 bad usage/args. --format json on render/validate/check emits machine-readable output so an agent can self-correct without scraping text.

Every command is kind-aware: render, validate, and check — and both splice styles, Markdown and comment-prefix — treat a kind: "sequence" sidecar identically to a box diagram. init --kind sequence is the only place a kind is ever chosen; everything downstream dispatches on the sidecar itself.

Worked example

Box diagram

arch.diagram.json:

{
  "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": [] }
  ]
}
mdd render --json arch.diagram.json --file arch.md --id arch

renders (and splices into arch.md):

           ┌───────────────┐
           │ Load Balancer │
           └───────┬───────┘
         ┌─────────┴────────┐
         │                  │
 ┌───────▼──────┐   ┌───────▼──────┐
 │ API Server A │   │ API Server B │
 └──────────────┘   └──────────────┘

Sequence diagram

Every sequence feature in one sidecar: an actor participant, a weight-3 header, a labeled call, a self-message, a dashed reply, and a bare label-less arrow. checkout.diagram.json:

{
  "version": 1,
  "kind": "sequence",
  "participants": [
    { "id": "user", "label": "User", "actor": true },
    { "id": "web", "label": "Web App" },
    { "id": "db", "label": "Orders DB", "style": 3 }
  ],
  "messages": [
    { "from": "user", "to": "web", "label": "checkout" },
    { "from": "web", "to": "web", "label": "validate cart" },
    { "from": "web", "to": "db", "label": "INSERT order" },
    { "from": "db", "to": "web", "label": "order id", "reply": true },
    { "from": "web", "to": "user", "reply": true }
  ]
}
mdd render --json checkout.diagram.json --file checkout.md --id checkout

renders (and splices into checkout.md):

   o     ┌─────────┐    ┏━━━━━━━━━━━┓
  /|\    │ Web App │    ┃ Orders DB ┃
  / \    └─────────┘    ┗━━━━━━━━━━━┛
 User         │               │
   │          │               │
   │ checkout │               │
   │─────────▶│               │
   │          │               │
   │          │ validate cart │
   │          │──┐            │
   │          │◀─┘            │
   │          │               │
   │          │ INSERT order  │
   │          │──────────────▶│
   │          │               │
   │          │   order id    │
   │          │◀┄┄┄┄┄┄┄┄┄┄┄┄┄┄│
   │          │               │
   │◀┄┄┄┄┄┄┄┄┄│               │
   │          │               │

Programmatic API

Importable from the package root (mdd-markdown-diagram):

A Diagram is a kind-discriminated union — BoxDiagram | SequenceDiagram — and every function here that takes a diagram accepts either kind, dispatching on kind internally. isSequenceDiagram(d) narrows the union. Sequence renders never produce warnings: columns widen to fit their labels, so nothing can be dropped.

ExportPurpose
renderToString(d)Validates and renders a diagram to an ASCII string; throws DiagramValidationError if invalid.
renderDiagramWithWarnings(d)Renders without validating; returns { text, warnings }. Warnings are ValidationErrors; connection-scoped ones (edge-label-unplaced) also carry the target box id as to. renderDiagram returns only text.
renderToFile(opts)Validates, writes the JSON sidecar, and splices the ASCII into a Markdown or source file's mdd block (comment-prefix aware; optional prefix override; either kind). Returns any render-time warnings.
initFile(opts)Writes an empty sidecar and an empty block (Markdown or comment-prefixed). kind: "sequence" scaffolds a sequence sidecar instead of a box diagram — the API twin of init --kind sequence.
validate(d)Structured validation; never throws; returns [] when valid.
DiagramValidationErrorThrown on invalid render; carries the structured errors array.

Model mutation ops

Pure functions over an in-memory BoxDiagram (see the note below the table):

ExportPurpose
emptyDiagram()Returns { version: 1, boxes: [] } — a BoxDiagram.
emptySequenceDiagram()Returns { version: 1, kind: "sequence", participants: [], messages: [] }.
isSequenceDiagram(d)Type guard narrowing Diagram to SequenceDiagram — the discriminator for code that handles both kinds.
addBox(d, box)Appends a box.
connect / disconnect / disconnectAtAdd or remove connections. connect options include style, arrow, and fromRow/toRow row anchors. disconnect(d, from, to) removes all to that target; disconnectAt(d, from, index) removes one by index.
setLabel / setRow / setStyleEdit a box's label, row, or border weight.
setBoxPadding / setDiagramPaddingSet the per-box or global padding (a Spacing).
setBoxMargin / setDiagramMarginSet the per-box or global margin (a Spacing).
addTableBox(d, box)Adds a box with a starter 2-column table: a header row plus two empty body rows.
addTableRow / deleteTableRowAppend or delete a row. Deleting demotes connections anchored to that row to box-level endpoints — or removes a self-connection that would lose its second anchor.
setRowHeader / setCellTextToggle a row's header flag; set a cell's text (\n makes a multi-line cell).
setCellSpan(d, boxId, rowId, i, span)Widens a cell by merging its right neighbor, or narrows it by splitting one off — the row's span sum always still equals columns.
setTableColumns(d, boxId, n)Grows or shrinks the column grid, adjusting every row; throws if shrinking would destroy a non-empty cell.
removeBox(d, id)Removes a box and any connections pointing at it.
addCluster / removeClusterAdd or remove a cluster. connect, disconnect and disconnectAt above already accept a cluster id as the owner (and a cluster id as the target), so a cluster's own connections list supports parallel edges exactly like a box's. Removing a cluster keeps its member boxes and strips any connection pointing at it.
addClusterMember / removeClusterMemberAdd or remove a box id from a cluster's members.
setClusterLabel / setClusterStyle / setClusterPaddingEdit a cluster's label, border weight, or cluster-padding (a Spacing). Passing an empty or whitespace-only label deletes the property, leaving a valid unlabeled cluster — addCluster's label is optional for the same reason.
parseDiagram / serializeDiagramJSON round-trip, either kind.

Every other op above takes a BoxDiagram — there are no sequence mutation ops in v1. A sequence sidecar is two flat arrays (participants, messages) edited as plain JSON: array order is the only layout input, so build one with emptySequenceDiagram() and ordinary array pushes.

The browser entry (mdd-markdown-diagram/browser) additionally exports resolveBoxPadding and resolveBoxMargin, which collapse a Spacing (with box + diagram defaults) into a concrete { x, y } — used by the editor's property panel — plus renderCommentBlock and prefixError, which the editor's comment-prefix export uses.

Validation reference

validate() returns a ValidationError[] with these codes. Errors name what they flag: boxId / clusterId on box diagrams, participantId on sequence participants — and for message-scoped errors, connIndex doubles as the message's index in messages.

CodeTrigger → fix
invalid-versionversion is not 1. → Set version to 1.
malformedboxes is not an array — or, on a sequence diagram, participants/messages is not, or a participant id is not a non-empty string. → Make the flagged field a JSON array; give every participant a string id.
duplicate-idTwo boxes, clusters, or participants share an id. → Make every id unique.
empty-labelA box label is blank after trimming — or a cluster or participant label is present but blank or multi-line. → Provide real text, or omit the optional label.
invalid-rowrow is not a non-negative integer. → Use an integer ≥ 0.
invalid-styleA weight is not 1–4. → Use 1, 2, 3, or 4.
invalid-box-paddingbox-padding is not an integer ≥ 1 (or {x,y} of them). → Use ≥ 1 per axis.
invalid-cluster-paddingcluster-padding is not an integer ≥ 0 (or {x,y} of them). → Use ≥ 0 per axis (its floor is 0, unlike box-padding's 1).
invalid-box-marginbox-margin is not an integer ≥ 0 (or {x,y} of them). → Use ≥ 0 per axis.
invalid-arrowarrow is not to/from/both/none. → Use one of those.
invalid-shapeshape is not "diamond", or is combined with table. → Omit shape, or drop the table.
unknown-connection-targetA connection's to matches no box. → Point to an existing id.
invalid-self-connectionA self-connection lacks two distinct row anchors. → Set fromRow and toRow to two different rows of the box's table, or remove the connection.
empty-connection-labelA connection label is blank. → Provide text or omit label.
invalid-tableA table is malformed: columns < 1, no rows, a row without an id or cells, or non-string cell text. → Fix the flagged field.
invalid-cell-spanA cell span is < 1, or a row's spans don't sum to columns. → Adjust spans so each row fills the grid exactly.
duplicate-row-idTwo rows in one table share an id. → Make row ids unique within the table.
unknown-anchor-rowA connection's fromRow/toRow names no row in the anchored table. → Use an existing row id.
anchor-on-non-tableA connection anchors a row on a box that has no table. → Remove the anchor or target a table box.
unknown-cluster-memberA cluster's members entry names no existing box. → Point to an existing box id.
empty-clusterA cluster's members is empty or missing. → Give it at least one member.
duplicate-cluster-memberThe same box id is listed twice in one cluster's members. → Remove the duplicate.
identical-cluster-membersTwo clusters have exactly the same member set. → Merge them, or change one's membership.
cluster-connection-overlapA connection joins a cluster and content already on the same side of its border — the cluster and one of its own members, or two clusters where one's members contain the other's. → Remove the connection or restructure the membership.
edge-label-unplacedRender-time warning (not a validate error): a label could not be placed without crossing a box or cluster outline, so it was skipped. A bounded search slides the label sideways first to dodge a nearby obstacle; this only fires once that search is exhausted. → Shorten the label or adjust the layout.
cluster-not-rectangularRender-time warning (not a validate error): a cluster's outline couldn't stay a clean rectangle without swallowing a non-member, so it detoured into a polygon. The outline IS drawn. → Reorder boxes so the membership forms a contiguous block, or accept the polygon.
cluster-label-unplacedRender-time warning (not a validate error): no run of the cluster's border was long enough and free to hold its label, even unpadded, so the outline was drawn WITHOUT the label. The outline IS drawn. → Shorten the label, or reorder boxes so the outline gets a longer top run.
cluster-not-drawnRender-time warning (not a validate error): the cluster has no outline on the canvas at all — either its ring would run along another cluster's ring (by far the most common), or it would re-trace another polygon almost exactly (three quarters or more of its ring already drawn by an earlier polygon's, with at most one corner standing clear — the later cluster loses, and the message names the one it would have traced), or it has no drawable region (disconnected members, or a fully enclosed non-member). The message says which. → Reorder boxes, or merge the two clusters.
invalid-kindkind is present but not "sequence". → Omit kind for a box diagram, or set it to "sequence".
unknown-participantA sequence message's from or to doesn't match a participant id. → Point to an existing participant id.
mixed-kindBox-diagram fields (boxes, clusters, box-padding, box-margin) appear on a sequence diagram, or sequence-only fields (participants, messages) appear on a box diagram. → Remove the fields that don't belong to that sidecar's kind.
empty-message-labelA sequence message label is present but blank after trimming. → Provide text or omit label.