Skip to content

Instantly share code, notes, and snippets.

@austinmw
Created April 14, 2026 03:55
Show Gist options
  • Select an option

  • Save austinmw/ca017a8c00ece736e5a971a479d65ab4 to your computer and use it in GitHub Desktop.

Select an option

Save austinmw/ca017a8c00ece736e5a971a479d65ab4 to your computer and use it in GitHub Desktop.
excalidraw-hybrid-skill — Hermes Agent skill: CLI layout + freeform agent JSON editing + Obsidian-native .excalidraw.md output
#!/usr/bin/env python3
"""
Excalidraw helper utilities:
1. Generate layout via excalidraw-cli DSL
2. Convert any .excalidraw JSON to Obsidian-native .excalidraw.md
The agent handles all styling/color modifications directly on the JSON
between steps 1 and 2. This script is just plumbing.
Usage:
# Generate layout from DSL → plain .excalidraw JSON
python3 hybrid.py layout --dsl "(Start) -> [Step] -> (End)" -o /tmp/layout.excalidraw
python3 hybrid.py layout --dsl-file flow.dsl -o /tmp/layout.excalidraw
# Convert .excalidraw JSON → Obsidian-native .excalidraw.md
python3 hybrid.py convert --input /tmp/layout.excalidraw -o diagram.excalidraw.md
# One-shot: DSL → Obsidian (no agent styling, just auto-color by shape type)
python3 hybrid.py quick --dsl "(Start) -> [Step] -> (End)" --title "Flow" -o diagram.excalidraw.md
"""
import argparse
import json
import os
import random
import subprocess
import sys
import tempfile
# ── CLI layout ──────────────────────────────────────────────────────────────
def run_cli(dsl_text: str, output_path: str) -> dict:
"""Run excalidraw-cli to generate layout, return parsed JSON."""
with tempfile.NamedTemporaryFile(suffix=".excalidraw", delete=False) as tmp:
tmp_path = tmp.name
try:
cmd = [
"npx", "@swiftlysingh/excalidraw-cli", "create",
"--inline", dsl_text, "-o", tmp_path
]
result = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
if result.returncode != 0:
print(f"CLI error: {result.stderr}", file=sys.stderr)
sys.exit(1)
with open(tmp_path) as f:
return json.load(f)
finally:
if os.path.exists(tmp_path):
os.unlink(tmp_path)
# ── Obsidian conversion ────────────────────────────────────────────────────
def to_obsidian_native(data: dict) -> str:
"""Wrap excalidraw JSON in Obsidian plugin-native .excalidraw.md format."""
elements = data.get("elements", [])
text_entries = []
for el in elements:
if el.get("type") == "text" and not el.get("isDeleted", False):
text = el.get("originalText", el.get("text", ""))
eid = el.get("id", "")
text_entries.append(f"{text} ^{eid}")
text_section = "\n\n".join(text_entries)
json_str = json.dumps(data, indent=2)
return f"""---
excalidraw-plugin: parsed
tags: [excalidraw]
---
==⚠ Switch to EXCALIDRAW VIEW in the MORE OPTIONS menu of this document. ⚠== You can decompress Drawing data with the command palette: 'Decompress current Excalidraw file'. For more info check in plugin settings under 'Saving'
# Excalidraw Data
## Text Elements
{text_section}
%%
## Drawing
```json
{json_str}
```
%%"""
# ── Quick mode (auto-color, no agent intervention) ─────────────────────────
AUTO_COLORS = {
"ellipse": "#d0bfff",
"diamond": "#fff3bf",
"rectangle": "#a5d8ff",
}
def auto_style(data: dict) -> dict:
"""Basic auto-color by shape type + rounded corners. For quick mode only."""
for el in data.get("elements", []):
etype = el.get("type", "")
if etype in ("text", "arrow", "line"):
continue
if etype == "rectangle":
el["roundness"] = {"type": 3}
bg = AUTO_COLORS.get(etype)
if bg:
el["backgroundColor"] = bg
el["fillStyle"] = "solid"
return data
def add_title(data: dict, title: str) -> dict:
"""Add a title text element above the diagram."""
elements = data.get("elements", [])
min_y = min((el.get("y", 0) for el in elements), default=0)
xs = [(el.get("x", 0), el.get("width", 0)) for el in elements
if el.get("type") not in ("text",) or not el.get("containerId")]
center_x = (min(x for x, w in xs) + max(x + w for x, w in xs)) / 2 if xs else 300
title_el = {
"type": "text", "id": f"title_{random.randint(1000, 9999)}",
"x": center_x - len(title) * 8, "y": min_y - 50,
"width": len(title) * 16, "height": 35,
"text": title, "fontSize": 28, "fontFamily": 1, "strokeColor": "#1e1e1e",
"originalText": title, "autoResize": True,
"isDeleted": False, "angle": 0, "opacity": 100, "roughness": 1,
"strokeWidth": 2, "fillStyle": "solid", "backgroundColor": "transparent",
"seed": random.randint(1, 999999), "version": 1, "groupIds": [],
"boundElements": None,
}
elements.insert(0, title_el)
return data
# ── CLI entry points ───────────────────────────────────────────────────────
def cmd_layout(args):
dsl = args.dsl if args.dsl else open(args.dsl_file).read().strip()
data = run_cli(dsl, args.output)
with open(args.output, "w") as f:
json.dump(data, f, indent=2)
print(f"Layout: {args.output} ({len(data.get('elements', []))} elements)")
def cmd_convert(args):
with open(args.input) as f:
data = json.load(f)
content = to_obsidian_native(data)
with open(args.output, "w") as f:
f.write(content)
print(f"Converted: {args.output}")
def cmd_quick(args):
dsl = args.dsl if args.dsl else open(args.dsl_file).read().strip()
data = run_cli(dsl, args.output)
data = auto_style(data)
if args.title:
data = add_title(data, args.title)
if args.output.endswith(".excalidraw.md"):
content = to_obsidian_native(data)
with open(args.output, "w") as f:
f.write(content)
else:
with open(args.output, "w") as f:
json.dump(data, f, indent=2)
print(f"Created: {args.output} ({len(data.get('elements', []))} elements)")
def main():
parser = argparse.ArgumentParser(description="Excalidraw helper utilities")
sub = parser.add_subparsers(dest="command", required=True)
# layout
p_layout = sub.add_parser("layout", help="Generate layout via CLI DSL")
g = p_layout.add_mutually_exclusive_group(required=True)
g.add_argument("--dsl", help="Inline DSL string")
g.add_argument("--dsl-file", help="Path to DSL file")
p_layout.add_argument("-o", "--output", required=True)
# convert
p_convert = sub.add_parser("convert", help="Convert .excalidraw to Obsidian .excalidraw.md")
p_convert.add_argument("--input", "-i", required=True)
p_convert.add_argument("-o", "--output", required=True)
# quick
p_quick = sub.add_parser("quick", help="One-shot: DSL → auto-styled Obsidian output")
g2 = p_quick.add_mutually_exclusive_group(required=True)
g2.add_argument("--dsl", help="Inline DSL string")
g2.add_argument("--dsl-file", help="Path to DSL file")
p_quick.add_argument("--title", help="Add title above diagram")
p_quick.add_argument("-o", "--output", required=True)
args = parser.parse_args()
{"layout": cmd_layout, "convert": cmd_convert, "quick": cmd_quick}[args.command](args)
if __name__ == "__main__":
main()
name excalidraw
description Create hand-drawn style diagrams — flowcharts, architecture, sequence, concept maps. Default workflow for flowcharts is CLI layout + freeform agent JSON editing + Obsidian native output. Raw JSON for non-flowchart layouts.
version 3.0.0
author Hermes Agent
license MIT
dependencies
metadata
hermes
tags related_skills
Excalidraw
Diagrams
Flowcharts
Architecture
Visualization
excalidraw-flowchart

