Drawing diagrams with LLMs

2026-09-30

Diagrams are more effective at communicating ideas than prose alone, and LLMs are great at drawing and updating these diagrams quickly. But it's easy to get caught up in the complexities of how to lay out components, and keep them looking neat. This is where "diagrams as code" tools like D2, Mermaid and Reladraw are effective for keeping conversations focused on content without worrying about the layout.

LLMs read and write D2, Mermaid, and Reladraw script directly, without needing to see diagrams laid out or rendered. This means a 90% reduction of token spend versus using an SVG directly, and makes feedback much faster. With this comparison, plus D2's newly open-sourced TALA renderer,[1] I'll be switching to D2 for my system design diagrams, and following Reladraw with interest.

Throughout this article we'll focus on a simple event processing service, rendering it as D2, Mermaid, Reladraw and freestyle SVG diagrams. Notice the difference between constraint-based, layered, relative and freestyle layouts.

All of the conversations, diagrams and token metrics were captured through my Diagram Chat tool (which renders D2, Mermaid and Reladraw client-side), and deepseek-4.1-flash as the LLM.

Updating a D2 diagram

Consider this chat conversation with an LLM. We can request a simple change to a diagram, passing in the D2 script. The chat client renders the diagrams to SVG, but the LLM operates on the script directly and never sees the rendered diagram.

User

Update this D2 diagram. A new decision node between the service and the queue. if it's the 3rd retry, send it to the deadletter queue.

Event processing diagram rendered from D2, showing event sources flowing through an event queue into validation and deduplication, with retry paths and a dead letter queue
d2
direction: right
event_sources: Event sources {
  shape: cloud
}
event_queue: Event queue {
  shape: queue
}
dlq: Dead letter queue {
  shape: queue
}
service: Event processing service {
  validate: validate {
    shape: diamond
  }
  dedupe: deduplicate {
    shape: diamond
  }
  process: further processing {
    shape: step
  }
}
event_sources -> event_queue: publish
event_queue -> service.validate: dequeue
service.validate -> service.dedupe: valid
service.validate -> dlq: invalid
service.dedupe -> service.process: new
service -> event_queue: retry
Assistant

Here's the diagram with the new decision node.

Updated event processing diagram with a retry counter node that routes to the dead letter queue after three failed attempts
d2
direction: right

event_sources: Event sources {
  shape: cloud
}

event_queue: Event queue {
  shape: queue
}

dlq: Dead letter queue {
  shape: queue
}

service: Event processing service {
  validate: validate {
    shape: diamond
  }
  dedupe: deduplicate {
    shape: diamond
  }
  retry_counter: retry counter {
    shape: step
    label: "retry counter\n(max 3 attempts)"
  }
  process: further processing {
    shape: step
  }
  discard: discard {
    shape: rectangle
  }
}

event_sources -> event_queue: publish
event_queue -> service.validate: dequeue
service.validate -> service.dedupe: valid
service.validate -> dlq: invalid
service.dedupe -> service.process: new
service.dedupe -> service.discard: duplicate
service.validate -> service.retry_counter: transient failure {
  style: {
    stroke-dash: 4
    stroke: "#9aa0a6"
    font-color: "#9aa0a6"
  }
}
service.dedupe -> service.retry_counter: transient failure {
  style: {
    stroke-dash: 4
    stroke: "#9aa0a6"
    font-color: "#9aa0a6"
  }
}
service.retry_counter -> event_queue: "retry (attempt < 3)" {
  style: {
    stroke-dash: 4
    stroke: "#9aa0a6"
    font-color: "#9aa0a6"
  }
}
service.retry_counter -> dlq: "retries exhausted (attempt = 3)" {
  style: {
    stroke: "#d93025"
    font-color: "#d93025"
    bold: true
  }
}

The whole update only cost a few hundred tokens: 361 in, 666 out (460 thinking + 206 response).

Updating an SVG diagram

Let's compare this to a full SVG instead. The input SVG was originally drawn by an LLM following the same spec as the others.

User

Update this SVG diagram. A new decision node between the service and the queue. if it's the 3rd retry, send it to the deadletter queue.

