see your LangGraph from above
catwalk for LangGraph is a self-contained ReactFlow viewer for compiled LangGraph topologies: nodes, edges, conditional-edge labels, and optional per-node "wiring annotation" status. It ships as a single ~300KB static HTML file with no server and no external requests.
→ Live demo — the shipped
dist/index.html, served as-is. No sign-up, no upload, nothing to install.
On the name. There is an unrelated npm package called
catwalk, and an unrelatedcharmbracelet/catwalk. This project is catwalk for LangGraph — use the qualified name when referring to it, since the bare word is ambiguous in exactly the audience that would search for this.
dist/index.html is one file. Open it in a browser and it renders the
graph baked into it at build time -- no server, no CDN script, no
network call. That graph is a demo: a fictional research assistant
(a supervisor routing to five specialists, two tool-calling loops, a
self-critique cycle, and a human-approval interrupt), not real data.
Every node in it is a stub -- only the wiring is meaningful. Point the
build at your own exported graph and rebuild to view your own topology.
# view the shipped demo
open dist/index.html # or just double-click it
# view your own graph
# 1. produce graph.json for your compiled LangGraph (see "Schema" and
# export.py below)
# 2. drop it at export/graph.json
# 3. rebuild
./build.sh
open dist/index.htmlbuild.sh needs Node.js + npm (it runs npm install and esbuild inside
app/). It refuses to build without export/graph.json present -- no
silent fallback to demo data.
export.py is a standalone exporter: stdlib + langgraph only, no other
dependencies. Given a compiled graph (your_graph.compile()), it walks
.get_graph() into the schema below.
python3 export.py mymodule:compiled_graph --out export/graph.jsonor as a library:
from export import write_document
compiled = my_state_graph.compile()
write_document(compiled, "export/graph.json", source_ref="deadbeef")export.py only ever emits topology (nodes/edges) -- it has no
opinion about a rule/annotation system, so annotations is always []
and annotations_status is always "not_built". If you track your own
per-node rules or invariants, write annotation objects matching the
schema below yourself and set annotations_status to "collected".
graph.json is a single JSON object:
Notes:
nodesandedgesare the direct shape of LangGraph's ownCompiledGraph.get_graph()--kindis derived only from the two special ids__start__/__end__, everything else is"node".conditional/labelare independent fields; don't assume one implies the other.annotations_statusis the source of truth for whether annotation data exists at all. The viewer reads it directly rather than inferring presence fromannotations.length-- a"collected"export with zero annotations for a given node and a"not_built"export both produce an empty list for that node, but they are different facts, and the panel displays them differently ("no annotations attached to this node" vs. "no annotation data available yet").node_idon an annotation is the exporter's own resolved attachment, not something the viewer re-derives fromanchortext. If you write your own annotations, resolvenode_idyourself (or leave itnullfor a whole-graph/topology-level annotation) -- do not rely on the viewer to guess it from a substring match, since a node id can appear inside a longer identifier inanchorwithout actually naming that node (e.g.escalateinsidecheck_requires_escalate_gate).source.spine_refis rendered with.slice(0, 12)in the header with no null check, so keep it a string (export.pydefaults to"unknown"rather than emittingnull).
catwalk is a static viewer. It renders one exported snapshot of a graph's topology (and, optionally, annotation data you supply) -- it does not connect to a running LangGraph process, does not show live execution state, and does not re-derive anything the export didn't already compute. Rebuilding is how you refresh it.
MIT, see LICENSE.
{ "schema_version": "1.0.0", "source": { "langgraph_version": "0.2.6", // string or null, best-effort metadata "spine_ref": "demo-0000000" // string -- see note below, should not be null }, "nodes": [ { "id": "__start__", "kind": "start" }, // kind: "start" | "end" | "node" { "id": "validate", "kind": "node" } ], "edges": [ { "source": "validate", "target": "escalate", "conditional": true, // get_graph()'s own edge.conditional "label": "flagged" // get_graph()'s own edge.data, or null } ], "annotations_status": "collected", // "not_built" | "collected" "annotations": [ { "rule_id": "OP-3", "normative_text": "Escalated orders must be resolved before they can close.", "status": "BUILT", // any string; the viewer renders it verbatim "anchor": "pipeline.py::escalate_order", "node_id": "escalate", // the node this attaches to, or null (topology-level) "planted_failure": "test_pipeline.py::test_op3_escalate_requires_resolution", "proof_seam_note": null, "gap": null } ] }