How I built an app that renders real, LLM-generated architecture diagrams with @eraserlabs/diagrams — plus a brutally honest roast mode, zero hardcoded credentials, and two different layout strategies from a single prompt.
The idea
Most "AI diagram" demos show you a fake image. I wanted the real thing: type "an app that helps dog owners find playdates" and get back a board-ready PNG — icons, routed edges, clean layout — actually rendered by a diagram engine, not a screenshot.
The key ingredient is Eraser Diagrams, an open-source, AI-native, coordinate-aware diagram format. Unlike Mermaid, D2, or
Graphviz — which can't say where anything goes — Eraser lets the LLM emit coordinates itself, and the renderer respects them. It also ships a corridor router that routes connections around whatever geometry you hand it.
So the architecture is simple and honest:
User sentence
└─► one LLM call (JSON mode) ──► { title, summary, diagram, roast? }
└─► validate + (optionally) auto-layout
└─► @eraserlabs/diagrams ──► real PNG ──► browser
Stack
- Frontend: Vite + React + TypeScript
- Backend: Node.js + Express + TypeScript
-
Rendering:
@eraserlabs/diagrams(boots headless Chromium, returns a PNG buffer) -
Model calls: plain
fetchagainst any OpenAI-compatible/chat/completionsendpoint - Layout: a small custom layered graph engine I wrote for the "engine-laid" mode
No SDK lock-in, no hardcoded credentials, no placeholder images.
Part 1 — Talking to the model without an SDK
The settings panel captures a base URL, an API key, and a model id. The server normalizes the
URL and speaks the standard OpenAI wire format with plain fetch:
// server/src/llm.ts
export function chatCompletionsUrl(baseUrl: string): string {
let base = baseUrl.trim();
if (!base) throw new LlmError('Base URL is required.');
base = base.replace(/\/+$/, '');
if (base.endsWith('/chat/completions')) return base;
return `${base}/chat/completions`;
}
const body = {
model: config.model,
messages: [
{ role: 'system', content: buildSystemPrompt(mode) },
{ role: 'user', content: userPrompt },
],
temperature: 0.7,
response_format: { type: 'json_object' }, // constrain output so parsing never fails
};
Two details matter here:
-
response_format: { type: "json_object" }forces a structured response. I still write a defensiveextractJsonthat strips markdown fences if a provider ignores the hint. -
Real error surfacing. If the endpoint returns
401, I parse the body and pass the provider's actual message through to the UI — "Missing or invalid bearer credential" beats a generic "something went wrong."
Part 2 — The system prompt is the product
The prompt (server/src/prompt.ts) tells the model to output only a JSON object with
title, summary, diagram, and — in roast mode — roast. The diagram field must be the
exact { entities, connections } split form the renderer accepts.
The trick that keeps diagrams clean: an allowlist of icon names I verified against Eraser's
hosted catalog. I curl-tested each one (aws-s3 is actually aws-simple-storage-service,
aws-sns is aws-simple-notification-service) so the model picks recognizable icons and never
emits a broken placeholder.
Roast mode appends a second instruction block:
"roast"is a string with 2–5 punchy lines… Sharp, specific, meme-worthy… the joke is on
the architecture, not the user.
Part 3 — Rendering a real PNG
@eraserlabs/diagrams exposes a createRenderer that boots Chromium once and reuses it:
// server/src/diagram.ts
const renderer = await createRenderer({
chromiumPath: findChromium(),
deviceScaleFactor: 2, // retina-quality PNG
});
const outcome = await renderer.render({
entities: doc.entities,
connections: doc.connections,
outputs: { png: true, json: true },
});
if (outcome.ok) {
res.json({ png: outcome.png.toString('base64'), /* … */ });
}
The renderer is warmed at startup and reused per request. Chromium is auto-detected from common
paths (or CHROMIUM_PATH). Because LLMs occasionally emit a slightly-off spec, I normalize
first: coerce coordinates, drop entities without an id, and drop connections that reference
missing nodes.
Part 4 — Same prompt, two layouts
Eraser's headline is "same prompt, two layouts": the LLM can emit coordinates, or a
layout engine can derive them from the relationships. I implemented both:
-
LLM-placed — pass the model's
x/ystraight through. This showcases the coordinate-aware format. -
Engine-laid —
server/src/layout.tsignores the coordinates and lays the graph out itself: longest-path layering from source nodes, left-to-right placement, and containers (Group/Lane/Pool) that wrap their children in a grid.
// server/src/layout.ts (abridged)
// Layer top-level units by longest path from sources, then position them:
for (const layer of sortedLayers) {
let y = 40;
for (const unit of layer) {
pos.set(unit, { x, y });
y += totalSize(unit).height + Y_GAP;
}
x += layerMaxW + X_GAP;
}
The same spec, fed through the two paths, produces two visibly different diagrams — which is a
great way to explain what "coordinate-aware" actually buys you.
Part 5 — The frontend
The client is a single-page dark UI: a big input, Architecture/Roast toggle, LLM-placed /
Engine-laid toggle, a settings gear, and a result area with loading / error / empty states.
Settings persist to localStorage — the app is fully usable with no code changes.
// client/src/App.tsx
const res = await generate(prompt, mode, layoutMode, settings);
setResult(res);
// result.png is a base64 PNG → <img src={`data:image/png;base64,${res.png}`} />
Roast mode renders the critique in a styled card under the image:
{result.roast && (
<div className="roast-card">
<div className="roast-head">🔥 Roast Mode</div>
<pre className="roast-body">{result.roast}</pre>
</div>
)}
Verification
I tested the full flow against a live OpenAI-compatible endpoint:
- Different sentences → different diagrams (different dimensions, different content).
- Same sentence, two layouts → genuinely different geometry.
- Invalid key →
HTTP 401 — Missing or invalid bearer credential…surfaced in the UI. - Zero icon warnings after the allowlist pass.
What I'd add next
-
Download / export the PNG or the raw measured JSON (
outputs: { json: true }). - Auto-repair loop — feed renderer warnings back to the model and re-render.
- Iterative refinement — "fewer services", "add a CDN", instead of regenerating.
-
Offline icons via a custom
iconLoaderoricons.baseUrl.
Takeaways
- Render, don't screenshot. Wiring a real renderer is more work but the output is honest, inspectable, and editable.
-
Constrain the model.
response_format: json_objectplus a defensive parser means the pipeline rarely fails. - Verify your vocabulary. Curating the icon allowlist was the difference between polished diagrams and a wall of broken placeholders.
- Coordinate-aware formats are the future of agent↔human diagrams. Letting the LLM place nodes (or handing that to an engine) is a capability Mermaid/D2/Graphviz simply don't have.
Code & more: https://www.dailybuild.xyz/project/248-architectgpt



Top comments (0)