Use this skill whenever the user asks you to draw, diagram, visualise, sketch, or chart something in Excalidraw — whether they name the URL explicitly or just say "draw me a flowchart of X".
Fire this skill when the user's message matches any of these patterns:
| Signal | Example |
|---|---|
| Explicit URL | "draw X in https://excalidraw.ucalyptus.me/" |
| Named tool | "use excalidraw to diagram…" |
| Draw/sketch verb | "draw a flowchart of…", "sketch the architecture for…" |
| Diagram noun | "make a sequence diagram", "create a system diagram" |
| Visualise intent | "show me how X works visually", "map out the relationships between…" |
Do NOT fire for: ASCII art requests, Mermaid/PlantUML requests (unless the user specifically wants Excalidraw), chart data requests (use a charting tool instead).
Endpoint: https://excalidraw.ucalyptus.me/mcp
Transport: Streamable HTTP (JSON-RPC 2.0, POST)
Auth: Authorization: Bearer <token>
The token is a static secret — it does not expire. It lives in Cloudflare Worker secrets
(source of truth) and must be copied to each device once. To rotate it:
echo "new-secret" | npx wrangler secret put MCP_SECRET (no redeploy needed).
Retrieve the token from your password manager, then run:
mkdir -p ~/.claude
cat > ~/.claude/mcp.json << 'EOF'
{
"mcpServers": {
"excalidraw": {
"type": "http",
"url": "https://excalidraw.ucalyptus.me/mcp",
"headers": {
"Authorization": "Bearer <paste-token-here>"
}
}
}
}
EOFAfter that, restart Claude Code. The tools will be available as mcp__excalidraw__* in every
session on that device. You never need to touch this file again unless you rotate the token.
curl -s -X POST https://excalidraw.ucalyptus.me/mcp \
-H "Content-Type: application/json" \
-H "Authorization: Bearer <your-token>" \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' | python3 -m json.toolA 200 response listing the four tools confirms you're connected.
The server is registered in ~/.claude/mcp.json. In a Claude Code session the tools are available
directly as mcp__excalidraw__*. In any other context call the endpoint via HTTP.
| Tool | Purpose |
|---|---|
read_me |
Returns the element format cheat sheet. Call once per session before drawing. |
create_drawing |
Creates a blank drawing. Returns {id, url}. |
get_drawing |
Fetches current elements for an existing drawing by id. |
update_drawing |
Full-replaces the scene. Args: id, elements[], optional appState. |
Follow these steps in order every time. Do not skip steps.
Step 1 Detect intent → this skill applies
Step 2 Call read_me (skip if already called this session)
Step 3 Does the user give you an existing drawing URL/id?
YES → call get_drawing to load current elements, then plan additions/changes
NO → call create_drawing to get a fresh id
Step 4 Plan the layout on paper (mentally) before writing JSON
Step 5 Call update_drawing with the full elements array
Step 6 Return the drawing URL to the user
Always return the URL. The user needs it to open the drawing.
Every element is a JSON object in the elements array passed to update_drawing.
id string Unique within the drawing. Use short descriptive ids: "user-svc", "db", "a1".
x, y number Top-left corner position in canvas units.
strokeColor string Hex colour for the border/line. Default: "#1e1e1e"
opacity number 0–100. Default: 100
{
"type": "rectangle",
"id": "db",
"x": 400, "y": 200,
"width": 160, "height": 60,
"backgroundColor": "#a5d8ff",
"fillStyle": "solid",
"strokeColor": "#1e1e1e",
"strokeWidth": 2,
"roughness": 1,
"roundness": {"type": 3},
"label": {"text": "Postgres", "fontSize": 16}
}| Field | Values | Notes |
|---|---|---|
type |
"rectangle" | "ellipse" | "diamond" |
|
width, height |
number | See sizing guide §5.1 |
backgroundColor |
hex | Omit for transparent |
fillStyle |
"solid" | "hachure" | "cross-hatch" |
"solid" for clean look |
strokeWidth |
1 | 2 | 4 |
2 is default |
roughness |
0 | 1 | 2 |
0 = clean, 1 = sketchy, 2 = very rough |
roundness |
{"type": 3} |
Rounded corners; omit for sharp |
label.text |
string | Text rendered inside the shape |
label.fontSize |
number | 14–20 for node labels |
{"type": "text", "id": "t1", "x": 100, "y": 50, "text": "My heading", "fontSize": 24, "strokeColor": "#1e1e1e"}Use standalone text for titles, section labels, and annotations. For node labels use the label
field on shapes instead.
{
"type": "arrow",
"id": "a-user-api",
"x": 220, "y": 190,
"width": 80, "height": 0,
"points": [[0, 0], [80, 0]],
"strokeColor": "#1e1e1e",
"strokeWidth": 2,
"endArrowhead": "arrow",
"startBinding": {"elementId": "user", "fixedPoint": [1, 0.5]},
"endBinding": {"elementId": "api", "fixedPoint": [0, 0.5]}
}Critical rules for arrows:
x, y= the START point of the arrow in canvas coordinates.points= relative offsets fromx, y. First point is always[0, 0]. Last point is[width, height]— so a rightward arrow of length 80 haspoints: [[0,0],[80,0]].widthandheight= the bounding-box size (can be negative for left/up arrows).endArrowhead:"arrow"(filled triangle) |"bar"|"dot"|null(no head, gives a line).startArrowhead: same options; omit for single-headed arrows.
Binding arrows to shapes (makes them snap to the node):
"startBinding": {"elementId": "<shape-id>", "fixedPoint": [<rx>, <ry>]}
"endBinding": {"elementId": "<shape-id>", "fixedPoint": [<rx>, <ry>]}
fixedPoint is a relative position on the shape's bounding box:
[0, 0.5]= left midpoint (use asendBindingwhen arrow enters from the left)[1, 0.5]= right midpoint (use asstartBindingwhen arrow leaves to the right)[0.5, 0]= top midpoint[0.5, 1]= bottom midpoint
The arrow's x, y must be set to the actual canvas coordinates of the binding point, not the
center of the source shape. Calculate: shape.x + shape.width * fixedPoint[0] for x, etc.
Diagonal arrows: set both width and height to non-zero and adjust points accordingly.
| Element | Width | Height |
|---|---|---|
| Service / component node | 160 | 60 |
| Database / storage node | 140 | 60 |
| User / actor (ellipse) | 80 | 80 |
| Decision diamond | 120 | 80 |
| Section container (rectangle, no fill) | varies | varies |
| Title text | — | fontSize 24 |
| Label text | — | fontSize 14–16 |
- Horizontal gap between nodes: 80–120px (arrow length = gap)
- Vertical gap between rows: 80–100px
- Start x: 60–100 (leave margin from canvas edge)
- Start y: 60–100 (leave room for a title at y=20)
- Use multiples of 20 for all positions — stays on Excalidraw's grid
Row at y=160: [Node A] --arrow--> [Node B] --arrow--> [Node C]
x=60 x=300 x=540
[Root] y=80, x=300 (centred)
|
[Child A] y=220, x=100
[Child B] y=220, x=400
[Hub] at centre (x=340, y=260)
Spokes at: top, bottom, left, right (200px away)
Actors as labelled rectangles across the top at y=60, spaced 200px apart
Vertical lifelines as thin lines below each actor
Numbered arrows connecting lifelines
| Meaning | Fill hex | Use for |
|---|---|---|
| Client / user | #a5d8ff (light blue) |
Browsers, mobile apps, users |
| Service / API | #b2f2bb (light green) |
Backend services, workers |
| Storage | #fff3bf (light yellow) |
Databases, caches, queues |
| External / 3rd party | #ffd8a8 (light orange) |
CDNs, payment providers |
| Infra / runtime | #d0bfff (light purple) |
K8s, Cloudflare, VMs |
| Warning / error | #ffc9c9 (light red) |
Error paths, alerts |
| Neutral container | no fill, strokeColor: "#ced4da" |
Grouping boxes |
Stroke colour is always "#1e1e1e" unless you're colour-coding arrows.
[
{"type":"text","id":"title","x":180,"y":20,"text":"HTTP Request Flow","fontSize":22,"strokeColor":"#1e1e1e"},
{"type":"rectangle","id":"browser","x":60,"y":140,"width":140,"height":60,"backgroundColor":"#a5d8ff","fillStyle":"solid","roundness":{"type":3},"label":{"text":"Browser","fontSize":16}},
{"type":"rectangle","id":"server","x":300,"y":140,"width":140,"height":60,"backgroundColor":"#b2f2bb","fillStyle":"solid","roundness":{"type":3},"label":{"text":"Server","fontSize":16}},
{"type":"rectangle","id":"db","x":540,"y":140,"width":140,"height":60,"backgroundColor":"#fff3bf","fillStyle":"solid","roundness":{"type":3},"label":{"text":"Database","fontSize":16}},
{"type":"arrow","id":"a1","x":200,"y":163,"width":100,"height":0,"points":[[0,0],[100,0]],"endArrowhead":"arrow","strokeColor":"#1e1e1e","startBinding":{"elementId":"browser","fixedPoint":[1,0.5]},"endBinding":{"elementId":"server","fixedPoint":[0,0.5]}},
{"type":"arrow","id":"a2","x":440,"y":163,"width":100,"height":0,"points":[[0,0],[100,0]],"endArrowhead":"arrow","strokeColor":"#1e1e1e","startBinding":{"elementId":"server","fixedPoint":[1,0.5]},"endBinding":{"elementId":"db","fixedPoint":[0,0.5]}}
][
{"type":"text","id":"title","x":220,"y":20,"text":"Login Flow","fontSize":22,"strokeColor":"#1e1e1e"},
{"type":"rectangle","id":"start","x":260,"y":80,"width":120,"height":50,"backgroundColor":"#b2f2bb","fillStyle":"solid","roundness":{"type":3},"label":{"text":"Start","fontSize":16}},
{"type":"diamond","id":"check","x":250,"y":200,"width":140,"height":80,"backgroundColor":"#fff3bf","fillStyle":"solid","label":{"text":"Valid?","fontSize":15}},
{"type":"rectangle","id":"ok","x":440,"y":215,"width":140,"height":50,"backgroundColor":"#b2f2bb","fillStyle":"solid","roundness":{"type":3},"label":{"text":"Dashboard","fontSize":16}},
{"type":"rectangle","id":"fail","x":60,"y":215,"width":140,"height":50,"backgroundColor":"#ffc9c9","fillStyle":"solid","roundness":{"type":3},"label":{"text":"Show Error","fontSize":16}},
{"type":"arrow","id":"a1","x":320,"y":130,"width":0,"height":70,"points":[[0,0],[0,70]],"endArrowhead":"arrow","startBinding":{"elementId":"start","fixedPoint":[0.5,1]},"endBinding":{"elementId":"check","fixedPoint":[0.5,0]}},
{"type":"arrow","id":"a2","x":390,"y":240,"width":50,"height":0,"points":[[0,0],[50,0]],"endArrowhead":"arrow","startBinding":{"elementId":"check","fixedPoint":[1,0.5]},"endBinding":{"elementId":"ok","fixedPoint":[0,0.5]}},
{"type":"text","id":"yes","x":400,"y":224,"text":"yes","fontSize":13,"strokeColor":"#22c55e"},
{"type":"arrow","id":"a3","x":250,"y":240,"width":-50,"height":0,"points":[[0,0],[-50,0]],"endArrowhead":"arrow","startBinding":{"elementId":"check","fixedPoint":[0,0.5]},"endBinding":{"elementId":"fail","fixedPoint":[1,0.5]}},
{"type":"text","id":"no","x":192,"y":224,"text":"no","fontSize":13,"strokeColor":"#ef4444"}
][
{"type":"text","id":"title","x":160,"y":20,"text":"Cloudflare Worker Stack","fontSize":22,"strokeColor":"#1e1e1e"},
{"type":"rectangle","id":"cf-box","x":240,"y":80,"width":420,"height":280,"backgroundColor":"transparent","fillStyle":"solid","strokeColor":"#ced4da","strokeWidth":1,"roundness":{"type":3}},
{"type":"text","id":"cf-label","x":260,"y":90,"text":"Cloudflare Edge","fontSize":14,"strokeColor":"#868e96"},
{"type":"rectangle","id":"worker","x":280,"y":120,"width":140,"height":60,"backgroundColor":"#d0bfff","fillStyle":"solid","roundness":{"type":3},"label":{"text":"Worker","fontSize":16}},
{"type":"rectangle","id":"d1","x":480,"y":120,"width":140,"height":60,"backgroundColor":"#fff3bf","fillStyle":"solid","roundness":{"type":3},"label":{"text":"D1","fontSize":16}},
{"type":"rectangle","id":"r2","x":480,"y":220,"width":140,"height":60,"backgroundColor":"#ffd8a8","fillStyle":"solid","roundness":{"type":3},"label":{"text":"R2","fontSize":16}},
{"type":"rectangle","id":"client","x":60,"y":230,"width":140,"height":60,"backgroundColor":"#a5d8ff","fillStyle":"solid","roundness":{"type":3},"label":{"text":"Browser","fontSize":16}},
{"type":"arrow","id":"a1","x":200,"y":260,"width":80,"height":-110,"points":[[0,0],[80,-110]],"endArrowhead":"arrow","startBinding":{"elementId":"client","fixedPoint":[1,0.5]},"endBinding":{"elementId":"worker","fixedPoint":[0,0.5]}},
{"type":"arrow","id":"a2","x":420,"y":150,"width":60,"height":0,"points":[[0,0],[60,0]],"endArrowhead":"arrow","startBinding":{"elementId":"worker","fixedPoint":[1,0.5]},"endBinding":{"elementId":"d1","fixedPoint":[0,0.5]}},
{"type":"arrow","id":"a3","x":420,"y":160,"width":60,"height":90,"points":[[0,0],[60,90]],"endArrowhead":"arrow","startBinding":{"elementId":"worker","fixedPoint":[1,0.7]},"endBinding":{"elementId":"r2","fixedPoint":[0,0.5]}}
]When the user gives you an existing URL like https://excalidraw.ucalyptus.me/d/abc123:
- Extract the id: everything after
/d/→abc123 - Call
get_drawingwith that id - Parse the returned
elementsarray - Modify or extend the array as needed (add new elements, change properties, remove by omitting)
- Call
update_drawingwith the full new array — it is a full replace, not a patch
Preserve existing element ids when modifying so arrows keep their bindings.
Given two shapes A and B, to draw a rightward arrow from A to B:
arrowX = A.x + A.width (right edge of A)
arrowY = A.y + A.height / 2 (vertical midpoint of A)
arrowWidth = B.x - arrowX (distance to left edge of B)
arrowHeight = 0 (horizontal arrow)
points = [[0,0],[arrowWidth,0]]
startBinding.elementId = A.id
startBinding.fixedPoint = [1, 0.5] (right midpoint of A)
endBinding.elementId = B.id
endBinding.fixedPoint = [0, 0.5] (left midpoint of B)
For a downward arrow from A to B:
arrowX = A.x + A.width / 2
arrowY = A.y + A.height
arrowWidth = 0
arrowHeight = B.y - arrowY
points = [[0,0],[0,arrowHeight]]
startBinding.fixedPoint = [0.5, 1] (bottom midpoint of A)
endBinding.fixedPoint = [0.5, 0] (top midpoint of B)
| Mistake | Fix |
|---|---|
Arrow points don't match width/height |
Last point must equal [width, height] |
fixedPoint backwards |
[1,0.5] = right exit, [0,0.5] = left entry — don't swap |
| Shapes overlap because of wrong x/y | Plan layout on paper first, check spacing with §5.2 |
| Empty label text crashes render | Either omit label or ensure label.text is non-empty |
| Ids with spaces or special chars | Use only a-z, 0-9, -, _ in ids |
| Forgetting to return the URL | Always end with the drawing URL |
Calling read_me every turn |
Call it once per session; do not repeat |
After a successful draw, your reply should be:
Here's the diagram: https://excalidraw.ucalyptus.me/d/<id>
[Optional: 1–2 sentences describing what you drew and any notable layout decisions.]
If the user asked for changes to an existing drawing:
Updated: https://excalidraw.ucalyptus.me/d/<id>
[What changed.]
Keep it short — the diagram speaks for itself.