Generate .excalidraw JSON files and export to PNG/SVG.
Two export options:
curl) — zero install, SVG output onlySupported formats: PNG (local CLI only), SVG (both options). PDF is NOT supported.
Explicit triggers: user says "画图", "diagram", "visualize", "flowchart", "draw", "架构图", "流程图"
Proactive triggers:
Skip when: a simple list or table suffices, or user is in a quick Q&A flow
bash
# Just needs curl (pre-installed on macOS/Linux/Windows Git Bash)
curl --version
No additional setup. SVG rendered via https://kroki.io.
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.
.excalidraw JSON file (section-by-section for large diagrams)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 snippetsfillStyle: "solid" — default fillA 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.
text element unless the box earns its place.LevelSizeUse forTitle28pxDiagram titleHeader24pxSection/group headersLabel20pxPrimary element labelsDescription16pxSecondary text, descriptionsNote14pxAnnotations, fine print
Follow the 60-30-10 rule: 60% whitespace/neutral, 30% primary accent, 10% highlight.
Semantic fill colors (use with strokeColor one shade darker):
CategoryFillStrokeUse forPrimary / Input#dbeafe``#1e40afEntry points, APIs, user-facingSuccess / Data#dcfce7``#166534Data stores, success statesWarning / Decision#fef9c3``#854d0eDecision points, conditionsError / Critical#fee2e2``#991b1bErrors, alerts, critical pathsExternal / Storage#f3e8ff``#6b21a8External services, databases, AI/MLProcess / Default#e0f2fe``#0369a1Standard process stepsTrigger / Start#fed7aa``#c2410cStart nodes, triggers, eventsNeutral / Container#f1f5f9``#475569Groups, swimlanes, backgrounds
Text colors:
LevelColorTitle#1e293bLabel#334155Description#64748b
Rule: Do not invent new colors. Pick from this palette.
StyleMeaningSolid ( strokeStyle: "solid")Primary flow, main pathDashed ( "dashed")Response, async, callbackDotted ( "dotted")Optional, reference, weak dependency
json
{
"type": "excalidraw",
"version": 2,
"source": "claude-code",
"elements": [],
"appState": { "viewBackgroundColor": "#ffffff" }
}
typeuse forrectangleboxes, components, modulesellipsestart/end nodes, databasesdiamonddecision pointsarrowdirected connectionslineundirected connectionstextstandalone 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.
Calculate element width from label text to prevent truncation:
text
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.)
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.
boundElements: use null when empty, never []updated: always use 1, never timestampsframeId, index, versionNonce, rawTextpoints in arrows: always start at [0, 0]seed: must be a positive integer, unique per elementUse only these values — all verified to render via Kroki and the local CLI:
PropertyValid valuesfillStyle``"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.
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" }]
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.
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.
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 }
}
Related elements share groupIds. Nested groups list IDs innermost-first:
json
"groupIds": ["inner_group", "outer_group"]
Choose the right visual pattern for each diagram type.
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 ideaVisual metaphorBuild withOne → many (broadcast, dispatch)Fan-outone node, arrows radiating outwardMany → one (aggregate, merge)Convergenceseveral inputs, arrows into one nodeParent → children (hierarchy)Treetrunk + branch lines, free-floating textRepeating cycle (loop, feedback)Cyclenodes in a ring, curved arrows back to startInput → transform → outputAssembly lineleft-to-right pipeline of stepsA vs B (comparison)Side-by-sidetwo parallel columns on a shared baselineBefore / after, phase breakGapwhitespace or a dashed divider between groupsFuzzy / overlapping stateCloudoverlapping ellipses, no hard borders
ScenarioSpacingLabeled arrow gap (between shapes)150–200pxUnlabeled arrow gap100–120pxColumn spacing (labeled arrows)400px (220px box + 180px gap)Column spacing (unlabeled arrows)340px (220px box + 120px gap)Row spacing280–350px (150px box + 130–200px gap)Zone/container padding50–60px around childrenZone/container opacity25–40Minimum gap between any elements40px
Neutral containers (opacity: 30, padding: 50px)Trigger colorPrimary color, radial around centerProcess colorNeutral colorR ≈ 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.Neutral fill, "dashed" stroke, opacity: 30) as lane boundariesFor diagrams with 10+ elements, do NOT generate the entire JSON at once. Build in sections:
elements arrayboundElements and startBinding/ endBinding references are consistentNamespace element seeds by section (100xxx, 200xxx, 300xxx) to avoid collisions.
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
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.)
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.
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.
Audit the image:
Look forFixText clipped / overflowing its shapeWiden the shape ( max(160, charCount * 9), ×2 for CJK) or pre-wrap with \nBoxes or labels overlappingRe-space using the Spacing Reference (≥40px gap)Arrow cutting straight through a shapeMove endpoints to the shape borders, not centersArrow invisible — only its label showsShrink the label width to fit the textElement off-canvas or floating with no connectionReposition / connect itIsomorphism Test: mentally delete all text — does the structure alone still convey the idea?If not, the layout is wrong, not the labels — restructure
Fix the JSON and re-export. Repeat until clean — typically 1–3 passes. Skip only for trivial 2–3 element diagrams.
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.
MistakeFixKroki returns HTTP 400Send -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" arrayKroki only outputs SVGUse local CLI ( excalidraw-brute-export-cli) for PNGExport fails with "Missing required flag"Always pass -f png and -s 2Export fails with "Executable doesn't exist"Run npx playwright install firefoxmacOS: timeout waiting for file chooserApply the macOS Meta patch aboveArrow points not relative to originpoints always start at [0,0]Missing id on elementsUse descriptive string IDs per elementOverlapping elementsUse spacing reference table; minimum 40px gapArrows not interactive in excalidraw.comAdd boundElements to shapes referencing all bound arrows/textArrow/line cuts straight through the shapesCompute endpoints at the shape borders, not centers — bindings don't clip the static exportArrow invisible — only its label showsBound label width spans the whole arrow and masks the line; set label width to fit the text ( charCount * 9)Exported PNG/SVG has no backgroundCLI export is transparent by default; pass -b true to bake in viewBackgroundColorText not centered in shapeSet containerId on text AND add text to shape's boundElementsAll text same sizeUse font size hierarchy: 28 → 24 → 20 → 16 → 14Diagram looks monotoneApply semantic colors from the palette, follow 60-30-10 ruleText invisible / same color as backgroundAlways set strokeColor on text elements to a dark color ( #1e293b, #334155, or #64748b)Text overlaps inside zone/containerDon't bind text to zone rectangles; use free-standing text at topText truncated in shapesUse width formula: max(160, charCount * 9), double for CJKboundElements: [] causes issuesUse null for empty boundElements, never []