The DOT format

The smallest valid input

digraph { core -> access }

Two devices, one link, two tiers. Everything else on this page is optional detail layered onto that. The input is standard Graphviz DOT — the graph, not the styling: Drawbridge reads the topology and the data attributes, redraws everything with its own shapes and routing, and ignores decoration (fontname=, layout=, named colors). One presentation attribute IS read: pos= — when every device carries one, as in Drawbridge's own export or dot -Tdot output, the drawing keeps that layout instead of being laid out afresh (the DOT reference page has the exact rules). A file written for Graphviz imports here; a file written for Drawbridge still renders in any Graphviz tool.

Ids, labels and quoting

The id is what edges wire against; the label is what the tile shows. When a label= is present it becomes the device's display name; without one, the id is shown.

digraph {
  fw1 [kind=firewall, label="FW-EDGE-01"]
  sw1 [kind=switch, label="SW-CORE-01"]
  fw1 -> sw1
}

Line breaks. DOT writes a break as \n inside the quoted string, and that is what you get: label="SW-CORE-01\nfloor 2" imports as a two-line name. \l and \r (Graphviz's left- and right-justified breaks) break the line too — the justification is presentation and is dropped, the break is not. \\ is a literal backslash, and \N stays as written: it is Graphviz's node-name placeholder, not text.

Quoting is the one DOT rule that bites: an unquoted id or value may contain only letters, digits and underscores. Anything with a hyphen, dot, space or slash must be quoted — label="SW-CORE-01", kind="l3-switch", "10.0.0.1". An unquoted kind=l3-switch is not "an attribute Drawbridge rejects", it is invalid DOT, and the whole block is reported as unreadable.

Arrows decide the tiers

The layout is layered, top-down: whatever arrows point away from sits above whatever they point at. Write edges downstream — internet and core at the tail, access at the head — and the tiers fall out with no extra syntax:

digraph {
  inet [kind=internet]
  fw1 [kind=firewall]
  core [kind="l3-switch"]
  acc1 [kind=switch]
  acc2 [kind=switch]
  inet -> fw1
  fw1 -> core
  core -> {acc1 acc2}
}

A big fan-out does not become one endless row. When six or more endpoints — anything that is not a switch, router or firewall — hang off one device by a single cable each, they are drawn in a block directly under it, in the order of its ports (pve:eno1 -> vm1 style port names) or the order you wrote them. A hypervisor with seventeen VMs is a five-wide block, not a line across the page. If its endpoints stand in different zones, each zone gets its own block, side by side, and the device sits over the block of its own zone. Fewer than six keep their row.

Three forms all work and mix freely: single edges (a -> b), chains (a -> b -> c — every hop a link), and brace fan-outs (core -> {acc1 acc2} — one link per member). An undirected graph (graph { a -- b }) imports too; with no arrow to read, tiers follow write order.

Same-tier peers — HA pairs and MCLAG

Two devices that are peers — an HA firewall pair, MCLAG distribution switches — must not stack, even though a link runs between them. Declare the tier with a multi-node rank=same group and link them normally:

digraph {
  fw1 [kind=firewall, label="FW-A"]
  fw2 [kind=firewall, label="FW-B"]
  core [kind="l3-switch"]
  {rank=same; fw1 fw2}
  fw1 -> fw2
  fw1 -> core
  fw2 -> core
}

The peer link is drawn and routed like any other, but it is kept out of the layering, so fw1 and fw2 share a row instead of one being pushed a tier below the other. A one-node rank=same group (which the FortiGate emits) is harmless and means nothing.

One nuance: the declaration removes the peer link from the layering — the peers' other links still decide their rows. Give both members uplinks at the same depth (as above, where both firewalls hang off inet) and they land level; a peer whose only uplink arrives from a deeper device will sit on that deeper row regardless of the declaration.

Device attributes

digraph {
  fw1 [kind=firewall, label="FW-EDGE-01", mgmtIp="10.0.0.1",
       serial="FG100FTK00000001", model="FortiGate-100F",
       location="DC1 rack 3", notes="HA primary", color="#e11d48"]
}

When the id itself is a Fortinet serial (FG100FTK…, S448DFTF…), the serial lands in the asset fields automatically, and a FortiGate/FortiWiFi model is derived from it where the serial encodes one.

digraph {
  fw1 [kind=firewall]
  sw1 [kind="l3-switch"]
  fw1:port1 -> sw1:ge1 [speed="10G", link=trunk, label="uplink", color="#0284c7"]
}
digraph {
  sw1 [kind=switch]
  srv [kind=server]
  spine1 [kind="l3-switch"]
  sw1:ge1 -> srv:eth0 [bundle=po1]
  sw1:ge2 -> srv:eth1 [bundle=po1]
  sw1:ge9 -> spine1:et1 [bundle="po1 spine_a"]
}

Real-world pastes: chrome, VDOMs, defaults

The importer is built for output that was never meant to be clean:

What gets reported, and what gets refused

The policy is import the good rows, report the rest — real pastes always carry legends, prompts and truncated scrollback, and refusing everything over one bad line would fail on exactly the input this feature exists for. The preview shows every reported line with its line number before anything is committed:

One thing is a hard refusal: a block that is not valid DOT at all — a quote that never closes, an arrow with nothing after it, an attribute list that never ends. The report names the line the trouble is on, and says what is wrong:

Line 6: a ? b — not readable as DOT — "?" cannot be used here — quote it if it is part of a name
Line 4: } — not readable as DOT — the statement before "}" is unfinished