Excalidraw Diagram Skill

Default Workflow (flowcharts/process diagrams)

Three steps:

  1. CLI generates layoutexcalidraw-cli places nodes with proper spacing, no overlaps
  2. Agent edits JSON freeform — read the output, add colors, restyle arrows, add background zones, tweak anything. No constraints — full access to the Excalidraw JSON spec.
  3. Convert to Obsidian native — wrap in .excalidraw.md for inline rendering

Step 1: Generate layout

# DSL → plain .excalidraw JSON
python3 ~/.hermes/skills/creative/excalidraw/scripts/hybrid.py layout \
  --dsl "(Start) -> [Process] -> {OK?} -> (End)" \
  -o /tmp/diagram.excalidraw

# Or from a .dsl file
python3 ~/.hermes/skills/creative/excalidraw/scripts/hybrid.py layout \
  --dsl-file /tmp/flow.dsl -o /tmp/diagram.excalidraw

DSL reference:

  • [Label] → rectangle | {Label?} → diamond | (Label) → ellipse | [[Label]] → database
  • -> arrow | -> "text" -> labeled arrow | --> dashed arrow
  • @direction TB|BT|LR|RL | @spacing N

Step 2: Agent edits JSON

Read the generated .excalidraw file, then modify elements freely via execute_code or patch. Common operations:

Color nodes by meaning:

# Color map: label substring → background color
colors = {"Deploy": "#b2f2bb", "Error": "#ffc9c9", "Test": "#fff3bf"}
for el in data["elements"]:
    if el["type"] in ("text", "arrow"): continue
    label = labels.get(el["id"], "")  # lookup from bound text elements
    for pattern, color in colors.items():
        if pattern.lower() in label.lower():
            el["backgroundColor"] = color
            el["fillStyle"] = "solid"
            break

