Working with coding agents
Localidraw is built for drawing in the same files a coding agent reads and writes. Ask Claude Code, Codex or Cursor to “add the cache to the architecture diagram”, keep the canvas open, and its edit shows up within a second, merged with whatever you’ve drawn since. No plugin or API: the agent edits the JSON file, and Localidraw watches it.
This page is what an agent needs to know to do that well. You can point it here, or paste the rules
into your AGENTS.md or CLAUDE.md.
The file
Section titled “The file”A .excalidraw file is JSON:
{ "type": "excalidraw", "version": 2, "source": "http://localhost:3729", "elements": [ … ], "appState": { "viewBackgroundColor": "#ffffff", "gridSize": 20 }, "files": {}}elements is the drawing, back to front. Each element has an id, a type (rectangle, ellipse, diamond,
arrow, line, text, freedraw, image, frame), its geometry and style, and three fields that matter here:
version: a number Excalidraw raises every time the element changes. The merge compares it.index: the element’s place in the z-order, as a fractional index string ("a0","a1","a1V"…).isDeleted: Excalidraw keeps deleted elements as tombstones withisDeleted: true. Skip them when reading.
The format is Excalidraw’s own, documented in its JSON schema.
The rules
Section titled “The rules”- Bump
versionon anything you change. Add 1 to theversionof every existing element you edit (and set a new randomversionNonce). If the person at the canvas has changed the same element since it was last saved, the higher version wins; an edit that leavesversionalone loses. - New elements get new ids and
version: 1. Any unique string will do for anid. - Drop
indexon new elements, or set it yourself. Don’t copy anindexfrom another element or number your own from"a0": they’d clash with the file’s. Without one, Excalidraw numbers the element after the others, on top, and the number is written with the next save. To choose its place yourself, give it anindexbetween its neighbours’; above the highest in the file, the highest with a character added ("a5"→"a5V") always is. - Leave alone what you didn’t mean to change, including other elements’
version, andappState. - Write the whole file, atomically: write to a temporary file in the same folder and rename it over the canvas. Localidraw reads the file as soon as it changes. A half-written file won’t be opened or saved over, but it shows as an error until the write finishes.
- Give text room. Excalidraw draws a text element at the
widthandheightthe file gives it, so a box that’s too small cuts the text off. Localidraw measures text again when it opens a canvas, and when your change arrives, so there it shows in full (and the right size is saved with the next edit). Other editors, the VS Code extension included, don’t. In Excalifont (fontFamily: 5), allow about 0.6 ×fontSizeper character, and 1.25 ×fontSizeper line. - Keep bindings two-way. A label is a
textelement whosecontainerIdis its box, and the box lists it inboundElements. An arrow attached to a shape names it instartBinding/endBinding, and the shape lists the arrow in itsboundElements; adding the arrow changes the shape, so bump the shape’sversiontoo.
Making elements: convertToExcalidrawElements
Section titled “Making elements: convertToExcalidrawElements”Excalidraw elements have many required fields. Rather than write them by hand, an agent that can run JavaScript can describe elements as skeletons and let Excalidraw fill in the rest, with fresh ids, versions and seeds:
import { convertToExcalidrawElements } from "@excalidraw/excalidraw";
const elements = convertToExcalidrawElements([ { type: "rectangle", id: "cache", x: 640, y: 180, width: 180, height: 80, backgroundColor: "#ffc9c9", fillStyle: "solid", label: { text: "Redis cache" } }, { type: "arrow", x: 500, y: 60, width: 140, height: 160, start: { id: "api" }, end: { id: "cache" } },], { regenerateIds: false });It handles labels, bindings and text measurement (when it runs where fonts can be measured, as in a browser), and the
skeleton format is in Excalidraw’s docs.
Its output still needs the rules above: drop the index it numbers from "a0" (it doesn’t know the file’s), and bump
the version of any existing element you bind to. And give a straight arrow or line its points: a width or
height of 0 counts as unset and becomes 100, so { type: "arrow", width: 0, height: 70 } comes out diagonal, while
points: [[0, 0], [0, 70]] is straight down.
Editing an element by hand is fine too: change the fields, then bump version. Copying an existing element as a
template, as below, is the easiest way to get every field right.
An example: adding a box and an arrow in Node
Section titled “An example: adding a box and an arrow in Node”What the script behind the demo GIF does, trimmed. Run from
the repo’s demo/ folder, it works as is:
import { readFileSync, renameSync, writeFileSync } from "node:fs";
const file = "web-app/architecture.excalidraw";const scene = JSON.parse(readFileSync(file, "utf8"));const api = scene.elements.find((e) => e.id === "api");const label = scene.elements.find((e) => e.containerId === "api");const rand = () => Math.floor(Math.random() * 2 ** 31);// a copy of an existing element, as new: a new version, nonce and seed, and no indexconst fresh = ({ index, ...e }) => ({ ...e, version: 1, versionNonce: rand(), seed: rand(), updated: Date.now() });
const box = { ...fresh(api), id: "cache", x: 640, y: 180, strokeColor: "#e03131", backgroundColor: "#ffc9c9", boundElements: [{ type: "text", id: "cache-label" }, { type: "arrow", id: "api-cache" }] };const text = { ...fresh(label), id: "cache-label", containerId: "cache", strokeColor: "#e03131", text: "Redis cache", originalText: "Redis cache", width: 11 * label.fontSize * 0.6, height: label.fontSize * 1.25 };text.x = box.x + (box.width - text.width) / 2;text.y = box.y + (box.height - text.height) / 2;const arrow = { ...fresh(scene.elements.find((e) => e.type === "arrow")), id: "api-cache", x: 500, y: 60, width: 140, height: 160, points: [[0, 0], [140, 160]], startBinding: { elementId: "api", focus: 0, gap: 1 }, endBinding: { elementId: "cache", focus: 0, gap: 1 } };
// the API box gains the arrow, so it changed: bump itapi.boundElements = [...(api.boundElements ?? []), { type: "arrow", id: "api-cache" }];api.version += 1;api.versionNonce = rand();
scene.elements.push(box, text, arrow);writeFileSync(`${file}.tmp`, JSON.stringify(scene, null, 2) + "\n");renameSync(`${file}.tmp`, file);Reading a canvas
Section titled “Reading a canvas”To read what someone drew, skip elements with isDeleted: true, and read labels from text elements: a label’s
containerId says which shape it’s on. Arrows name what they connect in startBinding.elementId and
endBinding.elementId.
For your AGENTS.md
Section titled “For your AGENTS.md”## Diagrams (.excalidraw)
Diagrams are Excalidraw JSON files, often open in Localidraw while you work. When you edit one:- add 1 to the `version` (and set a new random `versionNonce`) of every element you change;- give new elements new ids and `version: 1`, and no `index` (or one above the file's highest);- keep bindings two-way (`containerId`/`boundElements`, `startBinding`/`endBinding`), bumping the shapes you bind to;- size text generously: about 0.6 × fontSize per character, 1.25 × fontSize per line;- write the whole file to a temporary file and rename it over the original.See https://localidraw.edspencer.net/agents/