Unquoted names are not in that category, even though strict DOT says they should be. 10405-001-sw02, l3-switch, rack-4.2 — a name that leads with a digit, or carries a hyphen or a dot — are all things Graphviz itself refuses to read, and all things a switch is genuinely called. They are quoted for you on the way in. So is a comma where DOT wants a semicolon ({rank=same; sw01, sw02}). You never have to tidy a paste up before it will import.

Purdue levels

One container DOES ride the import: declare Purdue levels as clusters and the backdrop draws itself —

digraph {
  subgraph cluster_l3 {
    purdue="3"
    scada [kind="scada-server"]
  }
  subgraph cluster_l2 {
    purdue="2"
    hmi1 [kind=hmi]
  }
  scada -> hmi1
}

The import places the palette's Purdue-levels underlay behind the topology, sized so every declared band fits its devices, and seats each cluster's members inside band N (5, 4, 3.5, 3, 2, 1, 0 — the model's own numbering; anything else is reported). Devices outside any declared level keep their laid-out position. The DOT examples page has the full reference architecture.

The way back out

Export → Graphviz DOT (or Copy DOT) writes the page back out in this same profile — layout included — so the loop closes: text in, drawing out, text again, and importing your own export puts every tile back exactly where it was. A page with an HA pair over a core switch comes back as the file it would have been imported from —

digraph Core {
  inputscale=72
  subgraph cluster_1 {
    kind=zone
    label=DMZ
    cidr="10.20.30.0/24"
    bb="120,480,600,720"
    "FW-CORE-01" [kind=firewall, mgmtIp="10.20.30.1", pos="264,560!", width="1.333333", height="1.111111"]
    "FW-CORE-02" [kind=firewall, pos="440,560!", width="1.333333", height="1.111111"]
  }

  "SW-CORE-01" [kind="l3-switch", pos="352,240!", width="1.333333", height="1.111111"]

  {rank=same; "FW-CORE-01" "FW-CORE-02"}

  "FW-CORE-01" -> "FW-CORE-02" [id=e1, label=HA]
  "FW-CORE-01" -> "SW-CORE-01" [id=e2, taillabel=port1, headlabel=ge1, link=trunk, speed="10G"]
}

pos= is Graphviz's own attribute — the centre, in points, y growing up — and inputscale=72 is what lets plain neato render this file with your geometry, so the export stays ordinary Graphviz. Every edge carries its id=: that is what keeps the two cables of an HA pair two cables on the way back in, when nothing else tells the identical lines apart. Two more things are written for you rather than copied. Arrows come out pointing down the page — the device drawn higher is at the tail — because that is what decides tiers when a file has no positions, and two devices sharing a name are numbered (Switch, Switch 2), because a name is what identifies a device here and identical names would merge into one tile. The details, and what a zone does or does not survive as, are in Export under Graphviz DOT.

Mermaid comes back too

Export → Copy Mermaid is the other way out, and since it can also come back in, the same loop closes there. Mermaid has no attributes to hang a management IP on, so the profile travels as comment lines instead — %% fk: followed by the node's id and the same key=value pairs this page has been describing:

flowchart LR
  %% fk: z1 kind=zone label=DMZ cidr=10.20.30.0/24
  subgraph z1["DMZ (10.20.30.0/24)"]
    %% fk: n1 kind=firewall mgmtIp=10.20.30.1
    n1("FW-CORE-01")
  end
  %% fk: n2 kind=l3-switch
  n2("SW-CORE-01")
  %% fk: n1 --> n2 label=uplink fromPort=port1 toPort=ge1 speed=10G link=trunk
  n1 -- "uplink<br/>port1 → ge1<br/>10G" --- n2

A mermaid renderer ignores every %% line, so what GitHub or Notion draws is the clean diagram underneath; Drawbridge reads them and gets the kinds, the asset data, the zone's subnet, the port names and the speed back. The keys are the same as the DOT ones with two additions — label= overrides the text in the brackets (that is how the subnet comes out of the zone's title again) and a cable's ends are fromPort=/toPort=. The arrow in the comment is what carries the cable's tail and head: the drawn line has no arrowhead on purpose, because a cable has no traffic direction, and without the comment every cable would read back as a same-tier peer and the layout would come back flat.

You can write these lines by hand in front of any mermaid diagram you already have — one %% fk: line per node is enough to turn a wall of grey boxes into firewalls and switches.

Zones, racks and virtual contexts

A cluster that names its kind is a real container: subgraph cluster_x { kind=zone label="DMZ" cidr="10.1.2.0/24" … } imports as a drawn zone holding whatever stands on it — with its subnet in the title, at its recorded rect when the file carries one (bb=, which the export writes), or wrapped around its members with comfortable padding when it does not. kind=rack and kind=vcontext work the same way. Zones nest: a zone cluster written inside another one — VLANs inside a site — draws inside it, because a device in the inner cluster belongs to the outer one too, as in Graphviz. With cables, each zone is laid out as a block of its own: its members together inside it, nothing that is not a member inside it, and the block placed by the cables that leave it — a site whose VLANs all hang off one host sits under that host, and devices with nothing cabled between them inside a zone are drawn as a row, or a grid past five. A cluster without a kind still imports flat (its devices come in, the box does not) — that is what a grouping in a foreign Graphviz file usually means.

A site on a page of its own. Write page="HQ" inside a zone cluster and that zone, with everything in it, goes on a new page called HQ — one per page= value, in the order the file names them. The page you import onto becomes the overview: it keeps everything that is on no page, and shows each site as one folded zone tile that says "Opens page “HQ”". Its link badge, or a double-click, takes you there. A cable between a site and anything outside it is drawn on the overview, ending on the site's tile — wan to HQ, HQ to DR — and the import's notes say how many went there. Nothing is drawn twice: each device lives on exactly one page. Only a .drawio file carries the extra pages; an SVG or PNG export of the import is the overview.

Not yet importable

Maps arrive as a plain world map: kind=world imports one, drawn on its own Map layer, and a cable to it comes with it. What a map is drawn like — the country or region it shows, its frame, its cities — has no DOT spelling, so a country map comes back as the whole world; set its view in the editor afterwards. Imported devices drop below existing content and never disturb what is already drawn.