Restyle specific arrows (dashed, colored):

for el in data["elements"]:
    if el["type"] == "arrow" and el.get("endBinding", {}).get("elementId") == "error_node":
        el["strokeColor"] = "#e03131"
        el["strokeStyle"] = "dashed"

Add background zones (group related nodes):

zone = {"type": "rectangle", "id": "zone_deploy", "x": 400, "y": 500,
        "width": 350, "height": 250, "backgroundColor": "#b2f2bb",
        "fillStyle": "solid", "opacity": 20, "strokeStyle": "dashed", ...}
data["elements"].insert(0, zone)  # insert at back (z-order)

Add rounded corners:

for el in data["elements"]:
    if el["type"] == "rectangle":
        el["roundness"] = {"type": 3}

Add title:

title = {"type": "text", "id": "title", "x": 250, "y": 10,
         "text": "My Pipeline", "fontSize": 28, "fontFamily": 1, ...}
data["elements"].insert(0, title)

The point: you're not limited to flags. Do whatever the Excalidraw JSON format supports.

Pitfalls with CLI output:

  • The CLI does NOT use containerId binding. Text elements float spatially on top of shapes without linking. To find a shape's label, use bounding-box overlap matching (check if text x/y falls within shape x/y/w/h ± 10px tolerance), NOT containerId lookups.
  • Element IDs are random — you can't predict them. Always match by label content or position.
  • Arrow labels may also lack containerId. Use the same spatial matching approach.

Step 3: Convert to Obsidian native

python3 ~/.hermes/skills/creative/excalidraw/scripts/hybrid.py convert \
  --input /tmp/diagram.excalidraw \
  -o "~/Documents/My Vault/path/to/diagram.excalidraw.md"

Or write the wrapper yourself — it's just frontmatter + text element index + JSON in a code block (see Obsidian format section below).

Quick mode (skip step 2)

For simple diagrams where auto-color-by-shape-type is fine:

python3 ~/.hermes/skills/creative/excalidraw/scripts/hybrid.py quick \
  --dsl "(Start) -> [Process] -> {OK?} -> (End)" \
  --title "My Flow" \
  -o diagram.excalidraw.md

Auto-colors: rectangle=#a5d8ff blue, diamond=#fff3bf yellow, ellipse=#d0bfff purple.


Raw JSON (non-flowchart diagrams)

For architecture diagrams, sequence diagrams, concept maps, spatial layouts — anything the DSL can't express. Hand-place elements with x/y coordinates.

Labeled shapes (container binding)

WARNING: "label": { "text": "..." } is NOT valid. Use container binding.

{ "type": "rectangle", "id": "r1", "x": 100, "y": 100, "width": 200, "height": 80,
  "roundness": { "type": 3 }, "backgroundColor": "#a5d8ff", "fillStyle": "solid",
  "boundElements": [{ "id": "t_r1", "type": "text" }] },
{ "type": "text", "id": "t_r1", "x": 105, "y": 110, "width": 190, "height": 25,
  "text": "Hello", "fontSize": 20, "fontFamily": 1, "strokeColor": "#1e1e1e",
  "textAlign": "center", "verticalAlign": "middle",
  "containerId": "r1", "originalText": "Hello", "autoResize": true }

Arrows

{ "type": "arrow", "id": "a1", "x": 300, "y": 150, "width": 150, "height": 0,
  "points": [[0,0],[150,0]], "endArrowhead": "arrow",
  "startBinding": { "elementId": "r1", "fixedPoint": [1, 0.5] },
  "endBinding": { "elementId": "r2", "fixedPoint": [0, 0.5] } }

fixedPoint: top=[0.5,0] bottom=[0.5,1] left=[0,0.5] right=[1,0.5] Arrowheads: null | "arrow" | "bar" | "dot" | "triangle" Stroke: "solid" | "dashed" | "dotted"

Z-order

Array order = z-order. Emit: shape → its bound text → its arrows → next shape.

Sizing

  • Font: min 16 body, min 20 titles, never below 14
  • Shapes: min 120x60 for labeled elements
  • Spacing: min 20-30px gaps

Color Palette

Use Hex
Primary / Input #a5d8ff
Success / Output #b2f2bb
Warning / External #ffd8a8
Processing / Special #d0bfff
Error / Critical #ffc9c9
Decisions #fff3bf
Storage / Data #c3fae8

Text contrast minimum on white: #757575. No emoji in text (won't render).


Obsidian Native Format

Always use for vault files. Extension: .excalidraw.md

---
excalidraw-plugin: parsed
tags: [excalidraw]
---

