diagram_scribe

DiagramScribe — turn natural language descriptions into diagrams.

Quick start (requires OPENROUTER_API_KEY)::

from diagram_scribe import DiagramScribe

ds = DiagramScribe()
ds.draw("CI/CD pipeline — push code, run tests, deploy to staging.")
ds.refine("add a manual approval step before deploy")

Public API:

See docs/guide.md for full setup and usage instructions.

 1"""DiagramScribe — turn natural language descriptions into diagrams.
 2
 3Quick start (requires ``OPENROUTER_API_KEY``)::
 4
 5    from diagram_scribe import DiagramScribe
 6
 7    ds = DiagramScribe()
 8    ds.draw("CI/CD pipeline — push code, run tests, deploy to staging.")
 9    ds.refine("add a manual approval step before deploy")
10
11Public API:
12
13- :class:`DiagramScribe` — main class, wires LLM + backend adapters
14- :class:`~diagram_scribe.models.DiagramIR` — intermediate representation
15- :class:`~diagram_scribe.models.Node` — a node in a diagram
16- :class:`~diagram_scribe.models.Edge` — a directed edge between nodes
17- :class:`~diagram_scribe.adapters.llm.openrouter.OpenRouterAdapter` — OpenRouter (default)
18- :class:`~diagram_scribe.adapters.llm.ollama.OllamaAdapter` — local Ollama
19- :class:`~diagram_scribe.adapters.llm.claude.ClaudeAdapter` — Anthropic API (``pip install diagram-scribe[claude]``)
20- :class:`~diagram_scribe.adapters.backend.excalidraw.ExcalidrawAdapter` — Excalidraw
21
22See ``docs/guide.md`` for full setup and usage instructions.
23"""
24from .core import DiagramScribe
25from .models import Node, Edge, DiagramIR
26from .adapters.llm.openrouter import OpenRouterAdapter
27from .adapters.backend.excalidraw import ExcalidrawAdapter
28
29__all__ = [
30    "DiagramScribe",
31    "Node", "Edge", "DiagramIR",
32    "OpenRouterAdapter",
33    "ExcalidrawAdapter",
34]
class DiagramScribe:
 17class DiagramScribe:
 18    """Orchestrates diagram generation and refinement.
 19
 20    Routes LLM output to the appropriate backend: ``ExcalidrawAdapter`` for
 21    ``DiagramIR`` (simple spatial diagrams) or ``MermaidAdapter`` for
 22    ``MermaidIR`` (flowcharts, sequence diagrams, ER diagrams, class diagrams).
 23
 24    Args:
 25        llm: An LLM adapter. Defaults to ``OpenRouterAdapter`` using env vars.
 26        backend: A backend adapter for the graph path. Defaults to
 27            ``ExcalidrawAdapter``. Used directly in tests to inject mocks.
 28        output_path: Output ``.excalidraw`` file path. Passed to both
 29            ``ExcalidrawAdapter`` and ``MermaidAdapter``.
 30
 31    Example::
 32
 33        from diagram_scribe import DiagramScribe
 34
 35        ds = DiagramScribe()
 36        ds.draw("Two services: API gateway routes to user service.")
 37        ds.refine("add a database behind the user service")
 38    """
 39
 40    def __init__(
 41        self,
 42        llm: LLMAdapter | None = None,
 43        backend: BackendAdapter | None = None,
 44        output_path: str | None = None,
 45    ):
 46        self._llm = llm or self._default_llm()
 47        self._output_path = output_path
 48        self._excalidraw_backend = backend or self._default_backend(output_path)
 49        self._mermaid_backend: object | None = None
 50        self._current_ir: DiagramIR | MermaidIR | None = None
 51
 52    @staticmethod
 53    def _default_llm() -> LLMAdapter:
 54        import os
 55        from .adapters.llm.openrouter import OpenRouterAdapter
 56        return OpenRouterAdapter(
 57            api_key=os.environ.get("OPENROUTER_API_KEY", ""),
 58            model=os.environ.get("OPENROUTER_MODEL", "nvidia/nemotron-super-49b-v1:free"),
 59        )
 60
 61    @staticmethod
 62    def _default_backend(output_path: str | None = None) -> BackendAdapter:
 63        from .adapters.backend.excalidraw import ExcalidrawAdapter
 64        return ExcalidrawAdapter(output_path=output_path)
 65
 66    def _get_mermaid_backend(self) -> object:
 67        if self._mermaid_backend is None:
 68            from .adapters.backend.mermaid import MermaidAdapter
 69            self._mermaid_backend = MermaidAdapter(output_path=self._output_path)
 70        return self._mermaid_backend
 71
 72    def _render(self, ir: DiagramIR | MermaidIR) -> None:
 73        if isinstance(ir, MermaidIR):
 74            self._get_mermaid_backend().render(ir)
 75        else:
 76            self._excalidraw_backend.render(ir)
 77
 78    def draw(self, description: str) -> DiagramIR | MermaidIR:
 79        """Generate a new diagram from a natural language description.
 80
 81        The LLM decides the output format (Mermaid or graph). The appropriate
 82        backend renders and saves the result as a ``.excalidraw`` file.
 83
 84        Args:
 85            description: Plain English description of the diagram to create.
 86
 87        Returns:
 88            The generated ``DiagramIR`` or ``MermaidIR``.
 89        """
 90        self._current_ir = self._llm.generate(description)
 91        self._render(self._current_ir)
 92        return self._current_ir
 93
 94    def refine(self, feedback: str) -> DiagramIR | MermaidIR:
 95        """Update the current diagram based on feedback.
 96
 97        Must be called after :meth:`draw`. The LLM receives the current
 98        diagram state and the feedback, returns an updated IR of the same type,
 99        and the backend re-renders it.
100
101        Args:
102            feedback: Plain English instruction describing what to change.
103
104        Returns:
105            The updated ``DiagramIR`` or ``MermaidIR``.
106
107        Raises:
108            RuntimeError: If called before :meth:`draw`.
109        """
110        if self._current_ir is None:
111            raise RuntimeError("Call draw() before refine()")
112        self._current_ir = self._llm.refine(feedback, self._current_ir)
113        self._render(self._current_ir)
114        return self._current_ir

