← All articles

From Skill to CLI to npm: Building an Excalidraw Toolchain with Claude Code

How a single Claude Code skill turned into two published npm packages in one session — and what it reveals about building with AI agents.

2025-02-07

It started with a simple realization: Claude is surprisingly good at spatial reasoning.

I had been using Claude Code for a while — the CLI-based coding agent from Anthropic. It operates entirely in your terminal, reading and writing files, running commands, searching codebases. No browser. No GUI. Just you, your shell, and a very capable model.

I decided to see if it could generate Excalidraw diagrams — the hand-drawn-style whiteboarding tool that developers love. Excalidraw stores its diagrams as plain JSON: an array of elements with coordinates, dimensions, colors, and bindings between shapes. If Claude could output valid JSON in this format, we’d have instant diagrams from natural language.

So I wrote a Claude Code skill — a reusable prompt template that lives in ~/.claude/commands/excalidraw.md. When invoked with /excalidraw <description>, it instructs Claude to:

  1. Parse the description and identify the diagram type (architecture, flowchart, sequence, ERD, mind map)
  2. Plan the layout using precise coordinate math
  3. Generate valid Excalidraw JSON with proper bidirectional bindings
  4. Write the .excalidraw file

The skill prompt is ~800 lines of carefully structured instructions: layout formulas, color semantics, complete JSON templates, a quality checklist. It’s essentially a visual DSL encoded as a system prompt.

And it works. Remarkably well. One-shot architecture diagrams, decision flowcharts with labeled branches, multi-tier service layouts with container groupings. Claude nails the spatial layout because the skill gives it concrete formulas:

For box[i] in a horizontal row (0-indexed):
  x = START_X + i * (BOX_W + GAP)
  y = START_Y

Arrow from box[i] to box[i+1]:
  x = box[i].x + BOX_W
  y = box[i].y + BOX_H/2
  points = [[0, 0], [GAP, 0]]

No ambiguity. No “place it roughly to the right.” Exact pixel coordinates. The model follows the math, and the diagrams come out aligned and properly spaced.

But there was a problem.

The Manual Step That Shouldn’t Exist

After Claude generated the .excalidraw file, you had to:

  1. Open excalidraw.com
  2. Click the menu → “Open” → select the file
  3. Export as PNG or SVG
  4. Save it

Four manual steps. Every time. For a tool built for an AI agent workflow, this friction killed the whole point. Claude should generate the diagram and show it to you. End of story.

My first instinct was Puppeteer — spin up a headless browser, load Excalidraw, inject the JSON, trigger an export. It’s the brute-force answer to “I need what a browser does, but without a browser.” But it’s the wrong answer, and the reason why is worth understanding.

Why Puppeteer Is the Wrong Tool

How Excalidraw Renders (The Browser Path)

When you open an Excalidraw diagram in the browser, here’s what actually happens:

  1. roughjs generates hand-drawn-style geometry. For each shape (rectangle, ellipse, diamond), it computes a series of mathematical operations — cubic bezier curves that simulate imprecise hand-drawn strokes. The “roughness” parameter controls how much jitter is added to the control points.

  2. These operations are rendered onto an HTML5 Canvas 2D context — the browser’s pixel-level drawing API. Calls like ctx.moveTo(), ctx.bezierCurveTo(), ctx.stroke() paint each shape.

  3. Text rendering uses the Canvas 2D measureText() API for layout calculations and fillText() for rendering — including the custom Virgil font that gives Excalidraw its hand-written look.

  4. For export, the canvas is converted to a bitmap via canvas.toBlob() (PNG) or serialized as SVG with embedded font data.

The critical browser APIs involved:

APIPurpose
CanvasRenderingContext2DAll shape rendering
ctx.measureText()Text width calculation for centering
ctx.fillText()Text rendering with custom fonts
FontFace APILoading Virgil and other custom fonts
canvas.toBlob()PNG export
canvas.toDataURL()Inline image generation
Path2DComplex path construction

Puppeteer would give us all of these. But it means:

  • ~400MB Chromium download as a dependency
  • 2-5 second startup per export (cold browser launch)
  • Memory overhead — a full browser process for what should be a pure computation
  • System dependency — needs a display server or --no-sandbox flags in CI
  • Fragile — tied to Excalidraw’s web app DOM structure, which changes between versions

