diff --git a/data/.lfs/mermaid.min.js.tar.gz b/data/.lfs/mermaid.min.js.tar.gz
new file mode 100644
index 0000000000..27599a64ca
--- /dev/null
+++ b/data/.lfs/mermaid.min.js.tar.gz
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:016ba263bb37555515227f55ed59f72cfe761110c00ef6c1b8aa0d88b9e6c20c
+size 907963
diff --git a/dimos/core/introspection/mermaid.py b/dimos/core/introspection/mermaid.py
new file mode 100644
index 0000000000..83f44feeb7
--- /dev/null
+++ b/dimos/core/introspection/mermaid.py
@@ -0,0 +1,370 @@
+# Copyright 2025-2026 Dimensional Inc.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Mermaid flowchart renderer for Blueprint visualization."""
+
+from __future__ import annotations
+
+from collections import defaultdict
+from typing import Any
+
+from dimos.core.coordination.blueprints import Blueprint
+from dimos.core.introspection.utils import sanitize_id
+from dimos.core.module import ModuleBase
+
+Theme = dict[str, Any]
+
+THEMES: dict[str, Theme] = {
+ "tailwind": {
+ "background": "#1e1e1e",
+ "mermaid_theme": "dark",
+ "nodes": [
+ "#3b82f6",
+ "#ef4444",
+ "#22c55e",
+ "#8b5cf6",
+ "#f97316",
+ "#06b6d4",
+ "#ec4899",
+ "#6366f1",
+ "#eab308",
+ "#14b8a6",
+ "#f43f5e",
+ "#84cc16",
+ "#0ea5e9",
+ "#d946ef",
+ "#10b981",
+ "#a855f7",
+ "#f59e0b",
+ "#38bdf8",
+ "#fb7185",
+ "#a3e635",
+ ],
+ "edges": [
+ "#60a5fa",
+ "#f87171",
+ "#4ade80",
+ "#a78bfa",
+ "#fb923c",
+ "#22d3ee",
+ "#f472b6",
+ "#818cf8",
+ "#facc15",
+ "#2dd4bf",
+ "#fb7185",
+ "#a3e635",
+ "#38bdf8",
+ "#e879f9",
+ "#34d399",
+ "#c084fc",
+ "#fbbf24",
+ "#67e8f9",
+ "#fda4af",
+ "#bef264",
+ ],
+ },
+ "ocean": {
+ "background": "#0f172a",
+ "mermaid_theme": "dark",
+ "nodes": [
+ "#38bdf8",
+ "#818cf8",
+ "#2dd4bf",
+ "#a78bfa",
+ "#67e8f9",
+ "#c084fc",
+ "#5eead4",
+ "#93c5fd",
+ "#7dd3fc",
+ "#6366f1",
+ ],
+ "edges": [
+ "#7dd3fc",
+ "#a5b4fc",
+ "#99f6e4",
+ "#c4b5fd",
+ "#a5f3fc",
+ "#ddd6fe",
+ "#6ee7b7",
+ "#bfdbfe",
+ "#bae6fd",
+ "#a5b4fc",
+ ],
+ },
+ "ember": {
+ "background": "#1c1210",
+ "mermaid_theme": "dark",
+ "nodes": [
+ "#ef4444",
+ "#f97316",
+ "#eab308",
+ "#f59e0b",
+ "#fb923c",
+ "#fbbf24",
+ "#f87171",
+ "#facc15",
+ "#fb7185",
+ "#fca5a5",
+ ],
+ "edges": [
+ "#fca5a5",
+ "#fdba74",
+ "#fde047",
+ "#fcd34d",
+ "#fed7aa",
+ "#fef08a",
+ "#fecaca",
+ "#fef9c3",
+ "#fda4af",
+ "#fecdd3",
+ ],
+ },
+ "forest": {
+ "background": "#0f1a14",
+ "mermaid_theme": "dark",
+ "nodes": [
+ "#22c55e",
+ "#14b8a6",
+ "#84cc16",
+ "#10b981",
+ "#a3e635",
+ "#34d399",
+ "#4ade80",
+ "#2dd4bf",
+ "#86efac",
+ "#6ee7b7",
+ ],
+ "edges": [
+ "#86efac",
+ "#5eead4",
+ "#bef264",
+ "#6ee7b7",
+ "#d9f99d",
+ "#99f6e4",
+ "#bbf7d0",
+ "#a7f3d0",
+ "#ecfccb",
+ "#ccfbf1",
+ ],
+ },
+ "light": {
+ "background": "#f8fafc",
+ "mermaid_theme": "default",
+ "nodes": [
+ "#2563eb",
+ "#dc2626",
+ "#16a34a",
+ "#7c3aed",
+ "#ea580c",
+ "#0891b2",
+ "#db2777",
+ "#4f46e5",
+ "#ca8a04",
+ "#0d9488",
+ ],
+ "edges": [
+ "#3b82f6",
+ "#ef4444",
+ "#22c55e",
+ "#8b5cf6",
+ "#f97316",
+ "#06b6d4",
+ "#ec4899",
+ "#6366f1",
+ "#eab308",
+ "#14b8a6",
+ ],
+ },
+}
+
+DEFAULT_THEME = "tailwind"
+
+DEFAULT_IGNORED_CONNECTIONS: set[tuple[str, str]] = set()
+DEFAULT_IGNORED_MODULES: set[str] = set()
+
+
+class _ColorAssigner:
+ def __init__(self, palette: list[str]) -> None:
+ self._palette = palette
+ self._assigned: dict[str, str] = {}
+ self._next = 0
+
+ def __call__(self, key: str) -> str:
+ if key not in self._assigned:
+ self._assigned[key] = self._palette[self._next % len(self._palette)]
+ self._next += 1
+ return self._assigned[key]
+
+
+def render_mermaid(
+ blueprint_set: Blueprint,
+ *,
+ ignored_streams: set[tuple[str, str]] | None = None,
+ ignored_modules: set[str] | None = None,
+ show_disconnected: bool = False,
+ theme: str = DEFAULT_THEME,
+) -> tuple[str, dict[str, str], set[str], dict[str, str]]:
+ """Generate a Mermaid flowchart from a Blueprint.
+
+ Returns (mermaid_code, label_color_map, disconnected_labels, node_color_map).
+ """
+ if ignored_streams is None:
+ ignored_streams = DEFAULT_IGNORED_CONNECTIONS
+ if ignored_modules is None:
+ ignored_modules = DEFAULT_IGNORED_MODULES
+
+ producers: dict[tuple[str, type], list[type[ModuleBase]]] = defaultdict(list)
+ consumers: dict[tuple[str, type], list[type[ModuleBase]]] = defaultdict(list)
+ module_names: set[str] = set()
+
+ for bp in blueprint_set.blueprints:
+ if bp.module.__name__ in ignored_modules:
+ continue
+ module_names.add(bp.module.__name__)
+ for conn in bp.streams:
+ remapped_name = blueprint_set.remapping_map.get((bp.module, conn.name), conn.name)
+ if not isinstance(remapped_name, str):
+ continue
+ key = (remapped_name, conn.type)
+ if conn.direction == "out":
+ producers[key].append(bp.module)
+ else:
+ consumers[key].append(bp.module)
+
+ active_keys: list[tuple[str, type]] = []
+ for key in producers:
+ name, type_ = key
+ if key not in consumers:
+ continue
+ if (name, type_.__name__) in ignored_streams:
+ continue
+ valid_producers = [m for m in producers[key] if m.__name__ not in ignored_modules]
+ valid_consumers = [m for m in consumers[key] if m.__name__ not in ignored_modules]
+ if valid_producers and valid_consumers:
+ active_keys.append(key)
+
+ disconnected_keys: list[tuple[str, type]] = []
+ if show_disconnected:
+ all_keys = set(producers.keys()) | set(consumers.keys())
+ for key in all_keys:
+ if key in active_keys:
+ continue
+ name, type_ = key
+ if (name, type_.__name__) in ignored_streams:
+ continue
+ relevant = producers.get(key, []) + consumers.get(key, [])
+ if all(m.__name__ in ignored_modules for m in relevant):
+ continue
+ disconnected_keys.append(key)
+
+ palette = THEMES.get(theme, THEMES[DEFAULT_THEME])
+ node_color = _ColorAssigner(palette["nodes"])
+ edge_color = _ColorAssigner(palette["edges"])
+
+ lines = ["graph LR"]
+
+ sorted_modules = sorted(module_names)
+ for module_name in sorted_modules:
+ mermaid_id = sanitize_id(module_name)
+ lines.append(f" {mermaid_id}([{module_name}]):::moduleNode")
+
+ lines.append("")
+
+ edge_idx = 0
+ edge_colors: list[str] = []
+ label_color_map: dict[str, str] = {}
+ stream_node_ids: dict[str, str] = {}
+ disconnected_labels: set[str] = set()
+
+ lines.append(" %% Stream nodes and edges")
+ for key in sorted(active_keys, key=lambda k: f"{k[0]}:{k[1].__name__}"):
+ name, type_ = key
+ label = f"{name}:{type_.__name__}"
+ color = edge_color(label)
+ label_color_map[label] = color
+
+ valid_producers = [m for m in producers[key] if m.__name__ not in ignored_modules]
+ valid_consumers = [m for m in consumers[key] if m.__name__ not in ignored_modules]
+
+ for prod in valid_producers:
+ stream_node_id = sanitize_id(f"{prod.__name__}_{name}_{type_.__name__}")
+ if stream_node_id not in stream_node_ids:
+ lines.append(f" {stream_node_id}[{label}]:::streamNode")
+ stream_node_ids[stream_node_id] = color
+
+ producer_id = sanitize_id(prod.__name__)
+ lines.append(f" {producer_id} --- {stream_node_id}")
+ edge_colors.append(node_color(prod.__name__))
+ edge_idx += 1
+
+ for cons in valid_consumers:
+ consumer_id = sanitize_id(cons.__name__)
+ lines.append(f" {stream_node_id} --> {consumer_id}")
+ edge_colors.append(color)
+ edge_idx += 1
+
+ if disconnected_keys:
+ lines.append("")
+ lines.append(" %% Disconnected streams")
+ for key in sorted(disconnected_keys, key=lambda k: f"{k[0]}:{k[1].__name__}"):
+ name, type_ = key
+ label = f"{name}:{type_.__name__}"
+ color = edge_color(label)
+ label_color_map[label] = color
+ disconnected_labels.add(label)
+
+ for prod in producers.get(key, []):
+ if prod.__name__ in ignored_modules:
+ continue
+ stream_node_id = sanitize_id(f"{prod.__name__}_{name}_{type_.__name__}")
+ if stream_node_id not in stream_node_ids:
+ lines.append(f" {stream_node_id}[{label}]:::streamNode")
+ stream_node_ids[stream_node_id] = color
+ producer_id = sanitize_id(prod.__name__)
+ lines.append(f" {producer_id} -.- {stream_node_id}")
+ edge_colors.append(node_color(prod.__name__))
+ edge_idx += 1
+
+ for cons in consumers.get(key, []):
+ if cons.__name__ in ignored_modules:
+ continue
+ stream_node_id = sanitize_id(f"dangling_{name}_{type_.__name__}")
+ if stream_node_id not in stream_node_ids:
+ lines.append(f" {stream_node_id}[{label}]:::streamNode")
+ stream_node_ids[stream_node_id] = color
+ consumer_id = sanitize_id(cons.__name__)
+ lines.append(f" {stream_node_id} -.-> {consumer_id}")
+ edge_colors.append(color)
+ edge_idx += 1
+
+ lines.append("")
+ for module_name in sorted_modules:
+ mermaid_id = sanitize_id(module_name)
+ color = node_color(module_name)
+ lines.append(
+ f" style {mermaid_id} fill:{color}bf,stroke:{color},color:#eee,stroke-width:2px"
+ )
+
+ for stream_node_id, color in stream_node_ids.items():
+ lines.append(
+ f" style {stream_node_id} fill:transparent,stroke:{color},color:{color},stroke-width:1px"
+ )
+
+ if edge_colors:
+ lines.append("")
+ for i, color in enumerate(edge_colors):
+ lines.append(f" linkStyle {i} stroke:{color},stroke-width:2px")
+
+ node_color_map = dict(node_color._assigned)
+ return "\n".join(lines), label_color_map, disconnected_labels, node_color_map
diff --git a/dimos/robot/cli/dimos.py b/dimos/robot/cli/dimos.py
index 3f94a6be4e..48b9acd235 100644
--- a/dimos/robot/cli/dimos.py
+++ b/dimos/robot/cli/dimos.py
@@ -802,6 +802,34 @@ def apriltag(
typer.echo(f"Wrote {len(id_list)} tag(s) to {path}")
+@main.command()
+def graph(
+ python_file: str = typer.Argument(..., help="Python file containing Blueprint globals"),
+ no_disconnected: bool = typer.Option(
+ False, "--no-disconnected", help="Hide disconnected streams"
+ ),
+ port: int = typer.Option(0, "--port", help="HTTP server port (0 = random free port)"),
+ markdown: bool = typer.Option(
+ False, "--markdown", help="Print Mermaid markdown to stdout and exit"
+ ),
+ html: str = typer.Option("", "--html", help="Write HTML to a file instead of serving"),
+ theme: str = typer.Option(
+ "tailwind", "--theme", help="Color theme (tailwind, ocean, ember, forest, light)"
+ ),
+) -> None:
+ """Render DimOS Blueprint graphs. Serves in browser by default, or use --markdown / --html."""
+ from dimos.utils.cli.graph import print_markdown, save_html, serve_graph
+
+ show_disconnected = not no_disconnected
+ if markdown:
+ print_markdown(python_file, show_disconnected=show_disconnected, theme=theme)
+ return
+ if html:
+ save_html(python_file, output_path=html, show_disconnected=show_disconnected, theme=theme)
+ return
+ serve_graph(python_file, show_disconnected=show_disconnected, port=port, theme=theme)
+
+
@main.command(name="rerun-bridge")
def rerun_bridge_cmd(
memory_limit: str = typer.Option(
diff --git a/dimos/utils/cli/graph.html.jinja b/dimos/utils/cli/graph.html.jinja
new file mode 100644
index 0000000000..88c9e39dd7
--- /dev/null
+++ b/dimos/utils/cli/graph.html.jinja
@@ -0,0 +1,331 @@
+
+
+
+Blueprint Diagrams
+
+
+
+{% if tab_buttons | length > 1 %}
+
+ {% for button in tab_buttons %}
+
+ {% endfor %}
+
+{% endif %}
+{% for panel in tab_panels %}
+
+
+
+{{ panel.mermaid_code }}
+
+
+
+{% endfor %}
+
+
+
+
+
+
+
+
+
diff --git a/dimos/utils/cli/graph.py b/dimos/utils/cli/graph.py
new file mode 100644
index 0000000000..b80340d0cb
--- /dev/null
+++ b/dimos/utils/cli/graph.py
@@ -0,0 +1,292 @@
+# Copyright 2025-2026 Dimensional Inc.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Render DimOS Blueprint graphs as Mermaid diagrams in the browser.
+
+Loads Blueprint instances defined as module-level variables in a Python file
+and serves an interactive Mermaid flowchart per blueprint.
+"""
+
+from __future__ import annotations
+
+from http.server import BaseHTTPRequestHandler, HTTPServer
+import importlib.util
+import json
+import os
+from pathlib import Path
+import sys
+from typing import Any
+import webbrowser
+
+import jinja2
+
+from dimos.core.coordination.blueprints import Blueprint
+from dimos.core.introspection.mermaid import DEFAULT_THEME, THEMES, render_mermaid
+from dimos.utils.data import get_data
+
+_CLI_DIR = Path(__file__).parent
+_MERMAID_JS = get_data("mermaid.min.js").read_text(encoding="utf-8")
+_TEMPLATE = jinja2.Template(
+ (_CLI_DIR / "graph.html.jinja").read_text(encoding="utf-8"),
+ autoescape=False,
+)
+
+
+def _levenshtein(a: str, b: str) -> int:
+ if len(a) < len(b):
+ return _levenshtein(b, a)
+ if not b:
+ return len(a)
+ prev = list(range(len(b) + 1))
+ for i, char_a in enumerate(a):
+ curr = [i + 1]
+ for j, char_b in enumerate(b):
+ curr.append(min(prev[j + 1] + 1, curr[j] + 1, prev[j] + (char_a != char_b)))
+ prev = curr
+ return prev[-1]
+
+
+def _find_package_root(filepath: str) -> str | None:
+ directory = os.path.dirname(filepath)
+ root = None
+ while os.path.isfile(os.path.join(directory, "__init__.py")):
+ root = directory
+ parent = os.path.dirname(directory)
+ if parent == directory:
+ break
+ directory = parent
+ if root is not None:
+ return os.path.dirname(root)
+ return None
+
+
+def _load_blueprints(python_file: str) -> list[tuple[str, Blueprint]]:
+ filepath = os.path.abspath(python_file)
+ if not os.path.isfile(filepath):
+ raise FileNotFoundError(filepath)
+
+ pkg_root = _find_package_root(filepath)
+ if pkg_root and pkg_root not in sys.path:
+ sys.path.insert(0, pkg_root)
+
+ spec = importlib.util.spec_from_file_location("_render_target", filepath)
+ if spec is None or spec.loader is None:
+ raise RuntimeError(f"Could not load {filepath}")
+ module = importlib.util.module_from_spec(spec)
+ sys.modules[spec.name] = module
+ spec.loader.exec_module(module)
+
+ blueprints: list[tuple[str, Blueprint]] = []
+ for name, obj in vars(module).items():
+ if isinstance(obj, Blueprint):
+ blueprints.append((name, obj))
+
+ if not blueprints:
+ raise RuntimeError("No Blueprint instances found in module globals.")
+
+ blueprints.reverse()
+ print(
+ f"Found {len(blueprints)} blueprint(s): {', '.join(n for n, _ in blueprints)}",
+ file=sys.stderr,
+ )
+ return blueprints
+
+
+def _build_html(
+ python_file: str, *, show_disconnected: bool = True, theme: str = DEFAULT_THEME
+) -> str:
+ blueprints = _load_blueprints(python_file)
+ palette = THEMES.get(theme, THEMES[DEFAULT_THEME])
+ background = palette.get("background", "#1e1e1e")
+ mermaid_theme = palette.get("mermaid_theme", "dark")
+ is_light = mermaid_theme != "dark"
+ text_color = "#334155" if is_light else "#ccc"
+ text_muted = "#64748b" if is_light else "#888"
+ text_bright = "#1e293b" if is_light else "#eee"
+ surface = "#e2e8f0" if is_light else "#252525"
+ surface_hover = "#cbd5e1" if is_light else "#2a2a2a"
+ controls_bg = "#e2e8f0" if is_light else "#2a2a2a"
+ controls_btn = "#cbd5e1" if is_light else "#333"
+ controls_border = "#94a3b8" if is_light else "#555"
+ border_color = "#cbd5e1" if is_light else "#444"
+ label_bg = "rgba(248,250,252,0.85)" if is_light else "rgba(30,30,30,0.7)"
+
+ per_bp_label_colors: list[dict[str, str]] = []
+ per_bp_disconnected: list[set[str]] = []
+ per_bp_node_colors: list[dict[str, str]] = []
+ per_bp_conflicts: list[list[dict[str, Any]]] = []
+ per_bp_typos: list[list[dict[str, object]]] = []
+
+ tab_buttons: list[dict[str, str]] = []
+ tab_panels: list[dict[str, str]] = []
+ for name, bp in blueprints:
+ mermaid_code, label_colors, disconnected, node_colors = render_mermaid(
+ bp, show_disconnected=show_disconnected, theme=theme
+ )
+ per_bp_label_colors.append(label_colors)
+ per_bp_disconnected.append(disconnected)
+ per_bp_node_colors.append(node_colors)
+
+ producers: dict[str, list[str]] = {}
+ for atom in bp.blueprints:
+ for stream in atom.streams:
+ if stream.direction == "out":
+ topic = f"{stream.name}:{stream.type.__name__}"
+ producers.setdefault(topic, []).append(atom.module.__name__)
+ conflicts: list[dict[str, object]] = [
+ {
+ "topic": topic,
+ "topicColor": label_colors.get(topic, "#ccc"),
+ "modules": [
+ {"name": module_name, "color": node_colors.get(module_name, "#ccc")}
+ for module_name in modules
+ ],
+ }
+ for topic, modules in producers.items()
+ if len(modules) > 1
+ ]
+ per_bp_conflicts.append(conflicts)
+
+ outputs: dict[tuple[str, str], list[str]] = {}
+ inputs: dict[tuple[str, str], list[str]] = {}
+ for atom in bp.blueprints:
+ for stream in atom.streams:
+ key = (stream.name, stream.type.__name__)
+ if stream.direction == "out":
+ outputs.setdefault(key, []).append(atom.module.__name__)
+ else:
+ inputs.setdefault(key, []).append(atom.module.__name__)
+ dangling_outs = {k: v for k, v in outputs.items() if k not in inputs}
+ dangling_ins = {k: v for k, v in inputs.items() if k not in outputs}
+ typos: list[dict[str, object]] = []
+ for (out_name, out_type), out_modules in dangling_outs.items():
+ for (in_name, in_type), in_modules in dangling_ins.items():
+ if out_type != in_type:
+ continue
+ distance = _levenshtein(out_name, in_name)
+ if 0 < distance <= 2:
+ out_label = f"{out_name}:{out_type}"
+ in_label = f"{in_name}:{in_type}"
+ typos.append(
+ {
+ "outLabel": out_label,
+ "inLabel": in_label,
+ "outColor": label_colors.get(out_label, "#ccc"),
+ "inColor": label_colors.get(in_label, "#ccc"),
+ "outModules": [
+ {"name": m, "color": node_colors.get(m, "#ccc")}
+ for m in out_modules
+ ],
+ "inModules": [
+ {"name": m, "color": node_colors.get(m, "#ccc")} for m in in_modules
+ ],
+ }
+ )
+ per_bp_typos.append(typos)
+
+ tab_buttons.append({"name": name})
+ tab_panels.append({"mermaid_code": mermaid_code})
+
+ return _TEMPLATE.render(
+ background=background,
+ text_color=text_color,
+ text_muted=text_muted,
+ text_bright=text_bright,
+ surface=surface,
+ surface_hover=surface_hover,
+ controls_bg=controls_bg,
+ controls_btn=controls_btn,
+ controls_border=controls_border,
+ border_color=border_color,
+ label_bg=label_bg,
+ mermaid_theme=mermaid_theme,
+ mermaid_js=_MERMAID_JS,
+ tab_buttons=tab_buttons,
+ tab_panels=tab_panels,
+ all_label_colors_json=json.dumps(per_bp_label_colors),
+ all_disconnected_json=json.dumps([sorted(d) for d in per_bp_disconnected]),
+ all_conflicts_json=json.dumps(per_bp_conflicts),
+ all_typos_json=json.dumps(per_bp_typos),
+ )
+
+
+def print_markdown(
+ python_file: str, *, show_disconnected: bool, theme: str = DEFAULT_THEME
+) -> None:
+ blueprints = _load_blueprints(python_file)
+ sections: list[str] = []
+ for name, bp in blueprints:
+ mermaid_code, _, _, _ = render_mermaid(bp, show_disconnected=show_disconnected, theme=theme)
+ sections.append(f"## {name}\n\n```mermaid\n{mermaid_code}\n```")
+ print("\n\n".join(sections))
+
+
+def save_html(
+ python_file: str,
+ *,
+ output_path: str,
+ show_disconnected: bool,
+ theme: str = DEFAULT_THEME,
+) -> None:
+ html = _build_html(python_file, show_disconnected=show_disconnected, theme=theme)
+ with open(output_path, "w") as file:
+ file.write(html)
+ print(f"Wrote {output_path}", file=sys.stderr)
+
+
+def serve_graph(
+ python_file: str, *, show_disconnected: bool, port: int, theme: str = DEFAULT_THEME
+) -> None:
+ html = _build_html(python_file, show_disconnected=show_disconnected, theme=theme)
+ html_bytes = html.encode("utf-8")
+
+ favicon_svg = (
+ b'"
+ )
+
+ class Handler(BaseHTTPRequestHandler):
+ def do_GET(self) -> None:
+ if self.path == "/favicon.ico":
+ self.send_response(200)
+ self.send_header("Content-Type", "image/svg+xml")
+ self.send_header("Content-Length", str(len(favicon_svg)))
+ self.end_headers()
+ self.wfile.write(favicon_svg)
+ return
+ if self.path not in ("/", ""):
+ self.send_response(204)
+ self.end_headers()
+ return
+ self.send_response(200)
+ self.send_header("Content-Type", "text/html; charset=utf-8")
+ self.send_header("Content-Length", str(len(html_bytes)))
+ self.end_headers()
+ self.wfile.write(html_bytes)
+
+ def log_message(self, format: str, *args: object) -> None:
+ pass
+
+ server = HTTPServer(("127.0.0.1", port), Handler)
+ actual_port = server.server_address[1]
+ url = f"http://localhost:{actual_port}"
+ print(f"Serving at {url} (will exit after first request)")
+ webbrowser.open(url)
+ server.handle_request()
+ print("Served. Exiting.")
diff --git a/dimos/utils/cli/test_graph.py b/dimos/utils/cli/test_graph.py
new file mode 100644
index 0000000000..fb8519cc5f
--- /dev/null
+++ b/dimos/utils/cli/test_graph.py
@@ -0,0 +1,164 @@
+# Copyright 2025-2026 Dimensional Inc.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+from __future__ import annotations
+
+from http.server import BaseHTTPRequestHandler, HTTPServer
+from pathlib import Path
+import re
+import threading
+
+import pytest
+import requests
+
+from dimos.core.coordination.blueprints import Blueprint, autoconnect
+from dimos.core.module import Module
+from dimos.core.stream import In, Out
+
+SNAPSHOT_PATH = Path(__file__).with_name("test_graph_snapshot.html")
+
+
+class ImageData:
+ pass
+
+
+class DepthData:
+ pass
+
+
+class OdometryData:
+ pass
+
+
+class PlanData:
+ pass
+
+
+class CmdVelData:
+ pass
+
+
+class PointCloudData:
+ pass
+
+
+class CameraModule(Module):
+ color_image: Out[ImageData]
+ depth_image: Out[DepthData]
+ point_cloud: Out[PointCloudData]
+
+
+class OdometryModule(Module):
+ odometry: Out[OdometryData]
+
+
+class PerceptionModule(Module):
+ color_image: In[ImageData]
+ depth_image: In[DepthData]
+ odometry: In[OdometryData]
+
+
+class PlannerModule(Module):
+ odometry: In[OdometryData]
+ plan: Out[PlanData]
+
+
+class ControllerModule(Module):
+ plan: In[PlanData]
+ odometry: In[OdometryData]
+ cmd_vel: Out[CmdVelData]
+
+
+class VisualizerModule(Module):
+ color_image: In[ImageData]
+ point_cloud: In[PointCloudData]
+
+
+def _build_blueprint() -> Blueprint:
+ return autoconnect(
+ CameraModule.blueprint(),
+ OdometryModule.blueprint(),
+ PerceptionModule.blueprint(),
+ PlannerModule.blueprint(),
+ ControllerModule.blueprint(),
+ VisualizerModule.blueprint(),
+ )
+
+
+def _normalize_html(html: str) -> str:
+ html = re.sub(
+ r"",
+ "",
+ html,
+ count=1,
+ flags=re.DOTALL,
+ )
+ return "\n".join(line.rstrip() for line in html.splitlines())
+
+
+def _serve_and_fetch(html: str) -> str:
+ html_bytes = html.encode()
+
+ class Handler(BaseHTTPRequestHandler):
+ def do_GET(self) -> None:
+ self.send_response(200)
+ self.send_header("Content-Type", "text/html; charset=utf-8")
+ self.send_header("Content-Length", str(len(html_bytes)))
+ self.end_headers()
+ self.wfile.write(html_bytes)
+
+ def log_message(self, format: str, *args: object) -> None:
+ pass
+
+ server = HTTPServer(("127.0.0.1", 0), Handler)
+ port = server.server_address[1]
+
+ thread = threading.Thread(target=server.handle_request, daemon=True)
+ thread.start()
+
+ response = requests.get(f"http://127.0.0.1:{port}/", timeout=10)
+ body = response.text
+
+ thread.join(timeout=5)
+ server.server_close()
+ return body
+
+
+def test_graph_server_snapshot() -> None:
+ blueprint_file = str(Path(__file__).with_name("test_graph_blueprints.py"))
+
+ from dimos.utils.cli.graph import _build_html
+
+ html = _build_html(blueprint_file, show_disconnected=True)
+
+ html_normalized = _normalize_html(html)
+
+ assert "" in html_normalized
+ assert "CameraModule" in html_normalized
+ assert "mermaid" in html_normalized
+
+ served_html = _serve_and_fetch(html)
+ served_normalized = _normalize_html(served_html)
+ assert served_normalized == html_normalized
+
+ if SNAPSHOT_PATH.exists():
+ snapshot = SNAPSHOT_PATH.read_text().rstrip("\n")
+ if snapshot != html_normalized.rstrip("\n"):
+ SNAPSHOT_PATH.write_text(html_normalized.rstrip("\n") + "\n")
+ pytest.fail(
+ f"Snapshot mismatch — updated {SNAPSHOT_PATH.name}. "
+ "Re-run to confirm the new snapshot passes."
+ )
+ else:
+ SNAPSHOT_PATH.write_text(html_normalized.rstrip("\n") + "\n")
diff --git a/dimos/utils/cli/test_graph_blueprints.py b/dimos/utils/cli/test_graph_blueprints.py
new file mode 100644
index 0000000000..cdbf7ef99b
--- /dev/null
+++ b/dimos/utils/cli/test_graph_blueprints.py
@@ -0,0 +1,119 @@
+# Copyright 2025-2026 Dimensional Inc.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+from __future__ import annotations
+
+from pathlib import Path
+
+from dimos.core.coordination.blueprints import autoconnect
+from dimos.core.module import Module
+from dimos.core.stream import In, Out
+
+SNAPSHOT_PATH = Path(__file__).with_name("test_graph_snapshot.html")
+
+
+class ImageData:
+ pass
+
+
+class DepthData:
+ pass
+
+
+class OdometryData:
+ pass
+
+
+class PlanData:
+ pass
+
+
+class CmdVelData:
+ pass
+
+
+class PointCloudData:
+ pass
+
+
+class CameraModule(Module):
+ # intentionally doesn't match "color_image"
+ color_img: Out[ImageData]
+ depth_image: Out[DepthData]
+ point_cloud: Out[PointCloudData]
+
+
+class OdometryModule(Module):
+ odometry: Out[OdometryData]
+
+
+class PerceptionModule(Module):
+ color_image: In[ImageData]
+ depth_image: In[DepthData]
+ odometry: In[OdometryData]
+
+
+class PlannerModule(Module):
+ odometry: In[OdometryData]
+ plan: Out[PlanData]
+
+
+class PlannerModule2(Module):
+ odometry: In[OdometryData]
+ plan: Out[PlanData]
+
+
+class ControllerModule(Module):
+ plan: In[PlanData]
+ odometry: In[OdometryData]
+ cmd_vel: Out[CmdVelData]
+
+
+class ControllerModule2(Module):
+ plan: In[PlanData]
+ odometry: In[OdometryData]
+ cmd_vel: Out[CmdVelData]
+
+
+class VisualizerModule(Module):
+ color_image: In[ImageData]
+ point_cloud: In[PointCloudData]
+
+
+_blueprint1 = autoconnect(
+ CameraModule.blueprint(),
+ OdometryModule.blueprint(),
+ PerceptionModule.blueprint(),
+ PlannerModule.blueprint(),
+ ControllerModule.blueprint(),
+ VisualizerModule.blueprint(),
+)
+
+_blueprint2 = autoconnect(
+ CameraModule.blueprint(),
+ PlannerModule.blueprint(),
+ ControllerModule.blueprint(),
+ VisualizerModule.blueprint(),
+)
+
+_blueprint3 = autoconnect(
+ CameraModule.blueprint(),
+ OdometryModule.blueprint(),
+ PerceptionModule.blueprint(),
+ PlannerModule.blueprint(),
+ PlannerModule2.blueprint(), # intenional double
+ ControllerModule.blueprint(),
+ ControllerModule2.blueprint(),
+ VisualizerModule.blueprint(),
+)
diff --git a/dimos/utils/cli/test_graph_snapshot.html b/dimos/utils/cli/test_graph_snapshot.html
new file mode 100644
index 0000000000..af90b1795f
--- /dev/null
+++ b/dimos/utils/cli/test_graph_snapshot.html
@@ -0,0 +1,541 @@
+
+
+
+Blueprint Diagrams
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+graph LR
+ CameraModule([CameraModule]):::moduleNode
+ ControllerModule([ControllerModule]):::moduleNode
+ ControllerModule2([ControllerModule2]):::moduleNode
+ OdometryModule([OdometryModule]):::moduleNode
+ PerceptionModule([PerceptionModule]):::moduleNode
+ PlannerModule([PlannerModule]):::moduleNode
+ PlannerModule2([PlannerModule2]):::moduleNode
+ VisualizerModule([VisualizerModule]):::moduleNode
+
+ %% Stream nodes and edges
+ CameraModule_depth_image_DepthData[depth_image:DepthData]:::streamNode
+ CameraModule --- CameraModule_depth_image_DepthData
+ CameraModule_depth_image_DepthData --> PerceptionModule
+ OdometryModule_odometry_OdometryData[odometry:OdometryData]:::streamNode
+ OdometryModule --- OdometryModule_odometry_OdometryData
+ OdometryModule_odometry_OdometryData --> PerceptionModule
+ OdometryModule_odometry_OdometryData --> PlannerModule
+ OdometryModule_odometry_OdometryData --> PlannerModule2
+ OdometryModule_odometry_OdometryData --> ControllerModule
+ OdometryModule_odometry_OdometryData --> ControllerModule2
+ PlannerModule_plan_PlanData[plan:PlanData]:::streamNode
+ PlannerModule --- PlannerModule_plan_PlanData
+ PlannerModule_plan_PlanData --> ControllerModule
+ PlannerModule_plan_PlanData --> ControllerModule2
+ PlannerModule2_plan_PlanData[plan:PlanData]:::streamNode
+ PlannerModule2 --- PlannerModule2_plan_PlanData
+ PlannerModule2_plan_PlanData --> ControllerModule
+ PlannerModule2_plan_PlanData --> ControllerModule2
+ CameraModule_point_cloud_PointCloudData[point_cloud:PointCloudData]:::streamNode
+ CameraModule --- CameraModule_point_cloud_PointCloudData
+ CameraModule_point_cloud_PointCloudData --> VisualizerModule
+
+ %% Disconnected streams
+ ControllerModule_cmd_vel_CmdVelData[cmd_vel:CmdVelData]:::streamNode
+ ControllerModule -.- ControllerModule_cmd_vel_CmdVelData
+ ControllerModule2_cmd_vel_CmdVelData[cmd_vel:CmdVelData]:::streamNode
+ ControllerModule2 -.- ControllerModule2_cmd_vel_CmdVelData
+ dangling_color_image_ImageData[color_image:ImageData]:::streamNode
+ dangling_color_image_ImageData -.-> PerceptionModule
+ dangling_color_image_ImageData -.-> VisualizerModule
+ CameraModule_color_img_ImageData[color_img:ImageData]:::streamNode
+ CameraModule -.- CameraModule_color_img_ImageData
+
+ style CameraModule fill:#3b82f6bf,stroke:#3b82f6,color:#eee,stroke-width:2px
+ style ControllerModule fill:#f97316bf,stroke:#f97316,color:#eee,stroke-width:2px
+ style ControllerModule2 fill:#06b6d4bf,stroke:#06b6d4,color:#eee,stroke-width:2px
+ style OdometryModule fill:#ef4444bf,stroke:#ef4444,color:#eee,stroke-width:2px
+ style PerceptionModule fill:#ec4899bf,stroke:#ec4899,color:#eee,stroke-width:2px
+ style PlannerModule fill:#22c55ebf,stroke:#22c55e,color:#eee,stroke-width:2px
+ style PlannerModule2 fill:#8b5cf6bf,stroke:#8b5cf6,color:#eee,stroke-width:2px
+ style VisualizerModule fill:#6366f1bf,stroke:#6366f1,color:#eee,stroke-width:2px
+ style CameraModule_depth_image_DepthData fill:transparent,stroke:#60a5fa,color:#60a5fa,stroke-width:1px
+ style OdometryModule_odometry_OdometryData fill:transparent,stroke:#f87171,color:#f87171,stroke-width:1px
+ style PlannerModule_plan_PlanData fill:transparent,stroke:#4ade80,color:#4ade80,stroke-width:1px
+ style PlannerModule2_plan_PlanData fill:transparent,stroke:#4ade80,color:#4ade80,stroke-width:1px
+ style CameraModule_point_cloud_PointCloudData fill:transparent,stroke:#a78bfa,color:#a78bfa,stroke-width:1px
+ style ControllerModule_cmd_vel_CmdVelData fill:transparent,stroke:#fb923c,color:#fb923c,stroke-width:1px
+ style ControllerModule2_cmd_vel_CmdVelData fill:transparent,stroke:#fb923c,color:#fb923c,stroke-width:1px
+ style dangling_color_image_ImageData fill:transparent,stroke:#22d3ee,color:#22d3ee,stroke-width:1px
+ style CameraModule_color_img_ImageData fill:transparent,stroke:#f472b6,color:#f472b6,stroke-width:1px
+
+ linkStyle 0 stroke:#3b82f6,stroke-width:2px
+ linkStyle 1 stroke:#60a5fa,stroke-width:2px
+ linkStyle 2 stroke:#ef4444,stroke-width:2px
+ linkStyle 3 stroke:#f87171,stroke-width:2px
+ linkStyle 4 stroke:#f87171,stroke-width:2px
+ linkStyle 5 stroke:#f87171,stroke-width:2px
+ linkStyle 6 stroke:#f87171,stroke-width:2px
+ linkStyle 7 stroke:#f87171,stroke-width:2px
+ linkStyle 8 stroke:#22c55e,stroke-width:2px
+ linkStyle 9 stroke:#4ade80,stroke-width:2px
+ linkStyle 10 stroke:#4ade80,stroke-width:2px
+ linkStyle 11 stroke:#8b5cf6,stroke-width:2px
+ linkStyle 12 stroke:#4ade80,stroke-width:2px
+ linkStyle 13 stroke:#4ade80,stroke-width:2px
+ linkStyle 14 stroke:#3b82f6,stroke-width:2px
+ linkStyle 15 stroke:#a78bfa,stroke-width:2px
+ linkStyle 16 stroke:#f97316,stroke-width:2px
+ linkStyle 17 stroke:#06b6d4,stroke-width:2px
+ linkStyle 18 stroke:#22d3ee,stroke-width:2px
+ linkStyle 19 stroke:#22d3ee,stroke-width:2px
+ linkStyle 20 stroke:#3b82f6,stroke-width:2px
+
+
+
+
+
+
+
+graph LR
+ CameraModule([CameraModule]):::moduleNode
+ ControllerModule([ControllerModule]):::moduleNode
+ PlannerModule([PlannerModule]):::moduleNode
+ VisualizerModule([VisualizerModule]):::moduleNode
+
+ %% Stream nodes and edges
+ PlannerModule_plan_PlanData[plan:PlanData]:::streamNode
+ PlannerModule --- PlannerModule_plan_PlanData
+ PlannerModule_plan_PlanData --> ControllerModule
+ CameraModule_point_cloud_PointCloudData[point_cloud:PointCloudData]:::streamNode
+ CameraModule --- CameraModule_point_cloud_PointCloudData
+ CameraModule_point_cloud_PointCloudData --> VisualizerModule
+
+ %% Disconnected streams
+ ControllerModule_cmd_vel_CmdVelData[cmd_vel:CmdVelData]:::streamNode
+ ControllerModule -.- ControllerModule_cmd_vel_CmdVelData
+ dangling_color_image_ImageData[color_image:ImageData]:::streamNode
+ dangling_color_image_ImageData -.-> VisualizerModule
+ CameraModule_color_img_ImageData[color_img:ImageData]:::streamNode
+ CameraModule -.- CameraModule_color_img_ImageData
+ CameraModule_depth_image_DepthData[depth_image:DepthData]:::streamNode
+ CameraModule -.- CameraModule_depth_image_DepthData
+ dangling_odometry_OdometryData[odometry:OdometryData]:::streamNode
+ dangling_odometry_OdometryData -.-> PlannerModule
+ dangling_odometry_OdometryData -.-> ControllerModule
+
+ style CameraModule fill:#ef4444bf,stroke:#ef4444,color:#eee,stroke-width:2px
+ style ControllerModule fill:#22c55ebf,stroke:#22c55e,color:#eee,stroke-width:2px
+ style PlannerModule fill:#3b82f6bf,stroke:#3b82f6,color:#eee,stroke-width:2px
+ style VisualizerModule fill:#8b5cf6bf,stroke:#8b5cf6,color:#eee,stroke-width:2px
+ style PlannerModule_plan_PlanData fill:transparent,stroke:#60a5fa,color:#60a5fa,stroke-width:1px
+ style CameraModule_point_cloud_PointCloudData fill:transparent,stroke:#f87171,color:#f87171,stroke-width:1px
+ style ControllerModule_cmd_vel_CmdVelData fill:transparent,stroke:#4ade80,color:#4ade80,stroke-width:1px
+ style dangling_color_image_ImageData fill:transparent,stroke:#a78bfa,color:#a78bfa,stroke-width:1px
+ style CameraModule_color_img_ImageData fill:transparent,stroke:#fb923c,color:#fb923c,stroke-width:1px
+ style CameraModule_depth_image_DepthData fill:transparent,stroke:#22d3ee,color:#22d3ee,stroke-width:1px
+ style dangling_odometry_OdometryData fill:transparent,stroke:#f472b6,color:#f472b6,stroke-width:1px
+
+ linkStyle 0 stroke:#3b82f6,stroke-width:2px
+ linkStyle 1 stroke:#60a5fa,stroke-width:2px
+ linkStyle 2 stroke:#ef4444,stroke-width:2px
+ linkStyle 3 stroke:#f87171,stroke-width:2px
+ linkStyle 4 stroke:#22c55e,stroke-width:2px
+ linkStyle 5 stroke:#a78bfa,stroke-width:2px
+ linkStyle 6 stroke:#ef4444,stroke-width:2px
+ linkStyle 7 stroke:#ef4444,stroke-width:2px
+ linkStyle 8 stroke:#f472b6,stroke-width:2px
+ linkStyle 9 stroke:#f472b6,stroke-width:2px
+
+
+
+
+
+
+
+graph LR
+ CameraModule([CameraModule]):::moduleNode
+ ControllerModule([ControllerModule]):::moduleNode
+ OdometryModule([OdometryModule]):::moduleNode
+ PerceptionModule([PerceptionModule]):::moduleNode
+ PlannerModule([PlannerModule]):::moduleNode
+ VisualizerModule([VisualizerModule]):::moduleNode
+
+ %% Stream nodes and edges
+ CameraModule_depth_image_DepthData[depth_image:DepthData]:::streamNode
+ CameraModule --- CameraModule_depth_image_DepthData
+ CameraModule_depth_image_DepthData --> PerceptionModule
+ OdometryModule_odometry_OdometryData[odometry:OdometryData]:::streamNode
+ OdometryModule --- OdometryModule_odometry_OdometryData
+ OdometryModule_odometry_OdometryData --> PerceptionModule
+ OdometryModule_odometry_OdometryData --> PlannerModule
+ OdometryModule_odometry_OdometryData --> ControllerModule
+ PlannerModule_plan_PlanData[plan:PlanData]:::streamNode
+ PlannerModule --- PlannerModule_plan_PlanData
+ PlannerModule_plan_PlanData --> ControllerModule
+ CameraModule_point_cloud_PointCloudData[point_cloud:PointCloudData]:::streamNode
+ CameraModule --- CameraModule_point_cloud_PointCloudData
+ CameraModule_point_cloud_PointCloudData --> VisualizerModule
+
+ %% Disconnected streams
+ ControllerModule_cmd_vel_CmdVelData[cmd_vel:CmdVelData]:::streamNode
+ ControllerModule -.- ControllerModule_cmd_vel_CmdVelData
+ dangling_color_image_ImageData[color_image:ImageData]:::streamNode
+ dangling_color_image_ImageData -.-> PerceptionModule
+ dangling_color_image_ImageData -.-> VisualizerModule
+ CameraModule_color_img_ImageData[color_img:ImageData]:::streamNode
+ CameraModule -.- CameraModule_color_img_ImageData
+
+ style CameraModule fill:#3b82f6bf,stroke:#3b82f6,color:#eee,stroke-width:2px
+ style ControllerModule fill:#8b5cf6bf,stroke:#8b5cf6,color:#eee,stroke-width:2px
+ style OdometryModule fill:#ef4444bf,stroke:#ef4444,color:#eee,stroke-width:2px
+ style PerceptionModule fill:#f97316bf,stroke:#f97316,color:#eee,stroke-width:2px
+ style PlannerModule fill:#22c55ebf,stroke:#22c55e,color:#eee,stroke-width:2px
+ style VisualizerModule fill:#06b6d4bf,stroke:#06b6d4,color:#eee,stroke-width:2px
+ style CameraModule_depth_image_DepthData fill:transparent,stroke:#60a5fa,color:#60a5fa,stroke-width:1px
+ style OdometryModule_odometry_OdometryData fill:transparent,stroke:#f87171,color:#f87171,stroke-width:1px
+ style PlannerModule_plan_PlanData fill:transparent,stroke:#4ade80,color:#4ade80,stroke-width:1px
+ style CameraModule_point_cloud_PointCloudData fill:transparent,stroke:#a78bfa,color:#a78bfa,stroke-width:1px
+ style ControllerModule_cmd_vel_CmdVelData fill:transparent,stroke:#fb923c,color:#fb923c,stroke-width:1px
+ style dangling_color_image_ImageData fill:transparent,stroke:#22d3ee,color:#22d3ee,stroke-width:1px
+ style CameraModule_color_img_ImageData fill:transparent,stroke:#f472b6,color:#f472b6,stroke-width:1px
+
+ linkStyle 0 stroke:#3b82f6,stroke-width:2px
+ linkStyle 1 stroke:#60a5fa,stroke-width:2px
+ linkStyle 2 stroke:#ef4444,stroke-width:2px
+ linkStyle 3 stroke:#f87171,stroke-width:2px
+ linkStyle 4 stroke:#f87171,stroke-width:2px
+ linkStyle 5 stroke:#f87171,stroke-width:2px
+ linkStyle 6 stroke:#22c55e,stroke-width:2px
+ linkStyle 7 stroke:#4ade80,stroke-width:2px
+ linkStyle 8 stroke:#3b82f6,stroke-width:2px
+ linkStyle 9 stroke:#a78bfa,stroke-width:2px
+ linkStyle 10 stroke:#8b5cf6,stroke-width:2px
+ linkStyle 11 stroke:#22d3ee,stroke-width:2px
+ linkStyle 12 stroke:#22d3ee,stroke-width:2px
+ linkStyle 13 stroke:#3b82f6,stroke-width:2px
+
+
+
+
+
+
+
+
+
+
+
+
+