Whiteboard¶
Playwright-driven live diagram manipulation on excalidraw.com. Draw, annotate, save, and restore diagrams using a Mermaid-compatible DSL.
Short alias: wb
TL;DR¶
- Call
whiteboard.open()first, thenwhiteboard.draw(...). - Use
whiteboard.note(...)andwhiteboard.embed_dsl()for documentation overlays. - Auto-arrange with
whiteboard.layout()(ELK.js); fine-tune withwhiteboard.align(ids=[...], axis=...). - Persist with
whiteboard.save(file=...)/whiteboard.load(file=...). - Export visuals with
whiteboard.screenshot(...); recover withwhiteboard.hard_reset()when state is broken.
Requires the Playwright MCP server. Enable it in servers.yaml (persistent):
Or enable for the current session only:
Quick Start¶
whiteboard.open()
whiteboard.draw(input='a["API"]; b["DB"]; a-->b')
whiteboard.style(ids=["b"], style="bc:blue,sc:#1d4ed8")
whiteboard.screenshot()
whiteboard.save(file="diagrams/arch.excalidraw")
API Summary (Generated)¶
Source of truth: src/otdev/tools/excalidraw.py (__all__ + function docstrings).
| Function | Summary |
|---|---|
whiteboard.align(*, ids: list[str], axis: str) -> str |
Align or distribute a set of shapes using Excalidraw's built-in actions. |
whiteboard.clear() -> str |
Clear all elements from canvas and reset Python DSL state. |
whiteboard.close() -> str |
Close the excalidraw tab and reset all Python state. |
whiteboard.draw(*, input: str) -> str |
Add or update diagram elements from DSL. Always additive — never clears. |
whiteboard.embed_dsl() -> str |
Embed the current DSL as a note element on the canvas. |
whiteboard.erase(*, ids: list[str]) -> str |
Remove individual elements from the canvas and Python state. |
whiteboard.fit() -> str |
Fit all elements in view. |
whiteboard.hard_reset() -> str |
Reset Python DSL state unconditionally; attempt canvas clear if browser is available. |
whiteboard.help() -> str |
Return the full DSL and style reference. Call this before using whiteboard.draw or whiteboard.style. |
whiteboard.layout(*, direction: str = 'DOWN', gap_layer: int = 80, gap_node: int = 40, algorithm: str = 'layered', node_placement: str = 'NETWORK_SIMPLEX', crossing_min: str = 'LAYER_SWEEP', cycle_breaking: str = 'GREEDY', arrow_type: str | None = None, elk_options: dict[str, str] | None = None) -> str |
Apply ELK.js graph layout to the current whiteboard. |
whiteboard.load(*, file: str) -> str |
Restore diagram from a native .excalidraw file. |
whiteboard.note(*, input: str, background: str = '#f5f5dc') -> str |
Insert ASCII-rendered text annotations onto the canvas. |
whiteboard.open() -> str |
Open excalidraw.com and start with a clean canvas. |
whiteboard.read_scene(*, info: str = 'default') -> str |
Return a structured text summary of all canvas elements. |
whiteboard.save(*, file: str) -> str |
Save current diagram to a native .excalidraw JSON file. |
whiteboard.screenshot(*, file: str | None = None) -> Any |
Take a screenshot of the current canvas as PNG. |
whiteboard.scroll(*, dx: int = 0, dy: int = 0) -> str |
Pan the canvas by (dx, dy) pixels. |
whiteboard.share() -> str |
Generate a shareable Excalidraw link for the current canvas. |
whiteboard.style(*, ids: list[str], style: str) -> str |
Apply visual style properties to existing canvas elements in bulk. |
whiteboard.sync() -> str |
Sync Python DSL state from the __otDSL canvas element. |
whiteboard.zoom(*, level: float) -> str |
Set zoom level. Pass 0 to fit all elements in view. |
Configuration¶
Required¶
- No required
tools.whiteboardsettings.
Optional¶
- This pack does not define any pack-specific keys under
tools.whiteboard.
Defaults¶
- OneTool uses the built-in defaults for whiteboard layout, DSL state, and save/load behavior.
- Runtime access still depends on the
playwrightMCP server being enabled.
Tools¶
open()¶
Open excalidraw.com and start with a clean canvas. Resets all Python state.
draw(input)¶
Add or update diagram elements from DSL. Additive — never clears existing elements.
- New shapes get auto-layout positions.
- Existing shapes (by ID) are patched — label and inline style props are updated, position and size are preserved.
- Edges are deduplicated by
(src, dst, label, startArrowhead, endArrowhead).
whiteboard.draw(input='a["Service A"]; b["DB"]; a-->b')
# Returns: "+2 shapes, +1 edge(s): edge-a-b"
Inline style props can be appended after the closing ]:
style(ids, style)¶
Apply visual style properties to existing canvas elements. Never changes position or size unless x, y, w, h are given.
whiteboard.style(ids=["a", "b"], style="bc:green,sc:#16a34a")
whiteboard.style(ids=["c"], style="shape:d") # change to diamond
# Returns: "styled 2 element(s)"
Style shorthand reference (all keys and values are case-insensitive):
| Key | Excalidraw property | Notes |
|---|---|---|
bc |
backgroundColor | hex (requires #: bc:#ff0000) or named colour (bc:green) |
sc |
strokeColor | hex (requires #: sc:#1d4ed8) or named colour (sc:blue) |
sw |
strokeWidth | number |
ss |
strokeStyle | solid, dashed, dotted |
r |
roughness | 0-2 |
o |
opacity | 0-100 |
f |
fontFamily | hand, normal, mono, excalidraw |
fs |
fontSize | number |
ta |
textAlign | left, center, right |
va |
verticalAlign | top, middle, bottom |
shape |
element type | r=rect, d=diamond, c=circle |
x/y |
position | pixels |
w/h |
width/height | pixels |
New shapes are auto-sized from their label content: width scales with the longest line, height with the number of lines (minimum 160×60 px). Use w/h to override the auto-computed size: a["Label"] w:300,h:80.
Named colours: green, blue, red, purple, yellow, orange, pink, gray, white, black.
Colour format: Hex colours require the
#prefix (bc:#ff0000). Named colours do not (bc:green). All values are case-insensitive (bc:Greenandbc:greenare equivalent).
help()¶
Return the full DSL syntax and style shorthand reference as plain text. No browser required. Call this before using whiteboard.draw() or whiteboard.style() for the first time.
note(input, background)¶
Insert ASCII-rendered text annotations below any existing diagram content.
erase(ids)¶
Remove elements by ID. Dangling edges (whose src or dst is erased) are removed automatically.
Edge IDs use the format edge-{src}-{dst} (plus optional label and arrowhead suffix). The draw() return value lists newly created edge IDs.
whiteboard.erase(ids=["a", "edge-a-b"])
# Returns: "erased 2 element(s)"
whiteboard.erase(ids=["b"]) # b has edges a-->b and b-->c
# Returns: "erased 1 element(s), 2 dangling edge(s) removed"
align(ids, axis)¶
Align or distribute a set of shapes using Excalidraw's built-in alignment actions.
whiteboard.align(ids=["a", "b", "c"], axis="top") # snap top edges
whiteboard.align(ids=["a", "b", "c"], axis="hcenter") # centre horizontally
whiteboard.align(ids=["a", "b", "c"], axis="hdistribute") # even horizontal spacing
# Returns: "aligned 3 element(s) (top)"
axis |
Effect |
|---|---|
left |
Snap left edges |
hcenter |
Centre on vertical axis |
right |
Snap right edges |
top |
Snap top edges |
vcenter |
Centre on horizontal axis |
bottom |
Snap bottom edges |
hdistribute |
Even horizontal spacing |
vdistribute |
Even vertical spacing |
layout(...)¶
Apply ELK.js graph auto-layout to the canvas. Loads ELK.js from CDN (once per session), runs the chosen algorithm in the browser, patches every node position, recomputes subgraph bounding boxes, and calls wb.fit(). Works on the full canvas or a selection.
whiteboard.layout() # layered, top-to-bottom
whiteboard.layout(direction="RIGHT", gap_layer=120) # left-to-right pipeline
whiteboard.layout(algorithm="stress") # spring-based, undirected
whiteboard.layout(algorithm="mrtree", direction="DOWN") # tree with clear root
whiteboard.layout(direction="RIGHT", arrow_type="elbow") # post-layout elbow arrows
| Parameter | Default | Description |
|---|---|---|
algorithm |
"layered" |
layered, stress, mrtree, radial, force |
direction |
"DOWN" |
DOWN, RIGHT, UP, LEFT (layered only) |
gap_layer |
80 |
Pixels between layers (layered only) |
gap_node |
40 |
Pixels between nodes in the same layer |
node_placement |
"NETWORK_SIMPLEX" |
BRANDES_KOEPF, LINEAR_SEGMENTS, SIMPLE (layered only) |
crossing_min |
"LAYER_SWEEP" |
MEDIAN_LAYER_SWEEP, NONE (layered only) |
cycle_breaking |
"GREEDY" |
DEPTH_FIRST, MODEL_ORDER (layered only) |
arrow_type |
None |
After layout, patch all arrows to "curve", "sharp", or "elbow" |
elk_options |
None |
dict[str, str] of raw ELK key→value pairs; merged last, overrides all named params |
Algorithms:
| Algorithm | Best for |
|---|---|
layered |
DAGs and pipelines — ranks nodes into layers, minimises edge crossings |
stress |
Undirected or exploratory graphs — spring-based, increase gap_node for dense graphs |
mrtree |
Trees with a clear single root — minimal-spanning-tree layout |
radial |
Radial tree centred on one node |
force |
Clustered undirected graphs — force-directed |
read_scene(info)¶
Return a structured text summary of all canvas elements. Use to verify draw(), style(), and erase() results without a screenshot.
whiteboard.read_scene() # default detail
whiteboard.read_scene(info="min") # one-line count only
whiteboard.read_scene(info="full") # all style properties
info |
Output |
|---|---|
"min" |
Scene: N shapes, M edges — count only |
"default" |
Per-element: id, type, label, bc, sc, text-sc, groupIds; edges: arrowheads, stroke style |
"full" |
All of default + sw, ss, roughness, opacity, fillStyle, corners, font, textAlign, position, size |
"debug" |
All of full + deleted elements, __otDSL, deleted: flag, arrow points:, bound text with containerId |
A ⚠ TEXT=BG warning appears when a shape's text colour matches its background (invisible label).
sync()¶
Sync Python DSL state from the __otDSL canvas element. Use this after loading a file directly in the Excalidraw UI or drag-and-dropping an .excalidraw file.
save(file)¶
Save the diagram to a native .excalidraw file (JSON). Also embeds the current DSL as a __otDSL text element on the canvas for state restoration.
load(file)¶
Restore a diagram saved by save(). Reads the native .excalidraw JSON and restores Python state from the embedded __otDSL element.
If the file has no
__otDSLelement (e.g. created outsidewhiteboard.save()), the canvas is still restored but Python state will be empty and the return includes a warning. Callwhiteboard.sync()after manually adding a DSL element.
share()¶
Generate a shareable Excalidraw link. Encrypts the full scene client-side (AES-GCM, 128-bit) and uploads to Excalidraw's storage. Returns a URL anyone can open in a browser.
embed_dsl()¶
Insert the current DSL as a grey code-font box on the canvas. Idempotent. Excluded from save() snapshots.
clear()¶
Clear all elements from the canvas and reset Python state.
screenshot(file)¶
Take a PNG screenshot of the current canvas.
whiteboard.screenshot() # return inline image
whiteboard.screenshot(file="diagrams/canvas.png") # save to disk
scroll(dx, dy)¶
Pan the canvas.
zoom(level)¶
Set zoom level. Pass 0 to fit all elements in view.
fit()¶
Fit all elements in view. Equivalent to whiteboard.zoom(level=0).
close()¶
Close the excalidraw tab and reset all Python state.
hard_reset()¶
Reset Python state unconditionally; attempt canvas clear if browser is available. Use to recover from broken Playwright state.
Draw DSL¶
The draw() input uses a Mermaid-compatible syntax. Statements can be separated by semicolons (preferred) or newlines.
Shapes¶
Only rectangles are supported:
id["Label"] rectangle
id["Label"] bc:blue rectangle with inline style props
id["Line1\nLine2"] multiline label
id bc:blue style-only update (label unchanged)
Note: Ellipse
((...))and diamond{...}syntax raise aValueError. Usewhiteboard.style(ids=[...], style="shape:c")orshape:dto change shape after drawing.
Edges¶
a-->b directed arrow
a-->|label|b directed arrow with label
a---b undirected (no arrowheads)
a<-->b bidirectional arrows
a --o b dot/circle arrowhead at end
a --x b bar/cross arrowhead at end
a-.->b dashed directed arrow
a-.->|label|b dashed directed arrow with label
a-.-b dashed undirected
Subgraphs¶
Draws a bounding rectangle around the listed members.
Headers (ignored)¶
Mermaid direction headers are accepted and silently skipped.
Comments¶
Note DSL¶
The note() input uses tagged blocks:
One or more blocks per call. Each becomes a code-font rectangle placed below the diagram.
table — CSV grid¶
First row is the header. Columns separated by commas; rows by newlines or semicolons.
Output:
+----------------+------+------+
| Task | In | Out |
+================+======+======+
| compare:base | 33 | 273 |
+----------------+------+------+
| compare:mcp | 3261 | 92 |
+----------------+------+------+
tree — directory / hierarchy¶
Depth indicated by leading -, ., _, or spaces. One character = one level (for spaces, the smallest non-zero indent is the unit).
Output:
Space-indented trees also work:
seq — sequence diagram¶
One message per line: Actor -> Actor: label. Label is optional.
whiteboard.note(input="""
s[seq:
Client -> Server: request
Server -> DB: query
DB -> Server: rows
Server -> Client:
]
""")
Output:
+--------+ +--------+ +----+
| Client | | Server | | DB |
+--------+ +--------+ +----+
| | |
|---request----->| |
| |---query--->|
| |<--rows-----|
|<---------------| |
+--------+ +--------+ +----+
| Client | | Server | | DB |
+--------+ +--------+ +----+
timeline — Gantt bar chart¶
One task per line: name,start,duration (integers, 1-indexed).
Output:
note — plain text¶
Word-wrapped paragraph text (default wrap at 60 chars).
whiteboard.note(input="""
n[note:
This is a plain text annotation that will be
word-wrapped and displayed in a code-font box.
]
""")
Examples¶
Architecture diagram with styling¶
whiteboard.open()
whiteboard.draw(input='api["API Gateway"]; auth["Auth Service"]; users["Users DB"]; api-->auth; auth-->users')
whiteboard.style(ids=["api", "auth"], style="bc:#dae8fc,sc:#6c8ebf")
whiteboard.style(ids=["users"], style="bc:#d5e8d4,sc:#82b366")
whiteboard.note(input="""
t[table:
Service,Latency,Owner
API Gateway,12ms,Platform
Auth Service,8ms,Security
]
""")
whiteboard.fit()
whiteboard.screenshot()
Save and restore¶
whiteboard.save(file="diagrams/arch.excalidraw")
# ... later ...
whiteboard.open()
whiteboard.load(file="diagrams/arch.excalidraw")
Incremental drawing¶
whiteboard.draw(input='a["Start"]; b["Process"]')
whiteboard.draw(input='c["End"]; b-->c') # additive, positions relative to existing
Upsert — update label or style without moving¶
whiteboard.draw(input='a["API"]; b["DB"]')
whiteboard.draw(input='a["API Gateway"] bc:blue') # updates label and colour; position preserved