Code in, image out

The whole loop in one command

The desktop app doubles as a command-line renderer: give it topology text, get a finished image back — layout, icons and routing included, no window, no clicks.

Drawbridge --import topo.gv --export topo.svg

--export accepts nine extensions, and the extension picks the format:

Output What you get
.svg The drawing as a vector — crisp at any size, ideal for wikis and docs
.png A 2× raster of the same drawing
.drawio The fully editable document — open it in Drawbridge later and polish
.dot The laid-out diagram back as Graphviz DOT, in the profile the importer reads
.csv The cable schedule — one row per cable, both ends with their ports, speed, cable type, label
.netbox.csv The same cabling in NetBox's cable bulk-import columns, ready for its import form
.netbox-devices.csv The hardware in NetBox's device bulk-import columns — name, device_type, role, site, rack, position, face, serial, status
.describe.json The document as structured JSON — devices, links, pages, layers, rack positions — for scripts and agents that read drawings instead of making them
.clab.yml The page as a Containerlab topology — nodes, groups and links from the cables, so a design becomes a lab you can deploy

The extension is matched by its longest suffix, so plan.netbox-devices.csv is the hardware, plan.netbox.csv is the cabling and plan.csv is the readable schedule — three files that share .csv and must not collide. All three are the same ones the Export menu writes, so a pipeline and a person get identical output; Cable schedule CSV, and NetBox in the Export chapter describes the columns.

The input is anything Import accepts — Graphviz DOT (with the full attribute profile), a FortiLink dump with its CLI chrome, CDP/LLDP output, a CSV. --import - reads standard input, which is what makes pipes work:

ssh fgt 'execute switch-controller get-physical-conn dot fortilink' \
  | Drawbridge --import - --export topology.png

That one line is the function this chapter exists for: live gear in, presentation-ready image out, no human in the loop.

Reading a drawing instead of making one

--doc takes a finished .drawio file as the input — the question it answers is not "draw this" but "what is in this drawing":

Drawbridge --doc network.drawio --export audit.describe.json

The JSON opens with the document's title and its totals — pages, devices, links — and then lists every page. A device carries id, kind, label, its layer, its asset data (mgmtIp, serial, model, location, notes, link), its rack position (rack name, bottom-up U, and the face on a two-elevation rack), its role and its functions, whether it is installed, and which power feed it is on (A, B or AB). A cable carries both ends — device, label, port and that end's address — and then its own label, link type, speed, medium, wavelength, vlan, circuit, provider, length, the aggregates it belongs to and its installed tick. The page's layers come too, with their hidden and locked flags. Every one of those is omitted when it was never set, which is the difference that matters to a script: installed missing means nobody has said, not that the device is planned. A page this build could not read is marked readOnly and lists nothing, because its contents are unknown rather than absent. Same vocabulary as the cable schedule, without the geometry. --doc - reads the document from standard input like --import - does. It pairs with jq the way the import loop pairs with ssh:

# every device that has no management IP on file
Drawbridge --doc network.drawio --export audit.describe.json
jq -r '.pages[].devices[] | select(.data.mgmtIp == null) | .label' audit.describe.json

describe.json also works after --import: --import topo.gv --export topo.describe.json imports the topology and answers with the structured listing instead of a picture — useful when the DOT was generated and you want to check what the importer actually understood.

And --doc is not limited to describing: every export in the table works from a document, so --doc network.drawio --export network.png re-renders a finished drawing headlessly — regenerate the wiki image after an edit, produce the cable schedule from a file a colleague sent, pin --theme light so the output never follows the machine's appearance.

Changing a drawing instead of redrawing it

--edit takes a change set — a small JSON file listing devices and cables to add, update or remove — applies it to --doc, and writes the result:

Drawbridge --doc network.drawio --edit changes.json --export network.drawio
[
  { "op": "add_node", "kind": "switch", "label": "SW-ACC-07" },
  { "op": "add_edge", "from": "core1", "to": "n12", "speed": "10G" },
  { "op": "update_node", "id": "fw1", "label": "FW-EDGE-01" },
  { "op": "remove_edge", "id": "e4" }
]

The point is what it does not do. Re-importing a topology would redraw the page and lose every position somebody chose by hand; this moves nothing it was not told to move. Devices you add are placed below the existing drawing, the way an import lands, so you never have to work out a free coordinate — and should not try: leave x and y off.

Ids come from --export …describe.json on the same file. A mistyped one skips that op, prints a note to stderr and lets the rest through; removing a device takes the cables attached to it, and says how many. Any --export extension works, so --edit changes.json --export network.png applies the change and renders it in one run — the fastest way to look at what you just did.