Event processing diagram SVG, showing event sources flowing through an event queue into validation and deduplication, with retry paths and a dead letter queue
xml
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1620 800" width="1620" height="800"
    font-family="ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif">
    <defs>
        <marker id="arrow" viewBox="0 0 10 10" refX="10" refY="5" markerWidth="6.5"
            markerHeight="6.5" orient="auto">
            <path d="M0,0 L10,5 L0,10 Z" fill="#334155"></path>
        </marker>
        <marker id="arrow-muted" viewBox="0 0 10 10" refX="10" refY="5" markerWidth="6.5"
            markerHeight="6.5" orient="auto">
            <path d="M0,0 L10,5 L0,10 Z" fill="#94a3b8"></path>
        </marker>
    </defs>


    <!-- ============ SERVICE BOUNDARY ============ -->
    <rect x="600" y="130" width="960" height="590" rx="16" fill="#f8fafc" stroke="#94a3b8"
        stroke-width="2"></rect>
    <text x="626" y="168" font-size="16" font-weight="700" fill="#64748b" letter-spacing="0.6">Event
        processing service</text>

    <!-- ============ EXTERNAL: CLOUD ============ -->
    <path d="M 45 350 Q 35 300 80 290 Q 90 255 135 260 Q 180 250 195 290 Q 230 305 215 350 Z"
        fill="#ffffff" stroke="#334155" stroke-width="2" stroke-linejoin="round"></path>
    <text x="128" y="318" text-anchor="middle" font-size="14" fill="#0f172a">External</text>
    <text x="128" y="336" text-anchor="middle" font-size="14" fill="#0f172a">systems</text>

    <!-- ============ EXTERNAL: EVENT QUEUE ============ -->
    <rect x="340" y="260" width="160" height="80" rx="10" fill="#e0f2fe" stroke="#0369a1"
        stroke-width="2"></rect>
    <text x="420" y="306" text-anchor="middle" font-size="14" font-weight="600" fill="#0f172a">Event
        queue</text>

    <!-- ============ EXTERNAL: DLQ ============ -->
    <rect x="330" y="580" width="180" height="80" rx="10" fill="#fee2e2" stroke="#b91c1c"
        stroke-width="2"></rect>
    <text x="420" y="626" text-anchor="middle" font-size="13" font-weight="600" fill="#0f172a">Dead
        letter queue</text>

    <!-- ============ VALIDATE (diamond) ============ -->
    <polygon points="780,240 890,300 780,360 670,300" fill="#ffffff" stroke="#334155"
        stroke-width="2" stroke-linejoin="round"></polygon>
    <text x="780" y="306" text-anchor="middle" font-size="14" font-weight="600" fill="#0f172a">
        validate</text>

    <!-- ============ DEDUPLICATE (diamond) ============ -->
    <polygon points="1100,240 1210,300 1100,360 990,300" fill="#ffffff" stroke="#334155"
        stroke-width="2" stroke-linejoin="round"></polygon>
    <text x="1100" y="306" text-anchor="middle" font-size="13.5" font-weight="600" fill="#0f172a">
        deduplicate</text>

    <!-- ============ FURTHER PROCESSING (open-ended continuation) ============ -->
    <path d="M 1510 260 H 1330 V 340 H 1510" fill="#f0fdf4" stroke="#15803d" stroke-width="2"
        stroke-linejoin="round"></path>
    <line x1="1510" y1="260" x2="1510" y2="340" stroke="#15803d" stroke-width="2"
        stroke-dasharray="6 5"></line>
    <text x="1416" y="306" text-anchor="middle" font-size="13" font-weight="600" fill="#0f172a">further
        processing</text>

    <!-- ============ DISCARD ============ -->
    <rect x="1010" y="510" width="180" height="80" rx="10" fill="#f1f5f9" stroke="#64748b"
        stroke-width="2"></rect>
    <text x="1100" y="556" text-anchor="middle" font-size="14" font-weight="600" fill="#0f172a">
        discard</text>

    <!-- ============ PRIMARY FLOW ARROWS ============ -->
    <!-- external systems -> event queue -->
    <path d="M 208 300 H 340" fill="none" stroke="#334155" stroke-width="2" marker-end="url(#arrow)"></path>
    <text x="274" y="288" text-anchor="middle" font-size="12" fill="#334155">publish events</text>

    <!-- event queue -> validate (enters boundary) -->
    <path d="M 500 300 H 670" fill="none" stroke="#334155" stroke-width="2" marker-end="url(#arrow)"></path>
    <text x="585" y="288" text-anchor="middle" font-size="12" fill="#334155">consume</text>

    <!-- validate - valid -> deduplicate -->
    <path d="M 890 300 H 990" fill="none" stroke="#334155" stroke-width="2" marker-end="url(#arrow)"></path>
    <text x="940" y="288" text-anchor="middle" font-size="12" fill="#334155">valid</text>

    <!-- deduplicate - new -> further processing -->
    <path d="M 1210 300 H 1330" fill="none" stroke="#334155" stroke-width="2"
        marker-end="url(#arrow)"></path>
    <text x="1270" y="288" text-anchor="middle" font-size="12" fill="#334155">new</text>

    <!-- deduplicate - duplicate -> discard -->
    <path d="M 1100 360 V 510" fill="none" stroke="#334155" stroke-width="2"
        marker-end="url(#arrow)"></path>
    <text x="1112" y="440" font-size="12" fill="#334155">duplicate</text>

    <!-- validate - invalid -> DLQ (exits boundary) -->
    <path d="M 780 360 V 620 H 510" fill="none" stroke="#334155" stroke-width="2"
        marker-end="url(#arrow)"></path>
    <text x="645" y="608" text-anchor="middle" font-size="12" fill="#334155">invalid</text>

    <!-- ============ RETRY (dashed, muted) ============ -->
    <!-- validate -> event queue -->
    <path d="M 780 240 V 100 H 390 V 260" fill="none" stroke="#94a3b8" stroke-width="2"
        stroke-dasharray="7 6" marker-end="url(#arrow-muted)"></path>
    <text x="585" y="88" text-anchor="middle" font-size="12" fill="#94a3b8">retry</text>

    <!-- deduplicate -> event queue -->
    <path d="M 1100 240 V 70 H 450 V 260" fill="none" stroke="#94a3b8" stroke-width="2"
        stroke-dasharray="7 6" marker-end="url(#arrow-muted)"></path>
    <text x="775" y="58" text-anchor="middle" font-size="12" fill="#94a3b8">retry</text>
