@clawhub-scottgl9-461dd2bd2c
Read, search, send, trash, move, and label Gmail via IMAP. Requires GMAIL_IMAP_USER (Gmail address) and GMAIL_IMAP_PASSWORD (Google App Password) environment...
---
name: gmail-imap
description: "Read, search, send, trash, move, and label Gmail via IMAP. Requires GMAIL_IMAP_USER (Gmail address) and GMAIL_IMAP_PASSWORD (Google App Password) environment variables — no API key needed. Use when an agent needs Gmail access: reading inbox, searching messages, sending email, or deleting messages."
metadata:
openclaw:
requires:
env:
- GMAIL_IMAP_USER
- GMAIL_IMAP_PASSWORD
primaryEnv: GMAIL_IMAP_PASSWORD
homepage: https://clawhub.ai/skills/gmail-imap
---
# Gmail IMAP Skill
All Gmail access uses IMAP. Credentials are read from environment variables — never printed.
## Requirements
Set these environment variables before use:
| Variable | Description |
|----------|-------------|
| `GMAIL_IMAP_USER` | Your Gmail address (e.g. [email protected]) |
| `GMAIL_IMAP_PASSWORD` | A Google App Password (not your account password) |
Generate an App Password at: https://myaccount.google.com/apppasswords (requires 2FA enabled)
## Quick Reference
```bash
# Locate the script (adjust SKILL_DIR to match your install location):
# Default: ~/.openclaw/skills/gmail-imap
# Custom: <workspace>/skills/gmail-imap
SCRIPT="$SKILL_DIR/scripts/gmail_imap.py"
# List inbox (most recent 20)
"$SCRIPT" list
# List with custom limit
"$SCRIPT" list --limit 50
# List unread only (shorthand)
"$SCRIPT" list --unread
# List unread with limit
"$SCRIPT" list --unread --limit 10
# Search by sender (IMAP header search)
"$SCRIPT" list --search 'FROM "[email protected]"'
# Full-text search (Gmail X-GM-RAW — searches body + headers)
"$SCRIPT" search "invoice"
"$SCRIPT" search "from:[email protected] is:unread" --limit 5
"$SCRIPT" search "subject:meeting this week"
# Read a message (by UID shown in list output)
"$SCRIPT" read <uid>
# Read from a specific folder
"$SCRIPT" read <uid> --folder "[Gmail]/All Mail"
# Delete (moves to [Gmail]/Trash — correct Gmail deletion)
"$SCRIPT" trash <uid>
# Move to a label/folder
"$SCRIPT" move <uid> "Work"
# Send email
"$SCRIPT" send --to [email protected] --subject "Hello" --body "Message text"
```
## Deletion Rule (Critical)
Never use the standard IMAP `\Deleted` flag on Gmail — it only archives, it does not delete.
Always use `trash <uid>` which moves to `[Gmail]/Trash`. The script handles this correctly.
## Credentials
Set in env (never output raw password):
- `GMAIL_IMAP_USER` — Gmail address
- `GMAIL_IMAP_PASSWORD` — App password (generate at myaccount.google.com/apppasswords)
## Reference
For folder names, search syntax, direct Python IMAP usage, and connection details:
→ See [references/gmail-imap-reference.md](references/gmail-imap-reference.md)
FILE:README.md
# Gmail IMAP
OpenClaw skill for reading, searching, sending, and managing Gmail via IMAP.
## Author
Scott Glover <[email protected]>
ClawHub: [@scottgl9](https://clawhub.ai/scottgl9)
## Requirements
Set these environment variables before use:
| Variable | Description |
|----------|-------------|
| `GMAIL_IMAP_USER` | Your Gmail address |
| `GMAIL_IMAP_PASSWORD` | A Google App Password (not your account password) |
Generate an App Password at: https://myaccount.google.com/apppasswords (requires 2FA)
## Usage
```bash
SCRIPT="$SKILL_DIR/scripts/gmail_imap.py"
python3 "$SCRIPT" list # List inbox (20 most recent)
python3 "$SCRIPT" list --search UNSEEN # Unread only
python3 "$SCRIPT" read <uid> # Read a message
python3 "$SCRIPT" trash <uid> # Delete (move to Trash)
python3 "$SCRIPT" move <uid> "Label" # Move to folder/label
python3 "$SCRIPT" send --to [email protected] --subject "Hi" --body "Hello"
```
## Notes
- Uses IMAP/SMTP directly — no Google API key required
- Deletion uses `[Gmail]/Trash` (correct Gmail behavior, not IMAP archive)
- See `references/gmail-imap-reference.md` for full search syntax and folder names
## License
MIT-0 — free to use, modify, and redistribute without attribution.
FILE:references/gmail-imap-reference.md
# Gmail IMAP Reference
## Credentials
Set these env vars (never print the password):
```
[email protected]
GMAIL_IMAP_PASSWORD=<app-password>
```
Generate an app password at: https://myaccount.google.com/apppasswords
(Requires 2FA enabled on the Google account.)
## Connection Details
| Setting | Value |
|---------|-------|
| IMAP server | imap.gmail.com |
| IMAP port | 993 (SSL) |
| SMTP server | smtp.gmail.com |
| SMTP port | 587 (STARTTLS) |
## Gmail Folder Names
Gmail uses labels, exposed as IMAP folders. Standard names:
| Purpose | IMAP Folder Name |
|---------|-----------------|
| Inbox | `INBOX` |
| Sent | `[Gmail]/Sent Mail` |
| Drafts | `[Gmail]/Drafts` |
| Spam | `[Gmail]/Spam` |
| **Trash** | `[Gmail]/Trash` |
| All Mail | `[Gmail]/All Mail` |
| Starred | `[Gmail]/Starred` |
Custom labels appear as top-level folder names.
## Deletion Rules (Critical)
Standard IMAP `\Deleted` + EXPUNGE **does not delete** in Gmail — it only removes the Inbox
label and archives to All Mail.
**Correct deletion procedure:**
1. Use `MOVE` (RFC 6851) or `COPY` to `[Gmail]/Trash`
2. If `MOVE` unsupported: `COPY` to Trash + `STORE \Deleted` + `EXPUNGE`
3. Gmail auto-purges Trash after 30 days
The `gmail_imap.py` script handles this automatically in `cmd_trash()`.
## IMAP Search Criteria (Common)
```
ALL # all messages
UNSEEN # unread
SEEN # read
FROM "[email protected]" # from address
SUBJECT "keyword" # subject contains
SINCE 01-Mar-2026 # after date (DD-Mon-YYYY)
BEFORE 10-Mar-2026 # before date
BODY "text" # body contains
```
Combine with parentheses: `(FROM "boss" UNSEEN)`
## Direct IMAP via Python (without the helper script)
```python
import imaplib, os
mail = imaplib.IMAP4_SSL("imap.gmail.com", 993)
mail.login(os.environ["GMAIL_IMAP_USER"], os.environ["GMAIL_IMAP_PASSWORD"])
mail.select("INBOX", readonly=True)
# Search
_, data = mail.search(None, "UNSEEN")
uids = data[0].split()
# Fetch headers
_, msg_data = mail.fetch(uids[-1], "(BODY.PEEK[HEADER.FIELDS (FROM DATE SUBJECT)])")
# Move to trash (correct deletion)
mail.uid("MOVE", uid, "[Gmail]/Trash")
mail.logout()
```
FILE:scripts/gmail_imap.py
#!/usr/bin/env python3
"""
gmail_imap.py — Gmail IMAP helper for OpenClaw agents.
Credentials from env:
GMAIL_IMAP_USER e.g. [email protected]
GMAIL_IMAP_PASSWORD App password (never print)
Commands:
list [--folder INBOX] [--limit 20] [--search CRITERION] [--unread]
read <uid> [--folder INBOX]
search <query> [--limit 20] [--folder all] (full-text Gmail search)
trash <uid> [--folder INBOX]
move <uid> <destination_folder>
label <uid> <label>
send --to ADDR --subject SUBJ --body TEXT [--html]
IMAP search examples (--search flag):
UNSEEN unread messages
FROM "[email protected]" from address
SUBJECT "keyword" subject contains
SINCE 01-Apr-2026 after date (DD-Mon-YYYY)
BEFORE 10-Apr-2026 before date
TEXT "keyword" body or header contains (header-only on some servers)
Full-text search (search subcommand uses Gmail X-GM-RAW):
invoice any message containing "invoice"
from:[email protected] Gmail search syntax
is:unread subject:meeting Gmail search syntax
"""
import imaplib
import email
import os
import sys
import argparse
import smtplib
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
from email.header import decode_header
SERVER = "imap.gmail.com"
PORT = 993
SMTP_SERVER = "smtp.gmail.com"
SMTP_PORT = 587
TRASH = "[Gmail]/Trash"
ALL_MAIL = "[Gmail]/All Mail"
def connect():
user = os.environ.get("GMAIL_IMAP_USER")
password = os.environ.get("GMAIL_IMAP_PASSWORD")
if not user or not password:
sys.exit("ERROR: GMAIL_IMAP_USER and GMAIL_IMAP_PASSWORD must be set.")
mail = imaplib.IMAP4_SSL(SERVER, PORT)
mail.login(user, password)
return mail, user, password
def decode_str(s):
if s is None:
return ""
parts = decode_header(s)
result = []
for part, enc in parts:
if isinstance(part, bytes):
result.append(part.decode(enc or "utf-8", errors="replace"))
else:
result.append(part)
return "".join(result)
def imap_quote_folder(folder):
"""Quote folder name for IMAP if it contains spaces or special chars."""
if ' ' in folder or any(c in folder for c in ['[', ']', '(', ')']):
# Already quoted?
if folder.startswith('"') and folder.endswith('"'):
return folder
return f'"{folder}"'
return folder
def print_message_list(mail, uids, limit, use_uid=False):
"""Print a formatted table of messages given a list of UIDs."""
uids = uids[-limit:]
if not uids:
print("No messages found.")
return
print(f"{'UID':<8} {'FROM':<30} {'DATE':<20} SUBJECT")
print("-" * 90)
for uid in reversed(uids):
try:
if use_uid:
uid_str = uid.decode() if isinstance(uid, bytes) else str(uid)
_, msg_data = mail.uid("FETCH", uid_str, "(BODY.PEEK[HEADER.FIELDS (FROM DATE SUBJECT)])")
else:
_, msg_data = mail.fetch(uid, "(BODY.PEEK[HEADER.FIELDS (FROM DATE SUBJECT)])")
if not msg_data or msg_data[0] is None:
continue
raw = msg_data[0][1] if isinstance(msg_data[0], tuple) else msg_data[0]
msg = email.message_from_bytes(raw)
frm = decode_str(msg.get("From", ""))[:28]
date = decode_str(msg.get("Date", ""))[:18]
subj = decode_str(msg.get("Subject", ""))[:50]
uid_display = uid.decode() if isinstance(uid, bytes) else str(uid)
print(f"{uid_display:<8} {frm:<30} {date:<20} {subj}")
except Exception as e:
uid_display = uid.decode() if isinstance(uid, bytes) else str(uid)
print(f"{uid_display:<8} (fetch error: {e})")
def cmd_list(args):
mail, _, _ = connect()
folder = getattr(args, "folder", "INBOX")
limit = int(getattr(args, "limit", 20))
search = getattr(args, "search", "ALL") or "ALL"
# --unread shorthand
if getattr(args, "unread", False):
search = "UNSEEN"
mail.select(imap_quote_folder(folder), readonly=True)
_, data = mail.search(None, search)
uids = data[0].split()
print_message_list(mail, uids, limit)
mail.logout()
def cmd_search(args):
"""Full-text search using Gmail's X-GM-RAW IMAP extension."""
mail, _, _ = connect()
query = args.query
limit = int(getattr(args, "limit", 20))
folder = getattr(args, "folder", ALL_MAIL)
mail.select(imap_quote_folder(folder), readonly=True)
# Try Gmail X-GM-RAW first (supports full Gmail search syntax including body)
try:
_, data = mail.uid("SEARCH", None, f'X-GM-RAW "{query}"')
uids = data[0].split() if data and data[0] else []
except Exception:
# Fall back to standard IMAP TEXT search (headers + body, slower)
_, data = mail.search(None, f'TEXT "{query}"')
uids = data[0].split() if data and data[0] else []
print(f"Search: {query!r} ({len(uids)} results, showing {min(limit, len(uids))})")
print()
print_message_list(mail, uids, limit, use_uid=True)
mail.logout()
def cmd_read(args):
mail, _, _ = connect()
folder = getattr(args, "folder", "INBOX")
mail.select(imap_quote_folder(folder), readonly=True)
uid = args.uid.encode()
_, msg_data = mail.fetch(uid, "(RFC822)")
if not msg_data or msg_data[0] is None:
print(f"ERROR: UID {args.uid} not found in {folder}.")
mail.logout()
sys.exit(1)
msg = email.message_from_bytes(msg_data[0][1])
print(f"From: {decode_str(msg.get('From'))}")
print(f"To: {decode_str(msg.get('To'))}")
print(f"Date: {decode_str(msg.get('Date'))}")
print(f"Subject: {decode_str(msg.get('Subject'))}")
print("-" * 60)
if msg.is_multipart():
for part in msg.walk():
ct = part.get_content_type()
disp = str(part.get("Content-Disposition", ""))
if ct == "text/plain" and "attachment" not in disp:
body = part.get_payload(decode=True).decode(
part.get_content_charset() or "utf-8", errors="replace"
)
print(body)
break
else:
body = msg.get_payload(decode=True).decode(
msg.get_content_charset() or "utf-8", errors="replace"
)
print(body)
mail.logout()
def cmd_trash(args):
"""Move message to [Gmail]/Trash — the correct way to delete in Gmail."""
mail, _, _ = connect()
folder = getattr(args, "folder", "INBOX")
mail.select(folder)
uid = args.uid.encode()
result = mail.uid("MOVE", uid, TRASH)
if result[0] != "OK":
mail.uid("COPY", uid, TRASH)
mail.uid("STORE", uid, "+FLAGS", "\\Deleted")
mail.expunge()
print(f"Moved UID {args.uid} to {TRASH}.")
mail.logout()
def cmd_move(args):
mail, _, _ = connect()
mail.select("INBOX")
uid = args.uid.encode()
dest = args.destination
result = mail.uid("MOVE", uid, dest)
if result[0] != "OK":
mail.uid("COPY", uid, dest)
mail.uid("STORE", uid, "+FLAGS", "\\Deleted")
mail.expunge()
print(f"Moved UID {args.uid} to {dest}.")
mail.logout()
def cmd_label(args):
"""Apply a Gmail label to a message by copying to the label folder."""
mail, _, _ = connect()
mail.select("INBOX")
uid = args.uid.encode()
label_name = args.label
typ, _ = mail.list('""', label_name)
if typ != "OK":
mail.create(label_name)
result = mail.uid("COPY", uid, label_name)
if result[0] == "OK":
print(f"Applied label '{label_name}' to UID {args.uid}.")
else:
print(f"Warning: Could not apply label '{label_name}' to UID {args.uid}: {result[1]}")
mail.logout()
def cmd_send(args):
_, user, password = connect()
msg = MIMEMultipart("alternative") if args.html else MIMEText(args.body, "plain")
if args.html:
msg.attach(MIMEText(args.body, "plain"))
msg.attach(MIMEText(args.body, "html"))
msg["From"] = user
msg["To"] = args.to
msg["Subject"] = args.subject
with smtplib.SMTP(SMTP_SERVER, SMTP_PORT) as smtp:
smtp.starttls()
smtp.login(user, password)
smtp.sendmail(user, [args.to], msg.as_string())
print(f"Sent to {args.to}: {args.subject}")
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="Gmail IMAP helper")
sub = parser.add_subparsers(dest="command")
p_list = sub.add_parser("list", help="List messages")
p_list.add_argument("--folder", default="INBOX")
p_list.add_argument("--limit", type=int, default=20, help="Max messages to show (default: 20)")
p_list.add_argument("--search", default="ALL", help='IMAP search criterion e.g. UNSEEN, FROM "[email protected]"')
p_list.add_argument("--unread", action="store_true", help="Shorthand for --search UNSEEN")
p_search = sub.add_parser("search", help="Full-text search via Gmail X-GM-RAW")
p_search.add_argument("query", help='Gmail search query e.g. "invoice" or "from:boss is:unread"')
p_search.add_argument("--limit", type=int, default=20, help="Max results to show (default: 20)")
p_search.add_argument("--folder", default=ALL_MAIL, help=f"Folder to search (default: {ALL_MAIL})")
p_read = sub.add_parser("read", help="Read a message by UID")
p_read.add_argument("uid")
p_read.add_argument("--folder", default="INBOX", help="Folder containing the message")
p_trash = sub.add_parser("trash", help="Move message to Trash")
p_trash.add_argument("uid")
p_trash.add_argument("--folder", default="INBOX", help="Folder containing the message")
p_move = sub.add_parser("move", help="Move message to a folder/label")
p_move.add_argument("uid")
p_move.add_argument("destination")
p_label = sub.add_parser("label", help="Apply a Gmail label")
p_label.add_argument("uid")
p_label.add_argument("label")
p_send = sub.add_parser("send", help="Send an email")
p_send.add_argument("--to", required=True)
p_send.add_argument("--subject", required=True)
p_send.add_argument("--body", required=True)
p_send.add_argument("--html", action="store_true")
args = parser.parse_args()
dispatch = {
"list": cmd_list,
"search": cmd_search,
"read": cmd_read,
"trash": cmd_trash,
"move": cmd_move,
"label": cmd_label,
"send": cmd_send,
}
if args.command not in dispatch:
parser.print_help()
sys.exit(1)
dispatch[args.command](args)
Create professional Excalidraw diagrams — flowcharts, architecture diagrams, workflows, systems, processes, or concepts. Use when user asks to "create a diag...
---
name: excalidraw-diagram
description: Create professional Excalidraw diagrams — flowcharts, architecture diagrams, workflows, systems, processes, or concepts. Use when user asks to "create a diagram", "draw a flowchart", "visualize a process", "make a flow diagram", "architecture diagram", "excalidraw", "technical diagram", or discusses workflow/process visualization. Supports quick DSL-based flowcharts and comprehensive hand-crafted JSON diagrams. Built-in PNG rendering and PDF export.
metadata:
openclaw:
requires:
bins:
- python3
- uv
- node
- npm
homepage: https://clawhub.ai/skills/excalidraw-render
---
# Excalidraw Diagram Creator
Generate `.excalidraw` files — from quick flowcharts to comprehensive technical diagrams.
## ⚠️ Golden Rule
**Every diagram MUST be rendered to PNG and visually inspected before delivery.** Look at the actual image — check that text fits inside boxes, no elements overlap, arrows connect correctly, and spacing is balanced. Fix the JSON and re-render until it looks professional. See the **Render & Validate** section. No exceptions.
---
## Depth Gate (Do This First)
| Need | Approach | Time |
|------|----------|------|
| Simple flowchart, decision tree, linear process | **Quick Path** — CLI DSL | ~1 min |
| Architecture, multi-zoom technical, evidence artifacts | **Full Path** — hand-crafted JSON | ~10 min |
---
## Quick Path: CLI DSL Flowcharts
For straightforward flows, use `@swiftlysingh/excalidraw-cli` (installed locally by `setup.sh`):
```bash
excalidraw-cli create --inline "DSL" -o output.excalidraw
```
> **Note:** If `excalidraw-cli` is not in your PATH after setup, use:
> `"$SKILL_DIR/.npm/node_modules/.bin/excalidraw-cli"` or re-run `setup.sh`.
### DSL Syntax
| Syntax | Shape | Use For |
|--------|-------|---------|
| `[Label]` | Rectangle | Process steps |
| `{Label?}` | Diamond | Decisions |
| `(Label)` | Ellipse | Start/End |
| `[[Label]]` | Database | Data storage |
| `->` | Arrow | Connection |
| `-> "text" ->` | Labeled arrow | Conditional |
| `-->` | Dashed arrow | Optional path |
Directives: `@direction LR|TB|RL|BT`, `@spacing 60`
### DSL Example
```bash
excalidraw-cli create --inline "$(cat <<'EOF'
@direction TB
(Start) -> [Receive Request] -> {Authenticated?}
{Authenticated?} -> "yes" -> [Process Request] -> (Success)
{Authenticated?} -> "no" -> [Return 401] -> (End)
EOF
)" -o auth-flow.excalidraw
```
CLI options: `-d LR` (direction), `-s 80` (spacing), `--format dot` (DOT/Graphviz input).
After generation, **always render and validate** (see Render section below). Fix overlaps or clipping in the JSON if needed.
---
## Full Path: Hand-Crafted JSON Diagrams
For comprehensive, professional diagrams. Read these references as needed:
- **`references/color-palette.md`** — All colors (read FIRST, every time)
- **`references/element-templates.md`** — Copy-paste JSON for each element type
- **`references/json-schema.md`** — Full property reference
- **`references/layout-rules.md`** — Anti-overlap spacing and text-sizing rules ⚠️ READ THIS
### Design Process
1. **Assess depth** — simple/conceptual vs. comprehensive/technical
2. **Research** (technical diagrams) — look up real specs, event names, API formats
3. **Map concepts to visual patterns** — see Pattern Library below
4. **Sketch mentally** — trace how the eye moves through the diagram
5. **Generate JSON section-by-section** — see Large Diagram Strategy
6. **Render & validate** — MANDATORY loop (see below)
### JSON Structure
```json
{
"type": "excalidraw",
"version": 2,
"source": "https://excalidraw.com",
"elements": [...],
"appState": { "viewBackgroundColor": "#ffffff", "gridSize": 20 },
"files": {}
}
```
### Core Rules
- `fontFamily: 3`, `roughness: 0`, `opacity: 100` on all elements
- `text` property = ONLY readable words (no markup)
- **Size containers to fit text** — see `references/layout-rules.md`
- **Minimum 40px gap** between elements — see `references/layout-rules.md`
- Default to free-floating text; use containers only when meaningful (<30% text in boxes)
### Visual Pattern Library
| Concept Behavior | Pattern |
|------------------|---------|
| One source → many outputs | **Fan-out** (radial arrows from center) |
| Many inputs → one output | **Convergence** (arrows merging) |
| Hierarchy/nesting | **Tree** (lines + free-floating text) |
| Sequence of steps | **Timeline** (line + dots + labels) |
| Feedback loop | **Spiral/Cycle** (arrow returning to start) |
| Abstract state | **Cloud** (overlapping ellipses) |
| Transformation | **Assembly line** (before → process → after) |
| Comparison | **Side-by-side** (parallel structures) |
| Phase changes | **Gap/Break** (visual whitespace) |
### Shape Meaning
| Concept | Shape |
|---------|-------|
| Labels, descriptions | Free-floating text (no container) |
| Timeline markers | Small ellipse (12px) |
| Start/trigger | Ellipse |
| End/output | Ellipse |
| Decision | Diamond |
| Process/action | Rectangle |
### Evidence Artifacts (Technical Diagrams)
| Artifact | Rendering |
|----------|-----------|
| Code snippets | Dark rect (`#1e293b`) + syntax-colored text |
| JSON/data | Dark rect (`#1e293b`) + green text (`#22c55e`) |
| Event sequences | Timeline (line + dots + labels) |
| UI mockups | Nested rectangles |
### Large Diagram Strategy
Build JSON **one section at a time** (Claude has ~32k token output limit):
1. Create base file + first section
2. Add one section per edit — use descriptive IDs (`"trigger_rect"`, `"auth_arrow"`)
3. Namespace seeds by section (100xxx, 200xxx, etc.)
4. Update cross-section bindings as you go
5. Review the whole before rendering
### Multi-Zoom (Comprehensive Diagrams)
- **Level 1** — Summary flow (simplified overview)
- **Level 2** — Section boundaries (labeled regions)
- **Level 3** — Detail (evidence artifacts, code snippets, real data)
---
## Render & Validate (MANDATORY)
**Every diagram must be rendered and visually inspected before delivery.** This catches overlap, text clipping, and spacing issues that are invisible in JSON.
### Render Command
```bash
cd ~/.openclaw/skills/excalidraw-diagram && uv run python render_excalidraw.py <path-to-file.excalidraw>
```
Outputs a PNG next to the `.excalidraw` file. Use the **Read tool** to view it.
### First-Time Setup
```bash
cd ~/.openclaw/skills/excalidraw-diagram
bash setup.sh # builds local Excalidraw bundle (requires node/npm)
uv sync && uv run playwright install chromium
```
### The Loop (repeat until professional)
1. **Render** the PNG
2. **View the image** with the Read tool — actually look at it
3. **Inspect systematically:**
- Does every label fit cleanly inside its box? (no clipping, no overflow)
- Are all boxes/shapes clearly separated? (no overlapping edges)
- Are arrows connecting the right elements without crossing through others?
- Is spacing even and consistent between similar elements?
- Is text large enough to read?
- Does the overall layout look balanced and professional?
4. **Fix the JSON** for every issue found — widen containers, adjust x/y, add arrow waypoints, increase gaps
5. **Re-render and re-view** — look at the new PNG
6. **Repeat** until every issue is resolved (typically 2-4 iterations, sometimes more)
**Do not skip this loop.** JSON coordinates are approximate — you will almost always find issues on the first render. The visual check IS the quality gate.
### Stop When
- No text overflow or overlapping
- Arrows route cleanly
- Consistent spacing, balanced composition
- You'd show it without caveats
---
## PNG & PDF Export
### PNG (for Word, presentations, sharing)
The render script outputs high-res PNG (2x scale by default):
```bash
cd ~/.openclaw/skills/excalidraw-diagram && uv run python render_excalidraw.py diagram.excalidraw --output diagram.png --scale 3
```
Options: `--scale 3` (3x for print), `--width 2560` (wider viewport).
### PDF (for documents, printing)
Convert PNG to PDF:
```bash
# ImageMagick (most common)
convert diagram.png -density 150 diagram.pdf
# Or with a white background and margins
convert diagram.png -gravity center -background white -extent 110%x110% -density 150 diagram.pdf
```
For multi-page or A4/Letter sizing:
```bash
convert diagram.png -resize 1800x -gravity center -background white \
-extent 2100x2970 -units PixelsPerInch -density 254 diagram-a4.pdf
```
---
## Quality Checklist
### Layout & Overlap
- [ ] All text fits within containers (used layout-rules.md sizing formula)
- [ ] Minimum 40px gap between all elements
- [ ] Arrows don't cross through elements
- [ ] Even spacing between similar elements
- [ ] Balanced composition (no voids or overcrowding)
### Visual
- [ ] `roughness: 0`, `opacity: 100`, `fontFamily: 3` everywhere
- [ ] Colors from `references/color-palette.md`
- [ ] Text readable at export size
- [ ] Clear visual flow (eye path)
### Technical (if applicable)
- [ ] Real specs/event names/API formats (not placeholders)
- [ ] Evidence artifacts included
- [ ] Multi-zoom levels present
### Export
- [ ] Rendered to PNG and visually validated
- [ ] PNG/PDF delivered if user needs it
FILE:README.md
# Excalidraw Render
OpenClaw skill for creating, editing, and rendering Excalidraw diagrams to PNG and PDF.
## Author
Scott Glover <[email protected]>
ClawHub: [@scottgl9](https://clawhub.ai/scottgl9)
## Features
- **Quick path** — DSL-based flowcharts via `@swiftlysingh/excalidraw-cli`
- **Full path** — hand-crafted JSON diagrams with element templates and layout rules
- **PNG rendering** — Playwright + headless Chromium renders `.excalidraw` files to PNG
- **PDF export** — convert PNG to PDF via ImageMagick
## Setup
```bash
cd <skill-dir>
uv sync
uv run playwright install chromium
```
## Usage
```bash
# Render a diagram to PNG
cd <skill-dir>
uv run python render_excalidraw.py diagram.excalidraw
# With options
uv run python render_excalidraw.py diagram.excalidraw --output out.png --scale 3
```
## References
- `references/color-palette.md` — color values for all element types
- `references/element-templates.md` — copy-paste JSON for shapes, arrows, text
- `references/layout-rules.md` — anti-overlap spacing and text-sizing rules
- `references/json-schema.md` — full Excalidraw JSON property reference
## Requirements
- Python 3.11+
- `uv` package manager
- Chromium (installed via `uv run playwright install chromium`)
## License
MIT-0 — free to use, modify, and redistribute without attribution.
FILE:pyproject.toml
[project]
name = "excalidraw-render"
version = "0.1.0"
requires-python = ">=3.11"
dependencies = [
"playwright>=1.40.0",
]
FILE:references/color-palette.md
# Color Palette & Brand Style
**This is the single source of truth for all colors and brand-specific styles.** To customize diagrams for your own brand, edit this file — everything else in the skill is universal.
---
## Shape Colors (Semantic)
Colors encode meaning, not decoration. Each semantic purpose has a fill/stroke pair.
| Semantic Purpose | Fill | Stroke |
|------------------|------|--------|
| Primary/Neutral | `#3b82f6` | `#1e3a5f` |
| Secondary | `#60a5fa` | `#1e3a5f` |
| Tertiary | `#93c5fd` | `#1e3a5f` |
| Start/Trigger | `#fed7aa` | `#c2410c` |
| End/Success | `#a7f3d0` | `#047857` |
| Warning/Reset | `#fee2e2` | `#dc2626` |
| Decision | `#fef3c7` | `#b45309` |
| AI/LLM | `#ddd6fe` | `#6d28d9` |
| Inactive/Disabled | `#dbeafe` | `#1e40af` (use dashed stroke) |
| Error | `#fecaca` | `#b91c1c` |
**Rule**: Always pair a darker stroke with a lighter fill for contrast.
---
## Text Colors (Hierarchy)
Use color on free-floating text to create visual hierarchy without containers.
| Level | Color | Use For |
|-------|-------|---------|
| Title | `#1e40af` | Section headings, major labels |
| Subtitle | `#3b82f6` | Subheadings, secondary labels |
| Body/Detail | `#64748b` | Descriptions, annotations, metadata |
| On light fills | `#374151` | Text inside light-colored shapes |
| On dark fills | `#ffffff` | Text inside dark-colored shapes |
---
## Evidence Artifact Colors
Used for code snippets, data examples, and other concrete evidence inside technical diagrams.
| Artifact | Background | Text Color |
|----------|-----------|------------|
| Code snippet | `#1e293b` | Syntax-colored (language-appropriate) |
| JSON/data example | `#1e293b` | `#22c55e` (green) |
---
## Default Stroke & Line Colors
| Element | Color |
|---------|-------|
| Arrows | Use the stroke color of the source element's semantic purpose |
| Structural lines (dividers, trees, timelines) | Primary stroke (`#1e3a5f`) or Slate (`#64748b`) |
| Marker dots (fill + stroke) | Primary fill (`#3b82f6`) |
---
## Background
| Property | Value |
|----------|-------|
| Canvas background | `#ffffff` |
FILE:references/element-templates.md
# Element Templates
Copy-paste JSON templates for each Excalidraw element type. The `strokeColor` and `backgroundColor` values are placeholders — always pull actual colors from `color-palette.md` based on the element's semantic purpose.
## Free-Floating Text (no container)
```json
{
"type": "text",
"id": "label1",
"x": 100, "y": 100,
"width": 200, "height": 25,
"text": "Section Title",
"originalText": "Section Title",
"fontSize": 20,
"fontFamily": 3,
"textAlign": "left",
"verticalAlign": "top",
"strokeColor": "<title color from palette>",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 1,
"strokeStyle": "solid",
"roughness": 0,
"opacity": 100,
"angle": 0,
"seed": 11111,
"version": 1,
"versionNonce": 22222,
"isDeleted": false,
"groupIds": [],
"boundElements": null,
"link": null,
"locked": false,
"containerId": null,
"lineHeight": 1.25
}
```
## Line (structural, not arrow)
```json
{
"type": "line",
"id": "line1",
"x": 100, "y": 100,
"width": 0, "height": 200,
"strokeColor": "<structural line color from palette>",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 0,
"opacity": 100,
"angle": 0,
"seed": 44444,
"version": 1,
"versionNonce": 55555,
"isDeleted": false,
"groupIds": [],
"boundElements": null,
"link": null,
"locked": false,
"points": [[0, 0], [0, 200]]
}
```
## Small Marker Dot
```json
{
"type": "ellipse",
"id": "dot1",
"x": 94, "y": 94,
"width": 12, "height": 12,
"strokeColor": "<marker dot color from palette>",
"backgroundColor": "<marker dot color from palette>",
"fillStyle": "solid",
"strokeWidth": 1,
"strokeStyle": "solid",
"roughness": 0,
"opacity": 100,
"angle": 0,
"seed": 66666,
"version": 1,
"versionNonce": 77777,
"isDeleted": false,
"groupIds": [],
"boundElements": null,
"link": null,
"locked": false
}
```
## Rectangle
```json
{
"type": "rectangle",
"id": "elem1",
"x": 100, "y": 100, "width": 180, "height": 90,
"strokeColor": "<stroke from palette based on semantic purpose>",
"backgroundColor": "<fill from palette based on semantic purpose>",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 0,
"opacity": 100,
"angle": 0,
"seed": 12345,
"version": 1,
"versionNonce": 67890,
"isDeleted": false,
"groupIds": [],
"boundElements": [{"id": "text1", "type": "text"}],
"link": null,
"locked": false,
"roundness": {"type": 3}
}
```
## Text (centered in shape)
```json
{
"type": "text",
"id": "text1",
"x": 130, "y": 132,
"width": 120, "height": 25,
"text": "Process",
"originalText": "Process",
"fontSize": 16,
"fontFamily": 3,
"textAlign": "center",
"verticalAlign": "middle",
"strokeColor": "<text color — match parent shape's stroke or use 'on light/dark fills' from palette>",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 1,
"strokeStyle": "solid",
"roughness": 0,
"opacity": 100,
"angle": 0,
"seed": 11111,
"version": 1,
"versionNonce": 22222,
"isDeleted": false,
"groupIds": [],
"boundElements": null,
"link": null,
"locked": false,
"containerId": "elem1",
"lineHeight": 1.25
}
```
## Arrow
```json
{
"type": "arrow",
"id": "arrow1",
"x": 282, "y": 145, "width": 118, "height": 0,
"strokeColor": "<arrow color — typically matches source element's stroke from palette>",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 0,
"opacity": 100,
"angle": 0,
"seed": 33333,
"version": 1,
"versionNonce": 44444,
"isDeleted": false,
"groupIds": [],
"boundElements": null,
"link": null,
"locked": false,
"points": [[0, 0], [118, 0]],
"startBinding": {"elementId": "elem1", "focus": 0, "gap": 2},
"endBinding": {"elementId": "elem2", "focus": 0, "gap": 2},
"startArrowhead": null,
"endArrowhead": "arrow"
}
```
For curves: use 3+ points in `points` array.
FILE:references/json-schema.md
# Excalidraw JSON Schema
## Element Types
| Type | Use For |
|------|---------|
| `rectangle` | Processes, actions, components |
| `ellipse` | Entry/exit points, external systems |
| `diamond` | Decisions, conditionals |
| `arrow` | Connections between shapes |
| `text` | Labels inside shapes |
| `line` | Non-arrow connections |
| `frame` | Grouping containers |
## Common Properties
All elements share these:
| Property | Type | Description |
|----------|------|-------------|
| `id` | string | Unique identifier |
| `type` | string | Element type |
| `x`, `y` | number | Position in pixels |
| `width`, `height` | number | Size in pixels |
| `strokeColor` | string | Border color (hex) |
| `backgroundColor` | string | Fill color (hex or "transparent") |
| `fillStyle` | string | "solid", "hachure", "cross-hatch" |
| `strokeWidth` | number | 1, 2, or 4 |
| `strokeStyle` | string | "solid", "dashed", "dotted" |
| `roughness` | number | 0 (smooth), 1 (default), 2 (rough) |
| `opacity` | number | 0-100 |
| `seed` | number | Random seed for roughness |
## Text-Specific Properties
| Property | Description |
|----------|-------------|
| `text` | The display text |
| `originalText` | Same as text |
| `fontSize` | Size in pixels (16-20 recommended) |
| `fontFamily` | 3 for monospace (use this) |
| `textAlign` | "left", "center", "right" |
| `verticalAlign` | "top", "middle", "bottom" |
| `containerId` | ID of parent shape |
## Arrow-Specific Properties
| Property | Description |
|----------|-------------|
| `points` | Array of [x, y] coordinates |
| `startBinding` | Connection to start shape |
| `endBinding` | Connection to end shape |
| `startArrowhead` | null, "arrow", "bar", "dot", "triangle" |
| `endArrowhead` | null, "arrow", "bar", "dot", "triangle" |
## Binding Format
```json
{
"elementId": "shapeId",
"focus": 0,
"gap": 2
}
```
## Rectangle Roundness
Add for rounded corners:
```json
"roundness": { "type": 3 }
```
FILE:references/layout-rules.md
# Layout Rules: Anti-Overlap & Text Sizing
These rules prevent the most common visual defects: text overflow, element overlap, and cramped layouts.
---
## ⚠️ Critical Rules (Learned from Production Diagrams)
These mistakes have caused real defects. Follow them every time.
### 1. Destination box must span ALL incoming arrow y-coordinates
If multiple arrows fan into one box from different y positions, the destination box height must cover the full range.
```
dest_y ≤ min(arrow_y for all arrows)
dest_y + dest_height ≥ max(arrow_y for all arrows)
```
If the box is too short, arrows will appear to float above or below it.
### 2. Text must never exceed box width
After calculating text width, verify:
```
text_width + horizontal_padding < box_width
```
For long single-line text in wide footer/banner boxes: **always split onto 2 lines** rather than making the box wider than the canvas. Use `\n` in the text field and increase box height by one line-height.
### 3. Arrow coordinates use exact box edges
- Horizontal left→right: `arrow_x = src_x + src_width`, `arrow_y = src_y + src_height/2`, `width = dest_x - arrow_x`
- Vertical top→bottom: `arrow_x = src_x + src_width/2`, `arrow_y = src_y + src_height`, `height = dest_y - arrow_y`
- **No overlap, no gap** — start/end exactly at box edges
### 5. Title and subtitle must span the full canvas width
After placing all elements, calculate the canvas x range: `min_x` to `max_x + max_width`. Then set:
```
title_x = min_x
title_width = (max_x + max_width) - min_x
```
This ensures `textAlign: "center"` actually centers the text over the whole diagram, not just part of it.
For each arrow, confirm:
- Start point lands on a box edge (not inside, not outside)
- End point lands on a box edge (not inside, not outside)
- Arrow y is within the vertical range of the destination box
---
## Text Sizing Formula
Excalidraw does NOT auto-size containers. You must calculate dimensions manually.
### Character Width Estimates (fontFamily: 3, monospace)
| fontSize | Avg char width (px) | Line height (px) |
|----------|---------------------|-------------------|
| 12 | 7.2 | 15 |
| 14 | 8.4 | 17.5 |
| 16 | 9.6 | 20 |
| 20 | 12.0 | 25 |
| 24 | 14.4 | 30 |
| 28 | 16.8 | 35 |
### Container Sizing
For text inside a rectangle:
```
text_width = max_line_length × char_width
text_height = num_lines × line_height
container_width = text_width + horizontal_padding
container_height = text_height + vertical_padding
```
**Minimum padding:**
- Horizontal: `40px` (20px each side)
- Vertical: `30px` (15px each side)
**Example:** "Process Data" at fontSize 16:
- 12 chars × 9.6 = 115.2px text width
- 1 line × 20 = 20px text height
- Container: **156px × 50px** (115 + 40, 20 + 30)
**Example:** "Send Verification\nEmail to User" at fontSize 16:
- 18 chars × 9.6 = 172.8px (longest line)
- 2 lines × 20 = 40px text height
- Container: **213px × 70px** (173 + 40, 40 + 30)
### Multi-line Text
When a label is long (>15 chars at fontSize 16, >12 chars at fontSize 20), use `\n` to break it:
```json
{
"text": "Send Verification\nEmail to User",
"originalText": "Send Verification\nEmail to User"
}
```
Then size the container for the longest line and the total number of lines.
---
## Positioning Text Inside Containers
The text element's `x` and `y` must be centered within the container:
```
text_x = container_x + (container_width - text_width) / 2
text_y = container_y + (container_height - text_height) / 2
```
Set `textAlign: "center"` and `verticalAlign: "middle"` and `containerId: "<parent_id>"`.
---
## Element Spacing
### Minimum Gaps
| Between | Minimum gap |
|---------|-------------|
| Adjacent shapes (same row/column) | **60px** |
| Shape and its arrow label | **10px** |
| Parallel flow rows/columns | **100px** |
| Sections/groups | **120px** |
| Diagram edge to nearest element | **80px** (padding) |
### Grid Alignment
Snap element positions to a 20px grid for clean alignment:
```
x = round(x / 20) * 20
y = round(y / 20) * 20
```
### Arrow Routing
- Arrows should have **at least 20px clearance** from non-connected elements.
- For arrows that would cross elements, add waypoints:
```json
"points": [[0, 0], [50, 0], [50, -80], [150, -80], [150, 0], [200, 0]]
```
- Prefer orthogonal (right-angle) routing over diagonal for clean diagrams.
### ⚠️ Arrow Connection Rule — ALWAYS VERIFY
**Arrows MUST visually connect source to destination. Floating arrows are a critical defect.**
**MANDATORY: For every horizontal left→right arrow:**
- Arrow `x` = `src_x + src_width` (right edge of source box)
- Arrow `y` = `src_y + src_height / 2` (vertical center of source box)
- Arrow end x = `dest_x` (left edge of destination box)
- Arrow `width` = `dest_x - (src_x + src_width)`
- Arrow `points` = `[[0,0],[width,0]]`
**MANDATORY: For every vertical top→bottom arrow:**
- Arrow `x` = `src_x + src_width / 2` (horizontal center of source box)
- Arrow `y` = `src_y + src_height` (bottom edge of source box)
- Arrow end y = `dest_y` (top edge of destination box)
- Arrow `height` = `dest_y - (src_y + src_height)`
- Arrow `points` = `[[0,0],[0,height]]`
**Note:** The destination box must be tall enough to span the arrow's y coordinate, otherwise the arrow will appear to float. Adjust box height to cover all incoming arrows.
For horizontal intra-row arrows (connecting boxes left→right):
- Arrow `y` must equal the **vertical center of both boxes**: `box_y + box_height / 2`
- Arrow `x` (start) = `src_x + src_width`
- Arrow `width` = `dest_x - (src_x + src_width)`
**Checklist before finalizing any arrow:**
1. Start point (x,y) lies exactly on the edge of the source element
2. End point (x + width, y + height) lies exactly on the edge of the destination element
3. No arrow points into empty whitespace between unconnected elements
4. For `startBinding`/`endBinding`: verify `elementId` matches the actual element id
**Common mistake:** Using a fixed x=700 for all vertical arrows when the target box center is at a different x. Always calculate x from the destination box position.
**When source and destination have different center-x values**, use an L-shaped waypoint path instead of a diagonal:
```json
// Example: source cx=1000, dest cx=360, vertical drop=114px
// Route: down 57px, left 640px, down 57px
"x": 1000, "y": 470,
"width": -640, "height": 114,
"points": [[0,0], [0,57], [-640,57], [-640,114]]
```
This keeps routing orthogonal (no diagonals) and visually connects to both boxes.
**MANDATORY verification step for every arrow:**
After writing arrow JSON, trace the path manually:
- Start point: `(arrow.x + points[0][0], arrow.y + points[0][1])`
- End point: `(arrow.x + points[-1][0], arrow.y + points[-1][1])`
- Verify start point lies on source element's edge
- Verify end point lies on destination element's edge
- If either check fails, fix the coordinates before rendering
---
## Common Layout Patterns
### Horizontal Flow (LR)
```
[elem1] --60px-- [elem2] --60px-- [elem3]
y: same for all elements in a row
x: prev_x + prev_width + 60
```
### Vertical Flow (TB)
```
[elem1]
| 60px gap
[elem2]
| 60px gap
[elem3]
x: same for all elements in a column
y: prev_y + prev_height + 60
```
### Decision Branching
```
[elem1]
|
{decision}
/ \
--60px-- --60px--
[yes_path] [no_path]
```
- Branch targets should be at least 100px apart horizontally.
- Decision diamond: use 140×90 minimum for short labels.
### Fan-out
```
[source]
/ | \
--80px-- --80px-- --80px--
[t1] [t2] [t3]
```
- Space targets evenly; center the source above them.
---
## Diamond (Decision) Sizing
Diamonds are trickier because the visible text area is only ~50% of the bounding box.
```
diamond_width = text_width × 2 + 40
diamond_height = text_height × 2 + 20
```
**Example:** "Valid?" at fontSize 16:
- 6 chars × 9.6 = 57.6px
- Diamond: **155px × 60px** (58 × 2 + 40, 20 × 2 + 20)
---
## Preventing Overlap: Checklist
Before rendering, verify:
1. **No coordinate collisions:** For every pair of elements, check:
```
elem1.x + elem1.width + min_gap < elem2.x (if side by side)
elem1.y + elem1.height + min_gap < elem2.y (if stacked)
```
2. **Text fits in container:** Container dimensions ≥ text dimensions + padding.
3. **Arrow labels don't collide:** If an arrow has a label, place it offset from the midpoint, not overlapping other elements.
4. **Sections don't encroach:** Adjacent sections need 120px+ gap.
5. **Diamond text visible:** Diamond containers are 2× wider/taller than text needs.
FILE:references/pyproject.toml
[project]
name = "excalidraw-render"
version = "0.1.0"
requires-python = ">=3.11"
dependencies = [
"playwright>=1.40.0",
]
FILE:references/render_excalidraw.py
"""Render Excalidraw JSON to PNG using Playwright + headless Chromium.
Usage:
cd ~/.openclaw/skills/excalidraw-diagram
uv run python render_excalidraw.py <path-to-file.excalidraw> [--output path.png] [--scale 2] [--width 1920]
First-time setup:
cd ~/.openclaw/skills/excalidraw-diagram
uv sync
uv run playwright install chromium
"""
from __future__ import annotations
import argparse
import json
import sys
from pathlib import Path
def validate_excalidraw(data: dict) -> list[str]:
"""Validate Excalidraw JSON structure. Returns list of errors (empty = valid)."""
errors: list[str] = []
if data.get("type") != "excalidraw":
errors.append(f"Expected type 'excalidraw', got '{data.get('type')}'")
if "elements" not in data:
errors.append("Missing 'elements' array")
elif not isinstance(data["elements"], list):
errors.append("'elements' must be an array")
elif len(data["elements"]) == 0:
errors.append("'elements' array is empty — nothing to render")
return errors
def compute_bounding_box(elements: list[dict]) -> tuple[float, float, float, float]:
"""Compute bounding box (min_x, min_y, max_x, max_y) across all elements."""
min_x = float("inf")
min_y = float("inf")
max_x = float("-inf")
max_y = float("-inf")
for el in elements:
if el.get("isDeleted"):
continue
x = el.get("x", 0)
y = el.get("y", 0)
w = el.get("width", 0)
h = el.get("height", 0)
# For arrows/lines, points array defines the shape relative to x,y
if el.get("type") in ("arrow", "line") and "points" in el:
for px, py in el["points"]:
min_x = min(min_x, x + px)
min_y = min(min_y, y + py)
max_x = max(max_x, x + px)
max_y = max(max_y, y + py)
else:
min_x = min(min_x, x)
min_y = min(min_y, y)
max_x = max(max_x, x + abs(w))
max_y = max(max_y, y + abs(h))
if min_x == float("inf"):
return (0, 0, 800, 600)
return (min_x, min_y, max_x, max_y)
def render(
excalidraw_path: Path,
output_path: Path | None = None,
scale: int = 2,
max_width: int = 1920,
) -> Path:
"""Render an .excalidraw file to PNG. Returns the output PNG path."""
# Import playwright here so validation errors show before import errors
try:
from playwright.sync_api import sync_playwright
except ImportError:
print("ERROR: playwright not installed.", file=sys.stderr)
print("Run: cd ~/.openclaw/skills/excalidraw-diagram && uv sync && uv run playwright install chromium", file=sys.stderr)
sys.exit(1)
# Read and validate
raw = excalidraw_path.read_text(encoding="utf-8")
try:
data = json.loads(raw)
except json.JSONDecodeError as e:
print(f"ERROR: Invalid JSON in {excalidraw_path}: {e}", file=sys.stderr)
sys.exit(1)
errors = validate_excalidraw(data)
if errors:
print(f"ERROR: Invalid Excalidraw file:", file=sys.stderr)
for err in errors:
print(f" - {err}", file=sys.stderr)
sys.exit(1)
# Compute viewport size from element bounding box
elements = [e for e in data["elements"] if not e.get("isDeleted")]
min_x, min_y, max_x, max_y = compute_bounding_box(elements)
padding = 80
diagram_w = max_x - min_x + padding * 2
diagram_h = max_y - min_y + padding * 2
# Cap viewport width, let height be natural
vp_width = min(int(diagram_w), max_width)
vp_height = max(int(diagram_h), 600)
# Output path
if output_path is None:
output_path = excalidraw_path.with_suffix(".png")
# Template path (same directory as this script)
template_path = Path(__file__).parent / "render_template.html"
if not template_path.exists():
print(f"ERROR: Template not found at {template_path}", file=sys.stderr)
sys.exit(1)
template_url = template_path.as_uri()
with sync_playwright() as p:
try:
browser = p.chromium.launch(headless=True)
except Exception as e:
if "Executable doesn't exist" in str(e) or "browserType.launch" in str(e):
print("ERROR: Chromium not installed for Playwright.", file=sys.stderr)
print("Run: cd ~/.openclaw/skills/excalidraw-diagram && uv run playwright install chromium", file=sys.stderr)
sys.exit(1)
raise
page = browser.new_page(
viewport={"width": vp_width, "height": vp_height},
device_scale_factor=scale,
)
# Load the template
page.goto(template_url)
# Wait for the ES module to load (imports from esm.sh)
page.wait_for_function("window.__moduleReady === true", timeout=30000)
# Inject the diagram data and render
json_str = json.dumps(data)
result = page.evaluate(f"window.renderDiagram({json_str})")
if not result or not result.get("success"):
error_msg = result.get("error", "Unknown render error") if result else "renderDiagram returned null"
print(f"ERROR: Render failed: {error_msg}", file=sys.stderr)
browser.close()
sys.exit(1)
# Wait for render completion signal
page.wait_for_function("window.__renderComplete === true", timeout=15000)
# Screenshot the SVG element
svg_el = page.query_selector("#root svg")
if svg_el is None:
print("ERROR: No SVG element found after render.", file=sys.stderr)
browser.close()
sys.exit(1)
svg_el.screenshot(path=str(output_path))
browser.close()
return output_path
def main() -> None:
parser = argparse.ArgumentParser(description="Render Excalidraw JSON to PNG")
parser.add_argument("input", type=Path, help="Path to .excalidraw JSON file")
parser.add_argument("--output", "-o", type=Path, default=None, help="Output PNG path (default: same name with .png)")
parser.add_argument("--scale", "-s", type=int, default=2, help="Device scale factor (default: 2)")
parser.add_argument("--width", "-w", type=int, default=1920, help="Max viewport width (default: 1920)")
args = parser.parse_args()
if not args.input.exists():
print(f"ERROR: File not found: {args.input}", file=sys.stderr)
sys.exit(1)
png_path = render(args.input, args.output, args.scale, args.width)
print(str(png_path))
if __name__ == "__main__":
main()
FILE:references/render_template.html
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { background: #ffffff; overflow: hidden; }
#root { display: inline-block; }
#root svg { display: block; }
</style>
</head>
<body>
<div id="root"></div>
<script type="module">
import { exportToSvg } from "https://esm.sh/@excalidraw/excalidraw?bundle";
window.renderDiagram = async function(jsonData) {
try {
const data = typeof jsonData === "string" ? JSON.parse(jsonData) : jsonData;
const elements = data.elements || [];
const appState = data.appState || {};
const files = data.files || {};
// Force white background in appState
appState.viewBackgroundColor = appState.viewBackgroundColor || "#ffffff";
appState.exportWithDarkMode = false;
const svg = await exportToSvg({
elements: elements,
appState: {
...appState,
exportBackground: true,
},
files: files,
});
// Clear any previous render
const root = document.getElementById("root");
root.innerHTML = "";
root.appendChild(svg);
window.__renderComplete = true;
window.__renderError = null;
return { success: true, width: svg.getAttribute("width"), height: svg.getAttribute("height") };
} catch (err) {
window.__renderComplete = true;
window.__renderError = err.message;
return { success: false, error: err.message };
}
};
// Signal that the module is loaded and ready
window.__moduleReady = true;
</script>
</body>
</html>
FILE:render_excalidraw.py
"""Render Excalidraw JSON to PNG using Playwright + headless Chromium.
Usage:
cd ~/.openclaw/skills/excalidraw-diagram
uv run python render_excalidraw.py <path-to-file.excalidraw> [--output path.png] [--scale 2] [--width 1920]
First-time setup:
cd ~/.openclaw/skills/excalidraw-diagram
uv sync
uv run playwright install chromium
"""
from __future__ import annotations
import argparse
import json
import sys
from pathlib import Path
def validate_excalidraw(data: dict) -> list[str]:
"""Validate Excalidraw JSON structure. Returns list of errors (empty = valid)."""
errors: list[str] = []
if data.get("type") != "excalidraw":
errors.append(f"Expected type 'excalidraw', got '{data.get('type')}'")
if "elements" not in data:
errors.append("Missing 'elements' array")
elif not isinstance(data["elements"], list):
errors.append("'elements' must be an array")
elif len(data["elements"]) == 0:
errors.append("'elements' array is empty — nothing to render")
return errors
def compute_bounding_box(elements: list[dict]) -> tuple[float, float, float, float]:
"""Compute bounding box (min_x, min_y, max_x, max_y) across all elements."""
min_x = float("inf")
min_y = float("inf")
max_x = float("-inf")
max_y = float("-inf")
for el in elements:
if el.get("isDeleted"):
continue
x = el.get("x", 0)
y = el.get("y", 0)
w = el.get("width", 0)
h = el.get("height", 0)
# For arrows/lines, points array defines the shape relative to x,y
if el.get("type") in ("arrow", "line") and "points" in el:
for px, py in el["points"]:
min_x = min(min_x, x + px)
min_y = min(min_y, y + py)
max_x = max(max_x, x + px)
max_y = max(max_y, y + py)
else:
min_x = min(min_x, x)
min_y = min(min_y, y)
max_x = max(max_x, x + abs(w))
max_y = max(max_y, y + abs(h))
if min_x == float("inf"):
return (0, 0, 800, 600)
return (min_x, min_y, max_x, max_y)
def render(
excalidraw_path: Path,
output_path: Path | None = None,
scale: int = 2,
max_width: int = 1920,
) -> Path:
"""Render an .excalidraw file to PNG. Returns the output PNG path."""
# Import playwright here so validation errors show before import errors
try:
from playwright.sync_api import sync_playwright
except ImportError:
print("ERROR: playwright not installed.", file=sys.stderr)
print("Run: cd ~/.openclaw/skills/excalidraw-diagram && uv sync && uv run playwright install chromium", file=sys.stderr)
sys.exit(1)
# Read and validate
raw = excalidraw_path.read_text(encoding="utf-8")
try:
data = json.loads(raw)
except json.JSONDecodeError as e:
print(f"ERROR: Invalid JSON in {excalidraw_path}: {e}", file=sys.stderr)
sys.exit(1)
errors = validate_excalidraw(data)
if errors:
print(f"ERROR: Invalid Excalidraw file:", file=sys.stderr)
for err in errors:
print(f" - {err}", file=sys.stderr)
sys.exit(1)
# Compute viewport size from element bounding box
elements = [e for e in data["elements"] if not e.get("isDeleted")]
min_x, min_y, max_x, max_y = compute_bounding_box(elements)
padding = 80
diagram_w = max_x - min_x + padding * 2
diagram_h = max_y - min_y + padding * 2
# Cap viewport width, let height be natural
vp_width = min(int(diagram_w), max_width)
vp_height = max(int(diagram_h), 600)
# Output path
if output_path is None:
output_path = excalidraw_path.with_suffix(".png")
# Template path (same directory as this script)
template_path = Path(__file__).parent / "render_template.html"
bundle_path = Path(__file__).parent / "excalidraw.iife.js"
if not template_path.exists():
print(f"ERROR: Template not found at {template_path}", file=sys.stderr)
sys.exit(1)
if not bundle_path.exists():
print("ERROR: Local Excalidraw bundle not found.", file=sys.stderr)
print("Run: bash setup.sh (from the skill directory)", file=sys.stderr)
sys.exit(1)
# Inject the local bundle path into the template (avoids CDN fetch at render time)
template_html = template_path.read_text(encoding="utf-8")
template_html = template_html.replace("__EXCALIDRAW_BUNDLE_PATH__", bundle_path.as_uri())
import tempfile
with tempfile.NamedTemporaryFile(mode="w", suffix=".html", delete=False, encoding="utf-8") as tmp:
tmp.write(template_html)
tmp_template_path = Path(tmp.name)
template_url = tmp_template_path.as_uri()
with sync_playwright() as p:
try:
browser = p.chromium.launch(headless=True)
except Exception as e:
if "Executable doesn't exist" in str(e) or "browserType.launch" in str(e):
print("ERROR: Chromium not installed for Playwright.", file=sys.stderr)
print("Run: cd ~/.openclaw/skills/excalidraw-diagram && uv run playwright install chromium", file=sys.stderr)
sys.exit(1)
raise
page = browser.new_page(
viewport={"width": vp_width, "height": vp_height},
device_scale_factor=scale,
)
# Load the template
page.goto(template_url)
# Wait for the local bundle to load
page.wait_for_function("window.__moduleReady === true", timeout=30000)
# Inject the diagram data and render
json_str = json.dumps(data)
result = page.evaluate(f"window.renderDiagram({json_str})")
if not result or not result.get("success"):
error_msg = result.get("error", "Unknown render error") if result else "renderDiagram returned null"
print(f"ERROR: Render failed: {error_msg}", file=sys.stderr)
browser.close()
sys.exit(1)
# Wait for render completion signal
page.wait_for_function("window.__renderComplete === true", timeout=15000)
# Screenshot the SVG element
svg_el = page.query_selector("#root svg")
if svg_el is None:
print("ERROR: No SVG element found after render.", file=sys.stderr)
browser.close()
sys.exit(1)
svg_el.screenshot(path=str(output_path))
browser.close()
tmp_template_path.unlink(missing_ok=True)
return output_path
def main() -> None:
parser = argparse.ArgumentParser(description="Render Excalidraw JSON to PNG")
parser.add_argument("input", type=Path, help="Path to .excalidraw JSON file")
parser.add_argument("--output", "-o", type=Path, default=None, help="Output PNG path (default: same name with .png)")
parser.add_argument("--scale", "-s", type=int, default=2, help="Device scale factor (default: 2)")
parser.add_argument("--width", "-w", type=int, default=1920, help="Max viewport width (default: 1920)")
args = parser.parse_args()
if not args.input.exists():
print(f"ERROR: File not found: {args.input}", file=sys.stderr)
sys.exit(1)
png_path = render(args.input, args.output, args.scale, args.width)
print(str(png_path))
if __name__ == "__main__":
main()
FILE:render_template.html
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { background: #ffffff; overflow: hidden; }
#root { display: inline-block; }
#root svg { display: block; }
</style>
</head>
<body>
<div id="root"></div>
<!-- Local bundle — built by setup.sh (no CDN/network calls at render time) -->
<script src="__EXCALIDRAW_BUNDLE_PATH__"></script>
<script>
window.renderDiagram = async function(jsonData) {
try {
const data = typeof jsonData === "string" ? JSON.parse(jsonData) : jsonData;
const elements = data.elements || [];
const appState = data.appState || {};
const files = data.files || {};
appState.viewBackgroundColor = appState.viewBackgroundColor || "#ffffff";
appState.exportBackground = true;
appState.exportWithDarkMode = false;
const { exportToSvg } = ExcalidrawLib;
const svg = await exportToSvg({
elements,
appState,
files,
});
const root = document.getElementById("root");
root.innerHTML = "";
root.appendChild(svg);
window.__renderComplete = true;
window.__renderError = null;
return { success: true, width: svg.getAttribute("width"), height: svg.getAttribute("height") };
} catch (err) {
window.__renderComplete = true;
window.__renderError = err.message;
return { success: false, error: err.message };
}
};
window.__moduleReady = true;
</script>
</body>
</html>
FILE:setup.sh
#!/usr/bin/env bash
# Setup script for excalidraw-render skill.
# Run once before first use to build the local Excalidraw bundle.
# Usage: bash setup.sh
set -e
SKILL_DIR="$(cd "$(dirname "$0")" && pwd)"
echo "Building local Excalidraw bundle..."
# Install esbuild and excalidraw in a temp dir, then bundle
TMP="$(mktemp -d)"
trap 'rm -rf "$TMP"' EXIT
cd "$TMP"
npm init -y > /dev/null
npm install @excalidraw/excalidraw esbuild --save-dev > /dev/null 2>&1
npx esbuild node_modules/@excalidraw/excalidraw/dist/prod/index.js \
--bundle \
--format=iife \
--global-name=ExcalidrawLib \
--minify \
--outfile="$SKILL_DIR/excalidraw.iife.js" 2>&1
echo "Done. Bundle written to: $SKILL_DIR/excalidraw.iife.js"
# Install excalidraw-cli locally so it doesn't need npx at runtime
echo "Installing @swiftlysingh/excalidraw-cli..."
npm install -g @swiftlysingh/excalidraw-cli > /dev/null 2>&1 || \
npm install --prefix "$SKILL_DIR/.npm" @swiftlysingh/excalidraw-cli > /dev/null 2>&1
echo "Done."
echo "Now run: cd $SKILL_DIR && uv sync && uv run playwright install chromium"