The change set is the same vocabulary an AI assistant sends over MCP (AI assistants), so a recipe you write here is one you can hand to an assistant, and the other way round.

Checking a source before you draw it

--validate reads topology text with the importer and reports what it found, without laying anything out or rendering anything:

Drawbridge --import topo.gv --validate --export report.json
{
  "format": "dot",
  "formatLabel": "Graphviz DOT",
  "counts": { "devices": 12, "links": 14, "problems": 1 },
  "problems": [
    {
      "line": 7,
      "text": "sw3 [kind=swich]",
      "reason": "unknown kind 'swich' — imported as a generic device; did you mean 'switch'?",
      "severity": "adjusted"
    }
  ],
  "title": "campus"
}

Every problem carries its line number, and an unrecognised kind= names the nearest real one. skipped means the line produced nothing; adjusted means it was imported, but not as written. title is the graph's own name — digraph campus { … } — and is present only when the source declared one; it is the name the page would take. It is far cheaper than a render, so in a pipeline that generates its own DOT it belongs before the draw, not after it:

Drawbridge --import topo.gv --validate --export report.json
jq -e '.counts.problems == 0' report.json || { jq -r '.problems[] | "line \(.line): \(.reason)"' report.json; exit 1; }
Drawbridge --import topo.gv --export topo.svg

Comparing two drawings

--compare adds a second document and asks what is different between them. --doc is the newer side, --compare the baseline, and the extension of --export picks the format — .md for a Markdown change list, .json for the same content as data:

Drawbridge --doc network.drawio --compare last-month.drawio --export changes.md
Drawbridge --doc network.drawio --compare last-month.drawio --export changes.json

The Markdown is the same list the editor's Compare panel copies: a heading per page, a count at the top, one line per change — + Added switch SW-ACC-07, - Removed cable CORE-01 et1/1 → LEAF-03 et1/49, ~ Re-roled FW-01 EDGE → ISFW. Pages are paired by title and fall back to position, so a reordered document still lines "Core" up with "Core"; a page only one side has is reported whole, as all additions or all removals. Two identical documents produce No changes.

Nothing is drawn, laid out or routed for a comparison — both sides are only parsed — so it is the quickest thing the CLI does, and it is the one to reach for in a nightly job that asks whether the documentation still matches what was approved:

Drawbridge --doc /srv/wiki/network.drawio --compare /srv/approved/network.drawio --export drift.md
test -s drift.md && grep -q 'No changes' drift.md || mail -s "network drawing drift" ops < drift.md

The local HTTP API

For pipelines that would rather speak HTTP than spawn a process per file, Drawbridge --serve runs the same capabilities as a local web API:

Drawbridge --serve            # http://127.0.0.1:8722
Drawbridge --serve --port 9000
curl -X POST --data-binary @network.drawio http://127.0.0.1:8722/api/describe
curl -X POST --data-binary @network.drawio "http://127.0.0.1:8722/api/export/png?theme=light" -o network.png
curl -X POST --data-binary @topo.gv "http://127.0.0.1:8722/api/diagrams/svg?theme=light" -o topo.svg

POST /api/describe answers with the structured JSON; POST /api/export/{kind} renders a document to any format in the table; POST /api/diagrams/{kind} imports topology text and answers with the export — the importer's notes ride in the x-drawbridge-notes response header as a JSON array. The full surface is described by OpenAPI at GET /api/openapi.json, so client generators and API tooling can consume it directly.

Two more, the HTTP twins of --edit and --validate:

curl -X POST -H 'content-type: application/json' \
  -d "$(jq -n --rawfile d network.drawio '{document:$d, ops:[{op:"add_node",kind:"switch",label:"SW-ACC-07"}]}')" \
  http://127.0.0.1:8722/api/edit -o network.drawio
curl -X POST --data-binary @topo.gv http://127.0.0.1:8722/api/validate

POST /api/edit is the one route with a JSON body, because it is the one with two inputs — the document and the change set. It answers with the edited document (add ?kind=png to get a picture instead) and its per-op notes in x-drawbridge-notes. POST /api/validate takes the source as plain text like /api/diagrams does and answers with the report; it renders nothing, so it does not count against a render budget.

One route takes no body at all. GET /api/health answers with the server's name and version and, for anything scaling a fleet of them, how many renders are in flight, how many it will run at once, and how many callers it turned away in the last minute:

curl -s http://127.0.0.1:8722/api/health
# {"name":"drawbridge","version":"…","inFlight":0,"maxConcurrent":4,"rejects1m":0}

It is the readiness probe to point a container orchestrator at, and the one call that never touches a document.