</svg>
Assistant

Here's the diagram with the new decision node.

Updated SVG diagram with a retry count decision diamond and dashed paths routing exhausted retries to the dead letter queue
xml
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1620 800" width="1620" height="800"
    font-family="ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif">
    <defs>
        <marker id="arrow" viewBox="0 0 10 10" refX="10" refY="5" markerWidth="6.5"
            markerHeight="6.5" orient="auto">
            <path d="M0,0 L10,5 L0,10 Z" fill="#334155"></path>
        </marker>
        <marker id="arrow-muted" viewBox="0 0 10 10" refX="10" refY="5" markerWidth="6.5"
            markerHeight="6.5" orient="auto">
            <path d="M0,0 L10,5 L0,10 Z" fill="#94a3b8"></path>
        </marker>
    </defs>


    <!-- ============ SERVICE BOUNDARY ============ -->
    <rect x="600" y="130" width="960" height="590" rx="16" fill="#f8fafc" stroke="#94a3b8"
        stroke-width="2"></rect>
    <text x="626" y="168" font-size="16" font-weight="700" fill="#64748b" letter-spacing="0.6">Event
        processing service</text>

    <!-- ============ EXTERNAL: CLOUD ============ -->
    <path d="M 45 350 Q 35 300 80 290 Q 90 255 135 260 Q 180 250 195 290 Q 230 305 215 350 Z"
        fill="#ffffff" stroke="#334155" stroke-width="2" stroke-linejoin="round"></path>
    <text x="128" y="318" text-anchor="middle" font-size="14" fill="#0f172a">External</text>
    <text x="128" y="336" text-anchor="middle" font-size="14" fill="#0f172a">systems</text>

    <!-- ============ EXTERNAL: EVENT QUEUE ============ -->
    <rect x="340" y="260" width="160" height="80" rx="10" fill="#e0f2fe" stroke="#0369a1"
        stroke-width="2"></rect>
    <text x="420" y="306" text-anchor="middle" font-size="14" font-weight="600" fill="#0f172a">Event
        queue</text>

    <!-- ============ EXTERNAL: DLQ ============ -->
    <rect x="330" y="580" width="180" height="80" rx="10" fill="#fee2e2" stroke="#b91c1c"
        stroke-width="2"></rect>
    <text x="420" y="626" text-anchor="middle" font-size="13" font-weight="600" fill="#0f172a">Dead
        letter queue</text>

    <!-- ============ VALIDATE (diamond) ============ -->
    <polygon points="780,240 890,300 780,360 670,300" fill="#ffffff" stroke="#334155"
        stroke-width="2" stroke-linejoin="round"></polygon>
    <text x="780" y="306" text-anchor="middle" font-size="14" font-weight="600" fill="#0f172a">
        validate</text>

    <!-- ============ DEDUPLICATE (diamond) ============ -->
    <polygon points="1100,240 1210,300 1100,360 990,300" fill="#ffffff" stroke="#334155"
        stroke-width="2" stroke-linejoin="round"></polygon>
    <text x="1100" y="306" text-anchor="middle" font-size="13.5" font-weight="600" fill="#0f172a">
        deduplicate</text>

    <!-- ============ FURTHER PROCESSING (open-ended continuation) ============ -->
    <path d="M 1510 260 H 1330 V 340 H 1510" fill="#f0fdf4" stroke="#15803d" stroke-width="2"
        stroke-linejoin="round"></path>
    <line x1="1510" y1="260" x2="1510" y2="340" stroke="#15803d" stroke-width="2"
        stroke-dasharray="6 5"></line>
    <text x="1416" y="306" text-anchor="middle" font-size="13" font-weight="600" fill="#0f172a">further
        processing</text>

    <!-- ============ DISCARD ============ -->
    <rect x="1010" y="510" width="180" height="80" rx="10" fill="#f1f5f9" stroke="#64748b"
        stroke-width="2"></rect>
    <text x="1100" y="556" text-anchor="middle" font-size="14" font-weight="600" fill="#0f172a">
        discard</text>

    <!-- ============ RETRY DECISION (new diamond) ============ -->
    <polygon points="420,50 490,110 420,170 350,110" fill="#ffffff" stroke="#334155"
        stroke-width="2" stroke-linejoin="round"></polygon>
    <text x="420" y="106" text-anchor="middle" font-size="12" font-weight="600" fill="#0f172a">retry
        count</text>
    <text x="420" y="124" text-anchor="middle" font-size="12" font-weight="600" fill="#0f172a">=
        3?</text>

    <!-- ============ PRIMARY FLOW ARROWS ============ -->
    <!-- external systems -> event queue -->
    <path d="M 208 300 H 340" fill="none" stroke="#334155" stroke-width="2" marker-end="url(#arrow)"></path>
    <text x="274" y="288" text-anchor="middle" font-size="12" fill="#334155">publish events</text>

    <!-- event queue -> validate (enters boundary) -->
    <path d="M 500 300 H 670" fill="none" stroke="#334155" stroke-width="2" marker-end="url(#arrow)"></path>
    <text x="585" y="288" text-anchor="middle" font-size="12" fill="#334155">consume</text>

    <!-- validate - valid -> deduplicate -->
    <path d="M 890 300 H 990" fill="none" stroke="#334155" stroke-width="2" marker-end="url(#arrow)"></path>
    <text x="940" y="288" text-anchor="middle" font-size="12" fill="#334155">valid</text>

    <!-- deduplicate - new -> further processing -->
    <path d="M 1210 300 H 1330" fill="none" stroke="#334155" stroke-width="2"
        marker-end="url(#arrow)"></path>
    <text x="1270" y="288" text-anchor="middle" font-size="12" fill="#334155">new</text>

    <!-- deduplicate - duplicate -> discard -->
    <path d="M 1100 360 V 510" fill="none" stroke="#334155" stroke-width="2"
        marker-end="url(#arrow)"></path>
    <text x="1112" y="440" font-size="12" fill="#334155">duplicate</text>

    <!-- validate - invalid -> DLQ (exits boundary) -->
    <path d="M 780 360 V 620 H 510" fill="none" stroke="#334155" stroke-width="2"
        marker-end="url(#arrow)"></path>
    <text x="645" y="608" text-anchor="middle" font-size="12" fill="#334155">invalid</text>

    <!-- ============ RETRY (dashed, muted) ============ -->
    <!-- validate -> retry decision -->
    <path d="M 780 240 V 110 H 490" fill="none" stroke="#94a3b8" stroke-width="2"
        stroke-dasharray="7 6" marker-end="url(#arrow-muted)"></path>
    <text x="700" y="100" text-anchor="middle" font-size="12" fill="#94a3b8">retry</text>

    <!-- deduplicate -> retry decision (merges into the same path) -->
    <path d="M 1100 240 V 70 H 560 V 110" fill="none" stroke="#94a3b8" stroke-width="2"
        stroke-dasharray="7 6"></path>
    <text x="900" y="60" text-anchor="middle" font-size="12" fill="#94a3b8">retry</text>

    <!-- retry decision - no (under limit) -> back onto the event queue -->
    <path d="M 420 170 V 260" fill="none" stroke="#94a3b8" stroke-width="2" stroke-dasharray="7 6"
        marker-end="url(#arrow-muted)"></path>
    <text x="432" y="222" font-size="12" fill="#94a3b8">no</text>

    <!-- retry decision - yes (3rd retry) -> dead letter queue -->
    <path d="M 350 110 H 290 V 292 A 8 8 0 0 1 290 308 V 620 H 330" fill="none" stroke="#94a3b8"
        stroke-width="2" stroke-dasharray="7 6" marker-end="url(#arrow-muted)"></path>
    <text x="302" y="424" font-size="12" fill="#94a3b8">3rd retry</text>
