DEV Community

Cover image for ArchitectGPT: Turning One Sentence Into a Production Architecture Diagram (and a Roast)
Harish Kotra (he/him)
Harish Kotra (he/him)

Posted on AI-assisted

ArchitectGPT: Turning One Sentence Into a Production Architecture Diagram (and a Roast)

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
Enter fullscreen mode Exit fullscreen mode

Stack

  • Frontend: Vite + React + TypeScript
  • Backend: Node.js + Express + TypeScript
  • Rendering: @eraserlabs/diagrams (boots headless Chromium, returns a PNG buffer)
  • Model calls: plain fetch against any OpenAI-compatible /chat/completions endpoint
  • 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
};
Enter fullscreen mode Exit fullscreen mode

Two details matter here:

  1. response_format: { type: "json_object" } forces a structured response. I still write a defensive extractJson that strips markdown fences if a provider ignores the hint.
  2. 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'), /* … */ });
}
Enter fullscreen mode Exit fullscreen mode

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/y straight through. This showcases the coordinate-aware format.
  • Engine-laidserver/src/layout.ts ignores 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;
}
Enter fullscreen mode Exit fullscreen mode

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}`} />
Enter fullscreen mode Exit fullscreen mode

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>
)}
Enter fullscreen mode Exit fullscreen mode

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 iconLoader or icons.baseUrl.

Takeaways

  1. Render, don't screenshot. Wiring a real renderer is more work but the output is honest, inspectable, and editable.
  2. Constrain the model. response_format: json_object plus a defensive parser means the pipeline rarely fails.
  3. Verify your vocabulary. Curating the icon allowlist was the difference between polished diagrams and a wall of broken placeholders.
  4. 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.

How it works 1

How it works 2

How it works 3

Code & more: https://www.dailybuild.xyz/project/248-architectgpt

Top comments (0)