The language
Last updated: 1 September 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
Two statements configure the whole diagram.
| Statement | Values | Default |
|---|---|---|
direction: | down, up, left, right | down |
routing: | curved, orthogonal, straight | curved |
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.
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: can also appear inside a container block to flow that group differently from its parent. 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 |
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.
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 -> pgIcons need a live connection — they're the one part of a diagram that reaches the internet, and the request goes to whichever host you named. 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, 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. Nest as deep as you like.
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 input mode
The toolbar's Input: Diagon / D2 button switches the code pane to reading a subset of D2, so you can open an existing .d2 file and look at it in Diagon. Opening a *.d2 file switches the mode automatically; opening a *.diag switches back.
What's supported: containers, shapes, style with fill, stroke and stroke-width, classes, shape: image with icon, the same four edge operators with chains and labels, \n in labels, and per-container direction. D2 shapes Diagon doesn't have become rectangles.
What isn't: SQL tables, markdown blocks, grids, sequence diagrams, imports and globs. Those are skipped silently in substance — the status line appends a count (3 notes) and nothing more. There is nowhere in the app to read which constructs were dropped, so on an unfamiliar .d2 file, treat that count as "this drawing is incomplete by N things" and check the source.
D2 mode is for reading. The AI won't edit D2 source, and the toolbar's direction and routing controls act as view-only overrides rather than editing your file.
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).