Orchestrates diagram generation and refinement.

Routes LLM output to the appropriate backend: ExcalidrawAdapter for DiagramIR (simple spatial diagrams) or MermaidAdapter for MermaidIR (flowcharts, sequence diagrams, ER diagrams, class diagrams).

Args: llm: An LLM adapter. Defaults to OpenRouterAdapter using env vars. backend: A backend adapter for the graph path. Defaults to ExcalidrawAdapter. Used directly in tests to inject mocks. output_path: Output .excalidraw file path. Passed to both ExcalidrawAdapter and MermaidAdapter.

Example::

from diagram_scribe import DiagramScribe

ds = DiagramScribe()
ds.draw("Two services: API gateway routes to user service.")
ds.refine("add a database behind the user service")
DiagramScribe( llm: diagram_scribe.protocols.LLMAdapter | None = None, backend: diagram_scribe.protocols.BackendAdapter | None = None, output_path: str | None = None)
40    def __init__(
41        self,
42        llm: LLMAdapter | None = None,
43        backend: BackendAdapter | None = None,
44        output_path: str | None = None,
45    ):
46        self._llm = llm or self._default_llm()
47        self._output_path = output_path
48        self._excalidraw_backend = backend or self._default_backend(output_path)
49        self._mermaid_backend: object | None = None
50        self._current_ir: DiagramIR | MermaidIR | None = None
def draw( self, description: str) -> DiagramIR | diagram_scribe.models.MermaidIR:
78    def draw(self, description: str) -> DiagramIR | MermaidIR:
79        """Generate a new diagram from a natural language description.
80
81        The LLM decides the output format (Mermaid or graph). The appropriate
82        backend renders and saves the result as a ``.excalidraw`` file.
83
84        Args:
85            description: Plain English description of the diagram to create.
86
87        Returns:
88            The generated ``DiagramIR`` or ``MermaidIR``.
89        """
90        self._current_ir = self._llm.generate(description)
91        self._render(self._current_ir)
92        return self._current_ir

Generate a new diagram from a natural language description.

The LLM decides the output format (Mermaid or graph). The appropriate backend renders and saves the result as a .excalidraw file.

Args: description: Plain English description of the diagram to create.

Returns: The generated DiagramIR or MermaidIR.

def refine( self, feedback: str) -> DiagramIR | diagram_scribe.models.MermaidIR:
 94    def refine(self, feedback: str) -> DiagramIR | MermaidIR:
 95        """Update the current diagram based on feedback.
 96
 97        Must be called after :meth:`draw`. The LLM receives the current
 98        diagram state and the feedback, returns an updated IR of the same type,
 99        and the backend re-renders it.
100
101        Args:
102            feedback: Plain English instruction describing what to change.
103
104        Returns:
105            The updated ``DiagramIR`` or ``MermaidIR``.
106
107        Raises:
108            RuntimeError: If called before :meth:`draw`.
109        """
110        if self._current_ir is None:
111            raise RuntimeError("Call draw() before refine()")
112        self._current_ir = self._llm.refine(feedback, self._current_ir)
113        self._render(self._current_ir)
114        return self._current_ir