</svg>

Just one turn, and the tokens have already taken a bite out of the context window: 2,113 in, 15,017 out (12,739 thinking + 2,278 response).

Updating a Mermaid diagram

The D2 and SVG diagrams contrasted on token consumption, but Mermaid is a more fair comparison. Let's make a similar change.

User

Update this Mermaid diagram. A new decision node between the service and the queue. if it's the 3rd retry, send it to the deadletter queue.

Event processing diagram rendered from Mermaid, showing the same flow with a layered left-to-right layout
mermaid
flowchart LR
    ES@{ shape: cloud, label: "Event sources" }
    EQ[["Event queue"]]
    DLQ[["Dead letter queue"]]

    subgraph EPS["Event processing service"]
        direction LR
        V{"Validate"}
        D{"Deduplicate"}
        FP["Further processing …"]
        DISC["Discard"]
    end

    ES -->|"publish events"| EQ
    ES -->|"undeliverable events"| DLQ
    EQ -->|"deliver batch"| V
    V -->|"valid"| D
    V -->|"invalid"| DLQ
    D -->|"new"| FP
    D -->|"duplicate"| DISC
    V -.->|"retry"| EQ
    D -.->|"retry"| EQ
Assistant

Here's the diagram with the new decision node.

Updated Mermaid diagram with a third-retry decision diamond routing exhausted retries to the dead letter queue, but with a cramped layout
mermaid
flowchart LR
    ES@{ shape: cloud, label: "Event sources" }
    EQ[["Event queue"]]
    DLQ[["Dead letter queue"]]
    RC{"3rd retry?"}

    subgraph EPS["Event processing service"]
        direction LR
        V{"Validate"}
        D{"Deduplicate"}
        FP["Further processing …"]
        DISC["Discard"]
    end

    ES -->|"publish events"| EQ
    ES -->|"undeliverable events"| DLQ
    EQ -->|"deliver batch"| V
    V -->|"valid"| D
    V -->|"invalid"| DLQ
    D -->|"new"| FP
    D -->|"duplicate"| DISC
    V -.->|"retry"| RC
    D -.->|"retry"| RC
    RC -.->|"yes"| DLQ
    RC -.->|"no"| EQ