==⚠  Switch to EXCALIDRAW VIEW in the MORE OPTIONS menu of this document. ⚠==

# Excalidraw Data

## Text Elements
Label Text ^elementId

Another Label ^anotherId

%%
## Drawing
```json
{ ...excalidraw JSON... }

%%


Renders inline via `![[file]]` in Reading View (Cmd+E).

---

## Uploading / PNG Export

**Shareable link:**
```bash
python3 ~/.hermes/skills/creative/excalidraw/scripts/upload.py diagram.excalidraw

PNG for chat (no headless export exists):

  1. Upload → get URL
  2. browser_navigate(url)Ctrl+ACtrl+- (repeat) → Alt+ZEscape
  3. browser_visionMEDIA:<path>

Tips

  • See references/colors.md, references/dark-mode.md, references/examples.md
  • @excalidraw/utils npm DOES NOT WORK headless (needs browser DOM)
  • Export dialog (Ctrl+Shift+E) tends to timeout on clicks
#!/usr/bin/env python3
"""
Upload an .excalidraw file to excalidraw.com and print a shareable URL.
No account required. The diagram is encrypted client-side (AES-GCM) before
upload -- the encryption key is embedded in the URL fragment, so the server
never sees plaintext.
Requirements:
pip install cryptography
Usage:
python upload.py <path-to-file.excalidraw>
Example:
python upload.py ~/diagrams/architecture.excalidraw
# prints: https://excalidraw.com/#json=abc123,encryptionKeyHere
"""
import json
import os
import struct
import sys
import zlib
import base64
import urllib.request
try:
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
except ImportError:
print("Error: 'cryptography' package is required for upload.")
print("Install it with: pip install cryptography")
sys.exit(1)
# Excalidraw public upload endpoint (no auth needed)
UPLOAD_URL = "https://json.excalidraw.com/api/v2/post/"
def concat_buffers(*buffers: bytes) -> bytes:
"""
Build the Excalidraw v2 concat-buffers binary format.
Layout: [version=1 (4B big-endian)] then for each buffer:
[length (4B big-endian)] [data bytes]
"""
parts = [struct.pack(">I", 1)] # version = 1
for buf in buffers:
parts.append(struct.pack(">I", len(buf)))
parts.append(buf)
return b"".join(parts)
def upload(excalidraw_json: str) -> str:
"""
Encrypt and upload Excalidraw JSON to excalidraw.com.
Args:
excalidraw_json: The full .excalidraw file content as a string.
Returns:
Shareable URL string.
"""
# 1. Inner payload: concat_buffers(file_metadata, data)
file_metadata = json.dumps({}).encode("utf-8")
data_bytes = excalidraw_json.encode("utf-8")
inner_payload = concat_buffers(file_metadata, data_bytes)
# 2. Compress with zlib
compressed = zlib.compress(inner_payload)
# 3. AES-GCM 128-bit encrypt
raw_key = os.urandom(16) # 128-bit key
iv = os.urandom(12) # 12-byte nonce
aesgcm = AESGCM(raw_key)
encrypted = aesgcm.encrypt(iv, compressed, None)
# 4. Encoding metadata
encoding_meta = json.dumps({
"version": 2,
"compression": "pako@1",
"encryption": "AES-GCM",
}).encode("utf-8")
# 5. Outer payload: concat_buffers(encoding_meta, iv, encrypted)
payload = concat_buffers(encoding_meta, iv, encrypted)
# 6. Upload
req = urllib.request.Request(UPLOAD_URL, data=payload, method="POST")
with urllib.request.urlopen(req, timeout=30) as resp:
if resp.status != 200:
raise RuntimeError(f"Upload failed with HTTP {resp.status}")
result = json.loads(resp.read().decode("utf-8"))
file_id = result.get("id")
if not file_id:
raise RuntimeError(f"Upload returned no file ID. Response: {result}")
# 7. Key as base64url (JWK 'k' format, no padding)
key_b64 = base64.urlsafe_b64encode(raw_key).rstrip(b"=").decode("ascii")
return f"https://excalidraw.com/#json={file_id},{key_b64}"
def main():
if len(sys.argv) < 2:
print("Usage: python upload.py <path-to-file.excalidraw>")
sys.exit(1)
file_path = sys.argv[1]
if not os.path.isfile(file_path):
print(f"Error: File not found: {file_path}")
sys.exit(1)
with open(file_path, "r", encoding="utf-8") as f:
content = f.read()
# Basic validation: should be valid JSON with an "elements" key
try:
doc = json.loads(content)
except json.JSONDecodeError as e:
print(f"Error: File is not valid JSON: {e}")
sys.exit(1)
if "elements" not in doc:
print("Warning: File does not contain an 'elements' key. Uploading anyway.")
url = upload(content)
print(url)
if __name__ == "__main__":
main()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment