Diagram Rendering 📈
Draw a diagram or chart from text and deliver it inline in chat. One script,
scripts/render.py, covers three diagram languages and data charts, and always ends in
a PNG (or an inline image URL) — because chat platforms render images, not
.d2/.mmd/.dot/.svg source.
Overview
Two jobs, one tool:
- Diagrams (flowcharts, architecture, sequence, ER) — you write terse text in D2, Mermaid, or Graphviz; the hosted renderer (Kroki) returns an SVG and the script rasterizes it locally to a crisp 2× PNG.
- Data charts (line, bar, pie, etc.) — you write a Chart.js JSON config; QuickChart renders it, either as a local PNG (default) or a short inline URL.
Pick the language by the tradeoff you want:
| Language | Layout | Tradeoff |
|---|---|---|
| D2 | modern, rounded, auto-laid-out | best-looking default; layout can wander on very dense graphs |
| Graphviz | strict orthogonal, deterministic | most robust auto-layout for dense/dependency graphs; visually stiffer |
| Mermaid | simple flow/sequence | fastest to write; plainest default styling |
Charts (QuickChart) cover the data side — P&L curves, win-rate bars, category breakdowns. Same tool, so one skill draws both a flow and a plot.
When to Use
Use for fast, auto-laid-out diagrams or charts when hosted rendering is acceptable
(the content is not sensitive, or you point *_BASE at a self-hosted instance).
- "draw / show / diagram this", "make a flowchart / architecture / sequence / ER diagram"
- "chart / plot / graph our P&L / win rate / breakdown"
Route elsewhere:
- Hand-drawn / whiteboard aesthetic →
excalidraw. - Offline, pixel-controlled dark-SVG "card" where you hand-place every box →
architecture-diagram. - Photographic / illustrative images → an image-generation skill.
Requirements
- Headless Chromium for the diagram paths (rasterizes SVG). The script probes
chromium-browser,chromium,google-chrome,google-chrome-stable, or honorsCHROMIUM_BIN. On macOS setCHROMIUM_BIN="/Applications/Google Chrome.app/Contents/MacOS/Google Chrome". Not needed forchart --short-url(pure API call). - Network to
kroki.io(diagrams) andquickchart.io(charts) — free, no key. Point at self-hosted instances withKROKI_BASE/QUICKCHART_BASE(http(s) only; these are trusted operator config, never derive them from an untrusted prompt). - Pillow (
pip install pillow) — recommended; enables real blank-render detection. Without it the script only checks PNG magic + a weak size heuristic and cannot reliably catch a blank canvas — so eyeball the image regardless.
Workflow
The script lives next to this
SKILL.md. Resolve its directory asSKILL_DIRand call it by absolute path — do not assume the caller's working directory:SKILL_DIR="$(dirname "$(realpath path/to/this/SKILL.md)")" # or the skill dir you loaded
1. Write the diagram text
Save to a file. Example D2 (~/flow.d2):
direction: down
firehose: "Trades firehose" {shape: oval; style: {fill: "#083344"; stroke: "#22d3ee"; font-color: "#e2e8f0"}}
elig: "Eligible?" {shape: diamond; style: {fill: "#1e293b"; stroke: "#94a3b8"; font-color: "#e2e8f0"}}
ladder: "Price ladder" {style: {fill: "#2e1065"; stroke: "#a78bfa"; font-color: "#e2e8f0"; border-radius: 8}}
firehose -> elig
elig -> ladder: yes
Mermaid (.mmd) and Graphviz (.dot) work the same way — the script passes the text
straight through to Kroki.
2. Render to PNG
python3 "$SKILL_DIR/scripts/render.py" diagram --lang d2 --in ~/flow.d2 --out ~/flow.png
python3 "$SKILL_DIR/scripts/render.py" diagram --lang mermaid --in ~/flow.mmd --out ~/flow.png
python3 "$SKILL_DIR/scripts/render.py" diagram --lang graphviz --in ~/flow.dot --out ~/flow.png
The script prints the absolute PNG path on success and exits non-zero on a failed or
blank render (it retries transient host errors first). --out must be under $HOME
(see pitfall #1). Optional --width / --height (default 900×700, scaled 2×).
3. Deliver inline
The printed path is what the platform renders. Put it in your reply as a standalone,
unfenced line (a code-fenced MEDIA: will NOT attach the image):
MEDIA:/home/you/flow.png
Inspect the image first — the blank-check catches empty renders, not wrong content.
Charts
Write a Chart.js config (~/pnl.json):
{
"type": "line",
"data": {
"labels": ["Mon", "Tue", "Wed"],
"datasets": [
{
"label": "P&L $",
"data": [0, 42, 124],
"borderColor": "#34d399",
"backgroundColor": "rgba(52,211,153,0.15)",
"fill": true,
"tension": 0.3
}
]
}
}
Default: local PNG (POSTs the config, so large configs are fine), deliver via
MEDIA::
python3 "$SKILL_DIR/scripts/render.py" chart --config ~/pnl.json --out ~/pnl.png
Only if the target channel is known to auto-embed remote image URLs, a short URL is lighter (the platform fetches it; no local file):
python3 "$SKILL_DIR/scripts/render.py" chart --config ~/pnl.json --short-url
# -> https://quickchart.io/chart/render/zf-... (a public bearer link)
Prefer the local PNG + MEDIA: default — it renders on every platform and you can
verify the image before sending. Use --short-url only for channels that reliably
inline remote images.
Tiered strategy (what to reach for)
- Default diagram → D2. Best-looking, one command.
- Dense / dependency graph, layout getting messy → Graphviz. Deterministic layout.
- Just need a quick flow, styling irrelevant → Mermaid.
- Data, not boxes → chart (QuickChart). Local PNG default; short-URL only where it inlines.
- Fully offline / pixel-exact brand card →
architecture-diagram(hand SVG).
Common Pitfalls
- Snap Chromium can only WRITE under
$HOME, and NOT to hidden (dot-prefixed) files. AppArmor silently blocks writes to/tmpand to~/.foo.png(the screenshot just never appears). The script enforces--outunder$HOME(symlink-resolved) and uses a visible temp filename for the render. Reading a hidden source file is fine; writing a hidden output is not. - Kroki renders D2 to SVG only — its PNG endpoint 400s for D2. The script always
fetches SVG and rasterizes locally (for every language, so 2× scale +
--width/heightapply uniformly). Don't "optimize" it back to requesting D2 PNG. - Kroki's Mermaid renderer is intermittently flaky — it occasionally 400s with "Failed to launch the browser process" (a failure inside Kroki's own headless browser, under load). The script retries transient 5xx/that-400 a couple times. If it still fails, it's the host — retry later or switch language.
- Untrusted render output is rasterized with JavaScript DISABLED. Kroki/QuickChart
responses are outside your trust boundary; SVG can carry scripts. The script renders
with
--disable-javascript(Kroki SVG is static and needs none). Thesvgsubcommand is for trusted SVG only. - Blank-render detection catches empty canvases, not wrong content, and is only authoritative with Pillow installed. Valid-but-wrong text renders fine and passes. Always eyeball anything that matters for legibility, clipping, and correctness.
- Default
Python-urllibUser-Agent gets 403'd by Kroki. The script sends a real UA. If you write your own fetch, setUser-Agentor you'll get 403. - Both diagram source AND chart data leave the machine (to Kroki / QuickChart
respectively). A "private thread" does not protect the data from those providers, and
a
--short-urlis a public bearer link. For sensitive content, use approved self-hostedKROKI_BASE/QUICKCHART_BASEendpoints or an offline sibling skill (architecture-diagram,excalidraw). - Don't paste a 1000-char raw QuickChart GET URL into chat. Use the local-PNG
default or
--short-url.
Verification Checklist
-
--outpath is under$HOMEand not dot-prefixed - Chromium present for diagram paths (
CHROMIUM_BINor one of the four probed names) - Script exited 0 and printed a PNG path (non-zero = blank/failed/host-down after retries)
- Eyeballed the image for correctness, clipping, legibility (guard only catches blank)
- Delivered as an unfenced
MEDIA:<path>line (PNG) or a--short-urllink on a channel that inlines it - Sensitive content? Used a self-hosted
*_BASEor an offline skill instead