Update the current diagram based on feedback.

Must be called after draw(). The LLM receives the current diagram state and the feedback, returns an updated IR of the same type, and the backend re-renders it.

Args: feedback: Plain English instruction describing what to change.

Returns: The updated DiagramIR or MermaidIR.

Raises: RuntimeError: If called before draw().

@dataclass
class Node:
 6@dataclass
 7class Node:
 8    """A single node in a diagram.
 9
10    Attributes:
11        id: Unique identifier used to reference this node in edges.
12        label: Display text rendered inside the shape.
13        shape: Visual shape. One of ``"box"``, ``"diamond"``, ``"circle"``,
14            ``"cylinder"``, or ``"text"`` (floating label, no border).
15    """
16    id: str
17    label: str
18    shape: str

A single node in a diagram.

Attributes: id: Unique identifier used to reference this node in edges. label: Display text rendered inside the shape. shape: Visual shape. One of "box", "diamond", "circle", "cylinder", or "text" (floating label, no border).

Node(id: str, label: str, shape: str)
id: str
label: str
shape: str
@dataclass
class Edge:
21@dataclass
22class Edge:
23    """A directed connection between two nodes.
24
25    Attributes:
26        from_id: ``id`` of the source node.
27        to_id: ``id`` of the target node.
28        label: Optional text rendered along the arrow.
29    """
30    from_id: str
31    to_id: str
32    label: str | None = None

A directed connection between two nodes.

Attributes: from_id: id of the source node. to_id: id of the target node. label: Optional text rendered along the arrow.

Edge(from_id: str, to_id: str, label: str | None = None)
from_id: str
to_id: str
label: str | None = None
@dataclass
class DiagramIR:
35@dataclass
36class DiagramIR:
37    """Intermediate representation of a diagram.
38
39    This is the contract between LLM adapters and backend adapters.
40    LLM adapters produce a ``DiagramIR``; backend adapters consume one.
41    Neither side needs to know anything about the other.
42
43    Attributes:
44        nodes: All nodes in the diagram, in no particular order.
45        edges: All directed edges, referencing nodes by ``id``.
46    """
47    nodes: list[Node] = field(default_factory=list)
48    edges: list[Edge] = field(default_factory=list)

Intermediate representation of a diagram.

This is the contract between LLM adapters and backend adapters. LLM adapters produce a DiagramIR; backend adapters consume one. Neither side needs to know anything about the other.

Attributes: nodes: All nodes in the diagram, in no particular order. edges: All directed edges, referencing nodes by id.

DiagramIR( nodes: list[Node] = <factory>, edges: list[Edge] = <factory>)
nodes: list[Node]
edges: list[Edge]
class OpenRouterAdapter:
11class OpenRouterAdapter:
12    """LLM adapter that calls models via OpenRouter.
13
14    OpenRouter provides access to hundreds of models — free and paid —
15    under a single API key at https://openrouter.ai. The adapter uses the
16    OpenAI-compatible chat completions endpoint.
17
18    Free models have a ``:free`` suffix, e.g.
19    ``meta-llama/llama-3.1-8b-instruct:free``. Browse models at
20    https://openrouter.ai/models.
21
22    Args:
23        api_key: OpenRouter API key. Get one at https://openrouter.ai.
24        model: Model ID to use. Defaults to
25            ``"meta-llama/llama-3.1-8b-instruct:free"`` (free, no billing required).
26
27    Example::
28
29        from diagram_scribe.adapters.llm.openrouter import OpenRouterAdapter
30        adapter = OpenRouterAdapter(api_key="sk-or-...", model="anthropic/claude-sonnet-4-6")
31        ir = adapter.generate("CI/CD pipeline")
32    """
33
34    def __init__(
35        self,
36        api_key: str,
37        model: str = "meta-llama/llama-3.1-8b-instruct:free",
38    ):
39        self._client = OpenAI(
40            base_url="https://openrouter.ai/api/v1",
41            api_key=api_key,
42        )
43        self._model = model
44
45    def _call(self, messages: list[dict]) -> str:
46        response = self._client.chat.completions.create(
47            model=self._model,
48            messages=[{"role": "system", "content": SYSTEM_PROMPT}] + messages,
49        )
50        return response.choices[0].message.content
51
52    def generate(self, description: str) -> DiagramIR | MermaidIR:
53        return parse_response(self._call(build_generate_messages(description)))
54
55    def refine(self, feedback: str, current: DiagramIR | MermaidIR) -> DiagramIR | MermaidIR:
56        if isinstance(current, MermaidIR):
57            return parse_response(self._call(build_mermaid_refine_messages(feedback, current)))
58        return parse_response(self._call(build_refine_messages(feedback, current)))

