---
name: apt:diagram
description: "Generate .excalidraw or MCP-canvas diagrams"
apt-skill-version: {{APT_VERSION}}
stage: design
intent: design
when_to_use: "The user wants to generate a diagram from a prompt — system architecture sketch, flowchart, data-flow picture. Produces either a local .excalidraw file (default) or a live Excalidraw MCP canvas."
user_invocable: true
internal: false
spawns_agent: false
agent_name: null
task_context: self-managed
# default_track omitted — self-managed skill, router does not consult this field
default_execution_mode: auto
execution_modes:
  - auto
  - step
allowed-tools: "Read, Write, Edit, Bash, Grep, Glob"
argument-hint: "apt:diagram [prompt] [--mode=file|--mode=mcp] [--depth=default|comprehensive]"
gates: []
---

<objective>
Turn a natural-language prompt into a diagram. The skill splits the
work into two phases: (1) the agent itself produces a `DiagramScene`
JSON (a minimal graph of labelled `nodes` and directed `edges`) from
the user prompt, and (2) a deterministic renderer turns that scene
into Excalidraw geometry — either a local `.excalidraw` file under
`docs/diagrams/<slug>.excalidraw` or a live canvas via the Excalidraw
MCP server.

There are exactly two render modes — `file` (offline, default) and
`mcp` (live canvas). There is no `auto` mode and no automatic
degradation between them: if the chosen renderer fails, the skill
hard-errors with a message telling the user to re-run with the other
mode. This is intentional in v1 — diagnostics over magic.
</objective>

<process>

## 1. Parse arguments

`$ARGUMENTS` may contain free-text prompt material plus at most one
`--mode=<value>` flag and at most one `--depth=<value>` flag. Extract the
prompt by removing any flags (if present) and trimming. Extract each
flag value if any.

- If a `--mode=...` flag is present and its value is anything other
  than `"file"` or `"mcp"`, emit a hard error of the exact shape:
  `Unknown --mode value: <value>. Expected file or mcp.` and exit.