All of this to do what is fundamentally math — computing bezier curves and assembling SVG.

The Insight: roughjs Doesn’t Need a Browser

Here’s the key realization: roughjs has a generator mode that never touches the DOM.

import rough from "roughjs";

// This creates a generator that outputs pure math — no canvas, no DOM
const gen = rough.generator();

// Returns an object with .sets[] containing operation arrays
const drawable = gen.rectangle(0, 0, 200, 80, {
  roughness: 1,
  seed: 12345,  // deterministic output
});

// drawable.sets[0].ops = [
//   { op: "move", data: [2.34, 1.12] },
//   { op: "bcurveTo", data: [45.2, -1.3, 98.7, 2.1, 200.4, 0.8] },
//   { op: "lineTo", data: [200.1, 79.3] },
//   ...
// ]

Those operation arrays are just numbers — cubic bezier curve control points that describe the hand-drawn stroke paths. They can be converted directly to SVG <path> elements:

M2.34 1.12 C45.20 -1.30,98.70 2.10,200.40 0.80 L200.10 79.30 ...

No Canvas API needed. No browser needed. Pure computation → SVG string.

Building the CLI Export Tool

With this insight, the architecture became clear:

.excalidraw JSON → Parse elements → roughjs generator → SVG paths → SVG document → resvg → PNG

The Rendering Pipeline

Step 1: Parse the Excalidraw JSON. Filter deleted elements, sort by layer order, extract the files map (embedded images).

Step 2: For each element, generate SVG. This is where the shape-specific logic lives:

  • Rectangles, ellipses, diamondsrough.generator() computes the hand-drawn strokes. Each shape produces a drawable with multiple “sets”: a path set (the outline strokes) and optionally a fillPath or fillSketch set (the interior). Each set contains an array of operations (move, lineTo, bcurveTo).

  • Text → SVG <text> elements with Excalidraw’s font families. The Virgil font (fontFamily 1) is embedded as a base64-encoded @font-face declaration in the SVG’s <defs>. Text alignment and multi-line wrapping are computed from the element’s width and text content.

  • Arrows and lines → Multi-point paths. Straight arrows use rough.linearPath(). Curved arrows (those with roundness set) use rough.curve() to generate smooth bezier curves through the control points. Arrowheads are computed as small triangular polygons at the endpoint, rotated to match the arrow’s final direction.

  • Freedraw → Raw point arrays are converted directly to SVG <path> data by connecting each recorded point with line-to commands. The stroke-linecap: round and stroke-linejoin: round attributes smooth out the visual result without needing any external library.

  • Images → Direct <image> SVG elements referencing the base64 data URLs from the Excalidraw file’s files map.

  • Frames → Dashed rectangles with optional text labels, rendered behind their contained elements.

Step 3: Handle rotation. Excalidraw stores rotation as angle in radians, centered on the element’s midpoint. Each rotated element gets wrapped in an SVG <g> with a transform="rotate(deg, cx, cy)".

Step 4: Compute the viewport. The bounding box calculation iterates all elements, rotating corner points for elements with non-zero angles, and finds the min/max coordinates. A padding of 40px is added on all sides.

Step 5: Assemble the SVG document. The elements become children of an <svg> root element with the computed viewBox. Font definitions go in <defs>. Background color (if not transparent) becomes a full-viewport <rect>.

Step 6: PNG conversion. For PNG output, the SVG string is passed to resvg-js — a Rust-based SVG renderer compiled to a native Node addon via napi-rs. It rasterizes the SVG at the requested scale factor (default 2x for retina quality).

import { Resvg } from "@resvg/resvg-js";

const resvg = new Resvg(svgString, {
  fitTo: { mode: "zoom", value: scale },
});
const pngBuffer = resvg.render().asPng();

The entire pipeline — JSON parse, roughjs generation, SVG assembly, PNG rasterization — runs in under 500ms for typical diagrams.

The Operation-to-Path Conversion

The core utility function that makes this work converts roughjs operations to SVG path data:

export function opsToPath(ops) {
  return ops.map(op => {
    switch (op.op) {
      case "move":
        return `M${op.data[0].toFixed(2)} ${op.data[1].toFixed(2)}`;
      case "lineTo":
        return `L${op.data[0].toFixed(2)} ${op.data[1].toFixed(2)}`;
      case "bcurveTo":
        // Cubic bezier: 6 values = 3 control points (cp1x, cp1y, cp2x, cp2y, x, y)
        return `C${op.data[0].toFixed(2)} ${op.data[1].toFixed(2)},` +
               `${op.data[2].toFixed(2)} ${op.data[3].toFixed(2)},` +
               `${op.data[4].toFixed(2)} ${op.data[5].toFixed(2)}`;
    }
  }).join(" ");
}

Each roughjs operation maps 1:1 to an SVG path command. moveM, lineToL, bcurveToC. The hand-drawn appearance comes entirely from the jittered control point positions that roughjs computes — the SVG rendering is perfectly standard.

The Dependency Tree

@moona3k/excalidraw-export
├── roughjs         — Hand-drawn geometry generation (generator mode, no DOM)
└── @resvg/resvg-js — Rust-based SVG → PNG rasterization (native addon)

Two dependencies. No browser. No Puppeteer. No Canvas polyfill.

Publishing to npm

This was my first npm publish. A few things I learned along the way:

npm 2FA with security keys doesn’t work for CLI publishing. npm’s publish command requires a TOTP (time-based one-time password) for 2FA verification, but security keys (WebAuthn) only work in the browser UI. The workaround: create a granular access token on npmjs.com with “Bypass 2FA on publish” enabled, then use that token for CLI publishing.

Scoped package names avoid collisions. My first choice — excalidraw-export — was rejected because npm considers it too similar to an existing package excalidraw_export (underscore vs hyphen). Scoping to @moona3k/excalidraw-export solves this, but requires --access=public when publishing (scoped packages are private by default).

Registry propagation is not instant. After npm publish succeeds, the package returns 404 on the registry API for 2-5 minutes. The npmjs.com web UI shows it immediately, but npm view and npx need the CDN to catch up.

Published as @moona3k/excalidraw-export. The skill prompt was updated to call npx @moona3k/excalidraw-export after generating the JSON, closing the loop: /excalidraw now generates the diagram, exports it to PNG, and shows you the rendered image — all without leaving your terminal.

The Mermaid Conversion: Filling an Open Gap

While working on the export tool, I noticed excalidraw/mermaid-to-excalidraw#66 — a long-standing GitHub issue requesting the reverse direction: Excalidraw → Mermaid.

The existing mermaid-to-excalidraw library converts Mermaid text to Excalidraw JSON. But many users wanted to go the other way — take a visual diagram they’d drawn in Excalidraw and get Mermaid syntax they could paste into GitHub READMEs, Notion pages, or documentation systems that render Mermaid.

I’d already built a parser that understands Excalidraw’s element structure. The conversion logic was straightforward:

How Excalidraw-to-Mermaid Works

Step 1: Parse the document into a graph.

Excalidraw stores diagrams as a flat array of elements. The parser reconstructs the graph structure:

  • Nodes: rectangles, ellipses, diamonds — anything in NODE_TYPES
  • Edges: arrows with startBinding and endBinding pointing to node IDs
  • Labels: text elements with containerId pointing to their parent shape or arrow
  • Groups: frames (spatial containment) or shared groupIds

The binding system is key. When you draw an arrow between two shapes in Excalidraw, it creates a bidirectional binding:

// Arrow element
{
  "type": "arrow",
  "startBinding": { "elementId": "box_001" },
  "endBinding": { "elementId": "box_002" }
}

// Source shape
{
  "id": "box_001",
  "boundElements": [{ "id": "arrow_001", "type": "arrow" }]
}

This is what makes the conversion reliable — we’re not guessing connectivity from proximity. The graph topology is explicit in the data.

Step 2: Map shapes to Mermaid syntax.

Excalidraw ElementMermaid SyntaxVisual
Rectangle (no roundness)A[Label]Square box
Rectangle (with roundness)A(Label)Rounded box
DiamondA{Label}Rhombus
EllipseA((Label))Circle
Dashed rectangleA[[Label]]Subroutine box

Step 3: Map arrow styles.

Excalidraw ArrowMermaidSyntax
Solid + arrowheadForward arrow-->
Solid + no headPlain line---
Dashed + arrowheadDotted arrow-.->
Thick (strokeWidth >= 4)Bold arrow==>

Arrow labels (text elements bound to arrows via containerId) become Mermaid edge labels: -->|label|.

Step 4: Detect flow direction.

Rather than forcing the user to specify graph TD vs graph LR, the converter analyzes the spatial layout. For each edge, it computes the horizontal vs vertical displacement between source and target node centers:

const dx = Math.abs(target.cx - source.cx);
const dy = Math.abs(target.cy - source.cy);

if (dx > dy) horizontalScore++;
else verticalScore++;

Majority wins. If most edges flow left-to-right, it’s graph LR. If most flow top-to-bottom, it’s graph TD.

Step 5: Handle groups as subgraphs.

Excalidraw frames (a type: "frame" element with spatial bounds) become Mermaid subgraph blocks. The converter checks which nodes fall within the frame’s bounding box and nests them:

graph TD
    subgraph backend[Backend Services]
        A[API Server]
        B[Database]
    end
    C[Client]
    A --> B
    C --> A

Step 6: Assign short IDs and generate output.

Excalidraw element IDs are long strings like "xK2jF9qL...". The converter assigns clean alphabetic IDs (A, B, C, … Z, AA, AB, …) and outputs clean Mermaid syntax. Labels with special characters (colons, brackets, pipes) are automatically quoted per Mermaid’s escaping rules.

Published as excalidraw-to-mermaid. Zero dependencies.

The Bigger Picture: What This Session Reveals

In a single Claude Code session, we went from “I wish this manual step didn’t exist” to two published npm packages with 159 combined tests, full CLI interfaces, and programmatic APIs. The entire toolchain:

Natural language → /excalidraw skill → .excalidraw JSON

                              excalidraw-export CLI → PNG/SVG

                           excalidraw-to-mermaid CLI → Mermaid syntax

Each piece solves a real problem. The skill generates diagrams from descriptions. The export tool removes the browser dependency. The Mermaid converter fills a gap that developers had been requesting for years.

What made this possible:

  1. A capable model that can reason about coordinate math, SVG paths, and bezier curves
  2. An agent interface that can read files, run commands, write code, and iterate on test failures
  3. A human who knows what to build and can steer

The model didn’t design this on its own. The human identified the opportunity (automate the export), rejected the wrong approach (Puppeteer), and pointed toward the right one (roughjs generator mode). The model handled the implementation — parsing Excalidraw’s JSON format, computing arrowhead angles, debugging npm 2FA token flows, writing 159 tests.

This is what building with AI looks like in 2026. Not “AI writes my app.” More like: I have an idea, I have taste, and I have an incredibly fast collaborator who can turn direction into working code.

And Yes, This Website Too

After the two packages were published and tested, I said: “we should document all of this.” Claude wrote this article. Then I said: “let’s deploy it.” Claude scaffolded the site, configured Cloudflare Pages, and deployed it.

The website you’re reading — claudemaster.com — was built, written, and deployed in the same session as the tools it describes. The same Claude Code instance that computed bezier curve control points also wrote the HTML templates and ran wrangler pages deploy.

The entire chain — from “I wish this manual step didn’t exist” to a live website documenting the solution — happened in one sitting. The only thing the human needed to provide was direction, taste, and an npm token.


Technical Reference

excalidraw-export

npx @moona3k/excalidraw-export diagram.excalidraw

GitHub: moona3k/excalidraw-export | npm: @moona3k/excalidraw-export

Rectangles, ellipses, diamonds, arrows, lines, text (all 4 font families), freedraw, images, frames, rotation, fill styles, stroke styles, opacity, arrowheads, curved multi-point arrows. 71 tests.

excalidraw-to-mermaid

npx excalidraw-to-mermaid diagram.excalidraw

GitHub: moona3k/excalidraw-to-mermaid | npm: excalidraw-to-mermaid

5 node shapes, 6 edge styles, arrow labels, frames as subgraphs, groupId-based subgraphs, auto direction detection, special character quoting. 88 tests. Zero dependencies.

/excalidraw Skill

Available as a GitHub Gist — install into ~/.claude/commands/excalidraw.md for Claude Code.

Supports: architecture diagrams, flowcharts, sequence diagrams, data flow, ERDs, mind maps. Clean, hand-drawn, and sketchy style presets.