Naming what comes back. /api/export/{kind}, /api/diagrams/{kind} and /api/edit accept a ?name= query parameter — the basename to use for this diagram instead of the one derived from its content. It is slugified, never a path, and a value that slugifies to nothing falls back to the derived name rather than failing. The suggested basename comes back on every route, describe and validate included, in the x-drawbridge-name response header — which is what lets a script write <name>/<name>.svg beside <name>/<name>.describe.json without inventing a filename:

name=$(curl -sD - -o topo.svg --data-binary @topo.gv \
  "http://127.0.0.1:8722/api/diagrams/svg?name=campus-core" \
  | awk 'tolower($1)=="x-drawbridge-name:"{print $2}' | tr -d '\r')
mkdir -p "$name" && mv topo.svg "$name/$name.svg"

Every response also carries an x-drawbridge-usage header — one line of key=value pairs about that request and nothing else: which door it came through, the route template it matched, the tool and export kind, whether it could reach a renderer, how it ended, which limit refused it when one did, how long it took in milliseconds, and this instance's billable count for the trailing hour, requests in flight and concurrency ceiling.

x-drawbridge-usage: via=rest; route=/api/diagrams/{kind}; kind=svg; billable=1; outcome=ok; ms=812; hour=3; flight=1; max=4

It is a capacity signal, not a log of you: the route is the template rather than the path you called, precisely so a ?name= you chose is never echoed back into a record, and it carries no document, no fragment of one and no address. It is on by default; DRAWBRIDGE_USAGE_LOG=0 removes the header and the matching stdout line together.

The listener binds 127.0.0.1 only and carries no authentication — it is for the machine Drawbridge runs on, the HTTP twin of the CLI. Requests carrying an Origin header, or that your browser marks as coming from another site, are refused, so a web page cannot drive it from inside your browser. It also answers only when it is called by a local name — 127.0.0.1, localhost or [::1] with its port — which keeps out a page that tries to reach it by pointing its own web address at your machine.

The hosted API

The same routes run on our server, so a pipeline that cannot install anything still gets a rendered diagram back:

curl -X POST --data-binary @topo.gv \
  "https://drawbridge.fortiknight.com/api/diagrams/svg?theme=light" -o topo.svg

Same formats, same ?name=, and the same x-drawbridge-notes, x-drawbridge-name and x-drawbridge-usage response headers — swap the origin and a local recipe works unchanged. No account and no key: 2000 elements per drawing, 5 MB per request, a render killed at 60 seconds, and no per-caller quota on this deployment, so a burst answers 429 with Retry-After: 5 instead of closing the door for an hour. Documents are rendered, returned and forgotten; nothing is stored. GET /api/openapi.json describes the whole surface there too.

It is a beta on a small fleet, so treat it as best effort — and if what you are driving is an AI assistant rather than a script, use the MCP door on the same host instead: it is one line of configuration and the tools tell the assistant how to draw well. See AI assistants (MCP).

Where the command lives

Drawbridge above stands for the installed binary; the path differs per platform:

Platform Command
macOS /Applications/Drawbridge.app/Contents/MacOS/Drawbridge
Linux (.deb) /opt/Drawbridge/drawbridge
Linux (AppImage) ./Drawbridge-<version>-x86_64.AppImage
Windows (installed) "%LOCALAPPDATA%\Programs\Drawbridge\Drawbridge.exe"
Windows (portable) Drawbridge-Portable-<version>-x64.exe

A shell alias keeps recipes readable: alias drawbridge='/Applications/Drawbridge.app/Contents/MacOS/Drawbridge'.

The contract, for scripts

Without --export, --import opens the app normally with the Import box pre-filled — the halfway point when you want the preview before committing:

ssh fgt 'execute switch-controller get-physical-conn dot fortilink' | Drawbridge --import -

Recipes

A nightly topology snapshot. Cron on any machine with the app installed:

#!/bin/sh
ssh fgt 'execute switch-controller get-physical-conn dot fortilink' \
  | /opt/Drawbridge/drawbridge --import - --export "/srv/wiki/topology-$(date +%F).png"

Diagram-as-code in a repository. Commit network.gv next to your docs and regenerate the image whenever it changes:

drawbridge --import network.gv --export docs/network.svg
git add network.gv docs/network.svg

The .gv file is the reviewable source — a pull request shows the topology change as a readable text diff, and the SVG is its rendering.

Generate the DOT itself from data. Anything that can print text can feed the pipe — an inventory, IPAM, a CMDB export:

node -e '
  const hosts = [
    ["fw1", "firewall", "FW-EDGE"],
    ["sw1", "switch", "SW-CORE"],
    ["srv1", "server", "SRV-01"],
  ];
  console.log("digraph {");
  for (const [id, kind, label] of hosts) {
    const k = JSON.stringify(kind);
    const l = JSON.stringify(label);
    console.log(`  ${id} [kind=${k}, label=${l}]`);
  }
  console.log("  fw1 -> sw1 -> srv1");
  console.log("}");
