A shared whiteboard for you and your coding agents. Agents draw (usually as Mermaid), you mark up the drawing in a real canvas (Excalidraw), and the agent reads your changes back as a precise list: "relabeled X", "rewired A → B", "added note near Y". No more describing diagram feedback in prose.
Everything runs locally in one Docker container. Boards are plain files in ./boards, so
any agent that can read and write files can join in.
Status: prototype. Flowcharts only. See Coming soon.
- Docker: Docker Desktop (Windows/macOS) or Docker Engine with the Compose plugin
(Linux). Check it with
docker compose version. - git.
- Windows: PowerShell (5.1 or 7). macOS/Linux: bash and
jq.
git clone https://github.com/Hack4Impact/xcld-collab
cd xcld-collab
.\build.ps1 # macOS/Linux: ./build.shThe first build compiles Excalidraw from source, so go get a coffee. Rebuilds reuse the cache.
The build ends with .env updated (XCLD_TAG=...); that's how Compose knows which image to run.
Builds install the app from the committed app/package-lock.json (npm ci), which is the
reproducible default. If your environment can only use a private npm registry and the locked
build fails there, .\build.ps1 -NoLockfile (./build.sh --no-lockfile, or
XCLD_NO_LOCKFILE=1) resolves package.json ranges instead; its tag ends in -nolock.
Available after the public release. Until then the image is private and
docker compose pullfails withdenied; build from source as above.
Every push to main that changes the image publishes a multi-arch image (linux/amd64 and
linux/arm64) to ghcr.io/hack4impact/xcld-collab, so you can skip the build. You still need
the clone (for compose.yaml and boards/). Create .env next to compose.yaml:
XCLD_IMAGE=ghcr.io/hack4impact/xcld-collab
XCLD_TAG=latest
COMPOSE_PROFILES=widget
# Linux only, so boards stay writable: your `id -u` / `id -g`
# XCLD_UID=1000
# XCLD_GID=1000
# Linux only: run `mkdir -p ~/.excalidraw/history ~/.excalidraw/exports` first (Docker would
# create them owned by root), and keep version history there instead of in the xcld-state volume:
# XCLD_HISTORY=cachedocker compose pullThen continue with step 2. latest follows main; set XCLD_TAG to a full
<ours7>-<exc7>-<m2e7>-<mcp7> tag to stay on one build. Running build.ps1 / build.sh
later points .env back at your local build.
docker compose up -d --wait--wait returns once the workspace is healthy. This starts the canvas only, with no outside
requests. The chat widget is experimental and opt-in.
Open the board browser at http://127.0.0.1:3100/. examples/demo is a ready-made drawing
you can look around in.
To see the full loop, make your own copy of its Mermaid source and open that. Examples are checked into git, so work on copies:
New-Item -ItemType Directory -Force boards\sandbox | Out-Null
Copy-Item boards\examples\demo.mmd boards\sandbox\demo.mmd
# macOS/Linux: mkdir -p boards/sandbox && cp boards/examples/demo.mmd boards/sandbox/Open http://127.0.0.1:3100/?board=sandbox/demo. Your browser turns the Mermaid into an
editable drawing and saves it as boards/sandbox/demo.excalidraw. Move things, relabel,
recolor, add notes. Every change saves automatically.
docker exec xcld-collab xcld snapshot sandbox/demo # remember this version (pinned in history)
# ...edit the drawing in the browser...
docker exec xcld-collab xcld diff sandbox/demo # what changed since the snapshot
docker exec xcld-collab xcld diff sandbox/demo --since 10m # ...or since any point, with what was overwritten
docker exec xcld-collab xcld to-mermaid sandbox/demo # the drawing as Mermaid again
docker exec xcld-collab xcld open-in-canvas <checkpointId> sandbox/from-chat
docker exec -i xcld-collab xcld mcp # optional: MCP tools over stdioOpening this repo in VS Code offers the xcld tools server from .vscode/mcp.json. For
Copilot CLI, Claude Code, Codex and OpenCode, see the
client configs in the reference.
That's the whole default loop. Your agent runs those same commands; xcld mcp is the stdio
tools server inside the canvas container and does not need the chat widget.
The image also contains the upstream Excalidraw MCP Apps chat widget, which draws diagrams inside chat in hosts that render MCP Apps, such as VS Code. It is experimental and off by default:
- It sends requests outside your machine. When it renders, the widget loads React,
React DOM, Excalidraw 0.18.0 and morphdom, plus Excalidraw's CSS and some fonts, from
https://esm.sh. Attempts to serve that JavaScript locally rendered a blank diagram in VS Code (#3). - Known font errors. The VS Code webview console can show font Content-Security-Policy errors; affected UI text falls back to a system font (#2).
To opt in:
- Add
COMPOSE_PROFILES=widgetto.envand rundocker compose up -d --wait. This starts a second service,mcp, at http://127.0.0.1:3001/mcp. - Add the widget server to
.vscode/mcp.json(or your usermcp.json) next toxcld:"excalidraw": { "type": "http", "url": "http://127.0.0.1:3001/mcp" }.
If a host cannot open the widget editor, the widget shows its checkpoint id; use
open-in-canvas (or MCP open_in_canvas) with an explicit board path to move the drawing
into the persistent canvas.
To opt out again, remove widget from COMPOSE_PROFILES in .env, then run
docker compose --profile widget down followed by docker compose up -d --wait.
docker compose downBoards stay in ./boards, and version history stays where it lives
(see below); docker compose down -v also deletes the xcld-state
volume, which holds the history on Windows and macOS. Only boards/examples/ is tracked in
git; everything else you create there is gitignored. Nothing leaves your machine: the canvas is served from
127.0.0.1 only, and fonts and assets come from the container. The one exception is the
experimental chat widget, which is off unless you opt in.
| Setting | Default | What it does |
|---|---|---|
XCLD_PORT |
3100 |
Port on 127.0.0.1 |
XCLD_MCP_PORT |
3001 |
Host port for the experimental chat widget service at /mcp (only with COMPOSE_PROFILES=widget) |
XCLD_CONTAINER |
xcld-collab |
Canvas container name. Override when running multiple Compose projects side by side |
XCLD_MCP_CONTAINER |
xcld-mcp |
Chat widget service container name |
XCLD_BOARDS |
./boards |
Boards folder. Relative paths are relative to compose.yaml, so use an absolute path for a folder in another repo |
XCLD_WATCH_POLL_MS |
1000 |
How often the server checks for file changes |
XCLD_MAX_DEPTH |
0 |
Nested board folder limit; 0 means unlimited |
XCLD_AUTO_EXPORT |
snapshot |
When Mermaid is written for you: off, snapshot (each xcld snapshot also writes a .mmd) or save (also keeps boards/.exports/<path>.mmd current). See the decision tree |
XCLD_DESIGN_RULES |
<boards>/design-rules.csv |
Optional path to the default design rules CSV (inside Docker, use /boards/...). A folder's own design-rules.csv still replaces the inherited defaults for boards below it |
XCLD_PUBLIC_URL |
http://127.0.0.1:${XCLD_PORT} |
URL returned by MCP board_url; Compose sets this for the canvas service |
XCLD_AUTHOR_NAME |
your git config user.name, else your OS user |
The canvas's default author name in version history, shown as "Author: …" at the bottom of the canvas (click it to rename in that browser). The build writes it into .env once and never overwrites it; edit it there |
XCLD_TIMING |
unset | 1 records per-stage commit timings at GET /api/timings (for profiling, see tests/versions-load.mjs) |
XCLD_SLOW_IO_MS |
1000 |
A file operation of the commit pipeline that takes this many ms or more is logged once and listed under slowIo in GET /api/status (always on). See Troubleshooting |
XCLD_CACHE_DIR |
~/.excalidraw |
A folder on your machine. xcld history export writes to exports/ in it; on Linux it also holds version history (history/). Must be a path; on Linux, writable by you. See where history lives |
XCLD_HISTORY |
Linux: cache; Windows/macOS: volume |
Advanced. Where version history lives: volume (the Docker volume xcld-state) or cache (XCLD_CACHE_DIR/history/). The build writes the default for your OS once and never overwrites it. cache on Windows/macOS is slow |
COMPOSE_PROFILES |
unset (canvas only, no outside requests) | widget also starts the experimental chat widget, which loads JavaScript from esm.sh. The build never writes or changes this line; builds before the widget became opt-in added COMPOSE_PROFILES=widget, and the build prints a notice while it is there |
Put these in .env next to compose.yaml, then run docker compose up -d --wait again.
- User guide: the recommended way to draw, give feedback and work with agents.
- Reference: every
xcldcommand, the Mermaid mapping, and troubleshooting. - Agent skill: the safe MCP workflow for creating, reviewing and revising boards.
- Design: how it works and why, for contributors.
Parallel editing is supported. You can draw in the canvas while several agents write to the
same board; nothing is silently lost. The server merges writes instead of letting the last one
win. The tab's saves, agents' write_board and write_mermaid (MCP), xcld write and
xcld write-mermaid (CLI), and direct edits of a board or .mmd file all go through one
commit pipeline:
- Every write names the version it started from (
base). The server merges it with anything committed since. Edits to different shapes all apply. - Overwritten means: the same shape (or its label) was changed on both sides since the writer's base. The later edit (by when it was made, not when it arrived) takes the whole shape and its label; the other edit loses. A queued write made earlier loses to a newer edit even when it lands later.
- Losers live in version history only: the losing shape is kept in the history entry of the
write that overwrote it (or lost), nothing re-applies it, and it is reported three ways: the
canvas banner ("overwritten by …"), the write's answer, and
xcld diff <board> --since …(below).xcld history exportcopies it out with everything else. - Every change is kept: version history keeps one entry per author turn (a person's autosaves fold into one restore point until someone else writes, 3 minutes pass or a checkpoint). Each entry stores only what changed, with a full copy every 20 entries: 100 small turns on a 1,500-element board take about 1 MB. See where history lives.
queuedanswers: a write is safe once the server has it in its journal. If the merge takes longer than 5 s, typically because the disk is slow (Docker Desktop shares one disk among all containers; cloud VMs throttle), an agent getsqueuedwith a branch id instead ofmerged, and the write lands as soon as the disk catches up. Nothing is dropped; read the board again before building on it.GET /api/statuslists slow file operations (see Troubleshooting).- Mermaid merges too, with no tab open. The server applies an agent's Mermaid to the
board: your layout, notes and colors stay, existing shapes keep their place, new nodes go
next to a connected one, and only shapes that came from that Mermaid are ever removed (a copy
you made of one never is). Several agents can write Mermaid to the same board at once, each
diagram under its own
sourcename if they like. A new diagram is laid out by an open tab and added next to your drawing (below it, or to the right for left-right diagrams), never over it; with no tab, the server lays it out in a simple grid after about 2 minutes. A shape you edited keeps your version until the agent's Mermaid changes that shape.
The canvas takes part like any other writer:
- Your name is at the bottom of the canvas ("Author: …"), from
XCLD_AUTHOR_NAME. Click it to rename; the browser remembers it for all its tabs. Each tab also saves under a hidden tab id, so two tabs of yours never overwrite each other's turn. - Save before reload: when someone else's write lands, the tab first saves your pending edits (with the version they started from), then shows the merged board. A drag or a label you are typing carries on.
- A banner lists what merged from whom and every overwritten edit, with who won. A losing edit is kept in version history only; nothing re-applies it.
- Ctrl+S (Cmd+S) closes your current turn as a restore point ("Saved checkpoint"). It no longer downloads a file.
See what happened:
docker exec xcld-collab xcld diff myproject/demo --since 30m # changes and losers in the last 30 minutes
docker exec xcld-collab xcld diff myproject/demo --since author:Ada # since Ada's last turn, incl. what she lost
docker exec xcld-collab xcld snapshot myproject/demo --name review-1 # pin this version under a name...
docker exec xcld-collab xcld diff myproject/demo --since review-1 # ...and diff against it later
docker exec xcld-collab xcld watch myproject/demo # every merge and history entry, live--since takes a version id prefix, a time (10m, 2h, an ISO time), author:<name> or a
snapshot name; add --json for agents. Agents use MCP diff with since the same way. See
the reference.
Caveats:
- The same shape (or its label) edited on both sides goes whole to the later edit; there is no field-by-field merge. Look at the banner's details for what lost.
- An edit you make in the fraction of a second while a save is on its way, to a shape that the merge changed, is replaced by the merged shape. The banner says so; that edit is not in history.
- Arrow points can go stale when the bound shape moves on the other side (issue #6).
- Names are not checked for uniqueness. A shared server with several people (random names, a server-checked unique name) is future work.
One folder on your machine, XCLD_CACHE_DIR (default ~/.excalidraw), holds what you can
open: exports, and on Linux the history itself.
| Host | Version history | xcld history export writes to |
|---|---|---|
| Linux | ~/.excalidraw/history/ |
~/.excalidraw/exports/<board>/ |
| Windows, macOS (Docker Desktop) | the Docker volume xcld-state |
~/.excalidraw/exports/<board>/ |
-
Windows and macOS: history stays in a Docker volume because Docker Desktop folder mounts are too slow for the write path.
docker compose down -vdeletes the volume and all version history.docker compose downkeeps it. -
Linux: the build creates the folders as you and sets
XCLD_UID/XCLD_GID, so the container can write them. -
Move the folder with
XCLD_CACHE_DIR=<path>in.env. Advanced:XCLD_HISTORYpicks where history lives,volumeorcache(the folder). The build writes the default for your OS once and never overwrites a value you set. -
Copy a board's history out of either place to
~/.excalidraw/exports/<board>/:docker exec xcld-collab xcld history export myproject/demo # as stored: checkpoints + deltas docker exec xcld-collab xcld history export myproject/demo --full # every version as an .excalidraw file
See the reference.
-
An older
.envwithXCLD_STATE_DIRorXCLD_EXPORT_DIR(from development builds) still works: the build prints a notice and adds the matchingXCLD_HISTORY/XCLD_CACHE_DIR. It doesn't delete your lines.
Rebuild (or pull) the image and start it as usual; your boards and their .mmd files stay where
they are.
- Reload every open canvas tab after the upgrade. A tab keeps running the page it loaded, and the server refuses saves from a page of an earlier build: such a tab shows "Save failed: HTTP 409" and saves nothing until you reload it (your board is unchanged). Before this guard, an old tab could replace the board with a stale copy.
- Boards made from Mermaid before versions (when a tab converted
boards/<path>.mmdand replaced the board) are taken over as they are. The first time the new build looks at the.mmd(a tab opens the board, or the file watcher), it treats that file as already converted: the shapes it made become the board'smainMermaid source, so a laterwrite_mermaidupdates them in place instead of adding the diagram a second time. Nothing on the board moves or changes; shapes you edited since keep your edits, and shapes you drew yourself are never part of it. A.mmdthat no tab ever converted is not applied either: write it again (write_mermaid, or save the file) to apply it. - Scripts that
PUTa board without the tab's headers keep working as before.
The details are in DESIGN.
These are designed but not built yet. Don't rely on them.
- Version browsing in the canvas. History is recorded;
xcld diff --since, pinned snapshots andxcld history exportread it (see above), but the canvas can't open an old version yet. Ctrl+S closes a restore point; Excalidraw's menu "Save to…" still downloads a separate copy. - History pruning (keep every version 48 hours, then pinned snapshots only; #4). Today nothing is pruned.
- More diagram types: sequence, class, ER, state.
.\build.ps1 -Target vendor # upstream packages built from source, into app\vendor
cd app; npm ci; npm run dev # npm ci installs exactly app/package-lock.json
node --test tests # from the repo rootapp/package-lock.json is committed with public npm URLs only. If you change dependencies
(or pins.json, which changes the vendor tarballs), rebuild app\vendor, then run
npm install and npm run lockfile:public in app/ and commit the lockfile.
npm writes your registry's URLs into the lockfile, so after any npm install <pkg>
through a private registry, run npm run lockfile:public before committing.
npm run lockfile:public -- --check exits non-zero if any URL isn't public npm, and
tests/lockfile.test.mjs runs the same check. npm ci rejects stale vendor tarballs.
Installing through a mirror is fine: npm fetches the same tarballs from your configured
registry.
Optional: scripts/install-hooks.sh (Windows: scripts\install-hooks.ps1) adds a gitleaks
pre-commit hook with the same .gitleaks.toml rules CI enforces on main; it also rejects a staged
app/package-lock.json that names a non-public registry. CI runs only
after merge to main (see .github/workflows/).
MIT, © 2026 Hack4Impact. Bundles Excalidraw, mermaid-to-excalidraw and (later) excalidraw-mcp, all MIT. See LICENSE and NOTICE.