Mermaid's token consumption is much better: 397 in, 696 out (475 thinking + 221 response). But the layout is starting to get weird...

Updating a Reladraw diagram

Reladraw is a newcomer to the scene.[2] It will lay out components relative to each other, but without an auto-resolving layout engine it requires the definition file to describe the layout unambiguously.

With Mermaid, D2 and SVG we benefit from the LLM's existing knowledge of how to build these diagrams, but with Reladraw we need to provide additional guidance. For Diagram Chat I loaded a trimmed version of Reladraw's SYNTAX.md into the system context. It covers node placement, edges and styles, which is enough for the LLM to work with.

User

Update this Reladraw diagram. A new decision node between the service and the queue. if it's the 3rd retry, send it to the deadletter queue.

Event processing diagram rendered from Reladraw, with event sources, the event queue and the event processing service in a row, the dead letter queue below the queue, and a retry edge looping over the top back to the queue
reladraw
style decision  fill: theme-secondary-subtle  border: theme-secondary
style q  fill: theme-primary-subtle  border: theme-primary

node sources "Event sources"

node queue "Event queue"  right of sources  style: q

node service "Event processing service"  right of queue
node service.validate "validate"  style: decision
node service.dedupe "deduplicate"  right of service.validate  style: decision
node service.process "further processing"  right of service.dedupe

node dlq "Dead letter queue"  below queue  style: q

edge sources -> queue "publish"  from: right  to: left
edge queue -> service.validate "dequeue"  from: right  to: left
edge service.validate -> service.dedupe "valid"  from: right  to: left
edge service.validate -> dlq "invalid"  from: bottom  to: right
edge service.dedupe -> service.process "new"  from: right  to: left
edge service -> queue "retry"  from: top  to: top
Assistant

Here's the diagram with the new decision node.

