Using Draughtsman
Diagrams in git
Keep a diagram as a plain text file in your own git repository, and a pull request that touches it can say what changed in sentences instead of a wall of coordinates. Two commands of the draughtsman program do the describing. Draughtsman itself never runs git and never holds a git credential: your repository, your CI and your forge do all of that.
A diagram is a typed graph with a lossless text form, the DSL: one line per node, container or edge. Commit those .dsl files and a reviewer sees this in the pull request:
### Diagram changes: `Order service`
- Edge `Orders API` to `Postgres` relabelled from `read/write` to `reads`
- 1 layout-only change
The two commands read the files you give them and nothing else: no data folder, no settings, no database and no network, so they run on a CI runner with no internet access. (A server-side "export every diagram into a folder" command is a possible later step and is not built.)
What was run, and what was not
| Part | State |
|---|---|
draughtsman diff and draughtsman check: every output and exit code shown on this page | Run by us on the current build (transcript below). The product's own tests cover them too. |
| The branch-review script, in a real git repository (git 2.50.1, macOS) where the branch adds, changes and removes diagrams, and the base branch has moved on | Run. It found the merge base, failed on an error finding, and ignored an unrelated commit on the base branch. |
export-if-changed.sh and the .gitattributes diff driver | Run on the same machine. The driver was run for patch output only. |
| The GitHub Actions workflow | Not run on a real runner. The script it calls was run; the workflow around it was not. |
| The GitLab CI job | Not run on a real runner. |
Anything on Windows (a Windows runner, line endings, grep for the driver) | Not run. |
Getting the diagrams into the repository
Today this is a step you take, with what already exists:
- An agent or script holding an API token calls the MCP tool
read_documentwithformatset todsl(see Agents and MCP). A read token is enough, and is the right size for a job that only exports. - An administrator can download every diagram as
documents/<id>.dslinside the zip fromGET /api/admin/export. That is a one-off by hand: the endpoint wants an administrator's browser session, not a token.
Commit the text as it comes. It is canonical: reading a file and printing it again gives the same bytes, so an unchanged diagram is an unchanged file.
draughtsman diff
Usagedraughtsman diff <before.dsl> <after.dsl> [--format markdown|json] [--ignore-layout]
It parses both files and describes the change. Elements are matched by id, but the sentences use labels, never ids, so a reviewer reads "Edge Orders API to Postgres" and not e_2. Every label and value is printed as inline code on one line and cut at 120 characters: a label is text someone typed into a diagram and this output goes into a pull request, so it must not be able to mention a user or break the page.
| Exit | Meaning |
|---|---|
0 | No semantic change. |
1 | The diagram changed. |
2 | A file could not be read or parsed (the message names the file, line and column), or the command line is wrong. |
What is reported, one line each: the title, diagram type, schema version, document id and each theme override; nodes and edges added, removed, relabelled, changed in kind or shape, moved into or out of a container, reconnected or given another routing mode; props and style one line per key; position changes (a node moved or resized, an edge rerouted, declared ports changed); the order of messages in a sequence diagram, where order is time; and the fly-through, when its path differs.
Here are the real outputs for a copy of the order-service example in which we relabelled an edge, added a node and an edge, and moved the Redis box:
Run for real$ draughtsman diff base.dsl after.dsl
### Diagram changes: `Order service`
- Added node `Order events` (`queue`)
- Node `Redis` moved from (400, 360) to (420, 360)
- Added `flow` edge `Orders API` to `Order events` labelled `publishes`
- Edge `Orders API` to `Postgres` relabelled from `read/write` to `reads`
Not counted as changes (every save rewrites these):
- Last saved time changed from `2026-09-20T22:05:00.000Z` to `2026-10-05T10:00:00.000Z`
[exit 1]
$ draughtsman diff base.dsl after.dsl --ignore-layout
### Diagram changes: `Order service`
- Added node `Order events` (`queue`)
- Added `flow` edge `Orders API` to `Order events` labelled `publishes`
- Edge `Orders API` to `Postgres` relabelled from `read/write` to `reads`
- 1 layout-only change
Not counted as changes (every save rewrites these):
- Last saved time changed from `2026-09-20T22:05:00.000Z` to `2026-10-05T10:00:00.000Z`
[exit 1]
--ignore-layout
Position, size, rotation, waypoint and port-only changes (and drawing order outside sequence diagrams) collapse into one line, 12 layout-only changes, counting each element once. If layout is all that moved, the output says so and the exit code is 0: you asked to ignore layout, so it does not fail a check. A change made alongside the layout is still listed in full. One port change is never layout: an edge that now names a different table column says something about the data.
$ draughtsman diff base.dsl layout-only.dsl
### Diagram changes: `Order service`
- Node `Redis` moved from (400, 360) to (440, 380)
[exit 1]
$ draughtsman diff base.dsl layout-only.dsl --ignore-layout
### Diagram changes: `Order service`
No semantic changes; 1 layout-only change.
[exit 0]
The lines every save rewrites
The meta created= updated= by= line and the provenance lines change on every save, whether or not the diagram did. diff lists them apart, under "Not counted as changes", and they never decide the exit code or appear as an element change, so a save that changed nothing is not a change. In git itself they still show in git diff and, when two branches edit one diagram, they can conflict (see below).
$ draughtsman diff base.dsl save-only.dsl
### Diagram changes: `Order service`
No changes.
Not counted as changes (every save rewrites these):
- Last saved time changed from `2026-09-20T22:05:00.000Z` to `2026-10-01T10:00:00.000Z`
[exit 0]
$ draughtsman diff base.dsl base.dsl
### Diagram changes: `Order service`
No changes.
[exit 0]
--format json
For a script. category is document, node, edge, order or flythrough; elementId is there for the script, and the sentence never carries it.
$ draughtsman diff base.dsl after.dsl --format json --ignore-layout
{"title":"Order service","ignoreLayout":true,"changes":[{"category":"node","elementId":"n_queue","text":"Added node `Order events` (`queue`)","layout":false},{"category":"edge","elementId":"e_4","text":"Added `flow` edge `Orders API` to `Order events` labelled `publishes`","layout":false},{"category":"edge","elementId":"e_2","text":"Edge `Orders API` to `Postgres` relabelled from `read/write` to `reads`","layout":false}],"layoutOnlyChanges":1,"housekeeping":[{"category":"housekeeping","text":"Last saved time changed from `2026-09-20T22:05:00.000Z` to `2026-10-05T10:00:00.000Z`","layout":false}],"hasChanges":true}
[exit 1]
draughtsman check
Usagedraughtsman check <file.dsl> [--format text|json]
It reports the parse warnings (an unknown kind, an edge to a node that is not declared) and the deterministic critique findings, the same rules the critique_document tool runs. Exit 0 when there is no error-severity finding (warnings are printed and do not fail), 1 for at least one error, 2 when the file could not be read or parsed. The JSON form has errors, warnings, parseWarnings and findings.
$ draughtsman check after.dsl
after.dsl: no problems found
[exit 0]
$ draughtsman check broken.dsl
broken.dsl:17:24: warning: edge 'e_4' refers to node 'n_missing', which is not declared
broken.dsl: error [dangling-edge]: Edge 'e_4' references target node 'n_missing', which does not exist. (edges e_4)
broken.dsl: warning [isolated-node]: Node 'n_queue' has no edges connecting it to the rest of the diagram. (nodes n_queue)
broken.dsl: 1 error, 2 warnings
[exit 1]
$ draughtsman check missing.dsl
draughtsman: cannot read 'missing.dsl': Could not find file '/srv/diagrams/missing.dsl'.
[exit 2]
A script that reviews a branch
Both CI recipes below call one script, which you keep in your repository (for example tools/diagram-review.sh). It prints the Markdown for every .dsl file the branch changed. This is the script we ran, word for word:
#!/usr/bin/env bash
# diagram-review.sh <base-ref>
# Prints a Markdown review of every .dsl file this branch changed against <base-ref> (for example origin/main).
# Exit 0: fine. Exit 1: a diagram has an error-severity finding. Exit 2: a file could not be read or parsed.
set -u
base_ref="$1"
draughtsman="${DRAUGHTSMAN:-draughtsman}"
status=0
# The base side is the merge base, not the tip of the base branch: against the tip, anything that landed on main since
# this branch started would show up as a change this branch made.
base="$(git merge-base "$base_ref" HEAD)" || exit 2
work="$(mktemp -d)"
trap 'rm -rf "$work"' EXIT
changes="$(git diff --name-status --no-renames "$base" HEAD -- '*.dsl')"
if [ -z "$changes" ]; then
echo "No diagram changes."
exit 0
fi
while IFS=$'\t' read -r kind path; do
case "$kind" in
D)
printf '### Removed diagram: `%s`\n\n' "$path"
continue
;;
A)
printf '### New diagram: `%s`\n\n' "$path"
;;
M)
git show "$base:$path" > "$work/base.dsl" || { status=2; continue; }
"$draughtsman" diff "$work/base.dsl" "$path" --ignore-layout
rc=$?
# 0 and 1 are "no change" and "changed"; only 2 (unreadable or unparsable) is a failure here.
if [ "$rc" -ge 2 ]; then status=2; fi
echo
;;
esac
report="$("$draughtsman" check "$path")"
rc=$?
if [ "$rc" -eq 1 ] && [ "$status" -eq 0 ]; then status=1; fi
if [ "$rc" -ge 2 ] && [ "$status" -lt 2 ]; then status=2; fi
printf 'Checks for `%s`:\n\n```\n%s\n```\n\n' "$path" "$report"
done <<< "$changes"
exit "$status"
The base side is the merge base. git show origin/main:path is the base file only until main moves on; after that it shows other people's later changes as if this branch had undone them. The script resolves the base ref to git merge-base first. Your CI must fetch enough history for that (fetch-depth: 0 for GitHub, GIT_DEPTH: "0" for GitLab). New files have no base, so they get check only; removed files are named; renames are shown as a removal and an addition.
The program itself: get the draughtsman binary onto the runner the way you get any internal tool, from the release archive you hold, kept in an internal artifact store or a tools image. The recipes name it through DRAUGHTSMAN.
We ran it in a scratch repository: a branch that changes one diagram, adds one that has a dangling edge, removes one and saves another without changing it, while an unrelated commit landed on main. The output, and the exit code:
$ bash tools/diagram-review.sh main
### New diagram: `diagrams/broken.dsl`
Checks for `diagrams/broken.dsl`:
```
diagrams/broken.dsl:17:24: warning: edge 'e_4' refers to node 'n_missing', which is not declared
diagrams/broken.dsl: error [dangling-edge]: Edge 'e_4' references target node 'n_missing', which does not exist. (edges e_4)
diagrams/broken.dsl: warning [isolated-node]: Node 'n_queue' has no edges connecting it to the rest of the diagram. (nodes n_queue)
diagrams/broken.dsl: 1 error, 2 warnings
```
### Diagram changes: `Checkout`
No changes.
Not counted as changes (every save rewrites these):
- Last saved time changed from `2026-09-29T00:00:00.000Z` to `2026-10-05T11:00:00.000Z`
Checks for `diagrams/checkout.dsl`:
```
diagrams/checkout.dsl: no problems found
```
### Removed diagram: `diagrams/fragments.dsl`
### Diagram changes: `Order service`
- Added node `Order events` (`queue`)
- Added `flow` edge `Orders API` to `Order events` labelled `publishes`
- Edge `Orders API` to `Postgres` relabelled from `read/write` to `reads`
- 1 layout-only change
Not counted as changes (every save rewrites these):
- Last saved time changed from `2026-09-20T22:05:00.000Z` to `2026-10-05T10:00:00.000Z`
Checks for `diagrams/order.dsl`:
```
diagrams/order.dsl: no problems found
```
[exit 1]
GitHub Actions
The script it calls was run, as above. The workflow around it was not.
name: Diagram review
on:
pull_request:
paths: ["**/*.dsl"]
permissions:
contents: read
jobs:
diagrams:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # the merge base needs the history
- name: Install draughtsman
run: |
# Replace with however your organisation fetches its release archive.
tar -xzf "$ARCHIVE" -C "$RUNNER_TEMP"
echo "DRAUGHTSMAN=$(echo "$RUNNER_TEMP"/draughtsman-*/draughtsman)" >> "$GITHUB_ENV"
env:
ARCHIVE: tools/draughtsman-0.1.0-linux-x64.tar.gz
- name: Review the diagrams
env:
BASE_REF: origin/${{ github.base_ref }}
run: |
set -o pipefail
bash tools/diagram-review.sh "$BASE_REF" | tee diagram-review.md >> "$GITHUB_STEP_SUMMARY"
The Markdown lands on the run's summary page. The job fails when a diagram has an error finding or a file does not parse; set -o pipefail is what carries the script's exit code through tee. To post it as a comment instead, give the job pull-requests: write and add a step after the one above:
- name: Comment on the pull request
if: always()
env:
GH_TOKEN: ${{ github.token }}
run: gh pr comment "${{ github.event.pull_request.number }}" --body-file diagram-review.md
A pull request from a fork gets a read-only token, so the comment fails there; the step summary does not.
GitLab CI
GitLab has no job summary, so the report is the job log and an artifact; the optional last line posts it as a merge request note.
diagram-review:
stage: test
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
changes:
- "**/*.dsl"
variables:
GIT_DEPTH: "0" # the merge base needs the history
before_script:
# Replace with however your organisation fetches its release archive.
- mkdir -p "$CI_PROJECT_DIR/.draughtsman"
- tar -xzf tools/draughtsman-0.1.0-linux-x64.tar.gz -C "$CI_PROJECT_DIR/.draughtsman"
- export DRAUGHTSMAN="$(echo "$CI_PROJECT_DIR"/.draughtsman/draughtsman-*/draughtsman)"
- git fetch origin "$CI_MERGE_REQUEST_TARGET_BRANCH_NAME"
script:
- set -o pipefail
- bash tools/diagram-review.sh "origin/$CI_MERGE_REQUEST_TARGET_BRANCH_NAME" | tee diagram-review.md
after_script:
# Optional: needs a project access token with the api scope in GITLAB_NOTE_TOKEN.
- >
curl --fail --silent --header "PRIVATE-TOKEN: $GITLAB_NOTE_TOKEN"
--data-urlencode "body@diagram-review.md"
"$CI_API_V4_URL/projects/$CI_PROJECT_ID/merge_requests/$CI_MERGE_REQUEST_IID/notes"
artifacts:
when: always
paths:
- diagram-review.md
Drop the after_script block for the log-and-artifact version.
The header lines in git itself
Draughtsman does not change the file format to avoid the save-time churn. Three things help:
Do not write a file when the diagram did not change. If your export step replaces the committed file with today's export, a save that changed nothing becomes a one-line commit. This script keeps the committed file unless
export-if-changed.shdiffsays the diagram differs (layout counts, since a moved box is a real change to the file):#!/usr/bin/env bash # export-if-changed.sh <new.dsl> <committed.dsl> draughtsman="${DRAUGHTSMAN:-draughtsman}" new="$1"; committed="$2" if [ -f "$committed" ]; then "$draughtsman" diff "$committed" "$new" > /dev/null rc=$? if [ "$rc" -eq 0 ]; then exit 0; fi # same diagram: keep the committed file if [ "$rc" -ge 2 ]; then echo "cannot compare $committed with $new" >&2; exit "$rc"; fi fi cp "$new" "$committed"Hide the lines when you read a diff. In
.gitattributes, committed with the repository:*.dsl text eol=lf diff=draughtsmanand, once per clone (git does not let a repository ship this setting):
git config diff.draughtsman.textconv "grep -v -e '^meta ' -e '^provenance '"git diffthen leaves themetaandprovenancelines out, so a save-only change shows nothing. It changes what you see, never what is stored or merged, andgit diff --statstill counts the line.When a merge does conflict on those lines, take either side. They are bookkeeping, not part of the diagram. Keep the newer
updated=on themetaline and keep bothprovenancelines. A conflict on a node or edge line is a real one: two people changed the same element. A merge driver that does this by id is feasible, because every element is its own line, but it is not built.
We ran the second and first tips on a real branch:
Run for real# The .gitattributes diff driver hides the meta and provenance lines in git diff
$ cat .gitattributes
*.dsl text eol=lf diff=draughtsman
$ git config diff.draughtsman.textconv "grep -v -e '^meta ' -e '^provenance '"
$ git diff main...HEAD -- diagrams/checkout.dsl (a branch whose only change to this file is a save)
[no output: the save-only change is hidden]
$ git -c diff.draughtsman.textconv=cat diff main...HEAD -- diagrams/checkout.dsl (the same, without the driver)
id doc_checkout
type sequence
title "Checkout"
-meta created=2026-09-29T00:00:00.000Z updated=2026-09-29T00:00:00.000Z by=eric
+meta created=2026-09-29T00:00:00.000Z updated=2026-10-05T11:00:00.000Z by=eric
node p_user actor "Customer"
node p_web participant "Web app"
# export-if-changed.sh <new.dsl> <committed.dsl>
$ export-if-changed.sh save-only.dsl diagrams/o2.dsl (same diagram, only the saved time differs)
[exit 0]
committed file kept: byte-identical to the base
$ export-if-changed.sh after.dsl diagrams/o2.dsl (the diagram changed)
[exit 0]
committed file replaced by the new export
$ git diff --stat main...HEAD -- diagrams/checkout.dsl (the stat still counts the line)
diagrams/checkout.dsl | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
A hand-edited file can read back slightly differently from what you wrote (a dotted id is ambiguous with a port, and empty lists are not written). The DSL reference in the archive lists them; a file exported by Draughtsman does not have the problem.
Not built
- A server-side command that exports every diagram into a folder on a schedule.
- A git merge driver for
.dslfiles. - Anything that talks to git or a forge on your behalf. By design, Draughtsman holds no git credential.