' | drawbridge --import - --export inventory.png

Batch a directory of topologies:

for f in topologies/*.gv; do
  drawbridge --import "$f" --export "out/$(basename "${f%.gv}").svg"
done

Keep the editable file too. Export .drawio alongside the image and the generated diagram stays a living document — open it, add zones and notes, save:

drawbridge --import topo.gv --export site-a.drawio

The cabling, without opening the app. The same run can produce the patching list beside the picture — one command per file:

drawbridge --import topo.gv --export docs/topology.svg
drawbridge --import topo.gv --export cables.csv
drawbridge --import topo.gv --export cables.netbox.csv

An AI in the pipe. Any tool that can call an AI and capture text can complete the loop with no human step: prompt (the one on this chapter's first page) → DOT → drawbridge --import - --export out.svg. The DOT examples page shows what the intermediate text looks like.

In CI — GitHub Actions

Everything above works on a headless runner; two things are specific to CI and both are handled in the snippets below. Electron needs a display even when it never shows a window — xvfb-run provides a virtual one — and in containers running as root it needs --no-sandbox. This exact recipe (virtual display, --no-sandbox, the .deb install, the import→SVG run) is what Drawbridge's own release verification executes in a clean Ubuntu container, so it is tested machinery, not a sketch.

Re-render the map whenever the topology file changes. Commit network.gv to the repository; every change to it publishes a fresh SVG as a build artifact:

name: network-map
on:
  push:
    paths: ['network.gv']
jobs:
  render:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Install Drawbridge and a virtual display
        run: |
          url=$(curl -fsS https://drawbridge.fortiknight.com/download/manifest.json \
            | jq -r '.versions[0].files[] | select(.platform=="linux-x64-deb").url')
          curl -fsSLo drawbridge.deb "https://drawbridge.fortiknight.com$url"
          sudo apt-get update
          sudo apt-get install -y xvfb ./drawbridge.deb
      - name: Render
        run: |
          xvfb-run -a /opt/Drawbridge/drawbridge --no-sandbox \
            --import network.gv --export network.svg
      - uses: actions/upload-artifact@v4
        with:
          name: network-map
          path: network.svg

The download step asks the live manifest for the current release, so the pipeline tracks Drawbridge versions on its own; pin a version instead by hard-coding /download/files/vX.Y.Z/… from the same manifest.

A nightly map of the live network. The same job on a schedule, pulling the topology off the FortiGate over SSH and committing the rendered map back — documentation that redraws itself:

name: live-topology
on:
  schedule:
    - cron: '17 5 * * *'
  workflow_dispatch:
jobs:
  render:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Install Drawbridge and a virtual display
        run: |
          url=$(curl -fsS https://drawbridge.fortiknight.com/download/manifest.json \
            | jq -r '.versions[0].files[] | select(.platform=="linux-x64-deb").url')
          curl -fsSLo drawbridge.deb "https://drawbridge.fortiknight.com$url"
          sudo apt-get update
          sudo apt-get install -y xvfb ./drawbridge.deb
      - name: Pull the live topology
        env:
          SSH_KEY: ${{ secrets.FGT_SSH_KEY }}
        run: |
          install -m 600 /dev/null key && printf '%s' "$SSH_KEY" > key
          ssh -i key -o StrictHostKeyChecking=accept-new admin@fgt.example.net \
            'execute switch-controller get-physical-conn dot fortilink' > topology.gv
      - name: Render and commit
        run: |
          xvfb-run -a /opt/Drawbridge/drawbridge --no-sandbox \
            --import topology.gv --export docs/topology.svg
          git config user.name "topology-bot"
          git config user.email "topology-bot@users.noreply.github.com"
          git add docs/topology.svg topology.gv
          git diff --cached --quiet || git commit -m "docs: refresh network topology"
          git push

Notes for other CI systems: the pattern is identical — install the .deb (or unpack the AppImage), wrap the call in xvfb-run -a, add --no-sandbox when the job runs as root, and read the exit code. Everything the run reports arrives on stdout/stderr exactly as described in the contract above, so a red pipeline step means a real import or export failure, with the reason in the log.

The CLI needs the desktop app on the machine running it. When the recipient is a person with a browser, build an import link instead (see the chapter's first page): the diagram travels inside the URL, their browser does the rendering, and nothing is installed anywhere. CLI for machines and pipelines; links for people. When the person is the one running the cables, the cable schedule has a link of its own — Share in the schedule panel, described in the Connectors chapter.