The language
Last updated: 9 October 2026
A Diagon diagram is a text file. Every line is one statement, and there are only four kinds: a comment, a directive, a node, or an edge. That's the whole language — everything else is attributes in braces.
This page is the complete reference. If you're new, Getting started walks through a first diagram; The editor covers what happens once the diagram is on screen.
How a file is read
Diagon reads your file line by line, top to bottom. Order doesn't affect layout — the engine works out ranks and positions itself — but it does affect readability, so most people declare nodes first and edges after.
There's one rule that changes everything else on this page: a { … } block that spans more than one line switches the whole file to a second, slightly different parser. A single-line { … } is always plain attributes.
Whether that node also becomes a container depends on what's in the block. A multi-line block holding only attributes stays an ordinary node — x: X { / fill: #123 / } is one plain box, not an empty group. A block that holds nodes or edges becomes a container with children. Either way, the second parser is now reading your whole file. The Containers section below covers what changes, and Flat and nested compared lists every difference in one table.
Comments
A comment is a whole line whose first non-space character is # or //. Both work.
diagon
# this line is a comment
// so is this one
direction: right
a -> bThere are no end-of-line comments. A # partway through a line is ordinary text: on a node line it lands inside your label, and on a direction: or routing: line it makes the value unrecognisable and you get an error. This trips people up often enough to be worth repeating — put comments on their own line.
Directives
Three statements configure the whole diagram.
| Statement | Values | Default |
|---|---|---|
direction: | down, up, left, right | down |
routing: | curved, orthogonal, straight | curved |
icon-size: | small, medium, large, or a number of pixels | medium |
diagon
direction: down
routing: orthogonal
load: Load balancer
a: Server A
b: Server B
load -> a
load -> bdirection sets the flow of the layout. routing sets how edges are drawn: curved splines, right angles with rounded corners, or direct lines. icon-size (since 1.4.0) sets how big the pictures in nodes are — see Icons.
An unrecognised value is an error with a line number, for example line 1: unknown routing "wiggly". The value is the entire rest of the line, which is why a trailing comment breaks it.
In a diagram with containers, direction: and icon-size: can also appear inside a container block, to flow that group differently from its parent or to size the icons in it. routing: is top-level only — and putting it inside a container fails silently in an unhelpful way: the line is read as a node declaration, so you get a stray box inside the group with the id <container>.routing, labelled with the value you typed. No error, no warning.
Nodes
| Form | Meaning |
|---|---|
id | A node whose label is its id |
id: Label | A node with a label |
id: Label { … } | A node with a label and attributes |
id { … } | A node with attributes, label is the id |
diagon
db
api: API server
cache: Redis { shape: cylinder }
queue { fill: #1b2330 }An id may contain letters, digits, _, . and -. Anything else is an error: a$b: X gives line 1: invalid id "a$b".
An id may not contain spaces, and this one fails quietly. Diagon takes the first word before the colon as the id and discards the rest, so my node: Label creates a node called my labelled Label — no error, no node. If a node you referenced elsewhere doesn't appear, check for a space in its id.
The label is everything after the first colon, verbatim. It can contain colons, spaces and punctuation.
Attributes
Attributes go inside { } after the node, separated by commas. Each one is key: value (or key = value). Keys are case-insensitive.
| Attribute | Applies to | Effect |
|---|---|---|
shape: | nodes | One of the shapes listed below |
fill: | nodes, containers | Background colour |
stroke: | nodes, containers, edges | Border or line colour |
color: | nodes | Label text colour |
icon: | nodes | Image URL (SVG or PNG) drawn inside the node |
icon-size: | nodes, containers | small, medium, large or a number of pixels — the size of this node's icon, or of every icon in a container |
x: / y: | nodes, flat diagrams only | Pin to a fixed position |
w: / h: | nodes | Explicit width and height in pixels |
diagon
web: Web app { fill: #1b2330, stroke: #5db3ff, color: #ffcb6b }
db: Postgres { shape: cylinder, fill: #1f6f43 }
web -> db: query { stroke: #c792ea }Colours are hex (#1b2330) or CSS colour names (tomato). Functional forms have to be written without commas, because the comma is the attribute separator: rgb(255 0 0) — the space-separated CSS syntax — works, while rgb(255, 0, 0) is cut at the first comma and leaves you with the nonsense value rgb(255, silently. A value that isn't a colour at all — url(…), var(…) — is ignored, and the default colour is used.
Four things about attributes worth knowing before they surprise you:
- Values can't contain commas. The comma is the separator, so
icon: https://host/a,b.svgis silently cut at the comma. - Unknown keys are ignored.
{ wibble: 3 }parses without complaint and does nothing. - Shape names aren't validated.
shape: blobdoesn't error; you get a rectangle. label:as an attribute does nothing. It parses, but the label comes from the text after the colon. Writeid: My label, notid { label: My label }.
Attributes mean different things depending on what they're attached to. On an edge, only stroke: has any effect. On a container, only stroke: and fill: do, and the fill is drawn at 7% opacity so children stay readable.
Shapes
diagon
direction: right
r: Rect
p: Pill { shape: pill }
o: Round is the same pill { shape: round }
d: Diamond { shape: diamond }
c: Circle { shape: circle }
y: Cylinder { shape: cylinder }
h: Hexagon { shape: hexagon }
g: Parallelogram { shape: parallelogram }rect is the default, and it already draws softly rounded corners. The other names are pill, round, diamond, circle, cylinder, hexagon and parallelogram — but that's seven distinct shapes across eight names, because round is an exact alias of pill. Both draw a stadium: a rectangle with fully semicircular ends. If you reach for round expecting a rectangle with rounder corners, you'll get a pill instead — and the default rect is already the rounded-corner one.
Icons
icon: takes a URL. The image is fetched over the network and drawn inside the node, both in the preview and again when you export.
diagon
direction: right
redis: Redis { icon: https://cdn.simpleicons.org/redis }
pg: Postgres { icon: https://cdn.simpleicons.org/postgresql }
redis -> pgIcon size (since 1.4.0). Icons come in three sizes: small (32 px, close to the 34 px drawn before 1.4.0), medium (48 px, the default) and large (64 px); a bare number of pixels from 16 to 96 also works. Write icon-size: in any of three places, and the nearest one wins:
diagon
icon-size: large # every icon in the diagram
platform: Platform {
icon-size: medium # this box and everything nested in it
api: API { icon: https://cdn.simpleicons.org/nodedotjs }
}
db: Postgres { icon: https://cdn.simpleicons.org/postgresql, icon-size: small } # one nodeA diagram that will end up on a slide reads better with icon-size: large on its first line. An unknown value is an error with a line number, like a bad direction. Keep an icon node in the default shape or a cylinder: on a circle, diamond, hexagon or pill (and round, which is the same pill) the icon crosses the outline.
Icons need a live connection — they're the one thing in a diagram that makes Diagon itself reach the internet, and the request goes to whichever host the URL names. Icons larger than 6 MB or slower than 10 seconds are dropped from exports. A host that serves no CORS headers can also block PNG export; The editor explains what to do about that.
Size and position
w: and h: set a node's size in pixels. x: and y: pin it to a fixed spot instead of letting the layout engine place it.
diagon
direction: right
api: API
db: Database { shape: cylinder, x: 640, y: 120 }
api -> dbPins are what dragging writes back into your code — in flat diagrams. In diagrams with containers they're ignored, and a dragged position lives only in memory for that session. The editor covers the difference in detail.
Edges
There are four operators. They must have spaces around them — a->b is read as an id and rejected.
| Form | Meaning |
|---|---|
a -> b | Arrow from a to b |
a <- b | Arrow from b to a |
a <-> b | Arrowheads at both ends |
a -- b | A line, no arrowhead |
a -> b: label | A labelled edge |
a -> b -> c | A chain — one edge per pair |
a -> a | A self-loop |
diagon
a -> b: sends
b <- c
c <-> d
d -- e
e -> f -> g
g -> g: retryChains can mix operators, and a label or attribute block on a chain applies to every edge in it.
Endpoints don't need to be declared first. a -> b on its own is a complete diagram — Diagon creates both nodes with their ids as labels. Declaring nodes up front is how you give them labels, shapes and colours.
An edge missing an endpoint is an error: a -> gives line 1: edge needs two endpoints.
Cycles are fine. The layout engine breaks them internally to work out the ranking, then draws every edge you wrote.
Containers
Write a { } block across several lines and the node becomes a box that other nodes live inside.
diagon
direction: right
cloud: Cloud {
stroke: #607D8B
api: API
worker: Worker
api -> worker
}
office: Office {
stroke: #8D6E63
laptop: Laptop
}
office.laptop -> cloud.api: httpsInside a block, a line is an attribute of the container if its key is one of shape, fill, stroke, color, icon, icon-size, x, y, w, h or label and it has no block of its own. Everything else is a child — a node, an edge, or another container.
Containers get a title bar and padding, and the box grows to fit whatever's inside it — it is never narrower than its title. Nest as deep as you like. Children that have no arrows between them are packed into a few rows (seven become 4 + 3; columns in a left-to-right diagram), and when a box holds a small flow plus some unconnected children, the unconnected ones sit in a compact block beside the flow.
Dotted paths
A dot addresses a node inside a container: cloud.api, cloud.aks.worker. Missing ancestors are created as you go.
diagon
direction: right
cloud: CML Cloud {
stroke: #607D8B
aks: aks-prod {
api: API
worker: Worker
api -> worker
}
kafka: Kafka { shape: cylinder }
aks.worker -> kafka
}
onprem: On-Premise {
stroke: #8D6E63
gw: LLM Gateway
}
onprem.gw -> cloud.aks.api: mcp
cloud.kafka -> onprem.gw: eventsInside a block you can use bare names: Diagon looks in the current container first, then at the top level, and creates the node in the current container if it finds nothing. Cross-container edges written at the top level need the full dotted path.
Per-container direction
A container can flow differently from the diagram around it:
diagon
outer: Outer {
direction: right
a: A
b: B
a -> b
}
c: C
outer.b -> cSeveral statements on a line
Inside a container block, ; separates statements as well as a newline:
diagon
grp: Group {
a: A; b: B
a -> b
}Quoted and multi-line labels
Inside a diagram with containers, labels get two conveniences: one surrounding pair of " or ' is stripped, and \n becomes a real line break.
diagon
pipe: Pipeline {
ingest: "Ingest\nraw events"
norm: Normalise
ingest -> norm: "batch of 500"
}
sink: Sink { shape: cylinder }
pipe.norm -> sinkingest renders on two lines and the quotes don't appear. In a flat diagram neither of these happens — the quotes stay and \n is two literal characters.
Flat and nested compared
The two parsers agree on most things and differ on a handful. If a diagram behaves unexpectedly after you add your first container, this table is usually why.
| Behaviour | Flat diagram | With containers |
|---|---|---|
| Quotes around a label | Kept | Stripped |
\n in a label | Two literal characters | A line break |
| Id characters checked | Yes | No |
| Dots in ids | Ordinary characters | Hierarchy |
Bad direction: / routing: value | Reported as an error | Ignored |
x: / y: pins | Honoured, and drag writes them back | Ignored; drag pins are in memory only |
| Errors carry a line number | Yes | No line numbers — and no node errors at all. The only thing the nested parser reports is a malformed edge |
D2 files
Versions before 1.2.1 could read a subset of D2 behind a toolbar toggle. That mode is gone, and .d2 files no longer open.
Common errors
| Message | What happened |
|---|---|
line N: invalid id "a->b" | An operator with no spaces around it |
line N: invalid id "a$b" | A character that isn't a letter, digit, _, . or - |
line N: edge needs two endpoints | An operator with nothing on one side |
line N: unknown direction "…" | A bad value — often a trailing comment on the line |
line N: unknown routing "…" | The same, for routing |
And the quiet ones, which produce no message at all: a space in an id (the rest is dropped), a # on a node line (it joins the label), a comma inside an attribute value (the value is cut), an unknown attribute key (ignored), a misspelled shape (a rectangle), and a routing: line inside a container block (it becomes a node).