references/schema-reference.md
# Excalidraw Schema Reference
On-demand depth for the `excalidraw` skill. Read this when you need a copy-paste
starting point or the full catalogue of a property's values. The core generate-time
rules (binding, endpoints, sizing, arrow labels) live in `SKILL.md` — this file is
the long tail, not a replacement.
## Copy-paste element templates
All templates use descriptive string IDs and section-namespaced seeds (100xxx).
Swap colors from the palette in `SKILL.md`.
### File skeleton
```json
{
"type": "excalidraw",
"version": 2,
"source": "claude-code",
"elements": [],
"appState": { "viewBackgroundColor": "#ffffff" }
}
```
### Rectangle component + bound label (the workhorse)
```json
{
"id": "auth_service", "type": "rectangle",
"x": 100, "y": 100, "width": 160, "height": 60, "angle": 0,
"strokeColor": "#1e40af", "backgroundColor": "#dbeafe",
"fillStyle": "solid", "strokeWidth": 2, "roughness": 0, "opacity": 100,
"seed": 100001,
"boundElements": [{ "id": "label_auth", "type": "text" }]
},
{
"id": "label_auth", "type": "text", "text": "Auth Service",
"fontSize": 20, "fontFamily": 2, "textAlign": "center", "verticalAlign": "middle",
"strokeColor": "#1e293b", "seed": 100002,
"containerId": "auth_service"
}
```
### Bound arrow between two shapes
Both shapes must also list this arrow id in their own `boundElements`.
```json
{
"id": "arrow_gw_to_auth", "type": "arrow",
"x": 260, "y": 130, "width": 200, "height": 0, "angle": 0,
"strokeColor": "#475569", "backgroundColor": "transparent",
"fillStyle": "solid", "strokeWidth": 2, "strokeStyle": "solid",
"roughness": 0, "opacity": 100, "seed": 100010,
"points": [[0, 0], [200, 0]],
"startBinding": { "elementId": "api_gateway", "gap": 5, "focus": 0 },
"endBinding": { "elementId": "auth_service", "gap": 5, "focus": 0 }
}
```
### Arrow with a bound label (Yes/No, message names)
Label `width` must fit the **text**, never the arrow — a full-width label masks the whole line.
```json
{
"id": "arrow_valid_yes", "type": "arrow",
"x": 200, "y": 300, "width": 0, "height": 120,
"points": [[0, 0], [0, 120]], "seed": 100020,
"strokeColor": "#475569", "strokeWidth": 2, "roughness": 0, "opacity": 100,
"boundElements": [{ "id": "lbl_yes", "type": "text" }]
},
{
"id": "lbl_yes", "type": "text", "text": "Yes",
"fontSize": 14, "width": 36, "fontFamily": 2,
"strokeColor": "#1e293b", "seed": 100021,
"containerId": "arrow_valid_yes"
}
```
### Start/end ellipse and decision diamond
```json
{ "id": "start", "type": "ellipse", "x": 60, "y": 40, "width": 140, "height": 70,
"strokeColor": "#c2410c", "backgroundColor": "#fed7aa", "fillStyle": "solid",
"strokeWidth": 2, "roughness": 0, "opacity": 100, "seed": 100030,
"boundElements": [{ "id": "lbl_start", "type": "text" }] },
{ "id": "decide", "type": "diamond", "x": 60, "y": 260, "width": 160, "height": 100,
"strokeColor": "#854d0e", "backgroundColor": "#fef9c3", "fillStyle": "solid",
"strokeWidth": 2, "roughness": 0, "opacity": 100, "seed": 100031,
"boundElements": [{ "id": "lbl_decide", "type": "text" }] }
```
### Swimlane zone + free-standing lane label
Zone text is a **separate** top-left text element, never bound to the rectangle.
```json
{ "id": "lane_customer", "type": "rectangle", "x": 40, "y": 40,
"width": 1000, "height": 200, "strokeColor": "#475569",
"backgroundColor": "#f1f5f9", "fillStyle": "solid", "strokeStyle": "dashed",
"strokeWidth": 2, "roughness": 0, "opacity": 30, "seed": 100040,
"boundElements": null },
{ "id": "lbl_lane_customer", "type": "text", "text": "Customer",
"x": 56, "y": 52, "fontSize": 28, "fontFamily": 2,
"strokeColor": "#334155", "seed": 100041, "containerId": null }
```
### Mind-map line connector (not an arrow)
```json
{ "id": "line_center_a", "type": "line",
"x": 500, "y": 300, "width": 180, "height": -120,
"points": [[0, 0], [180, -120]],
"strokeColor": "#475569", "strokeWidth": 2, "roughness": 0,
"opacity": 100, "seed": 100050 }
```
## Full property catalogue
Every value below is verified to render through Kroki and the local CLI.
| Property | Valid values | Notes |
|----------|--------------|-------|
| `fillStyle` | `"solid"`, `"hachure"`, `"cross-hatch"`, `"zigzag"` | `solid` for clean diagrams; the others are sketch textures |
| `strokeStyle` | `"solid"` (or omit), `"dashed"`, `"dotted"` | dashed = response/async, dotted = optional/weak |
| `fontFamily` | `1` (Virgil, hand-drawn), `2` (Helvetica), `3` (Cascadia, code) | `2` for technical diagrams |
| `textAlign` | `"left"`, `"center"`, `"right"` | `center` for contained labels |
| `verticalAlign` | `"top"`, `"middle"`, `"bottom"` | `middle` for contained labels |
| `roughness` | `0` (clean), `1` (hand-drawn), `2` (very sketchy) | default `0` |
| `strokeWidth` | `1` (thin), `2` (standard), `3` (bold) | use width for emphasis, not color noise |
| `roundness` | `null` (sharp), `{ "type": 2 }` (rounded/curved) | `type: 2` on arrows = curved routing |
### Arrowhead catalogue (`startArrowhead` / `endArrowhead`)
| Value | Use for |
|-------|---------|
| `null` | no head (lines, mind-map connectors, `startArrowhead` of a one-way arrow) |
| `"arrow"` | standard directed flow (default `endArrowhead`) |
| `"triangle"` | UML inheritance / generalization |
| `"diamond"` | UML composition |
| `"bar"` | UML aggregation, hard stop |
| `"dot"` / `"circle"` | endpoint markers, BPMN-ish |
| `"crowfoot_many"` | ER "many" cardinality |
Arrows default to `endArrowhead: "arrow"`, `startArrowhead: null` — omit both for a
standard one-way arrow.
## JSON field rules (recap)
- `boundElements`: use `null` when empty, never `[]`.
- `updated`: always `1`, never timestamps.
- Do NOT include: `frameId`, `index`, `versionNonce`, `rawText`.
- Arrow `points` always start at `[0, 0]`; the arrow's `x`/`y` position the first point.
- `seed`: positive integer, unique per element, namespaced by section (100xxx, 200xxx…).
- Unsupported element types: `image` (needs a `files` map + `fileId`), `frame`, `embeddable`.
scripts/excalidraw_lib.py
#!/usr/bin/env python3
"""Fetch and embed Excalidraw community-library items into a .excalidraw scene.
Community libraries (https://libraries.excalidraw.com, repo excalidraw/excalidraw-libraries)
are almost all built from the same vector primitives this skill already exports
(rectangle/ellipse/line/arrow/diamond/text/freedraw), so their items render correctly
through Kroki and the local brute-export CLI. This helper discovers libraries, lists
their items, and merges an item into a scene with namespaced IDs and translated
coordinates so it drops in without collisions.
Usage:
excalidraw_lib.py search <keyword>
List libraries whose name/description/items match <keyword>.
excalidraw_lib.py items <source>
List the items in a library (index, name, element count, image flag).
excalidraw_lib.py merge <scene.excalidraw> <source> <item> <x> <y> [--prefix P] [--scale S]
Add <item> (index or name substring) to the scene at (x, y), in place.
<source> is "author/name.excalidrawlib" (fetched + cached from the official repo)
or a path to a local .excalidrawlib file.
"""
import argparse, copy, json, math, os, sys, urllib.request
BASE = "https://raw.githubusercontent.com/excalidraw/excalidraw-libraries/main"
CACHE = "/tmp/excalidraw-libs"
def fetch(url, dest):
os.makedirs(os.path.dirname(dest), exist_ok=True)
if not os.path.exists(dest):
urllib.request.urlretrieve(url, dest)
return dest
def load_index():
return json.load(open(fetch(f"{BASE}/libraries.json", f"{CACHE}/libraries.json")))
def load_lib(source):
if os.path.exists(source):
return json.load(open(source))
path = fetch(f"{BASE}/libraries/{source}", f"{CACHE}/{source}")
return json.load(open(path))
def items_of(lib):
"""Return list of (name, elements) regardless of library format version."""
raw = lib.get("libraryItems") or lib.get("library") or []
out = []
for i, it in enumerate(raw):
if isinstance(it, list):
out.append((f"item{i}", it))
else:
out.append((it.get("name") or f"item{i}", it.get("elements", [])))
return out
def _elem_bounds(e):
"""True visual bounds of one element — accounts for `points` (lines/arrows)
and `angle` (rotation), both of which a naive x/y/w/h box misses and which
otherwise mis-place rotated or arrow-based library icons."""
x, y, w, h = e.get("x", 0), e.get("y", 0), e.get("width", 0), e.get("height", 0)
pts = e.get("points")
if pts:
xs = [x + p[0] for p in pts]; ys = [y + p[1] for p in pts]
x0, y0, x1, y1 = min(xs), min(ys), max(xs), max(ys)
else:
x0, y0, x1, y1 = x, y, x + w, y + h
a = e.get("angle", 0) or 0
if a:
cx, cy = (x0 + x1) / 2, (y0 + y1) / 2
ca, sa = math.cos(a), math.sin(a)
corners = [(x0, y0), (x1, y0), (x1, y1), (x0, y1)]
rx = [cx + (px - cx) * ca - (py - cy) * sa for px, py in corners]
ry = [cy + (px - cx) * sa + (py - cy) * ca for px, py in corners]
x0, x1, y0, y1 = min(rx), max(rx), min(ry), max(ry)
return x0, y0, x1, y1
def bbox(els):
b = [_elem_bounds(e) for e in els]
return min(p[0] for p in b), min(p[1] for p in b), max(p[2] for p in b), max(p[3] for p in b)
def place(els, tx, ty, prefix, scale):
x0, y0, _, _ = bbox(els)
idmap = {e["id"]: f"{prefix}_{e['id']}" for e in els if "id" in e}
out = []
for e in els:
e = copy.deepcopy(e)
if "id" in e:
e["id"] = idmap[e["id"]]
e["x"] = (e.get("x", 0) - x0) * scale + tx
e["y"] = (e.get("y", 0) - y0) * scale + ty
for k in ("width", "height"):
if k in e:
e[k] *= scale
if "points" in e:
e["points"] = [[px * scale, py * scale] for px, py in e["points"]]
if "fontSize" in e:
e["fontSize"] *= scale
if e.get("containerId") in idmap:
e["containerId"] = idmap[e["containerId"]]
for be in (e.get("boundElements") or []):
if isinstance(be, dict) and be.get("id") in idmap:
be["id"] = idmap[be["id"]]
for b in ("startBinding", "endBinding"):
if e.get(b) and e[b].get("elementId") in idmap:
e[b]["elementId"] = idmap[e[b]["elementId"]]
out.append(e)
return out
def resolve_item(items, sel):
if sel.isdigit():
return items[int(sel)]
for name, els in items:
if sel.lower() in name.lower():
return name, els
sys.exit(f"No item matching '{sel}'. Run: excalidraw_lib.py items <source>")
def cmd_search(args):
hits = 0
for lib in load_index():
names = " ".join(lib.get("itemNames", []))
hay = f"{lib['name']} {lib.get('description','')} {names}".lower()
if args.keyword.lower() in hay:
hits += 1
print(f"{lib['source']}\n {lib['name']} — {len(lib.get('itemNames', []))} items")
if not hits:
print(f"No libraries match '{args.keyword}'. Browse: https://libraries.excalidraw.com")
def cmd_items(args):
for i, (name, els) in enumerate(items_of(load_lib(args.source))):
img = " [HAS IMAGE — won't render via export]" if any(e.get("type") == "image" for e in els) else ""
print(f"{i:3} {name} ({len(els)} els){img}")
def cmd_merge(args):
name, els = resolve_item(items_of(load_lib(args.source)), args.item)
if any(e.get("type") == "image" for e in els):
sys.exit(f"Item '{name}' contains an image element — skip it (won't render via Kroki/CLI).")
scene = json.load(open(args.scene))
prefix = args.prefix or f"lib{len(scene['elements'])}"
scene["elements"].extend(place(els, args.x, args.y, prefix, args.scale))
json.dump(scene, open(args.scene, "w"))
print(f"Merged '{name}' ({len(els)} els) at ({args.x},{args.y}) into {args.scene}")
def main():
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
sub = ap.add_subparsers(dest="cmd", required=True)
s = sub.add_parser("search"); s.add_argument("keyword"); s.set_defaults(fn=cmd_search)
s = sub.add_parser("items"); s.add_argument("source"); s.set_defaults(fn=cmd_items)
s = sub.add_parser("merge")
s.add_argument("scene"); s.add_argument("source"); s.add_argument("item")
s.add_argument("x", type=float); s.add_argument("y", type=float)
s.add_argument("--prefix"); s.add_argument("--scale", type=float, default=1.0)
s.set_defaults(fn=cmd_merge)
args = ap.parse_args()
args.fn(args)
if __name__ == "__main__":
main()
SKILL.md
---
disable-model-invocation: true
name: excalidraw
description: Use when user requests diagrams, flowcharts, architecture charts, or visualizations. Also use proactively when explaining systems with 3+ components, complex data flows, or relationships that benefit from visual representation. Generates .excalidraw files and exports to PNG/SVG via Kroki API or locally using excalidraw-brute-export-cli.
homepage: https://github.com/Agents365-ai/365-skills
metadata: {"version":"1.3.0","openclaw":{"requires":{"bins":["curl"]},"emoji":"🎨"}}
---
# Excalidraw Diagrams
## Overview
Generate `.excalidraw` JSON files and export to PNG/SVG.
**Two export options:**
- **Kroki API** (`curl`) — zero install, SVG output only
- **excalidraw-brute-export-cli** — local Firefox-based, PNG + SVG
**Supported formats:** PNG (local CLI only), SVG (both options). PDF is NOT supported.
## When to Use
**Explicit triggers:** user says "画图", "diagram", "visualize", "flowchart", "draw", "架构图", "流程图"
**Proactive triggers:**
- Explaining a system with 3+ interacting components
- Describing a multi-step process or decision tree
- Comparing architectures or approaches side by side
**Skip when:** a simple list or table suffices, or user is in a quick Q&A flow
**When NOT to use it — route elsewhere:**
- Polished, precise diagrams, strict UML, or branded vendor icons → **drawio**.
- Diagrams-as-code in git, auto-laid-out from text → **mermaid** (general) or **plantuml** (UML).
- An infinite-canvas whiteboard or programmatic freehand strokes → **tldraw**.
## Prerequisites
### Option A: Kroki API (recommended — zero install, SVG only)
```bash
# Just needs curl (pre-installed on macOS/Linux/Windows Git Bash)
curl --version
```
No additional setup. SVG rendered via `https://kroki.io`.
### Option B: Local CLI (required for PNG)
The CLI uses **Firefox** (not Chromium). Check and install:
```bash
npm install -g excalidraw-brute-export-cli
npx playwright install firefox
```
**macOS patch (one-time, required):**
```bash
CLI_MAIN=$(npm root -g)/excalidraw-brute-export-cli/src/main.js
sed -i '' 's/keyboard.press("Control+O")/keyboard.press("Meta+O")/' "$CLI_MAIN"
sed -i '' 's/keyboard.press("Control+Shift+E")/keyboard.press("Meta+Shift+E")/' "$CLI_MAIN"
```
**Windows/Linux:** No patch needed.
## Workflow
1. **Check deps** — use Kroki (curl) for SVG; use local CLI for PNG
2. **Plan** — pick the visual metaphor (see **Relationship-to-layout map**), then the diagram type and color palette
3. **Generate** — write `.excalidraw` JSON file (section-by-section for large diagrams)
4. **Export** — run Kroki or CLI command
5. **Verify the render** — view the exported PNG, fix any defects, re-export (see **Verify the Render**)
6. **Review loop** — show the image to the user, apply the minimal `.excalidraw` edit per request, re-export until approved (see **Review Loop**)
7. **Report** — tell user the output file path
## Design Principles
### Default style
- `roughness: 0` — clean, modern look for all technical diagrams (use `1` only when user requests hand-drawn/casual style)
- `fontFamily: 2` (Helvetica) — professional look; use `1` (Virgil) only for casual/sketch style, `3` (Cascadia) for code snippets
- `fillStyle: "solid"` — default fill
### Containers: prefer typography over boxes
A box around every label makes a diagram look like a wireframe. The cleanest Excalidraw diagrams use **free-floating text and lines** for structure and reserve filled boxes for things that are genuinely *components*.
- **Default to no container** — use a standalone `text` element unless the box earns its place.
- **Add a box only when** the element is a real system component, an arrow binds to it, the shape itself carries meaning (decision diamond, start/end ellipse), or it groups a zone.
- Aim for **under ~30% of text elements inside boxes.**
- For timelines, trees, and hierarchies, use a **line/connector + free-floating labels**, not a stack of rectangles. Size, weight, and color create hierarchy without boxes.
### Font size hierarchy
| Level | Size | Use for |
| ------- | ------ | --------- |
| Title | 28px | Diagram title |
| Header | 24px | Section/group headers |
| Label | 20px | Primary element labels |
| Description | 16px | Secondary text, descriptions |
| Note | 14px | Annotations, fine print |
### Color palette
Follow the **60-30-10 rule**: 60% whitespace/neutral, 30% primary accent, 10% highlight.
**Semantic fill colors** (use with `strokeColor` one shade darker):
| Category | Fill | Stroke | Use for |
| ---------- | ------ | -------- | --------- |
| Primary / Input | `#dbeafe` | `#1e40af` | Entry points, APIs, user-facing |
| Success / Data | `#dcfce7` | `#166534` | Data stores, success states |
| Warning / Decision | `#fef9c3` | `#854d0e` | Decision points, conditions |
| Error / Critical | `#fee2e2` | `#991b1b` | Errors, alerts, critical paths |
| External / Storage | `#f3e8ff` | `#6b21a8` | External services, databases, AI/ML |
| Process / Default | `#e0f2fe` | `#0369a1` | Standard process steps |
| Trigger / Start | `#fed7aa` | `#c2410c` | Start nodes, triggers, events |
| Neutral / Container | `#f1f5f9` | `#475569` | Groups, swimlanes, backgrounds |
**Text colors:**
| Level | Color |
|-------|-------|
| Title | `#1e293b` |
| Label | `#334155` |
| Description | `#64748b` |
**Rule: Do not invent new colors.** Pick from this palette.
### Arrow semantics
| Style | Meaning |
| ------- | --------- |
| Solid (`strokeStyle: "solid"`) | Primary flow, main path |
| Dashed (`"dashed"`) | Response, async, callback |
| Dotted (`"dotted"`) | Optional, reference, weak dependency |
## Excalidraw JSON Structure
### File skeleton
```json
{
"type": "excalidraw",
"version": 2,
"source": "claude-code",
"elements": [],
"appState": { "viewBackgroundColor": "#ffffff" }
}
```
### Element types
| type | use for |
|-----------|----------------------------------|
| rectangle | boxes, components, modules |
| ellipse | start/end nodes, databases |
| diamond | decision points |
| arrow | directed connections |
| line | undirected connections |
| text | standalone labels |
`image`, `frame`, and `embeddable` are **not covered** by this skill: `image` needs a separate `files` map plus a `fileId`, and frames/embeds render inconsistently through the export path. Stick to the six types above — and for ready-made icons built *from* these primitives, see **Community shape & icon libraries** below.
### Community shape & icon libraries
Need a real AWS / Azure / GCP / network / UML / BPMN icon instead of a plain box? The [Excalidraw community libraries](https://libraries.excalidraw.com) (200+ `.excalidrawlib` files) are built almost entirely from the **same vector primitives** above — so their items **render through Kroki and the local CLI** with no `image` element and no `files` map. Use the helper in `scripts/excalidraw_lib.py`:
```bash
# 1. Find a library (matches name / description / item names)
python scripts/excalidraw_lib.py search aws
# 2. List its items (index, name, element count; flags any image-based item)
python scripts/excalidraw_lib.py items slobodan/aws-serverless.excalidrawlib
# 3. Build your base scene first, then drop an item in at (x, y). IDs are
# namespaced and coordinates translated, so it merges without collisions:
python scripts/excalidraw_lib.py merge scene.excalidraw \
slobodan/aws-serverless.excalidrawlib 0 455 257 --scale 0.9 --prefix lambda
```
**Rules:**
- **Vector only.** `merge` refuses any item containing an `image` element (won't render via the export path); `items` flags them up front.
- **Use sparingly.** An icon is just a labeled node — keep the design system's spacing, labels, and arrow semantics. Icons accent a diagram; they don't replace it.
- **Arrows don't bind to library groups** — draw connectors with explicit edge-to-edge `points` (bindings don't affect the static export anyway).
- Libraries are MIT-licensed; a courtesy credit is welcome, not required.
- Still run **Verify the Render** afterward — icon bounding boxes vary, so check alignment and spacing.
### Element sizing
Calculate element width from label text to prevent truncation:
```
Latin text: width = max(160, charCount * 9)
CJK text: width = max(160, charCount * 18)
Mixed text: estimate each character individually, sum up
```
Height: use `60` for single-line labels, add `24` per additional line.
**Standalone `text` does NOT auto-wrap.** For multi-line standalone labels, insert manual `\n` line breaks yourself — aim for ≤ ~30 Latin (≤ ~15 CJK) characters per line at 16px — and add `24` height per line. (Text *bound inside a shape* via `containerId` wraps to the container width automatically, so size the container instead of adding `\n`.)
### Required properties (all elements)
```json
{
"id": "auth_service",
"type": "rectangle",
"x": 100, "y": 100,
"width": 160, "height": 60,
"angle": 0,
"strokeColor": "#1e40af",
"backgroundColor": "#dbeafe",
"fillStyle": "solid",
"strokeWidth": 2,
"roughness": 0,
"opacity": 100,
"seed": 100001,
"boundElements": [
{ "id": "arrow_to_db", "type": "arrow" },
{ "id": "label_auth", "type": "text" }
]
}
```
Use **descriptive string IDs** (e.g., `"api_gateway"`, `"arrow_gw_to_auth"`) instead of random strings.
Give each element a unique `seed` (integer). Namespace by section: 100xxx, 200xxx, 300xxx.
### JSON field rules
- `boundElements`: use `null` when empty, never `[]`
- `updated`: always use `1`, never timestamps
- Do NOT include: `frameId`, `index`, `versionNonce`, `rawText`
- `points` in arrows: always start at `[0, 0]`
- `seed`: must be a positive integer, unique per element
### Property values
Use only these values — all verified to render via Kroki and the local CLI:
| Property | Valid values |
| ---------- | -------------- |
| `fillStyle` | `"solid"`, `"hachure"`, `"cross-hatch"`, `"zigzag"` |
| `strokeStyle` | `"solid"` (or omit), `"dashed"`, `"dotted"` |
| `fontFamily` | `1` (Virgil, hand-drawn), `2` (Helvetica), `3` (Cascadia, code) |
| `textAlign` | `"left"`, `"center"`, `"right"` |
| `verticalAlign` | `"top"`, `"middle"`, `"bottom"` |
| `startArrowhead` / `endArrowhead` | `null`, `"arrow"`, `"triangle"`, `"bar"`, `"dot"`, `"circle"`, `"diamond"`, `"crowfoot_many"` |
Arrows default to `endArrowhead: "arrow"` and `startArrowhead: null` — omit both for a standard one-way arrow. Use `"triangle"` for UML inheritance, `"diamond"` for composition, and `"crowfoot_many"` for ER cardinality.
> **Need copy-paste templates or the full property/arrowhead catalogue?** Read `references/schema-reference.md` — complete element templates (component+label, bound arrow, arrow label, swimlane zone, mind-map connector) and every verified property value.
### Text inside shapes (contained text)
When text belongs inside a shape, bind them bidirectionally:
```json
{
"id": "label_auth",
"type": "text",
"text": "Auth Service",
"fontSize": 20,
"fontFamily": 2,
"textAlign": "center",
"verticalAlign": "middle",
"strokeColor": "#1e293b",
"containerId": "auth_service"
}
```
**CRITICAL: Text `strokeColor` is the text color.** Always set it explicitly to a dark color from the text color palette. Never omit it — omitting `strokeColor` on text can cause invisible text that blends with the shape background.
The parent shape must list the text in its `boundElements`:
```json
"boundElements": [{ "id": "label_auth", "type": "text" }]
```
### Arrow binding (bidirectional)
Arrows must bind to shapes, and shapes must reference bound arrows:
```json
{
"id": "arrow_gw_to_auth",
"type": "arrow",
"points": [[0, 0], [200, 0]],
"startBinding": { "elementId": "api_gateway", "gap": 5, "focus": 0 },
"endBinding": { "elementId": "auth_service", "gap": 5, "focus": 0 }
}
```
Both `api_gateway` and `auth_service` must include in their `boundElements`:
```json
"boundElements": [{ "id": "arrow_gw_to_auth", "type": "arrow" }]
```
**Endpoints must reach the shape borders.** `startBinding`/`endBinding` (and their `gap`) only affect interactive editing on excalidraw.com — they do **NOT** clip the line when exporting via Kroki or the local CLI. The exporter draws your `points` literally. So compute endpoints edge-to-edge: set the arrow's `x`/`y` to the source shape's border (the side facing the target) and the last point to the target's border. Center-to-center points draw the line straight *through* both shapes.
### Arrow labels
To label an arrow, bind a `text` element to it exactly like shape text: set the label's `containerId` to the **arrow's** id, and add the label to the arrow's `boundElements`. Excalidraw then centers the label on the arrow and masks the line behind the text, so it stays readable (no strike-through).
```json
{
"id": "arrow_valid_to_grant",
"type": "arrow",
"points": [[0, 0], [0, 120]],
"boundElements": [{ "id": "lbl_yes", "type": "text" }]
}
```
```json
{
"id": "lbl_yes",
"type": "text",
"text": "Yes",
"fontSize": 14,
"width": 36,
"strokeColor": "#1e293b",
"containerId": "arrow_valid_to_grant"
}
```
**CRITICAL: the label `width` must fit the text (`charCount * 9`), NOT the arrow length.** Excalidraw masks the line behind the label's full bounding box — a label as wide as the arrow masks the *entire* arrow, so the line disappears and only floating text remains. Keep label widths small.
### Arrow routing
**L-shaped (elbow) arrows** — orthogonal routing with 3+ points:
```json
"points": [[0, 0], [100, 0], [100, 150]]
```
**Elbowed arrows** — automatic right-angle routing:
```json
{
"type": "arrow",
"points": [[0, 0], [0, -50], [200, -50], [200, 0]],
"elbowed": true
}
```
**Curved arrows** — smooth routing with waypoints:
```json
{
"type": "arrow",
"points": [[0, 0], [50, -40], [200, 0]],
"roundness": { "type": 2 }
}
```
### Grouping
Related elements share `groupIds`. Nested groups list IDs innermost-first:
```json
"groupIds": ["inner_group", "outer_group"]
```
## Diagram Patterns
Choose the right visual pattern for each diagram type.
### Relationship-to-layout map
Before locking in a *diagram type*, pick the *visual metaphor* that matches the relationship in the idea — it drives the layout more than the type label does:
| Relationship in the idea | Visual metaphor | Build with |
| --- | --- | --- |
| One → many (broadcast, dispatch) | **Fan-out** | one node, arrows radiating outward |
| Many → one (aggregate, merge) | **Convergence** | several inputs, arrows into one node |
| Parent → children (hierarchy) | **Tree** | trunk + branch *lines*, free-floating text |
| Repeating cycle (loop, feedback) | **Cycle** | nodes in a ring, curved arrows back to start |
| Input → transform → output | **Assembly line** | left-to-right pipeline of steps |
| A vs B (comparison) | **Side-by-side** | two parallel columns on a shared baseline |
| Before / after, phase break | **Gap** | whitespace or a dashed divider between groups |
| Fuzzy / overlapping state | **Cloud** | overlapping ellipses, no hard borders |
### Spacing Reference
| Scenario | Spacing |
| ---------- | --------- |
| Labeled arrow gap (between shapes) | 150–200px |
| Unlabeled arrow gap | 100–120px |
| Column spacing (labeled arrows) | 400px (220px box + 180px gap) |
| Column spacing (unlabeled arrows) | 340px (220px box + 120px gap) |
| Row spacing | 280–350px (150px box + 130–200px gap) |
| Zone/container padding | 50–60px around children |
| Zone/container opacity | 25–40 |
| Minimum gap between any elements | 40px |
### Flowchart (LR or TB)
- Ellipse for start/end, diamond for decisions, rectangle for process
- 200px horizontal spacing, 150px vertical spacing
- Decision branches: "Yes" goes forward, "No" goes down
- 3–10 steps (max 15)
### Architecture / System Diagram
- Column spacing per table above; use labeled arrow spacing when connections have labels
- Group related services in dashed `Neutral` containers (opacity: 30, padding: 50px)
- Gateway/entry at left or top, databases at right or bottom
- 3–8 entities (max 12)
### Sequence Diagram
- 200px between participants (rectangles at top)
- Vertical lifelines as dashed lines
- Horizontal arrows for messages, 60px vertical spacing
- Solid arrow = request, dashed arrow = response
### Mind Map
- Central node: largest (200x100), `Trigger` color
- Level 1: 150x70, `Primary` color, radial around center
- Level 2: 120x50, `Process` color
- Level 3: 90x40, `Neutral` color
- Use lines (not arrows) for connections
- 4–6 branches (max 8), 2–4 sub-topics per branch
- **Place level-1 branches on a circle** of radius `R ≈ 280` around the center `(cx, cy)`: for branch `i` of `n`, `angle = 2π·i/n`, `x = cx + R·cos(angle)`, `y = cy + R·sin(angle)`. Even spacing prevents the crossed-line tangle that ad-hoc placement produces.
### Swimlane
- Large transparent rectangles (`Neutral` fill, `"dashed"` stroke, opacity: 30) as lane boundaries
- Lane label as free-standing text at top-left of lane (not bound to rectangle), 28px font
- Elements flow left-to-right within lanes
- Arrows cross lanes for handoffs
## Section-by-Section Construction
For diagrams with **10+ elements**, do NOT generate the entire JSON at once. Build in sections:
1. **Plan all sections first** — list element IDs, positions, and cross-section bindings
2. **Write section 1** — create the file with initial elements
3. **Append section 2** — read the file, add new elements to the `elements` array
4. **Repeat** — continue until all sections are done
5. **Final pass** — verify all `boundElements` and `startBinding`/`endBinding` references are consistent
Namespace element seeds by section (100xxx, 200xxx, 300xxx) to avoid collisions.
## Export
### Option A: Kroki API (SVG only — zero install)
```bash
# SVG via Kroki API
curl -s -X POST https://kroki.io/excalidraw/svg \
-H "Content-Type: text/plain" \
--data-binary "@diagram.excalidraw" \
-o diagram.svg
# Via local Kroki Docker (offline)
curl -s -X POST http://localhost:8000/excalidraw/svg \
-H "Content-Type: text/plain" \
--data-binary "@diagram.excalidraw" \
-o diagram.svg
```
### Option B: Local CLI (PNG + SVG)
```bash
# PNG at 2x scale, with background baked in (recommended)
excalidraw-brute-export-cli -i diagram.excalidraw -o diagram.png -f png -s 2 -b true
# PNG at 1x scale
excalidraw-brute-export-cli -i diagram.excalidraw -o diagram.png -f png -s 1 -b true
# SVG
excalidraw-brute-export-cli -i diagram.excalidraw -o diagram.svg -f svg -s 1 -b true
```
**Required flags:** `-f` (format: `png` or `svg`) and `-s` (scale: `1`, `2`, or `3`).
**Optional flags:** `-b true` bakes the `viewBackgroundColor` into the image — **the export is transparent by default**, so omit `-b` (or pass `-b false`) only when you want a transparent background. `-d true` exports dark mode; `-e true` embeds the scene so the PNG/SVG reopens as an editable drawing in excalidraw.com. (Long forms also work: `--background`, `--dark-mode`, `--embed-scene`, `--format`, `--scale`, `--input`, `--output`.)
## Verify the Render
**You cannot judge a diagram from its JSON.** The JSON can look perfect while the image has clipped text, overlapping boxes, or an arrow slicing through a shape. After exporting, *look at the result and fix it* — this is the single highest-leverage step.
1. **Render to PNG** (the image must be viewable — PNG, not SVG, even if the user ultimately wants SVG):
```bash
excalidraw-brute-export-cli -i diagram.excalidraw -o /tmp/check.png -f png -s 2 -b true
```
View `/tmp/check.png` (Claude can read PNGs directly). *Visual audit needs the local CLI; with Kroki-only (SVG), fall back to the structural checks below.*
2. **Audit the image:**
| Look for | Fix |
| ---------- | ----- |
| Text clipped / overflowing its shape | Widen the shape (`max(160, charCount * 9)`, ×2 for CJK) or pre-wrap with `\n` |
| Boxes or labels overlapping | Re-space using the Spacing Reference (≥40px gap) |
| Arrow cutting straight through a shape | Move endpoints to the shape borders, not centers |
| Arrow invisible — only its label shows | Shrink the label `width` to fit the text |
| Element off-canvas or floating with no connection | Reposition / connect it |
| **Isomorphism Test:** mentally delete all text — does the structure alone still convey the idea? | If not, the *layout* is wrong, not the labels — restructure |
3. **Fix the JSON and re-export.** Repeat until clean — typically 1–3 passes. Skip only for trivial 2–3 element diagrams.
## Review Loop
Verify-the-render fixes *defects*; the review loop incorporates *the user's* wishes. After the render is clean, show it and collect feedback, then apply the **minimal `.excalidraw` edit** for each request and re-export:
| User request | Edit action |
| --- | --- |
| Change a label | Edit the `text` (or the bound label element) |
| Change a color | Update `backgroundColor` / `strokeColor` on the element |
| Add / remove an element | Append or delete the element (fix any `boundElements` / binding refs) |
| Move / resize | Update `x` / `y` / `width` / `height` |
| Restructure / re-route | Re-apply the pattern's spacing & routing rules, or regenerate |
- Overwrite the same `diagram.excalidraw` / output file each round — don't create `v1`, `v2`, …
- Re-run **Verify the Render** after each edit (a change can introduce a new clip / overlap).
- **Safety valve:** after 5 rounds, suggest the user fine-tune in [excalidraw.com](https://excalidraw.com) — the output preserves arrow binding, so it opens fully editable.
## Anti-Patterns
**Never put `text` on large background/zone rectangles.** Excalidraw centers text in the middle of the shape, overlapping contained elements. Instead, use a free-standing `text` element positioned at the top of the zone.
**Avoid cross-zone arrows.** Long diagonal arrows create visual spaghetti. Route arrows within zones or along zone edges. If a cross-zone connection is unavoidable, route it along the perimeter.
**Use arrow labels sparingly.** Bind labels to the arrow (see **Arrow labels**) so the line is masked behind the text instead of striking through it — but keep the label `width` to the text, never the arrow length. Keep labels to ≤12 characters and ensure ≥120px clear space between connected shapes. Omit labels when the connection meaning is obvious from context.
**Don't use filled backgrounds on containers that hold other elements.** Use `opacity: 30` (or 25-40 range) for zone/container rectangles so contained elements remain visible.
**Always set explicit `strokeColor` on text elements.** Text `strokeColor` is the rendered text color. If omitted, text may inherit the parent shape's background color and become invisible. Use `#1e293b` (title), `#334155` (label), or `#64748b` (description) from the text color palette.
## Common Mistakes
| Mistake | Fix |
| --------- | ----- |
| Kroki returns HTTP 400 | Send `-H "Content-Type: text/plain"` (NOT `application/json`, which Kroki reads as a `{"diagram_source": ...}` wrapper and rejects); ensure valid JSON with `"type": "excalidraw"` and `"elements"` array |
| Kroki only outputs SVG | Use local CLI (`excalidraw-brute-export-cli`) for PNG |
| Export fails with "Missing required flag" | Always pass `-f png` and `-s 2` |
| Export fails with "Executable doesn't exist" | Run `npx playwright install firefox` |
| macOS: timeout waiting for file chooser | Apply the macOS Meta patch above |
| Arrow `points` not relative to origin | `points` always start at `[0,0]` |
| Missing `id` on elements | Use descriptive string IDs per element |
| Overlapping elements | Use spacing reference table; minimum 40px gap |
| Arrows not interactive in excalidraw.com | Add `boundElements` to shapes referencing all bound arrows/text |
| Arrow/line cuts straight through the shapes | Compute endpoints at the shape borders, not centers — bindings don't clip the static export |
| Arrow invisible — only its label shows | Bound label `width` spans the whole arrow and masks the line; set label `width` to fit the text (`charCount * 9`) |
| Exported PNG/SVG has no background | CLI export is transparent by default; pass `-b true` to bake in `viewBackgroundColor` |
| Text not centered in shape | Set `containerId` on text AND add text to shape's `boundElements` |
| All text same size | Use font size hierarchy: 28 → 24 → 20 → 16 → 14 |
| Diagram looks monotone | Apply semantic colors from the palette, follow 60-30-10 rule |
| Text invisible / same color as background | Always set `strokeColor` on text elements to a dark color (`#1e293b`, `#334155`, or `#64748b`) |
| Text overlaps inside zone/container | Don't bind text to zone rectangles; use free-standing text at top |
| Text truncated in shapes | Use width formula: `max(160, charCount * 9)`, double for CJK |
| `boundElements: []` causes issues | Use `null` for empty boundElements, never `[]` |