- Empty prompt → emit `apt:diagram requires a prompt describing what to draw.` and exit.
- If a `--depth=...` flag is present and its value is anything other
  than `"default"` or `"comprehensive"`, emit a hard error of the exact
  shape: `Unknown --depth value: <value>. Expected default or comprehensive.`
  and exit. The default value when absent is `default`. When the value
  is `comprehensive`, before generating the scene in Step 3 load
  `appendices/design-discipline.md` into reasoning context (mirrors
  apt:discuss's `appendices/grill-discipline.md` load pattern). The flag
  is per-call only — it is NOT persisted to `.aperant/config.json`.

## 2. Resolve mode

Resolution order: --mode flag > .aperant/config.json#diagram.mode > "file" default

Read `.aperant/config.json` and look for `diagram.mode`. The lookup
runs through the same in-skill Node one-liner pattern apt:quick uses
for `preferences.quick_task_post_verify` (see apt-quick/SKILL.md
Step 8):

```bash
node -e '
  const fs = require("fs");
  const path = require("path");
  const cfgPath = path.join(process.cwd(), ".aperant/config.json");
  let mode = "file"; // default
  try {
    const cfg = JSON.parse(fs.readFileSync(cfgPath, "utf-8"));
    if (cfg && cfg.diagram && (cfg.diagram.mode === "file" || cfg.diagram.mode === "mcp")) {
      mode = cfg.diagram.mode;
    }
  } catch {}
  // The flag override (if present) is layered on top in the agent step,
  // not in this snippet — this only reads the persisted default.
  process.stdout.write(mode);
'
```

If the user passed `--mode=file` or `--mode=mcp`, that wins
unconditionally over whatever the file says — the flag is a one-call
override and is not persisted to config.

## 3. Generate a `DiagramScene`

The agent itself produces a JSON object of shape:

```json
{
  "nodes": [{ "id": "<stable-id>", "label": "<short-label>", "x": <number>, "y": <number> }, ...],
  "edges": [{ "from": "<id>", "to": "<id>", "label": "<optional>" }, ...]
}
```

Constraints the agent MUST honour while producing the scene:

- **Stable string ids.** Every node has a short, stable `id` (lowercase
  kebab works well — `auth-service`, `db`, `cdn`). Edges reference
  these ids in `from`/`to`. NEVER use positional ids like `n0`/`n1`.
- **Endpoint validity.** Every `edge.from` and `edge.to` MUST appear in
  the `nodes` array. The renderer hard-errors on a missing endpoint
  with `DiagramScene edge references unknown node id: <id>` — that is
  a failure of scene generation, not a renderer bug, so the agent
  MUST validate this before dispatching to the renderer.
- **Coordinates.** Use a rough grid to avoid overlap — e.g. 200px
  horizontal spacing between sibling nodes, 150px vertical between
  layers. Nodes default to ~180x70 px in the renderer; keep ≥200px
  centre-to-centre.
- **Scope.** v1 has no groups, containers, or layout constraints.
  Don't invent extra fields — the renderer ignores them at best,
  rejects the scene at worst.

## 4. File mode — dispatch to `renderToFile`

When the resolved mode is `file`:

1. Derive a slug from the prompt by calling `deriveSlug(prompt)` from
   `@aperant/core/workflows/diagram`. The slug becomes the filename
   under `docs/diagrams/<slug>.excalidraw`.
2. Invoke `renderToFile({ scene, slug, projectDir })` via a small Node
   invocation — same precedent as apt:design Step 3 ("invoke the
   library entry point via a small Node invocation"). The agent
   serialises the scene into the command via a temp file or
   `JSON.stringify`-ed env var:

   ```bash
   node -e '
     import("@aperant/core/workflows/diagram").then(async (m) => {
       const scene = JSON.parse(process.env.APT_DIAGRAM_SCENE);
       const slug = m.deriveSlug(process.env.APT_DIAGRAM_PROMPT || "diagram");
       const result = await m.renderToFile({
         scene,
         slug,
         projectDir: process.cwd(),
       });
       process.stdout.write(result.absolutePath);
     }).catch((err) => {
       process.stderr.write("renderToFile failed: " + err.message + "\n");
       process.exit(1);
     });
   '
   ```

   Pass the scene as `APT_DIAGRAM_SCENE` env (JSON-encoded) and the raw
   prompt as `APT_DIAGRAM_PROMPT`. The wrapper prints the absolute path
   to stdout on success.

3. On success, print to the user:

   ```
   Diagram written to <absolutePath>. Open it in Excalidraw (drag-drop the file into excalidraw.com or open via Aperant's Excalidraw integration).
   ```

   Relative path under the project: `docs/diagrams/<slug>.excalidraw`.

4. On failure (any `Error` thrown from `renderToFile`, including the
   `unknown node id` validation case), surface the message to the user
   verbatim with no retry and no fallback. Keep the generated scene
   JSON visible in the chat so the user can inspect what was
   produced — that is the diagnostic.

## 5. MCP mode — consent gate + renderer dispatch

When the resolved mode is `mcp`, the skill runs three sub-steps in
order: (a) consent gate, (b) renderer dispatch, (c) failure handling.

### 5a. Consent gate

Per FRAMEWORK-AUDIT-001, `mcpConsent` is per-user state and lives in
`.aperant/config.local.json` (gitignored), NOT the shared
`.aperant/config.json`. The `diagram.mode` field stays in `config.json` as
team policy. Read the MERGED config (config.json deep-merged with
config.local.json — local wins on collision) and look at
`diagram.mcpConsent` via the same in-skill `node -e` pattern Step 2 uses:

```bash
node -e '
  const fs = require("fs");
  const path = require("path");
  const aperantDir = path.join(process.cwd(), ".aperant");
  function readJson(p) {
    try { return JSON.parse(fs.readFileSync(p, "utf-8")); } catch { return null; }
  }
  const shared = readJson(path.join(aperantDir, "config.json")) || {};
  const local = readJson(path.join(aperantDir, "config.local.json")) || {};
  // Deep-merge — local wins on collision; matches loadMergedProjectConfig semantics.
  function merge(a, b) {
    if (a === null || typeof a !== "object" || Array.isArray(a)) return b;
    if (b === null || typeof b !== "object" || Array.isArray(b)) return b;
    const out = { ...a };
    for (const k of Object.keys(b)) out[k] = (k in a) ? merge(a[k], b[k]) : b[k];
    return out;
  }
  const cfg = merge(shared, local);
  const consent = cfg && cfg.diagram ? cfg.diagram.mcpConsent : null;
  process.stdout.write(JSON.stringify(consent || null));
'
```

The shape is:

```json
{ "host": "<host string>", "decision": "granted" | "denied", "grantedAt": "<ISO 8601 timestamp>" }
```

or `null` if no decision has ever been recorded.

Determine the "configured MCP host" from the connected Excalidraw MCP
server. The skill does NOT manage MCP server installation — it reads
the host name from whatever `mcp__excalidraw__*` namespace is already
present on the agent's tool surface. (For most setups the host is
something like `mcp.excalidraw.com` or a self-hosted URL — use
whatever the surface reports.)

Decide whether to prompt:

- If `diagram.mcpConsent` is `null` → prompt.
- If `diagram.mcpConsent.host !== <configured MCP host>` → prompt.
- If `diagram.mcpConsent.host === <configured MCP host>` AND `diagram.mcpConsent.decision === "denied"` → hard-error immediately with the exact denied text (no re-prompt):
  ```
  diagram MCP consent denied; re-run with `--mode=file` or change diagram.mcpConsent in .aperant/config.local.json
  ```
- Otherwise (host matches AND decision is `"granted"`) → the existing consent applies; proceed to 5b.

When prompting, print a single line naming the host and ask `granted / denied`:

```
apt:diagram is about to send diagram data to the Excalidraw MCP server at <host>. Type `granted` to allow, `denied` to refuse.
```

Read one line of input. Persist the decision to
`.aperant/config.local.json` (NOT `config.json` — `mcpConsent` is per-user
state per FRAMEWORK-AUDIT-001; the local file is gitignored). The
read-mutate-write JSON pattern targets the local file only:

```bash
node -e '
  const fs = require("fs");
  const path = require("path");
  const cfgPath = path.join(process.cwd(), ".aperant/config.local.json");
  const decision = process.env.APT_DIAGRAM_DECISION; // "granted" | "denied"
  if (decision !== "granted" && decision !== "denied") {
    process.stderr.write("invalid decision\n");
    process.exit(1);
  }
  const host = process.env.APT_DIAGRAM_HOST;
  let cfg = { diagram: { mcpConsent: null } };
  try { cfg = JSON.parse(fs.readFileSync(cfgPath, "utf-8")); } catch {}
  cfg.diagram = cfg.diagram || { mcpConsent: null };
  cfg.diagram.mcpConsent = {
    host,
    decision,
    grantedAt: new Date().toISOString(),
  };
  fs.writeFileSync(cfgPath, JSON.stringify(cfg, null, 2) + "\n", "utf-8");
'
```

Note: the write target above is `config.local.json` (gitignored, per-user).
The shared `config.json` is not touched — `diagram.mode` lives there and
stays team-shared policy.

If the decision is `"denied"`, exit with EXACTLY this text (verbatim,
no rewording, no extra punctuation):

```
diagram MCP consent denied; re-run with `--mode=file` or change diagram.mcpConsent in .aperant/config.local.json
```

If the decision is `"granted"`, proceed to 5b.

### 5b. Renderer dispatch

The skill IS the MCP renderer — there is no shared TS module here. The
agent dispatches MCP tool calls directly:

1. Iterate `scene.nodes` FIRST. For each node, call the Excalidraw MCP
   `create_element` tool (expected name `mcp__excalidraw__create_element`;
   if the connected server exposes a different equivalent, use that
   discovered name) with rectangle parameters: position from
   `node.x`/`node.y`, default size 180x70, and the `node.label` as
   text content. Record the element id the MCP server returns.
2. Then iterate `scene.edges`. For each edge, call the same tool with
   arrow parameters, setting `startBinding.elementId` to the rectangle
   id created for `edge.from` and `endBinding.elementId` to the
   rectangle id created for `edge.to`. If `edge.label` is set, pass it
   as the arrow's label.

Nodes MUST be created before edges so the edge bindings have valid
element ids on both ends.

On success, surface whatever stable identifier the MCP server returned
(session id, canvas URL, or scene id) so the user can open the live
canvas — e.g. `Live canvas: <url>`.

### 5c. Failure handling

If ANY `mcp__excalidraw__*` tool call fails — tool error, transport
error, or the expected tool is missing from the surface — emit EXACTLY
this message (verbatim) and exit:

```
Excalidraw MCP renderer failed: <reason>. Re-run with `--mode=file` to generate an offline .excalidraw file.
```

No retries. No automatic switch to file mode. No partial cleanup of
already-created MCP elements (v1 leaves them as a debugging artifact;
v2 may add cleanup).

## 7. Quality pass loop (file mode only)

(Step 6 is intentionally reserved for future MCP-side quality work; v2
ships Step 7 for file mode only.)

After Step 4 (file written), if Playwright is installed, run a visual
quality-pass loop. The loop is capped at **3 iterations**. If
`validateScene(scene).score >= 70` AND the vision review reports no
critical issues, exit early.

### 7a. Detect Playwright

Probe with:

```bash
node -e 'import("playwright").then(() => process.stdout.write("yes"), () => process.stdout.write("no"))'
```

If the result is `no`, skip Step 7 silently and append the following
one-line tip to the success message printed in Step 4:

```
Tip: install playwright to enable visual quality-pass on future runs (pnpm add -D playwright && npx playwright install chromium).
```

### 7b. Loop body (max 3 iterations)

For each iteration `i` in `0..2`:

1. Call `renderToPng({ excalidrawPath })` from `@aperant/core/workflows/diagram`
   via the same Node-invocation pattern Step 4 uses. Capture the
   absolute PNG path.
2. Call `validateScene(scene)` from the same module. Capture
   `{warnings, score}`.
3. Read the PNG via the `Read` tool (multimodal vision review).
4. Decide:
   - If `score >= 70` AND the vision review reports no critical issues
     (overlaps, off-canvas elements, illegible text), exit the loop
     and return success.
   - Else: emit IR patches as a JSON delta. Mutable fields are
     exactly `DiagramNode.x`, `DiagramNode.y`, and `DiagramNode.label`.
     Apply the patches to `scene`, re-call `renderToFile` (re-using
     the slug from Step 4), and continue to the next iteration.
5. After 3 iterations: return final state. If unresolved warnings
   remain, print exactly:

```
Quality pass: <N> warning(s) remain after 3 iterations. See <png-path> to inspect.
```

### 7c. Hard rules

- Iteration cap is exactly **3** (hard-coded, not configurable in v2).
- Score threshold is exactly **70** (hard-coded, not configurable in v2).
- Patch grammar is `{x, y, label}` — no other `DiagramNode`/
  `DiagramEdge` fields are mutable.
- If `renderToPng` throws `DiagramMissingDep`, treat the same as Step
  7a's `no` branch (silent skip + tip). Any OTHER `renderToPng` error
  fails the loop and surfaces verbatim to the user.

</process>