LLM adapter that calls models via OpenRouter.

OpenRouter provides access to hundreds of models — free and paid — under a single API key at https://openrouter.ai. The adapter uses the OpenAI-compatible chat completions endpoint.

Free models have a :free suffix, e.g. meta-llama/llama-3.1-8b-instruct:free. Browse models at https://openrouter.ai/models.

Args: api_key: OpenRouter API key. Get one at https://openrouter.ai. model: Model ID to use. Defaults to "meta-llama/llama-3.1-8b-instruct:free" (free, no billing required).

Example::

from diagram_scribe.adapters.llm.openrouter import OpenRouterAdapter
adapter = OpenRouterAdapter(api_key="sk-or-...", model="anthropic/claude-sonnet-4-6")
ir = adapter.generate("CI/CD pipeline")
OpenRouterAdapter(api_key: str, model: str = 'meta-llama/llama-3.1-8b-instruct:free')
34    def __init__(
35        self,
36        api_key: str,
37        model: str = "meta-llama/llama-3.1-8b-instruct:free",
38    ):
39        self._client = OpenAI(
40            base_url="https://openrouter.ai/api/v1",
41            api_key=api_key,
42        )
43        self._model = model
def generate( self, description: str) -> DiagramIR | diagram_scribe.models.MermaidIR:
52    def generate(self, description: str) -> DiagramIR | MermaidIR:
53        return parse_response(self._call(build_generate_messages(description)))
def refine( self, feedback: str, current: DiagramIR | diagram_scribe.models.MermaidIR) -> DiagramIR | diagram_scribe.models.MermaidIR:
55    def refine(self, feedback: str, current: DiagramIR | MermaidIR) -> DiagramIR | MermaidIR:
56        if isinstance(current, MermaidIR):
57            return parse_response(self._call(build_mermaid_refine_messages(feedback, current)))
58        return parse_response(self._call(build_refine_messages(feedback, current)))
class ExcalidrawAdapter:
263class ExcalidrawAdapter:
264    """Backend adapter that renders diagrams as Excalidraw files.
265
266    Writes the diagram to ``~/Documents/diagram-scribe.excalidraw`` by
267    default (or a custom path if provided). Prints the file path after
268    each render so the user knows where to find it.
269
270    Args:
271        output_path: Path to write the ``.excalidraw`` file. Defaults to
272            ``~/Documents/diagram-scribe.excalidraw``.
273
274    Example::
275
276        from diagram_scribe.adapters.backend.excalidraw import ExcalidrawAdapter
277        adapter = ExcalidrawAdapter(output_path="/tmp/my-diagram.excalidraw")
278        adapter.render(ir)
279    """
280
281    def __init__(self, output_path: str | None = None):
282        self._output_path = output_path or _DEFAULT_PATH
283
284    def render(self, ir: DiagramIR) -> None:
285        data = _to_excalidraw(ir)
286        os.makedirs(os.path.dirname(self._output_path), exist_ok=True)
287        with open(self._output_path, "w", encoding="utf-8") as f:
288            json.dump(data, f, indent=2)
289        print(f"[diagram saved to {self._output_path}]")

Backend adapter that renders diagrams as Excalidraw files.

Writes the diagram to ~/Documents/diagram-scribe.excalidraw by default (or a custom path if provided). Prints the file path after each render so the user knows where to find it.

Args: output_path: Path to write the .excalidraw file. Defaults to ~/Documents/diagram-scribe.excalidraw.

Example::

from diagram_scribe.adapters.backend.excalidraw import ExcalidrawAdapter
adapter = ExcalidrawAdapter(output_path="/tmp/my-diagram.excalidraw")
adapter.render(ir)
ExcalidrawAdapter(output_path: str | None = None)
281    def __init__(self, output_path: str | None = None):
282        self._output_path = output_path or _DEFAULT_PATH
def render(self, ir: DiagramIR) -> None:
284    def render(self, ir: DiagramIR) -> None:
285        data = _to_excalidraw(ir)
286        os.makedirs(os.path.dirname(self._output_path), exist_ok=True)
287        with open(self._output_path, "w", encoding="utf-8") as f:
288            json.dump(data, f, indent=2)
289        print(f"[diagram saved to {self._output_path}]")