Updated Reladraw diagram with a retry count decision node below the service, routing retries back to the event queue and the third retry to the dead letter queue
reladraw
style decision  fill: theme-secondary-subtle  border: theme-secondary
style q  fill: theme-primary-subtle  border: theme-primary

node sources "Event sources"

node queue "Event queue"  right of sources  style: q

node service "Event processing service"  right of queue
node service.validate "validate"  style: decision
node service.dedupe "deduplicate"  right of service.validate  style: decision
node service.process "further processing"  right of service.dedupe

node retry "retry count / reached 3?"  below service  right of dlq  style: decision
node dlq "Dead letter queue"  below queue  style: q

edge sources -> queue "publish"  from: right  to: left
edge queue -> service.validate "dequeue"  from: right  to: left
edge service.validate -> service.dedupe "valid"  from: right  to: left
edge service.validate -> dlq "invalid"  from: bottom  to: right
edge service.dedupe -> service.process "new"  from: right  to: left
edge service -> retry "processing failed"  from: bottom  to: top
edge retry -> queue "retry (attempt < 3)"  from: left  to: bottom
edge retry -> dlq "3rd retry"  from: left  to: right

The input count includes the trimmed syntax guide, so it's higher than D2 or Mermaid. Even so, the count is reasonable: 1,190 in, 1,331 out (1,013 thinking + 318 response). The layout isn't ideal but it's salvageable; unlike Mermaid or D2, we can guide the LLM into repositioning the new decision node and arrows.

Direct comparisons

The tokens in this table are samples. In practice, I saw ±40% variance in these, depending on how long the LLM took to think, and the complexity of its response. However the scale still stands.

D2 SVG Mermaid Reladraw
Input tokens 361 2,113 397 1,190
Thinking tokens 460 12,739 475 1,013
Response tokens 206 2,278 221 318

Describing a diagram is cheaper than drawing it

D2, Mermaid and Reladraw are diagram languages: the source describes nodes and the edges between them, and a renderer turns that into coordinates and paths. SVG is a graphics format, so the LLM has to work out every coordinate itself. D2 and Mermaid produce similar token counts, and Reladraw uses roughly twice as many. SVG is around 20 times more expensive than D2. Some of that is the verbosity of the format, but most of it is the LLM working out the layout during the "thinking" stage.

Who decides where things go?

The diagram languages differ in how much of the layout they leave to the renderer:

  • Mermaid flowcharts use layered graph drawing, with the Dagre or ELK engines placing every node automatically.
  • D2 diagrams with the TALA engine use a constraint-based model, also placing every node automatically.
  • Reladraw has no layout engine. The definition places each node explicitly, relative to another (e.g. right of, below).

TALA's constraint-based layout optimises for short edges and balanced whitespace. Dagre and ELK's layered graph style assigns every node to a discrete rank. Each rank becomes a column, and the diagram grows horizontally. With automatic layout, the LLM only describes what connects to what. The trade-off is that you accept wherever the engine puts things, and a small change can shuffle the whole diagram.

Without a layout engine, Reladraw puts you in control of where every node goes. The trade-off is that the LLM needs more hand-holding: a syntax guide in its context, and more thinking about placement to get the layout right.

There are no guarantees with freestyle SVG. Every coordinate and path string is a token deliberately produced by the LLM, but you can't verify its correctness without inspecting it visually.

What's next?

LLMs can generate D2 code as comfortably as Mermaid code, but most agent tooling can't render it yet. Mermaid shows up extensively in markdown documents and LLM harnesses, while D2 has far less support. I built Diagram Chat with a custom rendering plugin to test D2 with TALA alongside Mermaid, Reladraw, and SVG, and most agent harnesses would need similar work. Now that TALA is open-sourced,[1] though, it could land in Mermaid itself, which would also close that gap. It's also possible to introduce an LLM to a new diagramming syntax like Reladraw, at the cost of adding to the system context.

In the meantime, for iterating on diagrams over several turns, D2 with TALA is the format that holds up, and it's possible to convert an existing diagram to Reladraw if you need finer grained positioning of components. You may need to vibe-code your own renderer, but it's worthwhile for the diagram quality and token consumption.

References

  1. ⬆️ TALA is open-source, Terrastruct
  2. ⬆️ Show HN: Reladraw – A diagram language where you decide where to place things, Hacker News

If you have feedback or questions about this article, let's catch up via LinkedIn or email.

Now (2026-09-20)Exposing LLM biasesDrawing diagrams with LLMsShare tokens instead of IDsAutomating automationsSmartifying your devices