This is an automated email from the ASF dual-hosted git repository.

skrawcz pushed a commit to branch feature/graph-builder
in repository https://gitbox.apache.org/repos/asf/burr.git

commit ea60cbe0bcfe852ab5bee434838995beda6551b1
Author: Stefan Krawczyk <[email protected]>
AuthorDate: Sun Mar 1 08:23:29 2026 -0800

    feat: add visual Graph Builder tool to Burr UI
    
    This takes work from @jaeyow and https://github.com/apache/burr/pull/572.
    
    Adds a drag-and-drop graph editor for designing Burr application graphs
    visually and exporting as Python code or JSON.
    
    Key changes:
    - New /graph-builder route with full visual editor (ReactFlow v12)
    - Migrate existing GraphView from reactflow v11 to @xyflow/react v12
    - Remove reactflow and @tisoap/react-flow-smart-edge dependencies
    - Per-node async/streaming toggles matching Burr's 4 action variants
    - Python code generation with correct decorators and signatures
    - 3 pre-built example graphs (MultiModal Chatbot, CRAG, Streaming)
    - localStorage auto-save/restore of graph state
    - Empty-state onboarding overlay and structured help sidebar
    - Fix appcontainer layout for full-height content
---
 telemetry/ui/package-lock.json                     |  363 +-----
 telemetry/ui/package.json                          |    3 +-
 telemetry/ui/src/App.tsx                           |    2 +
 telemetry/ui/src/components/nav/appcontainer.tsx   |   16 +-
 .../ui/src/components/routes/app/GraphView.tsx     |   43 +-
 .../components/ConfirmLoadExampleDialog.tsx        |   81 ++
 .../routes/graph-builder/components/CustomEdge.tsx |  161 +++
 .../routes/graph-builder/components/CustomNode.tsx |  230 ++++
 .../graph-builder/components/ExampleGallery.tsx    |   71 ++
 .../graph-builder/components/GraphBuilder.tsx      | 1307 ++++++++++++++++++++
 .../routes/graph-builder/data/examples.ts          |  564 +++++++++
 .../graph-builder/utils/BurrCodeGenerator.ts       |  321 +++++
 .../routes/graph-builder/utils/ExampleLoader.ts    |   93 ++
 .../routes/graph-builder/utils/GraphExporter.ts    |   83 ++
 14 files changed, 2973 insertions(+), 365 deletions(-)

diff --git a/telemetry/ui/package-lock.json b/telemetry/ui/package-lock.json
index 21461ed4..dfd25da4 100644
--- a/telemetry/ui/package-lock.json
+++ b/telemetry/ui/package-lock.json
@@ -14,7 +14,6 @@
         "@testing-library/jest-dom": "^5.17.0",
         "@testing-library/react": "^13.4.0",
         "@testing-library/user-event": "^13.5.0",
-        "@tisoap/react-flow-smart-edge": "^3.0.0",
         "@types/fuse": "^2.6.0",
         "@types/jest": "^27.5.2",
         "@types/node": "^16.18.82",
@@ -23,6 +22,7 @@
         "@types/react-select": "^5.0.1",
         "@types/react-syntax-highlighter": "^15.5.11",
         "@uiw/react-json-view": "^2.0.0-alpha.12",
+        "@xyflow/react": "^12.0.0",
         "clsx": "^2.1.0",
         "dagre": "^0.8.5",
         "es-abstract": "^1.22.4",
@@ -37,7 +37,6 @@
         "react-scripts": "5.0.1",
         "react-select": "^5.8.1",
         "react-syntax-highlighter": "^15.5.0",
-        "reactflow": "^11.10.4",
         "remark-gfm": "^4.0.0",
         "string.prototype.matchall": "^4.0.10",
         "tailwindcss-question-mark": "^0.4.0",
@@ -6105,102 +6104,6 @@
         "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0"
       }
     },
-    "node_modules/@reactflow/background": {
-      "version": "11.3.9",
-      "resolved": 
"https://registry.npmjs.org/@reactflow/background/-/background-11.3.9.tgz";,
-      "integrity": 
"sha512-byj/G9pEC8tN0wT/ptcl/LkEP/BBfa33/SvBkqE4XwyofckqF87lKp573qGlisfnsijwAbpDlf81PuFL41So4Q==",
-      "dependencies": {
-        "@reactflow/core": "11.10.4",
-        "classcat": "^5.0.3",
-        "zustand": "^4.4.1"
-      },
-      "peerDependencies": {
-        "react": ">=17",
-        "react-dom": ">=17"
-      }
-    },
-    "node_modules/@reactflow/controls": {
-      "version": "11.2.9",
-      "resolved": 
"https://registry.npmjs.org/@reactflow/controls/-/controls-11.2.9.tgz";,
-      "integrity": 
"sha512-e8nWplbYfOn83KN1BrxTXS17+enLyFnjZPbyDgHSRLtI5ZGPKF/8iRXV+VXb2LFVzlu4Wh3la/pkxtfP/0aguA==",
-      "dependencies": {
-        "@reactflow/core": "11.10.4",
-        "classcat": "^5.0.3",
-        "zustand": "^4.4.1"
-      },
-      "peerDependencies": {
-        "react": ">=17",
-        "react-dom": ">=17"
-      }
-    },
-    "node_modules/@reactflow/core": {
-      "version": "11.10.4",
-      "resolved": 
"https://registry.npmjs.org/@reactflow/core/-/core-11.10.4.tgz";,
-      "integrity": 
"sha512-j3i9b2fsTX/sBbOm+RmNzYEFWbNx4jGWGuGooh2r1jQaE2eV+TLJgiG/VNOp0q5mBl9f6g1IXs3Gm86S9JfcGw==",
-      "dependencies": {
-        "@types/d3": "^7.4.0",
-        "@types/d3-drag": "^3.0.1",
-        "@types/d3-selection": "^3.0.3",
-        "@types/d3-zoom": "^3.0.1",
-        "classcat": "^5.0.3",
-        "d3-drag": "^3.0.0",
-        "d3-selection": "^3.0.0",
-        "d3-zoom": "^3.0.0",
-        "zustand": "^4.4.1"
-      },
-      "peerDependencies": {
-        "react": ">=17",
-        "react-dom": ">=17"
-      }
-    },
-    "node_modules/@reactflow/minimap": {
-      "version": "11.7.9",
-      "resolved": 
"https://registry.npmjs.org/@reactflow/minimap/-/minimap-11.7.9.tgz";,
-      "integrity": 
"sha512-le95jyTtt3TEtJ1qa7tZ5hyM4S7gaEQkW43cixcMOZLu33VAdc2aCpJg/fXcRrrf7moN2Mbl9WIMNXUKsp5ILA==",
-      "dependencies": {
-        "@reactflow/core": "11.10.4",
-        "@types/d3-selection": "^3.0.3",
-        "@types/d3-zoom": "^3.0.1",
-        "classcat": "^5.0.3",
-        "d3-selection": "^3.0.0",
-        "d3-zoom": "^3.0.0",
-        "zustand": "^4.4.1"
-      },
-      "peerDependencies": {
-        "react": ">=17",
-        "react-dom": ">=17"
-      }
-    },
-    "node_modules/@reactflow/node-resizer": {
-      "version": "2.2.9",
-      "resolved": 
"https://registry.npmjs.org/@reactflow/node-resizer/-/node-resizer-2.2.9.tgz";,
-      "integrity": 
"sha512-HfickMm0hPDIHt9qH997nLdgLt0kayQyslKE0RS/GZvZ4UMQJlx/NRRyj5y47Qyg0NnC66KYOQWDM9LLzRTnUg==",
-      "dependencies": {
-        "@reactflow/core": "11.10.4",
-        "classcat": "^5.0.4",
-        "d3-drag": "^3.0.0",
-        "d3-selection": "^3.0.0",
-        "zustand": "^4.4.1"
-      },
-      "peerDependencies": {
-        "react": ">=17",
-        "react-dom": ">=17"
-      }
-    },
-    "node_modules/@reactflow/node-toolbar": {
-      "version": "1.3.9",
-      "resolved": 
"https://registry.npmjs.org/@reactflow/node-toolbar/-/node-toolbar-1.3.9.tgz";,
-      "integrity": 
"sha512-VmgxKmToax4sX1biZ9LXA7cj/TBJ+E5cklLGwquCCVVxh+lxpZGTBF3a5FJGVHiUNBBtFsC8ldcSZIK4cAlQww==",
-      "dependencies": {
-        "@reactflow/core": "11.10.4",
-        "classcat": "^5.0.3",
-        "zustand": "^4.4.1"
-      },
-      "peerDependencies": {
-        "react": ">=17",
-        "react-dom": ">=17"
-      }
-    },
     "node_modules/@remix-run/router": {
       "version": "1.15.1",
       "resolved": 
"https://registry.npmjs.org/@remix-run/router/-/router-1.15.1.tgz";,
@@ -7162,24 +7065,6 @@
         "@testing-library/dom": ">=7.21.4"
       }
     },
-    "node_modules/@tisoap/react-flow-smart-edge": {
-      "version": "3.0.0",
-      "resolved": 
"https://registry.npmjs.org/@tisoap/react-flow-smart-edge/-/react-flow-smart-edge-3.0.0.tgz";,
-      "integrity": 
"sha512-XtEQT0IrOqPwJvCzgEoj3Y16/EK4SOcjZO7FmOPU+qJWmgYjeTyv7J35CGm6dFeJYdZ2gHDrvQ1zwaXuo23/8g==",
-      "dependencies": {
-        "pathfinding": "0.4.18"
-      },
-      "engines": {
-        "node": ">=16",
-        "npm": "^8.0.0"
-      },
-      "peerDependencies": {
-        "react": ">=17",
-        "react-dom": ">=17",
-        "reactflow": ">=11",
-        "typescript": ">=4.6"
-      }
-    },
     "node_modules/@tootallnate/once": {
       "version": "1.1.2",
       "resolved": 
"https://registry.npmjs.org/@tootallnate/once/-/once-1.1.2.tgz";,
@@ -7278,93 +7163,11 @@
         "@types/node": "*"
       }
     },
-    "node_modules/@types/d3": {
-      "version": "7.4.3",
-      "resolved": "https://registry.npmjs.org/@types/d3/-/d3-7.4.3.tgz";,
-      "integrity": 
"sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww==",
-      "dependencies": {
-        "@types/d3-array": "*",
-        "@types/d3-axis": "*",
-        "@types/d3-brush": "*",
-        "@types/d3-chord": "*",
-        "@types/d3-color": "*",
-        "@types/d3-contour": "*",
-        "@types/d3-delaunay": "*",
-        "@types/d3-dispatch": "*",
-        "@types/d3-drag": "*",
-        "@types/d3-dsv": "*",
-        "@types/d3-ease": "*",
-        "@types/d3-fetch": "*",
-        "@types/d3-force": "*",
-        "@types/d3-format": "*",
-        "@types/d3-geo": "*",
-        "@types/d3-hierarchy": "*",
-        "@types/d3-interpolate": "*",
-        "@types/d3-path": "*",
-        "@types/d3-polygon": "*",
-        "@types/d3-quadtree": "*",
-        "@types/d3-random": "*",
-        "@types/d3-scale": "*",
-        "@types/d3-scale-chromatic": "*",
-        "@types/d3-selection": "*",
-        "@types/d3-shape": "*",
-        "@types/d3-time": "*",
-        "@types/d3-time-format": "*",
-        "@types/d3-timer": "*",
-        "@types/d3-transition": "*",
-        "@types/d3-zoom": "*"
-      }
-    },
-    "node_modules/@types/d3-array": {
-      "version": "3.2.1",
-      "resolved": 
"https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.1.tgz";,
-      "integrity": 
"sha512-Y2Jn2idRrLzUfAKV2LyRImR+y4oa2AntrgID95SHJxuMUrkNXmanDSed71sRNZysveJVt1hLLemQZIady0FpEg=="
-    },
-    "node_modules/@types/d3-axis": {
-      "version": "3.0.6",
-      "resolved": 
"https://registry.npmjs.org/@types/d3-axis/-/d3-axis-3.0.6.tgz";,
-      "integrity": 
"sha512-pYeijfZuBd87T0hGn0FO1vQ/cgLk6E1ALJjfkC0oJ8cbwkZl3TpgS8bVBLZN+2jjGgg38epgxb2zmoGtSfvgMw==",
-      "dependencies": {
-        "@types/d3-selection": "*"
-      }
-    },
-    "node_modules/@types/d3-brush": {
-      "version": "3.0.6",
-      "resolved": 
"https://registry.npmjs.org/@types/d3-brush/-/d3-brush-3.0.6.tgz";,
-      "integrity": 
"sha512-nH60IZNNxEcrh6L1ZSMNA28rj27ut/2ZmI3r96Zd+1jrZD++zD3LsMIjWlvg4AYrHn/Pqz4CF3veCxGjtbqt7A==",
-      "dependencies": {
-        "@types/d3-selection": "*"
-      }
-    },
-    "node_modules/@types/d3-chord": {
-      "version": "3.0.6",
-      "resolved": 
"https://registry.npmjs.org/@types/d3-chord/-/d3-chord-3.0.6.tgz";,
-      "integrity": 
"sha512-LFYWWd8nwfwEmTZG9PfQxd17HbNPksHBiJHaKuY1XeqscXacsS2tyoo6OdRsjf+NQYeB6XrNL3a25E3gH69lcg=="
-    },
     "node_modules/@types/d3-color": {
       "version": "3.1.3",
       "resolved": 
"https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz";,
       "integrity": 
"sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A=="
     },
-    "node_modules/@types/d3-contour": {
-      "version": "3.0.6",
-      "resolved": 
"https://registry.npmjs.org/@types/d3-contour/-/d3-contour-3.0.6.tgz";,
-      "integrity": 
"sha512-BjzLgXGnCWjUSYGfH1cpdo41/hgdWETu4YxpezoztawmqsvCeep+8QGfiY6YbDvfgHz/DkjeIkkZVJavB4a3rg==",
-      "dependencies": {
-        "@types/d3-array": "*",
-        "@types/geojson": "*"
-      }
-    },
-    "node_modules/@types/d3-delaunay": {
-      "version": "6.0.4",
-      "resolved": 
"https://registry.npmjs.org/@types/d3-delaunay/-/d3-delaunay-6.0.4.tgz";,
-      "integrity": 
"sha512-ZMaSKu4THYCU6sV64Lhg6qjf1orxBthaC161plr5KuPHo3CNm8DTHiLw/5Eq2b6TsNP0W0iJrUOFscY6Q450Hw=="
-    },
-    "node_modules/@types/d3-dispatch": {
-      "version": "3.0.6",
-      "resolved": 
"https://registry.npmjs.org/@types/d3-dispatch/-/d3-dispatch-3.0.6.tgz";,
-      "integrity": 
"sha512-4fvZhzMeeuBJYZXRXrRIQnvUYfyXwYmLsdiN7XXmVNQKKw1cM8a5WdID0g1hVFZDqT9ZqZEY5pD44p24VS7iZQ=="
-    },
     "node_modules/@types/d3-drag": {
       "version": "3.0.7",
       "resolved": 
"https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz";,
@@ -7373,47 +7176,6 @@
         "@types/d3-selection": "*"
       }
     },
-    "node_modules/@types/d3-dsv": {
-      "version": "3.0.7",
-      "resolved": 
"https://registry.npmjs.org/@types/d3-dsv/-/d3-dsv-3.0.7.tgz";,
-      "integrity": 
"sha512-n6QBF9/+XASqcKK6waudgL0pf/S5XHPPI8APyMLLUHd8NqouBGLsU8MgtO7NINGtPBtk9Kko/W4ea0oAspwh9g=="
-    },
-    "node_modules/@types/d3-ease": {
-      "version": "3.0.2",
-      "resolved": 
"https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz";,
-      "integrity": 
"sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA=="
-    },
-    "node_modules/@types/d3-fetch": {
-      "version": "3.0.7",
-      "resolved": 
"https://registry.npmjs.org/@types/d3-fetch/-/d3-fetch-3.0.7.tgz";,
-      "integrity": 
"sha512-fTAfNmxSb9SOWNB9IoG5c8Hg6R+AzUHDRlsXsDZsNp6sxAEOP0tkP3gKkNSO/qmHPoBFTxNrjDprVHDQDvo5aA==",
-      "dependencies": {
-        "@types/d3-dsv": "*"
-      }
-    },
-    "node_modules/@types/d3-force": {
-      "version": "3.0.9",
-      "resolved": 
"https://registry.npmjs.org/@types/d3-force/-/d3-force-3.0.9.tgz";,
-      "integrity": 
"sha512-IKtvyFdb4Q0LWna6ymywQsEYjK/94SGhPrMfEr1TIc5OBeziTi+1jcCvttts8e0UWZIxpasjnQk9MNk/3iS+kA=="
-    },
-    "node_modules/@types/d3-format": {
-      "version": "3.0.4",
-      "resolved": 
"https://registry.npmjs.org/@types/d3-format/-/d3-format-3.0.4.tgz";,
-      "integrity": 
"sha512-fALi2aI6shfg7vM5KiR1wNJnZ7r6UuggVqtDA+xiEdPZQwy/trcQaHnwShLuLdta2rTymCNpxYTiMZX/e09F4g=="
-    },
-    "node_modules/@types/d3-geo": {
-      "version": "3.1.0",
-      "resolved": 
"https://registry.npmjs.org/@types/d3-geo/-/d3-geo-3.1.0.tgz";,
-      "integrity": 
"sha512-856sckF0oP/diXtS4jNsiQw/UuK5fQG8l/a9VVLeSouf1/PPbBE1i1W852zVwKwYCBkFJJB7nCFTbk6UMEXBOQ==",
-      "dependencies": {
-        "@types/geojson": "*"
-      }
-    },
-    "node_modules/@types/d3-hierarchy": {
-      "version": "3.1.6",
-      "resolved": 
"https://registry.npmjs.org/@types/d3-hierarchy/-/d3-hierarchy-3.1.6.tgz";,
-      "integrity": 
"sha512-qlmD/8aMk5xGorUvTUWHCiumvgaUXYldYjNVOWtYoTYY/L+WwIEAmJxUmTgr9LoGNG0PPAOmqMDJVDPc7DOpPw=="
-    },
     "node_modules/@types/d3-interpolate": {
       "version": "3.0.4",
       "resolved": 
"https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz";,
@@ -7422,67 +7184,11 @@
         "@types/d3-color": "*"
       }
     },
-    "node_modules/@types/d3-path": {
-      "version": "3.1.0",
-      "resolved": 
"https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.0.tgz";,
-      "integrity": 
"sha512-P2dlU/q51fkOc/Gfl3Ul9kicV7l+ra934qBFXCFhrZMOL6du1TM0pm1ThYvENukyOn5h9v+yMJ9Fn5JK4QozrQ=="
-    },
-    "node_modules/@types/d3-polygon": {
-      "version": "3.0.2",
-      "resolved": 
"https://registry.npmjs.org/@types/d3-polygon/-/d3-polygon-3.0.2.tgz";,
-      "integrity": 
"sha512-ZuWOtMaHCkN9xoeEMr1ubW2nGWsp4nIql+OPQRstu4ypeZ+zk3YKqQT0CXVe/PYqrKpZAi+J9mTs05TKwjXSRA=="
-    },
-    "node_modules/@types/d3-quadtree": {
-      "version": "3.0.6",
-      "resolved": 
"https://registry.npmjs.org/@types/d3-quadtree/-/d3-quadtree-3.0.6.tgz";,
-      "integrity": 
"sha512-oUzyO1/Zm6rsxKRHA1vH0NEDG58HrT5icx/azi9MF1TWdtttWl0UIUsjEQBBh+SIkrpd21ZjEv7ptxWys1ncsg=="
-    },
-    "node_modules/@types/d3-random": {
-      "version": "3.0.3",
-      "resolved": 
"https://registry.npmjs.org/@types/d3-random/-/d3-random-3.0.3.tgz";,
-      "integrity": 
"sha512-Imagg1vJ3y76Y2ea0871wpabqp613+8/r0mCLEBfdtqC7xMSfj9idOnmBYyMoULfHePJyxMAw3nWhJxzc+LFwQ=="
-    },
-    "node_modules/@types/d3-scale": {
-      "version": "4.0.8",
-      "resolved": 
"https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.8.tgz";,
-      "integrity": 
"sha512-gkK1VVTr5iNiYJ7vWDI+yUFFlszhNMtVeneJ6lUTKPjprsvLLI9/tgEGiXJOnlINJA8FyA88gfnQsHbybVZrYQ==",
-      "dependencies": {
-        "@types/d3-time": "*"
-      }
-    },
-    "node_modules/@types/d3-scale-chromatic": {
-      "version": "3.0.3",
-      "resolved": 
"https://registry.npmjs.org/@types/d3-scale-chromatic/-/d3-scale-chromatic-3.0.3.tgz";,
-      "integrity": 
"sha512-laXM4+1o5ImZv3RpFAsTRn3TEkzqkytiOY0Dz0sq5cnd1dtNlk6sHLon4OvqaiJb28T0S/TdsBI3Sjsy+keJrw=="
-    },
     "node_modules/@types/d3-selection": {
       "version": "3.0.10",
       "resolved": 
"https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.10.tgz";,
       "integrity": 
"sha512-cuHoUgS/V3hLdjJOLTT691+G2QoqAjCVLmr4kJXR4ha56w1Zdu8UUQ5TxLRqudgNjwXeQxKMq4j+lyf9sWuslg=="
     },
-    "node_modules/@types/d3-shape": {
-      "version": "3.1.6",
-      "resolved": 
"https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.6.tgz";,
-      "integrity": 
"sha512-5KKk5aKGu2I+O6SONMYSNflgiP0WfZIQvVUMan50wHsLG1G94JlxEVnCpQARfTtzytuY0p/9PXXZb3I7giofIA==",
-      "dependencies": {
-        "@types/d3-path": "*"
-      }
-    },
-    "node_modules/@types/d3-time": {
-      "version": "3.0.3",
-      "resolved": 
"https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.3.tgz";,
-      "integrity": 
"sha512-2p6olUZ4w3s+07q3Tm2dbiMZy5pCDfYwtLXXHUnVzXgQlZ/OyPtUz6OL382BkOuGlLXqfT+wqv8Fw2v8/0geBw=="
-    },
-    "node_modules/@types/d3-time-format": {
-      "version": "4.0.3",
-      "resolved": 
"https://registry.npmjs.org/@types/d3-time-format/-/d3-time-format-4.0.3.tgz";,
-      "integrity": 
"sha512-5xg9rC+wWL8kdDj153qZcsJ0FWiFt0J5RB6LYUNZjwSnesfblqrI/bJ1wBdJ8OQfncgbJG5+2F+qfqnqyzYxyg=="
-    },
-    "node_modules/@types/d3-timer": {
-      "version": "3.0.2",
-      "resolved": 
"https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz";,
-      "integrity": 
"sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw=="
-    },
     "node_modules/@types/d3-transition": {
       "version": "3.0.8",
       "resolved": 
"https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.8.tgz";,
@@ -7577,11 +7283,6 @@
         "fuse": "*"
       }
     },
-    "node_modules/@types/geojson": {
-      "version": "7946.0.14",
-      "resolved": 
"https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.14.tgz";,
-      "integrity": 
"sha512-WCfD5Ht3ZesJUsONdhvm84dmzWOiOzOAqOncN0++w0lBw1o8OuDNJF2McvvCef/yBqb/HYRahp1BYtODFQ8bRg=="
-    },
     "node_modules/@types/graceful-fs": {
       "version": "4.1.9",
       "resolved": 
"https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.9.tgz";,
@@ -8715,6 +8416,38 @@
       "resolved": "https://registry.npmjs.org/@xtuc/long/-/long-4.2.2.tgz";,
       "integrity": 
"sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ=="
     },
+    "node_modules/@xyflow/react": {
+      "version": "12.10.1",
+      "resolved": 
"https://registry.npmjs.org/@xyflow/react/-/react-12.10.1.tgz";,
+      "integrity": 
"sha512-5eSWtIK/+rkldOuFbOOz44CRgQRjtS9v5nufk77DV+XBnfCGL9HAQ8PG00o2ZYKqkEU/Ak6wrKC95Tu+2zuK3Q==",
+      "license": "MIT",
+      "dependencies": {
+        "@xyflow/system": "0.0.75",
+        "classcat": "^5.0.3",
+        "zustand": "^4.4.0"
+      },
+      "peerDependencies": {
+        "react": ">=17",
+        "react-dom": ">=17"
+      }
+    },
+    "node_modules/@xyflow/system": {
+      "version": "0.0.75",
+      "resolved": 
"https://registry.npmjs.org/@xyflow/system/-/system-0.0.75.tgz";,
+      "integrity": 
"sha512-iXs+AGFLi8w/VlAoc/iSxk+CxfT6o64Uw/k0CKASOPqjqz6E0rb5jFZgJtXGZCpfQI6OQpu5EnumP5fGxQheaQ==",
+      "license": "MIT",
+      "dependencies": {
+        "@types/d3-drag": "^3.0.7",
+        "@types/d3-interpolate": "^3.0.4",
+        "@types/d3-selection": "^3.0.10",
+        "@types/d3-transition": "^3.0.8",
+        "@types/d3-zoom": "^3.0.8",
+        "d3-drag": "^3.0.0",
+        "d3-interpolate": "^3.0.1",
+        "d3-selection": "^3.0.0",
+        "d3-zoom": "^3.0.0"
+      }
+    },
     "node_modules/abab": {
       "version": "2.0.6",
       "resolved": "https://registry.npmjs.org/abab/-/abab-2.0.6.tgz";,
@@ -15058,11 +14791,6 @@
         "tslib": "^2.0.3"
       }
     },
-    "node_modules/heap": {
-      "version": "0.2.5",
-      "resolved": "https://registry.npmjs.org/heap/-/heap-0.2.5.tgz";,
-      "integrity": 
"sha512-G7HLD+WKcrOyJP5VQwYZNC3Z6FcQ7YYjEFiFoIj8PfEr73mu421o8B1N5DKUcc8K37EsJ2XXWA8DtrDz/2dReg=="
-    },
     "node_modules/heroicons": {
       "version": "2.1.1",
       "resolved": "https://registry.npmjs.org/heroicons/-/heroicons-2.1.1.tgz";,
@@ -21519,14 +21247,6 @@
         "node": ">=8"
       }
     },
-    "node_modules/pathfinding": {
-      "version": "0.4.18",
-      "resolved": 
"https://registry.npmjs.org/pathfinding/-/pathfinding-0.4.18.tgz";,
-      "integrity": 
"sha512-R0TGEQ9GRcFCDvAWlJAWC+KGJ9SLbW4c0nuZRcioVlXVTlw+F5RvXQ8SQgSqI9KXWC1ew95vgmIiyaWTlCe9Ag==",
-      "dependencies": {
-        "heap": "0.2.5"
-      }
-    },
     "node_modules/performance-now": {
       "version": "2.1.0",
       "resolved": 
"https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz";,
@@ -23634,23 +23354,6 @@
         "react-dom": ">=16.6.0"
       }
     },
-    "node_modules/reactflow": {
-      "version": "11.10.4",
-      "resolved": 
"https://registry.npmjs.org/reactflow/-/reactflow-11.10.4.tgz";,
-      "integrity": 
"sha512-0CApYhtYicXEDg/x2kvUHiUk26Qur8lAtTtiSlptNKuyEuGti6P1y5cS32YGaUoDMoCqkm/m+jcKkfMOvSCVRA==",
-      "dependencies": {
-        "@reactflow/background": "11.3.9",
-        "@reactflow/controls": "11.2.9",
-        "@reactflow/core": "11.10.4",
-        "@reactflow/minimap": "11.7.9",
-        "@reactflow/node-resizer": "2.2.9",
-        "@reactflow/node-toolbar": "1.3.9"
-      },
-      "peerDependencies": {
-        "react": ">=17",
-        "react-dom": ">=17"
-      }
-    },
     "node_modules/read-cache": {
       "version": "1.0.0",
       "resolved": 
"https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz";,
diff --git a/telemetry/ui/package.json b/telemetry/ui/package.json
index 1a391fcc..2145236c 100644
--- a/telemetry/ui/package.json
+++ b/telemetry/ui/package.json
@@ -9,7 +9,7 @@
     "@testing-library/jest-dom": "^5.17.0",
     "@testing-library/react": "^13.4.0",
     "@testing-library/user-event": "^13.5.0",
-    "@tisoap/react-flow-smart-edge": "^3.0.0",
+    "@xyflow/react": "^12.0.0",
     "@types/fuse": "^2.6.0",
     "@types/jest": "^27.5.2",
     "@types/node": "^16.18.82",
@@ -32,7 +32,6 @@
     "react-scripts": "5.0.1",
     "react-select": "^5.8.1",
     "react-syntax-highlighter": "^15.5.0",
-    "reactflow": "^11.10.4",
     "remark-gfm": "^4.0.0",
     "string.prototype.matchall": "^4.0.10",
     "tailwindcss-question-mark": "^0.4.0",
diff --git a/telemetry/ui/src/App.tsx b/telemetry/ui/src/App.tsx
index 4b80c5bd..fd0f1445 100644
--- a/telemetry/ui/src/App.tsx
+++ b/telemetry/ui/src/App.tsx
@@ -31,6 +31,7 @@ import { StreamingChatbotWithTelemetry } from 
'./examples/StreamingChatbot';
 import { AdminView } from './components/routes/AdminView';
 import { AnnotationsViewContainer } from 
'./components/routes/app/AnnotationsView';
 import { DeepResearcherWithTelemetry } from './examples/DeepResearcher';
+import GraphBuilder from 
'./components/routes/graph-builder/components/GraphBuilder';
 
 /**
  * Basic application. We have an AppContainer -- this has a breadcrumb and a 
sidebar.
@@ -65,6 +66,7 @@ const App = () => {
             <Route path="/demos/email-assistant" 
element={<EmailAssistantWithTelemetry />} />
             <Route path="/demos/deep-researcher" 
element={<DeepResearcherWithTelemetry />} />
             <Route path="/admin" element={<AdminView />} />
+            <Route path="/graph-builder" element={<GraphBuilder />} />
             <Route path="*" element={<Navigate to="/projects" />} />
           </Routes>
         </AppContainer>
diff --git a/telemetry/ui/src/components/nav/appcontainer.tsx 
b/telemetry/ui/src/components/nav/appcontainer.tsx
index edcce3f2..7277ac55 100644
--- a/telemetry/ui/src/components/nav/appcontainer.tsx
+++ b/telemetry/ui/src/components/nav/appcontainer.tsx
@@ -22,6 +22,7 @@ import { Dialog, Disclosure, Transition } from 
'@headlessui/react';
 import {
   ComputerDesktopIcon,
   Square2StackIcon,
+  SquaresPlusIcon,
   QuestionMarkCircleIcon,
   XMarkIcon,
   ChatBubbleLeftEllipsisIcon,
@@ -100,6 +101,12 @@ export const AppContainer = (props: { children: 
React.ReactNode }) => {
       icon: Square2StackIcon,
       linkType: 'internal'
     },
+    {
+      name: 'Graph Builder',
+      href: '/graph-builder',
+      icon: SquaresPlusIcon,
+      linkType: 'internal'
+    },
     {
       name: 'Examples',
       href: 'https://github.com/DAGWorks-Inc/burr/tree/main/examples',
@@ -384,13 +391,12 @@ export const AppContainer = (props: { children: 
React.ReactNode }) => {
           <ToggleOpenButton open={sidebarOpen} toggleSidebar={toggleSidebar} />
         </div>
 
-        {/* This is a bit hacky -- just quickly prototyping and these margins 
were the ones that worked! */}
-        <main className={`py-14 -my-1 ${sidebarOpen ? 'lg:pl-72' : 'lg:pl-5'} 
h-full`}>
-          <div className="flex items-center px-5 sm:px-7 lg:px-9 pb-8 -my-4">
+        <main className={`${sidebarOpen ? 'lg:pl-72' : 'lg:pl-5'} h-full flex 
flex-col`}>
+          <div className="flex items-center px-5 sm:px-7 lg:px-9 h-14 
flex-shrink-0">
             <BreadCrumb />
           </div>
-          <div className="flex h-full flex-col">
-            <div className="px-4 sm:px-6 lg:px-2 max-h-full h-full flex-1"> 
{props.children}</div>
+          <div className="flex-1 flex flex-col min-h-0">
+            <div className="px-4 sm:px-6 lg:px-2 h-full flex-1 
min-h-0">{props.children}</div>
           </div>
         </main>
       </div>
diff --git a/telemetry/ui/src/components/routes/app/GraphView.tsx 
b/telemetry/ui/src/components/routes/app/GraphView.tsx
index ce85b823..b12d9977 100644
--- a/telemetry/ui/src/components/routes/app/GraphView.tsx
+++ b/telemetry/ui/src/components/routes/app/GraphView.tsx
@@ -21,7 +21,8 @@ import { ActionModel, ApplicationModel, Step } from 
'../../../api';
 
 import dagre from 'dagre';
 import React, { createContext, useCallback, useLayoutEffect, useRef, useState 
} from 'react';
-import ReactFlow, {
+import {
+  ReactFlow,
   BaseEdge,
   Controls,
   EdgeProps,
@@ -30,14 +31,12 @@ import ReactFlow, {
   Position,
   ReactFlowProvider,
   getBezierPath,
-  useNodes,
   useReactFlow
-} from 'reactflow';
+} from '@xyflow/react';
 
-import 'reactflow/dist/style.css';
+import '@xyflow/react/dist/style.css';
 import { backgroundColorsForIndex } from './AppView';
 import { getActionStatus } from '../../../utils';
-import { getSmartEdge } from '@tisoap/react-flow-smart-edge';
 
 const dagreGraph = new dagre.graphlib.Graph();
 
@@ -149,38 +148,26 @@ export const ActionActionEdge = ({
   markerEnd,
   data
 }: EdgeProps) => {
-  const nodes = useNodes();
-  data = data as EdgeData;
+  const edgeData = data as EdgeData | undefined;
   const { highlightedActions: previousActions, currentAction } =
     React.useContext(NodeStateProvider);
   const allActionsInPath = [...(previousActions || []), ...(currentAction ? 
[currentAction] : [])];
   const containsFrom = allActionsInPath.some(
-    (action) => action.step_start_log.action === data.from
+    (action) => action.step_start_log.action === edgeData?.from
+  );
+  const containsTo = allActionsInPath.some(
+    (action) => action.step_start_log.action === edgeData?.to
   );
-  const containsTo = allActionsInPath.some((action) => 
action.step_start_log.action === data.to);
   const shouldHighlight = containsFrom && containsTo;
-  const getSmartEdgeResponse = getSmartEdge({
-    sourcePosition,
-    targetPosition,
+
+  const [edgePath] = getBezierPath({
     sourceX,
     sourceY,
+    sourcePosition,
     targetX,
     targetY,
-    nodes
+    targetPosition
   });
-  let edgePath = null;
-  if (getSmartEdgeResponse !== null) {
-    edgePath = getSmartEdgeResponse.svgPathString;
-  } else {
-    edgePath = getBezierPath({
-      sourceX,
-      sourceY,
-      sourcePosition,
-      targetX,
-      targetY,
-      targetPosition
-    })[0];
-  }
 
   const style = {
     markerColor: shouldHighlight ? 'black' : 'gray',
@@ -188,7 +175,7 @@ export const ActionActionEdge = ({
   };
   return (
     <>
-      <BaseEdge path={edgePath} markerEnd={markerEnd} style={style} 
label={'test'} />
+      <BaseEdge path={edgePath} markerEnd={markerEnd} style={style} />
     </>
   );
 };
@@ -358,7 +345,7 @@ export const _Graph = (props: {
         <ReactFlow
           nodes={nodes}
           edges={edges}
-          edgesUpdatable={false}
+          edgesReconnectable={false}
           nodesDraggable={false}
           nodeTypes={nodeTypes}
           edgeTypes={edgeTypes}
diff --git 
a/telemetry/ui/src/components/routes/graph-builder/components/ConfirmLoadExampleDialog.tsx
 
b/telemetry/ui/src/components/routes/graph-builder/components/ConfirmLoadExampleDialog.tsx
new file mode 100644
index 00000000..e1a0cf56
--- /dev/null
+++ 
b/telemetry/ui/src/components/routes/graph-builder/components/ConfirmLoadExampleDialog.tsx
@@ -0,0 +1,81 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you 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.
+ */
+
+import React from 'react';
+import { Button } from '../../../common/button';
+import { ExclamationTriangleIcon } from '@heroicons/react/24/outline';
+
+interface ConfirmLoadExampleDialogProps {
+  open: boolean;
+  onClose: () => void;
+  onConfirm: () => void;
+  exampleTitle: string;
+  hasExistingContent: boolean;
+}
+
+const ConfirmLoadExampleDialog: React.FC<ConfirmLoadExampleDialogProps> = ({
+  open,
+  onClose,
+  onConfirm,
+  exampleTitle,
+  hasExistingContent
+}) => {
+  if (!open) return null;
+
+  return (
+    <div className="fixed inset-0 bg-black bg-opacity-50 flex items-center 
justify-center z-50">
+      <div className="bg-white rounded-lg p-6 w-full max-w-md mx-4">
+        <div className="flex items-center gap-2 mb-4">
+          <ExclamationTriangleIcon className="w-6 h-6 text-amber-500" />
+          <h2 className="text-lg font-semibold">Load Example Graph</h2>
+        </div>
+
+        <div className="space-y-4">
+          <p className="text-gray-700">
+            Are you sure you want to load the &quot;{exampleTitle}&quot; 
example?
+          </p>
+
+          {hasExistingContent && (
+            <div className="bg-amber-50 border border-amber-200 rounded-md 
p-3">
+              <p className="text-sm text-amber-800">
+                This will replace your current graph. Any unsaved changes will 
be lost.
+              </p>
+            </div>
+          )}
+
+          <div className="text-sm text-gray-500">
+            You can always export your current work as JSON or Python code 
before loading the
+            example.
+          </div>
+        </div>
+
+        <div className="flex justify-end space-x-2 mt-6">
+          <Button onClick={onClose} outline>
+            Cancel
+          </Button>
+          <Button onClick={onConfirm} color={hasExistingContent ? 'amber' : 
'blue'}>
+            {hasExistingContent ? 'Replace Graph' : 'Load Example'}
+          </Button>
+        </div>
+      </div>
+    </div>
+  );
+};
+
+export default ConfirmLoadExampleDialog;
diff --git 
a/telemetry/ui/src/components/routes/graph-builder/components/CustomEdge.tsx 
b/telemetry/ui/src/components/routes/graph-builder/components/CustomEdge.tsx
new file mode 100644
index 00000000..f6e2447b
--- /dev/null
+++ b/telemetry/ui/src/components/routes/graph-builder/components/CustomEdge.tsx
@@ -0,0 +1,161 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you 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.
+ */
+
+import React, { useState, useCallback } from 'react';
+import {
+  BaseEdge,
+  EdgeLabelRenderer,
+  EdgeProps,
+  getBezierPath,
+  MarkerType,
+  Edge
+} from '@xyflow/react';
+
+export interface CustomEdgeData extends Record<string, unknown> {
+  condition?: string;
+  isConditional?: boolean;
+  label?: string;
+  onLabelChange?: (edgeId: string, newLabel: string) => void;
+}
+
+type CustomEdgeType = Edge<CustomEdgeData>;
+
+const CustomEdge: React.FC<EdgeProps<CustomEdgeType>> = ({
+  id,
+  sourceX,
+  sourceY,
+  targetX,
+  targetY,
+  sourcePosition,
+  targetPosition,
+  style = {},
+  data,
+  markerEnd,
+  selected
+}) => {
+  const [isEditing, setIsEditing] = useState(false);
+
+  const isConditional = data?.isConditional || (data?.condition && 
data.condition !== 'default');
+  const displayLabel = isConditional ? data?.label || 'condition' : '';
+  const [labelValue, setLabelValue] = useState(displayLabel);
+
+  React.useEffect(() => {
+    const newDisplayLabel = isConditional ? data?.label || 'condition' : '';
+    setLabelValue(newDisplayLabel);
+  }, [data?.label, data?.condition, isConditional]);
+
+  const [edgePath, labelX, labelY] = getBezierPath({
+    sourceX,
+    sourceY,
+    sourcePosition,
+    targetX,
+    targetY,
+    targetPosition
+  });
+
+  const edgeStyle: React.CSSProperties = {
+    strokeWidth: selected ? 4 : 2,
+    stroke:
+      (style as React.CSSProperties)?.stroke ||
+      (data?.condition === 'default' ? '#94a3b8' : '#429dbce6'),
+    ...(style as React.CSSProperties)
+  };
+
+  if (isConditional) {
+    edgeStyle.strokeDasharray = '8,4';
+    edgeStyle.animation = 'dash 2s linear infinite';
+  }
+
+  const handleLabelClick = useCallback((e: React.MouseEvent) => {
+    e.stopPropagation();
+    setIsEditing(true);
+  }, []);
+
+  const handleLabelChange = useCallback((event: 
React.ChangeEvent<HTMLInputElement>) => {
+    setLabelValue(event.target.value);
+  }, []);
+
+  const handleLabelBlur = useCallback(() => {
+    setIsEditing(false);
+    if (data?.onLabelChange) {
+      data.onLabelChange(id, labelValue);
+    }
+  }, [data, id, labelValue]);
+
+  const handleLabelKeyDown = useCallback(
+    (event: React.KeyboardEvent) => {
+      event.stopPropagation();
+
+      if (event.key === 'Enter') {
+        handleLabelBlur();
+      } else if (event.key === 'Escape') {
+        setLabelValue(data?.label || data?.condition || '');
+        setIsEditing(false);
+      }
+    },
+    [data?.label, data?.condition, handleLabelBlur]
+  );
+
+  return (
+    <>
+      <style>
+        {`
+          @keyframes dash {
+            to {
+              stroke-dashoffset: -12;
+            }
+          }
+        `}
+      </style>
+
+      <BaseEdge path={edgePath} markerEnd={markerEnd || 
MarkerType.ArrowClosed} style={edgeStyle} />
+      <EdgeLabelRenderer>
+        {isConditional && (
+          <div
+            className="absolute text-xs pointer-events-auto"
+            style={{
+              transform: `translate(-50%, -50%) 
translate(${labelX}px,${labelY}px)`
+            }}
+          >
+            {isEditing ? (
+              <input
+                value={labelValue}
+                onChange={handleLabelChange}
+                onBlur={handleLabelBlur}
+                onKeyDown={handleLabelKeyDown}
+                onClick={(e) => e.stopPropagation()}
+                autoFocus
+                className="border border-gray-300 rounded-xl px-2 py-1 text-xs 
bg-white min-w-16 text-center focus:outline-none focus:ring-2 
focus:ring-blue-500"
+              />
+            ) : (
+              <button
+                onClick={handleLabelClick}
+                className="bg-white border border-gray-300 rounded-xl px-2 
py-1 text-xs cursor-pointer hover:bg-gray-50 transition-colors min-h-5 flex 
items-center"
+              >
+                {data?.label ?? data?.condition ?? ''}
+              </button>
+            )}
+          </div>
+        )}
+      </EdgeLabelRenderer>
+    </>
+  );
+};
+
+export default CustomEdge;
diff --git 
a/telemetry/ui/src/components/routes/graph-builder/components/CustomNode.tsx 
b/telemetry/ui/src/components/routes/graph-builder/components/CustomNode.tsx
new file mode 100644
index 00000000..c7866110
--- /dev/null
+++ b/telemetry/ui/src/components/routes/graph-builder/components/CustomNode.tsx
@@ -0,0 +1,230 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you 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.
+ */
+
+import React, { memo, useState, useCallback, useRef, useEffect } from 'react';
+import { Handle, Position, NodeProps, Node } from '@xyflow/react';
+
+const pastelColors = [
+  { border: '#FF6B6B', background: '#FFE5E5' },
+  { border: '#4ECDC4', background: '#E5F9F6' },
+  { border: '#45B7D1', background: '#E5F4FD' },
+  { border: '#96CEB4', background: '#F0F9F4' },
+  { border: '#FFEAA7', background: '#FFFCF0' },
+  { border: '#DDA0DD', background: '#F5F0F5' },
+  { border: '#98D8C8', background: '#F0FAF7' },
+  { border: '#F7DC6F', background: '#FEFBF0' },
+  { border: '#BB8FCE', background: '#F4F1F7' },
+  { border: '#85C1E9', background: '#F0F8FF' }
+];
+
+export interface CustomNodeData extends Record<string, unknown> {
+  label: string;
+  description?: string;
+  nodeType: string;
+  isAsync?: boolean;
+  isStreaming?: boolean;
+  icon: string;
+  colorIndex?: number;
+  onDelete?: (nodeId: string) => void;
+  onLabelChange?: (nodeId: string, newLabel: string) => void;
+  onToggleProperty?: (nodeId: string, property: 'isAsync' | 'isStreaming') => 
void;
+}
+
+type CustomNodeType = Node<CustomNodeData>;
+
+const CustomNode: React.FC<NodeProps<CustomNodeType>> = ({ id, data, selected 
}) => {
+  const [isEditing, setIsEditing] = useState(false);
+  const [labelValue, setLabelValue] = useState(data.label);
+  const [fixedWidth, setFixedWidth] = useState<number | null>(null);
+  const [fixedHeight, setFixedHeight] = useState<number | null>(null);
+  const paperRef = useRef<HTMLDivElement>(null);
+
+  useEffect(() => {
+    setLabelValue(data.label);
+  }, [data.label]);
+
+  const colorIndex = data.colorIndex ?? parseInt(id.replace(/\D/g, '')) % 
pastelColors.length;
+  const colors = pastelColors[colorIndex];
+
+  const handleLabelClick = useCallback((e: React.MouseEvent) => {
+    e.stopPropagation();
+    if (paperRef.current) {
+      setFixedWidth(paperRef.current.offsetWidth);
+      setFixedHeight(paperRef.current.offsetHeight);
+    }
+    setIsEditing(true);
+  }, []);
+
+  const handleLabelChange = useCallback((event: 
React.ChangeEvent<HTMLInputElement>) => {
+    setLabelValue(event.target.value);
+  }, []);
+
+  const handleLabelBlur = useCallback(() => {
+    setIsEditing(false);
+    setFixedWidth(null);
+    setFixedHeight(null);
+    if (data.onLabelChange && labelValue.trim() !== data.label) {
+      data.onLabelChange(id, labelValue.trim() || data.label);
+    }
+  }, [data, id, labelValue]);
+
+  const handleLabelKeyDown = useCallback(
+    (event: React.KeyboardEvent) => {
+      event.stopPropagation();
+
+      if (event.key === 'Enter') {
+        handleLabelBlur();
+      } else if (event.key === 'Escape') {
+        setLabelValue(data.label);
+        setIsEditing(false);
+        setFixedWidth(null);
+        setFixedHeight(null);
+      }
+    },
+    [data.label, handleLabelBlur]
+  );
+
+  const isInputNode = data.nodeType === 'input';
+
+  return (
+    <div
+      ref={paperRef}
+      className={`
+        min-w-40 max-w-60 relative overflow-visible transition-all 
duration-200 ease-in-out
+        ${selected ? 'shadow-lg scale-105' : 'shadow-sm'}
+        ${isInputNode ? 'border-2 border-dashed border-gray-600 bg-white' : 
'border-2 border-solid bg-opacity-90'}
+        rounded-lg
+      `}
+      style={{
+        width: fixedWidth ? `${fixedWidth}px` : 'fit-content',
+        height: fixedHeight ? `${fixedHeight}px` : 'auto',
+        borderColor: isInputNode ? '#666' : colors.border,
+        backgroundColor: isInputNode ? '#fff' : colors.background
+      }}
+    >
+      <Handle
+        type="target"
+        position={Position.Top}
+        style={{
+          background: 'white',
+          border: `2px solid ${isInputNode ? '#666' : colors.border}`,
+          width: 12,
+          height: 12
+        }}
+      />
+
+      <div
+        className={`
+        p-3 relative
+        ${isInputNode ? 'flex flex-col items-center justify-center h-full 
text-center' : ''}
+      `}
+      >
+        {selected && !isInputNode && (
+          <div className="absolute top-0 right-0 flex gap-px">
+            <button
+              onClick={() => data.onToggleProperty?.(id, 'isAsync')}
+              className="px-1.5 py-0.5 text-[10px] font-semibold 
transition-opacity rounded-bl"
+              style={{
+                color: data.isAsync ? 'white' : colors.border,
+                backgroundColor: data.isAsync ? colors.border : 
`${colors.border}22`,
+                opacity: data.isAsync ? 1 : 0.6
+              }}
+              title={data.isAsync ? 'Click to make synchronous' : 'Click to 
make async'}
+            >
+              async
+            </button>
+            <button
+              onClick={() => data.onToggleProperty?.(id, 'isStreaming')}
+              className="px-1.5 py-0.5 text-[10px] font-semibold 
transition-opacity rounded-tr-lg"
+              style={{
+                color: data.isStreaming ? 'white' : colors.border,
+                backgroundColor: data.isStreaming ? colors.border : 
`${colors.border}22`,
+                opacity: data.isStreaming ? 1 : 0.6
+              }}
+              title={data.isStreaming ? 'Click to make regular action' : 
'Click to make streaming'}
+            >
+              stream
+            </button>
+          </div>
+        )}
+
+        {isEditing ? (
+          <input
+            value={labelValue}
+            onChange={handleLabelChange}
+            onBlur={handleLabelBlur}
+            onKeyDown={handleLabelKeyDown}
+            onClick={(e) => e.stopPropagation()}
+            autoFocus
+            className={`
+              border-none outline-none bg-transparent text-sm font-bold 
font-inherit
+              w-full box-border p-0 m-0 leading-normal block
+              ${isInputNode ? 'text-center text-gray-600' : 'text-left'}
+            `}
+            style={{
+              color: isInputNode ? '#666' : colors.border,
+              marginBottom: data.description ? '8px' : 0,
+              paddingRight: selected ? '24px' : 0
+            }}
+          />
+        ) : (
+          <div
+            onClick={handleLabelClick}
+            className={`
+              font-bold cursor-pointer hover:opacity-80 break-words min-h-5 
min-w-16
+              ${isInputNode ? 'text-center text-gray-600' : 'text-left'}
+              ${data.description ? 'mb-2' : ''}
+            `}
+            style={{
+              color: isInputNode ? '#666' : colors.border,
+              paddingRight: selected ? '12px' : 0
+            }}
+          >
+            {labelValue || <span className="opacity-40 italic 
font-normal">click to name</span>}
+          </div>
+        )}
+
+        {data.description && (
+          <div
+            className={`
+              text-xs opacity-80 block break-words
+              ${isInputNode ? 'text-center text-gray-600' : 'text-left'}
+            `}
+            style={{ color: isInputNode ? '#666' : colors.border }}
+          >
+            {data.description}
+          </div>
+        )}
+      </div>
+
+      <Handle
+        type="source"
+        position={Position.Bottom}
+        style={{
+          background: 'white',
+          border: `2px solid ${isInputNode ? '#666' : colors.border}`,
+          width: 12,
+          height: 12
+        }}
+      />
+    </div>
+  );
+};
+
+export default memo(CustomNode);
diff --git 
a/telemetry/ui/src/components/routes/graph-builder/components/ExampleGallery.tsx
 
b/telemetry/ui/src/components/routes/graph-builder/components/ExampleGallery.tsx
new file mode 100644
index 00000000..89d49cd7
--- /dev/null
+++ 
b/telemetry/ui/src/components/routes/graph-builder/components/ExampleGallery.tsx
@@ -0,0 +1,71 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you 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.
+ */
+
+import React from 'react';
+import { PlayIcon } from '@heroicons/react/24/outline';
+import { Button } from '../../../common/button';
+import { ExampleGraph } from '../data/examples';
+
+interface ExampleGalleryProps {
+  examples: ExampleGraph[];
+  onLoadExample: (example: ExampleGraph) => void;
+}
+
+const ExampleGallery: React.FC<ExampleGalleryProps> = ({ examples, 
onLoadExample }) => {
+  return (
+    <div className="mt-6 pt-4 border-t border-gray-200">
+      <h3 className="text-lg font-semibold text-gray-900 mb-1">Example 
Graphs</h3>
+      <p className="text-sm text-gray-600 mb-4">
+        Load pre-built examples to explore the graph builder
+      </p>
+
+      {examples.map((example) => (
+        <div
+          key={example.id}
+          className="mb-4 border border-gray-200 rounded-lg hover:shadow-md 
hover:-translate-y-0.5 transition-all duration-200 ease-in-out"
+        >
+          <div className="p-4 pb-2">
+            <h4 className="text-base font-medium text-gray-900 
mb-2">{example.title}</h4>
+            <p className="text-sm text-gray-600 mb-3">{example.description}</p>
+            <div className="flex gap-2 mb-2">
+              <span className="inline-flex items-center px-2 py-1 rounded 
text-xs border border-gray-300 bg-gray-50 text-gray-700">
+                {example.nodes.length} nodes
+              </span>
+              <span className="inline-flex items-center px-2 py-1 rounded 
text-xs border border-gray-300 bg-gray-50 text-gray-700">
+                {example.edges.length} edges
+              </span>
+            </div>
+          </div>
+          <div className="px-4 pb-4 pt-0 flex gap-2">
+            <Button onClick={() => onLoadExample(example)} className="text-xs 
cursor-pointer">
+              <PlayIcon className="w-3 h-3 mr-1" />
+              Load Example
+            </Button>
+          </div>
+        </div>
+      ))}
+
+      {examples.length === 0 && (
+        <p className="text-sm text-gray-500 italic">No examples available 
yet.</p>
+      )}
+    </div>
+  );
+};
+
+export default ExampleGallery;
diff --git 
a/telemetry/ui/src/components/routes/graph-builder/components/GraphBuilder.tsx 
b/telemetry/ui/src/components/routes/graph-builder/components/GraphBuilder.tsx
new file mode 100644
index 00000000..9d94afec
--- /dev/null
+++ 
b/telemetry/ui/src/components/routes/graph-builder/components/GraphBuilder.tsx
@@ -0,0 +1,1307 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you 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.
+ */
+
+import React, { useState, useCallback, useEffect, useMemo, useRef } from 
'react';
+import {
+  ReactFlow,
+  Node,
+  Edge,
+  addEdge,
+  Connection,
+  useNodesState,
+  useEdgesState,
+  Controls,
+  MiniMap,
+  Background,
+  BackgroundVariant,
+  NodeTypes,
+  EdgeTypes,
+  ReactFlowInstance,
+  MarkerType
+} from '@xyflow/react';
+import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
+import { vscDarkPlus } from 'react-syntax-highlighter/dist/esm/styles/prism';
+import { Button } from '../../../common/button';
+import {
+  PlusIcon,
+  ChevronLeftIcon,
+  ChevronRightIcon,
+  ClipboardDocumentIcon,
+  QuestionMarkCircleIcon,
+  TrashIcon
+} from '@heroicons/react/24/outline';
+
+import '@xyflow/react/dist/style.css';
+import CustomNode from './CustomNode';
+import CustomEdge from './CustomEdge';
+import ExampleGallery from './ExampleGallery';
+import ConfirmLoadExampleDialog from './ConfirmLoadExampleDialog';
+import { GraphExporter, BurrGraphJSON } from '../utils/GraphExporter';
+import { BurrGraphCodeGenerator } from '../utils/BurrCodeGenerator';
+import { ExampleLoader } from '../utils/ExampleLoader';
+import { examples } from '../data/examples';
+import type { ExampleGraph } from '../data/examples';
+
+const nodeTypes: NodeTypes = {
+  custom: CustomNode as NodeTypes['custom']
+};
+
+const edgeTypes: EdgeTypes = {
+  custom: CustomEdge as EdgeTypes['custom']
+};
+
+const defaultEdgeOptions = {
+  type: 'custom',
+  markerEnd: {
+    type: MarkerType.ArrowClosed,
+    width: 15,
+    height: 15,
+    color: '#429dbce6'
+  }
+};
+
+const STORAGE_KEY = 'burr-graph-builder-state';
+
+const nodeTemplates = [
+  { type: 'action', label: 'Action' },
+  { type: 'input', label: 'Input' }
+];
+
+interface NodeDialogData {
+  label: string;
+  description: string;
+  nodeType: string;
+  icon: string;
+}
+
+/**
+ * Visual graph builder for Burr applications.
+ *
+ * Accepts an optional initialGraph to pre-populate the canvas - this enables
+ * future flows like loading from the tracking API or from Pyodide-based
+ * Python AST parsing.
+ */
+interface GraphBuilderProps {
+  initialGraph?: BurrGraphJSON;
+}
+
+const GraphBuilder: React.FC<GraphBuilderProps> = ({ initialGraph }) => {
+  const nodeIdCounter = useRef(0);
+  const [leftOpen, setLeftOpen] = useState(true);
+  const [rightOpen, setRightOpen] = useState(true);
+  const [nodes, setNodes, onNodesChange] = useNodesState([] as Node[]);
+  const [edges, setEdges, onEdgesChange] = useEdgesState([] as Edge[]);
+  const [reactFlowInstance, setReactFlowInstance] = useState<ReactFlowInstance 
| null>(null);
+  const [nodeDialog, setNodeDialog] = useState(false);
+  const [selectedEdge, setSelectedEdge] = useState<string | null>(null);
+  const [selectedNode, setSelectedNode] = useState<string | null>(null);
+  const [colorPickerOpen, setColorPickerOpen] = useState(false);
+  const [colorPickerAnchor, setColorPickerAnchor] = useState<HTMLElement | 
null>(null);
+  const [confirmDialogOpen, setConfirmDialogOpen] = useState(false);
+  const [selectedExample, setSelectedExample] = useState<ExampleGraph | 
null>(null);
+  const [nodeDialogData, setNodeDialogData] = useState<NodeDialogData>({
+    label: '',
+    description: '',
+    nodeType: 'action',
+    icon: 'settings'
+  });
+  const [tabIndex, setTabIndex] = useState(0);
+  const [copied, setCopied] = useState<'python' | 'json' | null>(null);
+  const [showExamplePicker, setShowExamplePicker] = useState(false);
+
+  const edgeColors = [
+    '#429dbce6',
+    '#ef4444',
+    '#10b981',
+    '#f59e0b',
+    '#8b5cf6',
+    '#ec4899',
+    '#6b7280'
+  ];
+
+  const handleDeleteNode = useCallback(
+    (nodeId: string) => {
+      setNodes((nds) => nds.filter((node) => node.id !== nodeId));
+      setEdges((eds) => eds.filter((edge) => edge.source !== nodeId && 
edge.target !== nodeId));
+      setSelectedNode(null);
+    },
+    [setNodes, setEdges]
+  );
+
+  const handleLabelChange = useCallback(
+    (nodeId: string, newLabel: string) => {
+      setNodes((nds) =>
+        nds.map((node) =>
+          node.id === nodeId ? { ...node, data: { ...node.data, label: 
newLabel } } : node
+        )
+      );
+    },
+    [setNodes]
+  );
+
+  const handleToggleProperty = useCallback(
+    (nodeId: string, property: 'isAsync' | 'isStreaming') => {
+      setNodes((nds) =>
+        nds.map((node) =>
+          node.id === nodeId
+            ? { ...node, data: { ...node.data, [property]: 
!node.data[property] } }
+            : node
+        )
+      );
+    },
+    [setNodes]
+  );
+
+  const handleEdgeLabelChange = useCallback(
+    (edgeId: string, newLabel: string) => {
+      setEdges((eds) =>
+        eds.map((edge) =>
+          edge.id === edgeId
+            ? { ...edge, data: { ...edge.data, label: newLabel, condition: 
newLabel } }
+            : edge
+        )
+      );
+    },
+    [setEdges]
+  );
+
+  // Shared helper: convert BurrGraphJSON into ReactFlow nodes/edges and load 
them
+  const loadGraphIntoCanvas = useCallback(
+    (graphJson: BurrGraphJSON) => {
+      const newNodes: Node[] = graphJson.nodes.map((n, i) => ({
+        id: n.id,
+        type: 'custom',
+        position: n.position,
+        data: {
+          label: n.label,
+          description: n.description || '',
+          nodeType: n.nodeType,
+          isAsync: n.isAsync || false,
+          isStreaming: n.isStreaming || false,
+          icon: 'settings',
+          colorIndex: i % 10,
+          onDelete: handleDeleteNode,
+          onLabelChange: handleLabelChange,
+          onToggleProperty: handleToggleProperty
+        }
+      }));
+
+      const newEdges: Edge[] = graphJson.edges.map((e) => ({
+        id: e.id,
+        source: e.source,
+        target: e.target,
+        type: 'custom',
+        markerEnd: {
+          type: MarkerType.ArrowClosed,
+          width: 15,
+          height: 15,
+          color: '#429dbce6'
+        },
+        data: {
+          condition: e.condition,
+          isConditional: e.isConditional,
+          label: e.condition,
+          onLabelChange: handleEdgeLabelChange
+        }
+      }));
+
+      // Update nodeIdCounter to be higher than any existing node's numeric 
suffix
+      const maxId = graphJson.nodes.reduce((max, n) => {
+        const match = n.id.match(/(\d+)$/);
+        return match ? Math.max(max, parseInt(match[1], 10)) : max;
+      }, 0);
+      nodeIdCounter.current = Math.max(nodeIdCounter.current, maxId);
+
+      setNodes(newNodes);
+      setEdges(newEdges);
+    },
+    [
+      handleDeleteNode,
+      handleLabelChange,
+      handleToggleProperty,
+      handleEdgeLabelChange,
+      setNodes,
+      setEdges
+    ]
+  );
+
+  // Load initialGraph prop if provided, otherwise restore from localStorage
+  useEffect(() => {
+    if (initialGraph) {
+      loadGraphIntoCanvas(initialGraph);
+      return;
+    }
+
+    try {
+      const saved = localStorage.getItem(STORAGE_KEY);
+      if (saved) {
+        const parsed = JSON.parse(saved) as BurrGraphJSON;
+        if (parsed.nodes && parsed.nodes.length > 0) {
+          loadGraphIntoCanvas(parsed);
+        }
+      }
+    } catch {
+      // Corrupted data — ignore and start fresh
+    }
+  }, []); // eslint-disable-line react-hooks/exhaustive-deps
+
+  const onPaneClick = useCallback(
+    (event: React.MouseEvent) => {
+      if (event.metaKey || event.ctrlKey) {
+        const isRightClick = event.button === 2 || event.type === 
'contextmenu';
+        const nodeType = isRightClick ? 'input' : 'action';
+        const nodeLabel = isRightClick ? `Input ${nodes.length + 1}` : `Node 
${nodes.length + 1}`;
+
+        let position;
+        if (reactFlowInstance) {
+          position = reactFlowInstance.screenToFlowPosition({
+            x: event.clientX,
+            y: event.clientY
+          });
+        } else {
+          const rect = (event.currentTarget as 
HTMLElement).getBoundingClientRect();
+          position = {
+            x: event.clientX - rect.left,
+            y: event.clientY - rect.top
+          };
+        }
+
+        const newNode: Node = {
+          id: `node_${++nodeIdCounter.current}`,
+          type: 'custom',
+          position,
+          data: {
+            label: nodeLabel,
+            description: '',
+            nodeType: nodeType,
+            isAsync: false,
+            isStreaming: false,
+            icon: 'settings',
+            colorIndex: nodes.length % 10,
+            onDelete: handleDeleteNode,
+            onLabelChange: handleLabelChange,
+            onToggleProperty: handleToggleProperty
+          }
+        };
+
+        setNodes((nds) => [...nds, newNode]);
+
+        if (isRightClick) {
+          event.preventDefault();
+        }
+      }
+    },
+    [
+      nodes.length,
+      setNodes,
+      handleDeleteNode,
+      handleLabelChange,
+      handleToggleProperty,
+      reactFlowInstance
+    ]
+  );
+
+  const onPaneContextMenu = useCallback(
+    (event: React.MouseEvent | MouseEvent) => {
+      const reactEvent = event as React.MouseEvent;
+      if (reactEvent.metaKey || reactEvent.ctrlKey) {
+        onPaneClick(reactEvent);
+      }
+    },
+    [onPaneClick]
+  );
+
+  const onKeyDown = useCallback(
+    (event: KeyboardEvent) => {
+      const target = event.target as HTMLElement;
+      const isInputFocused =
+        target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || 
target.isContentEditable;
+
+      if ((event.key === 'Backspace' || event.key === 'Delete') && 
!isInputFocused) {
+        if (selectedNode) {
+          setNodes((nds) => nds.filter((node) => node.id !== selectedNode));
+          setEdges((eds) =>
+            eds.filter((edge) => edge.source !== selectedNode && edge.target 
!== selectedNode)
+          );
+          setSelectedNode(null);
+        } else if (selectedEdge) {
+          setEdges((eds) => {
+            const filteredEdges = eds.filter((edge) => edge.id !== 
selectedEdge);
+
+            const deletedEdge = eds.find((edge) => edge.id === selectedEdge);
+            if (deletedEdge) {
+              const sourceEdges = filteredEdges.filter(
+                (edge) => edge.source === deletedEdge.source
+              );
+              const shouldBeConditional = sourceEdges.length > 1;
+
+              return filteredEdges.map((edge) => {
+                if (edge.source === deletedEdge.source) {
+                  const preservedLabel = shouldBeConditional
+                    ? deletedEdge.data?.label || edge.data?.label || 
'condition'
+                    : undefined;
+                  return {
+                    ...edge,
+                    data: {
+                      ...edge.data,
+                      isConditional: shouldBeConditional,
+                      label: preservedLabel,
+                      onLabelChange: handleEdgeLabelChange
+                    }
+                  };
+                }
+                return edge;
+              });
+            }
+
+            return filteredEdges;
+          });
+          setSelectedEdge(null);
+        }
+      }
+    },
+    [selectedNode, selectedEdge, setNodes, setEdges, handleEdgeLabelChange]
+  );
+
+  useEffect(() => {
+    document.addEventListener('keydown', onKeyDown);
+    return () => {
+      document.removeEventListener('keydown', onKeyDown);
+    };
+  }, [onKeyDown]);
+
+  const onConnect = useCallback(
+    (params: Connection) => {
+      const sourceEdges = edges.filter((edge) => edge.source === 
params.source);
+      const willBeConditional = sourceEdges.length > 0;
+
+      const targetNode = nodes.find((node) => node.id === params.target);
+      const targetLabel = targetNode?.data?.label || params.target;
+      const conditionString = `condition="${targetLabel}"`;
+
+      const newEdge = {
+        ...params,
+        type: 'custom',
+        markerEnd: {
+          type: MarkerType.ArrowClosed,
+          width: 15,
+          height: 15,
+          color: '#429dbce6'
+        },
+        data: {
+          condition: willBeConditional ? conditionString : undefined,
+          isConditional: willBeConditional,
+          label: willBeConditional ? conditionString : undefined,
+          onLabelChange: handleEdgeLabelChange
+        }
+      };
+
+      setEdges((eds) => addEdge(newEdge, eds));
+    },
+    [setEdges, edges, nodes, handleEdgeLabelChange]
+  );
+
+  const onNodeClick = useCallback((_event: React.MouseEvent, node: Node) => {
+    setSelectedNode(node.id);
+    setSelectedEdge(null);
+    setColorPickerOpen(false);
+    setColorPickerAnchor(null);
+  }, []);
+
+  const onEdgeClick = useCallback((_event: React.MouseEvent, edge: Edge) => {
+    setSelectedEdge(edge.id);
+    setSelectedNode(null);
+    setColorPickerAnchor(_event.currentTarget as HTMLElement);
+    setColorPickerOpen(true);
+  }, []);
+
+  const handleEdgeColorChange = useCallback(
+    (color: string) => {
+      if (selectedEdge) {
+        setEdges((eds) =>
+          eds.map((edge) =>
+            edge.id === selectedEdge ? { ...edge, style: { ...edge.style, 
stroke: color } } : edge
+          )
+        );
+      }
+      setColorPickerOpen(false);
+      setColorPickerAnchor(null);
+    },
+    [selectedEdge, setEdges]
+  );
+
+  const handleToggleConditional = useCallback(() => {
+    if (!selectedEdge) return;
+    setEdges((eds) => {
+      const targetEdge = eds.find((e) => e.id === selectedEdge);
+      if (!targetEdge) return eds;
+      const source = targetEdge.source;
+      const target = targetEdge.target;
+      const groupEdges = eds.filter((e) => e.source === source);
+      const toggledIsConditional = !targetEdge.data?.isConditional;
+
+      const targetNode = nodes.find((node) => node.id === target);
+      const targetLabel = targetNode?.data?.label || target;
+      const conditionString = `condition="${targetLabel}"`;
+
+      return eds.map((edge) => {
+        if (edge.id === selectedEdge) {
+          return {
+            ...edge,
+            data: {
+              ...edge.data,
+              isConditional: toggledIsConditional,
+              condition: toggledIsConditional ? conditionString : undefined,
+              label: toggledIsConditional ? conditionString : undefined
+            }
+          };
+        }
+        if (edge.source === source && edge.id !== selectedEdge) {
+          if (!toggledIsConditional) {
+            const stillConditional =
+              groupEdges.filter((e) => e.id !== selectedEdge && 
e.data?.isConditional).length > 1;
+            return {
+              ...edge,
+              data: {
+                ...edge.data,
+                isConditional: stillConditional
+              }
+            };
+          }
+        }
+        return edge;
+      });
+    });
+    setColorPickerOpen(false);
+    setColorPickerAnchor(null);
+  }, [selectedEdge, setEdges, nodes]);
+
+  const handleAddNode = useCallback(() => {
+    setNodeDialog(true);
+  }, []);
+
+  const [confirmClearOpen, setConfirmClearOpen] = useState(false);
+  const handleClearCanvas = useCallback(() => {
+    setNodes([]);
+    setEdges([]);
+    setSelectedNode(null);
+    setSelectedEdge(null);
+    nodeIdCounter.current = 0;
+    setConfirmClearOpen(false);
+    try {
+      localStorage.removeItem(STORAGE_KEY);
+    } catch {
+      // ignore
+    }
+  }, [setNodes, setEdges]);
+
+  const handleCreateNode = useCallback(() => {
+    const newNode: Node = {
+      id: `node_${++nodeIdCounter.current}`,
+      type: 'custom',
+      position: { x: Math.random() * 500 + 100, y: Math.random() * 500 + 100 },
+      data: {
+        label: nodeDialogData.label,
+        description: nodeDialogData.description,
+        nodeType: nodeDialogData.nodeType,
+        isAsync: false,
+        isStreaming: false,
+        icon: nodeDialogData.icon,
+        colorIndex: nodes.length % 10,
+        onDelete: handleDeleteNode,
+        onLabelChange: handleLabelChange,
+        onToggleProperty: handleToggleProperty
+      }
+    };
+
+    setNodes((nds) => [...nds, newNode]);
+    setNodeDialog(false);
+    setNodeDialogData({
+      label: '',
+      description: '',
+      nodeType: 'action',
+      icon: 'settings'
+    });
+  }, [
+    nodeDialogData,
+    setNodes,
+    nodes.length,
+    handleDeleteNode,
+    handleLabelChange,
+    handleToggleProperty
+  ]);
+
+  const graphData = useMemo(() => GraphExporter.exportToJSON(nodes, edges), 
[nodes, edges]);
+  const pythonCode = useMemo(
+    () => BurrGraphCodeGenerator.generatePythonCode(graphData),
+    [graphData]
+  );
+  const jsonCode = useMemo(() => JSON.stringify(graphData, null, 2), 
[graphData]);
+
+  // Auto-save graph to localStorage (debounced to avoid writing on every drag 
frame)
+  const saveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
+  useEffect(() => {
+    if (saveTimerRef.current) {
+      clearTimeout(saveTimerRef.current);
+    }
+    saveTimerRef.current = setTimeout(() => {
+      try {
+        localStorage.setItem(STORAGE_KEY, JSON.stringify(graphData));
+      } catch {
+        // Storage full or unavailable — silently skip
+      }
+    }, 500);
+    return () => {
+      if (saveTimerRef.current) {
+        clearTimeout(saveTimerRef.current);
+      }
+    };
+  }, [graphData]);
+
+  const hasExistingContent = nodes.length > 0 || edges.length > 0;
+
+  const handleLoadExample = useCallback((example: ExampleGraph) => {
+    setSelectedExample(example);
+    setConfirmDialogOpen(true);
+  }, []);
+
+  const handleConfirmLoadExample = useCallback(() => {
+    if (!selectedExample) return;
+
+    const errors = ExampleLoader.validateExample(selectedExample);
+    if (errors.length > 0) {
+      console.error('Example validation failed:', errors);
+      return;
+    }
+
+    const { nodes: newNodes, edges: newEdges } = 
ExampleLoader.convertToReactFlow(selectedExample);
+
+    const nodesWithHandlers = newNodes.map((node) => ({
+      ...node,
+      data: {
+        ...node.data,
+        onDelete: handleDeleteNode,
+        onLabelChange: handleLabelChange,
+        onToggleProperty: handleToggleProperty
+      }
+    }));
+
+    const edgesWithHandlers = newEdges.map((edge) => ({
+      ...edge,
+      data: {
+        ...edge.data,
+        onLabelChange: handleEdgeLabelChange
+      }
+    }));
+
+    setNodes(nodesWithHandlers);
+    setEdges(edgesWithHandlers);
+    setConfirmDialogOpen(false);
+    setSelectedExample(null);
+
+    setTimeout(() => {
+      if (reactFlowInstance) {
+        reactFlowInstance.fitView({ padding: 0.1 });
+      }
+    }, 100);
+  }, [
+    selectedExample,
+    handleDeleteNode,
+    handleLabelChange,
+    handleToggleProperty,
+    handleEdgeLabelChange,
+    setNodes,
+    setEdges,
+    reactFlowInstance
+  ]);
+
+  const handleCancelLoadExample = useCallback(() => {
+    setConfirmDialogOpen(false);
+    setSelectedExample(null);
+  }, []);
+
+  return (
+    <div className="flex h-full overflow-hidden border-t">
+      {/* Left sidebar with help & instructions */}
+      <div
+        className={`${leftOpen ? 'w-72' : 'w-12'} flex-shrink-0 bg-white 
border-r border-gray-200 transition-all duration-200 overflow-hidden`}
+      >
+        <div className="flex flex-col h-full">
+          {leftOpen ? (
+            <div className="flex-1 overflow-auto p-4">
+              {/* Intro */}
+              <div className="mb-5">
+                <h3 className="text-lg font-semibold mb-1">Graph Builder</h3>
+                <p className="text-sm text-gray-500">
+                  Visually design Burr application graphs, then export as 
Python code or JSON.
+                </p>
+              </div>
+
+              {/* Quick Start */}
+              <div className="mb-5">
+                <h4 className="text-xs font-semibold text-gray-400 uppercase 
tracking-wider mb-3">
+                  Quick Start
+                </h4>
+                <ol className="text-sm text-gray-600 space-y-2 list-decimal 
list-inside">
+                  <li>Add action nodes to the canvas</li>
+                  <li>Connect them by dragging between handles</li>
+                  <li>
+                    Switch to the <span className="font-medium">Python</span> 
tab to see generated
+                    code
+                  </li>
+                </ol>
+              </div>
+
+              {/* Creating */}
+              <div className="mb-5">
+                <h4 className="text-xs font-semibold text-gray-400 uppercase 
tracking-wider mb-3">
+                  Creating
+                </h4>
+                <div className="space-y-3">
+                  <div className="flex items-start gap-2">
+                    <div className="flex-shrink-0 mt-0.5">
+                      <kbd className="inline-flex items-center px-1.5 py-0.5 
rounded bg-gray-100 border border-gray-300 text-xs font-mono text-gray-700">
+                        {navigator.platform?.includes('Mac') ? '\u2318' : 
'Ctrl'}+Click
+                      </kbd>
+                    </div>
+                    <span className="text-sm text-gray-600">
+                      Add an action node at that position
+                    </span>
+                  </div>
+                  <div className="flex items-start gap-2">
+                    <div className="flex-shrink-0 mt-0.5">
+                      <kbd className="inline-flex items-center px-1.5 py-0.5 
rounded bg-gray-100 border border-gray-300 text-xs font-mono text-gray-700">
+                        {navigator.platform?.includes('Mac') ? '\u2318' : 
'Ctrl'}+Right-click
+                      </kbd>
+                    </div>
+                    <span className="text-sm text-gray-600">Add an input 
node</span>
+                  </div>
+                  <div className="flex items-start gap-2">
+                    <div className="flex-shrink-0 mt-0.5">
+                      <kbd className="inline-flex items-center px-1.5 py-0.5 
rounded bg-gray-100 border border-gray-300 text-xs font-mono text-gray-700">
+                        Drag
+                      </kbd>
+                    </div>
+                    <span className="text-sm text-gray-600">
+                      From bottom handle to top handle to create an edge 
(transition)
+                    </span>
+                  </div>
+                  <div className="flex items-start gap-2">
+                    <div className="flex-shrink-0 mt-0.5">
+                      <kbd className="inline-flex items-center px-1.5 py-0.5 
rounded bg-gray-100 border border-gray-300 text-xs font-mono text-gray-700">
+                        +
+                      </kbd>
+                    </div>
+                    <span className="text-sm text-gray-600">
+                      Use the button at the bottom-right to add a node via 
dialog
+                    </span>
+                  </div>
+                </div>
+              </div>
+
+              {/* Editing */}
+              <div className="mb-5">
+                <h4 className="text-xs font-semibold text-gray-400 uppercase 
tracking-wider mb-3">
+                  Editing
+                </h4>
+                <div className="space-y-3">
+                  <div className="flex items-start gap-2">
+                    <div className="flex-shrink-0 mt-0.5">
+                      <span className="text-sm text-gray-500">Click 
label</span>
+                    </div>
+                    <span className="text-sm text-gray-600">Edit a node&apos;s 
name inline</span>
+                  </div>
+                  <div className="flex items-start gap-2">
+                    <div className="flex-shrink-0 mt-0.5">
+                      <span className="text-sm text-gray-500">Click edge 
label</span>
+                    </div>
+                    <span className="text-sm text-gray-600">
+                      Edit a conditional edge&apos;s condition text
+                    </span>
+                  </div>
+                  <div className="flex items-start gap-2">
+                    <div className="flex-shrink-0 mt-0.5">
+                      <span className="text-sm text-gray-500">Select 
node</span>
+                    </div>
+                    <span className="text-sm text-gray-600">
+                      Toggle action/streaming type via the badge in the 
top-right corner
+                    </span>
+                  </div>
+                  <div className="flex items-start gap-2">
+                    <div className="flex-shrink-0 mt-0.5">
+                      <span className="text-sm text-gray-500">Click edge</span>
+                    </div>
+                    <span className="text-sm text-gray-600">
+                      Pick a color or toggle conditional/default
+                    </span>
+                  </div>
+                </div>
+              </div>
+
+              {/* Deleting */}
+              <div className="mb-5">
+                <h4 className="text-xs font-semibold text-gray-400 uppercase 
tracking-wider mb-3">
+                  Deleting
+                </h4>
+                <div className="flex items-start gap-2">
+                  <div className="flex-shrink-0 mt-0.5">
+                    <kbd className="inline-flex items-center px-1.5 py-0.5 
rounded bg-gray-100 border border-gray-300 text-xs font-mono text-gray-700">
+                      {navigator.platform?.includes('Mac') ? '\u232b' : 
'Backspace'}
+                    </kbd>
+                  </div>
+                  <span className="text-sm text-gray-600">Delete the selected 
node or edge</span>
+                </div>
+              </div>
+
+              {/* Concepts */}
+              <div className="mb-5">
+                <h4 className="text-xs font-semibold text-gray-400 uppercase 
tracking-wider mb-3">
+                  Node Types
+                </h4>
+                <div className="space-y-2">
+                  <div className="flex items-start gap-2">
+                    <span className="inline-block w-3 h-3 rounded bg-blue-200 
border border-blue-400 flex-shrink-0 mt-1" />
+                    <div>
+                      <span className="text-sm font-medium 
text-gray-700">Action</span>
+                      <p className="text-xs text-gray-500">
+                        A step decorated with{' '}
+                        <code className="bg-gray-100 px-1 
rounded">@action</code>
+                      </p>
+                    </div>
+                  </div>
+                  <div className="flex items-start gap-2">
+                    <span className="inline-block w-3 h-3 rounded border-2 
border-dashed border-gray-400 flex-shrink-0 mt-1 w-3 h-3" />
+                    <div>
+                      <span className="text-sm font-medium 
text-gray-700">Input</span>
+                      <p className="text-xs text-gray-500">
+                        External input passed into an action at runtime
+                      </p>
+                    </div>
+                  </div>
+                </div>
+              </div>
+
+              {/* Node Flags */}
+              <div className="mb-5">
+                <h4 className="text-xs font-semibold text-gray-400 uppercase 
tracking-wider mb-3">
+                  Action Flags
+                </h4>
+                <p className="text-xs text-gray-500 mb-2">
+                  Select a node to toggle these independently:
+                </p>
+                <div className="space-y-2">
+                  <div className="flex items-start gap-2">
+                    <span className="inline-flex px-1.5 py-0.5 rounded 
bg-blue-500 text-white text-[10px] font-semibold flex-shrink-0 mt-0.5">
+                      async
+                    </span>
+                    <p className="text-xs text-gray-500">
+                      Makes the function <code className="bg-gray-100 px-1 
rounded">async def</code>
+                    </p>
+                  </div>
+                  <div className="flex items-start gap-2">
+                    <span className="inline-flex px-1.5 py-0.5 rounded 
bg-blue-500 text-white text-[10px] font-semibold flex-shrink-0 mt-0.5">
+                      stream
+                    </span>
+                    <p className="text-xs text-gray-500">
+                      Uses <code className="bg-gray-100 px-1 
rounded">@streaming_action</code> and
+                      yields results
+                    </p>
+                  </div>
+                </div>
+              </div>
+
+              {/* Edge Types */}
+              <div className="mb-5">
+                <h4 className="text-xs font-semibold text-gray-400 uppercase 
tracking-wider mb-3">
+                  Edge Types
+                </h4>
+                <div className="space-y-2">
+                  <div className="flex items-start gap-2">
+                    <span className="inline-block w-6 border-t-2 
border-blue-400 flex-shrink-0 mt-2" />
+                    <div>
+                      <span className="text-sm font-medium 
text-gray-700">Default</span>
+                      <p className="text-xs text-gray-500">
+                        Unconditional transition (uses{' '}
+                        <code className="bg-gray-100 px-1 
rounded">default</code>)
+                      </p>
+                    </div>
+                  </div>
+                  <div className="flex items-start gap-2">
+                    <span className="inline-block w-6 border-t-2 border-dashed 
border-blue-400 flex-shrink-0 mt-2" />
+                    <div>
+                      <span className="text-sm font-medium 
text-gray-700">Conditional</span>
+                      <p className="text-xs text-gray-500">
+                        Guarded transition (uses{' '}
+                        <code className="bg-gray-100 px-1 
rounded">when()</code>). Created
+                        automatically when a node has multiple outgoing edges.
+                      </p>
+                    </div>
+                  </div>
+                </div>
+              </div>
+            </div>
+          ) : (
+            <div className="flex-1 flex items-start justify-center pt-4">
+              <QuestionMarkCircleIcon className="w-5 h-5 text-gray-400" />
+            </div>
+          )}
+          <div className={`flex items-center ${leftOpen ? 'justify-start' : 
'justify-center'} p-2`}>
+            <button
+              onClick={() => setLeftOpen(!leftOpen)}
+              className="p-1 rounded hover:bg-gray-100"
+              title={leftOpen ? 'Collapse help panel' : 'Expand help panel'}
+            >
+              {leftOpen ? (
+                <ChevronLeftIcon className="w-5 h-5 text-gray-400" />
+              ) : (
+                <ChevronRightIcon className="w-5 h-5 text-gray-400" />
+              )}
+            </button>
+          </div>
+        </div>
+      </div>
+
+      {/* Main content area */}
+      <div className="flex flex-col flex-1 min-w-0">
+        {/* Tab navigation */}
+        <div className="border-b border-gray-200 flex-shrink-0">
+          <nav className="flex space-x-8 px-4">
+            {[
+              { label: 'Canvas', title: 'Visual graph editor' },
+              { label: 'Python', title: 'Generated Burr Python code' },
+              { label: 'JSON', title: 'Graph data as JSON (importable)' }
+            ].map((tab, idx) => (
+              <button
+                key={tab.label}
+                onClick={() => setTabIndex(idx)}
+                title={tab.title}
+                className={`py-2 px-1 border-b-2 font-medium text-sm ${
+                  tabIndex === idx
+                    ? 'border-blue-500 text-blue-600'
+                    : 'border-transparent text-gray-500 hover:text-gray-700 
hover:border-gray-300'
+                }`}
+              >
+                {tab.label}
+              </button>
+            ))}
+          </nav>
+        </div>
+
+        {/* Tab content */}
+        <div className="flex-1 min-h-0 relative">
+          {tabIndex === 0 && (
+            <div className="h-full w-full relative">
+              <ReactFlow
+                nodes={nodes}
+                edges={edges}
+                onNodesChange={onNodesChange}
+                onEdgesChange={onEdgesChange}
+                onConnect={onConnect}
+                onNodeClick={onNodeClick}
+                onEdgeClick={onEdgeClick}
+                onPaneClick={onPaneClick}
+                onPaneContextMenu={onPaneContextMenu}
+                onInit={setReactFlowInstance}
+                nodeTypes={nodeTypes}
+                edgeTypes={edgeTypes}
+                defaultEdgeOptions={defaultEdgeOptions}
+                defaultViewport={{ x: 0, y: 0, zoom: 1.0 }}
+                attributionPosition="bottom-left"
+                deleteKeyCode={null}
+                style={{ width: '100%', height: '100%' }}
+              >
+                <Controls />
+                <MiniMap />
+                <Background variant={BackgroundVariant.Dots} gap={20} size={1} 
/>
+              </ReactFlow>
+
+              {/* Empty state overlay */}
+              {nodes.length === 0 && (
+                <div className="absolute inset-0 flex items-center 
justify-center pointer-events-none z-10">
+                  <div className="bg-white/90 backdrop-blur-sm rounded-xl 
shadow-lg border border-gray-200 p-8 max-w-md text-center pointer-events-auto">
+                    <h3 className="text-xl font-semibold text-gray-800 mb-2">
+                      Design your Burr graph
+                    </h3>
+                    <p className="text-sm text-gray-500 mb-6">
+                      Build application graphs visually and export as Python 
code.
+                    </p>
+
+                    <div className="text-left space-y-3 mb-6">
+                      <div className="flex items-center gap-3 bg-gray-50 
rounded-lg px-4 py-2.5">
+                        <kbd className="inline-flex items-center px-2 py-1 
rounded bg-white border border-gray-300 text-xs font-mono text-gray-700 
shadow-sm flex-shrink-0">
+                          {navigator.platform?.includes('Mac') ? '\u2318' : 
'Ctrl'}+Click
+                        </kbd>
+                        <span className="text-sm text-gray-600">Add an action 
node</span>
+                      </div>
+                      <div className="flex items-center gap-3 bg-gray-50 
rounded-lg px-4 py-2.5">
+                        <kbd className="inline-flex items-center px-2 py-1 
rounded bg-white border border-gray-300 text-xs font-mono text-gray-700 
shadow-sm flex-shrink-0">
+                          Drag handle
+                        </kbd>
+                        <span className="text-sm text-gray-600">Connect nodes 
with edges</span>
+                      </div>
+                    </div>
+
+                    <div className="flex gap-3 justify-center">
+                      <button
+                        className="inline-flex items-center px-4 py-2 
bg-blue-500 hover:bg-blue-600 text-white text-sm font-medium rounded-lg 
transition-colors"
+                        onClick={handleAddNode}
+                      >
+                        <PlusIcon className="w-4 h-4 mr-1.5" />
+                        Add First Node
+                      </button>
+                      <button
+                        className="inline-flex items-center px-4 py-2 border 
border-gray-300 text-gray-700 text-sm font-medium rounded-lg hover:bg-gray-50 
transition-colors"
+                        onClick={() => setShowExamplePicker(true)}
+                      >
+                        Load Example
+                      </button>
+                    </div>
+                  </div>
+                </div>
+              )}
+
+              <div className="absolute bottom-4 right-4 flex gap-2">
+                {hasExistingContent && (
+                  <button
+                    className="bg-white hover:bg-red-50 text-red-500 border 
border-red-200 rounded-full w-14 h-14 flex items-center justify-center 
shadow-lg transition-colors"
+                    onClick={() => setConfirmClearOpen(true)}
+                    title="Clear canvas"
+                    aria-label="Clear canvas"
+                  >
+                    <TrashIcon className="w-6 h-6" />
+                  </button>
+                )}
+                <button
+                  className="bg-blue-500 hover:bg-blue-600 text-white 
rounded-full w-14 h-14 flex items-center justify-center shadow-lg 
transition-colors"
+                  onClick={handleAddNode}
+                  title="Add a new node via dialog"
+                  aria-label="Add a new node"
+                >
+                  <PlusIcon className="w-6 h-6" />
+                </button>
+              </div>
+            </div>
+          )}
+          {tabIndex === 1 && (
+            <div className="h-full flex flex-col bg-gray-900 relative">
+              <div className="absolute top-2 right-5 z-10 bg-white 
bg-opacity-85 rounded">
+                <button
+                  className={`p-2 rounded ${
+                    copied === 'python' ? 'text-green-600' : 'text-blue-600'
+                  } hover:bg-gray-100`}
+                  onClick={() => {
+                    navigator.clipboard
+                      .writeText(pythonCode)
+                      .then(() => {
+                        setCopied('python');
+                        setTimeout(() => setCopied(null), 1200);
+                      })
+                      .catch(() => {
+                        /* clipboard not available */
+                      });
+                  }}
+                  title={copied === 'python' ? 'Copied!' : 'Copy Python code 
to clipboard'}
+                  aria-label="Copy Python code"
+                >
+                  <ClipboardDocumentIcon className="w-5 h-5" />
+                </button>
+              </div>
+              <div className="flex-1 overflow-auto">
+                <SyntaxHighlighter
+                  language="python"
+                  style={vscDarkPlus}
+                  customStyle={{
+                    margin: 0,
+                    padding: 16,
+                    fontSize: 14,
+                    borderRadius: 0,
+                    minHeight: '100%'
+                  }}
+                >
+                  {pythonCode}
+                </SyntaxHighlighter>
+              </div>
+            </div>
+          )}
+          {tabIndex === 2 && (
+            <div className="h-full flex flex-col bg-gray-900 relative">
+              <div className="absolute top-2 right-5 z-10 bg-white 
bg-opacity-85 rounded">
+                <button
+                  className={`p-2 rounded ${
+                    copied === 'json' ? 'text-green-600' : 'text-blue-600'
+                  } hover:bg-gray-100`}
+                  onClick={() => {
+                    navigator.clipboard
+                      .writeText(jsonCode)
+                      .then(() => {
+                        setCopied('json');
+                        setTimeout(() => setCopied(null), 1200);
+                      })
+                      .catch(() => {
+                        /* clipboard not available */
+                      });
+                  }}
+                  title={copied === 'json' ? 'Copied!' : 'Copy JSON to 
clipboard'}
+                  aria-label="Copy JSON code"
+                >
+                  <ClipboardDocumentIcon className="w-5 h-5" />
+                </button>
+              </div>
+              <div className="flex-1 overflow-auto">
+                <SyntaxHighlighter
+                  language="json"
+                  style={vscDarkPlus}
+                  customStyle={{
+                    margin: 0,
+                    padding: 16,
+                    fontSize: 14,
+                    borderRadius: 0,
+                    minHeight: '100%'
+                  }}
+                >
+                  {jsonCode}
+                </SyntaxHighlighter>
+              </div>
+            </div>
+          )}
+        </div>
+      </div>
+
+      {/* Right panel: ExampleGallery */}
+      <div
+        className={`${rightOpen ? 'w-72' : 'w-12'} flex-shrink-0 bg-white 
shadow-lg z-10 transition-all duration-200`}
+      >
+        <div className="flex flex-col h-full">
+          {rightOpen ? (
+            <div className="flex-1 overflow-y-auto p-4">
+              <ExampleGallery examples={examples} 
onLoadExample={handleLoadExample} />
+            </div>
+          ) : (
+            <div className="flex-1" />
+          )}
+          <div
+            className={`flex items-center ${rightOpen ? 'justify-start' : 
'justify-center'} p-2 border-t border-gray-200 bg-white flex-shrink-0`}
+          >
+            <button
+              onClick={() => setRightOpen(!rightOpen)}
+              className="p-1 rounded hover:bg-gray-100"
+              title={rightOpen ? 'Collapse examples panel' : 'Expand examples 
panel'}
+            >
+              {rightOpen ? (
+                <ChevronRightIcon className="w-5 h-5 text-gray-400" />
+              ) : (
+                <ChevronLeftIcon className="w-5 h-5 text-gray-400" />
+              )}
+            </button>
+          </div>
+        </div>
+      </div>
+
+      {/* Add Node Dialog */}
+      {nodeDialog && (
+        <div
+          className="fixed inset-0 bg-black bg-opacity-50 flex items-center 
justify-center z-50"
+          onClick={() => setNodeDialog(false)}
+        >
+          <div
+            className="bg-white rounded-lg p-6 w-full max-w-md"
+            onClick={(e) => e.stopPropagation()}
+          >
+            <h2 className="text-lg font-semibold mb-4">Add New Node</h2>
+            <div className="space-y-4">
+              <div>
+                <label className="block text-sm font-medium text-gray-700 
mb-1">Node Label</label>
+                <input
+                  type="text"
+                  value={nodeDialogData.label}
+                  onChange={(e) => setNodeDialogData({ ...nodeDialogData, 
label: e.target.value })}
+                  className="w-full border border-gray-300 rounded-md px-3 
py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
+                />
+              </div>
+              <div>
+                <label className="block text-sm font-medium text-gray-700 
mb-1">Description</label>
+                <textarea
+                  value={nodeDialogData.description}
+                  onChange={(e) =>
+                    setNodeDialogData({ ...nodeDialogData, description: 
e.target.value })
+                  }
+                  rows={3}
+                  className="w-full border border-gray-300 rounded-md px-3 
py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
+                />
+              </div>
+              <div>
+                <label className="block text-sm font-medium text-gray-700 
mb-1">Node Type</label>
+                <select
+                  value={nodeDialogData.nodeType}
+                  onChange={(e) =>
+                    setNodeDialogData({ ...nodeDialogData, nodeType: 
e.target.value })
+                  }
+                  className="w-full border border-gray-300 rounded-md px-3 
py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
+                >
+                  {nodeTemplates.map((template) => (
+                    <option key={template.type} value={template.type}>
+                      {template.label}
+                    </option>
+                  ))}
+                </select>
+              </div>
+            </div>
+            <div className="flex justify-end space-x-2 mt-6">
+              <Button onClick={() => setNodeDialog(false)} outline>
+                Cancel
+              </Button>
+              <Button onClick={handleCreateNode}>Create Node</Button>
+            </div>
+          </div>
+        </div>
+      )}
+
+      {/* Color Picker Popover for Edges */}
+      {colorPickerOpen && colorPickerAnchor && (
+        <div
+          className="fixed z-50 bg-white rounded-lg shadow-lg border 
border-gray-200 p-4"
+          style={{
+            top: colorPickerAnchor.getBoundingClientRect().bottom + 8,
+            left: colorPickerAnchor.getBoundingClientRect().left
+          }}
+        >
+          <h3 className="text-sm font-medium mb-3">Select Edge Color</h3>
+          <div className="grid grid-cols-4 gap-2">
+            {edgeColors.map((color) => (
+              <button
+                key={color}
+                className="w-8 h-8 rounded border-2 border-transparent 
hover:border-black transition-colors"
+                style={{ backgroundColor: color }}
+                onClick={() => handleEdgeColorChange(color)}
+              />
+            ))}
+          </div>
+          {(() => {
+            if (!selectedEdge) return null;
+            const selected = edges.find((e) => e.id === selectedEdge);
+            if (!selected) return null;
+            const groupEdges = edges.filter((e) => e.source === 
selected.source);
+            if (groupEdges.length > 1) {
+              return (
+                <div className="mt-4">
+                  {selected.data?.isConditional ? (
+                    <Button className="w-full" color="blue" 
onClick={handleToggleConditional}>
+                      Make Default
+                    </Button>
+                  ) : (
+                    <Button className="w-full" outline 
onClick={handleToggleConditional}>
+                      Make Conditional
+                    </Button>
+                  )}
+                </div>
+              );
+            }
+            return null;
+          })()}
+        </div>
+      )}
+
+      {/* Example Picker Modal (from empty state) */}
+      {showExamplePicker && (
+        <div
+          className="fixed inset-0 bg-black bg-opacity-50 flex items-center 
justify-center z-50"
+          onClick={() => setShowExamplePicker(false)}
+        >
+          <div
+            className="bg-white rounded-lg p-6 w-full max-w-lg max-h-[80vh] 
overflow-y-auto"
+            onClick={(e) => e.stopPropagation()}
+          >
+            <h2 className="text-lg font-semibold mb-1">Load an Example</h2>
+            <p className="text-sm text-gray-500 mb-4">
+              Pick a pre-built graph to explore the builder.
+            </p>
+            <div className="space-y-3">
+              {examples.map((example) => (
+                <button
+                  key={example.id}
+                  className="w-full text-left border border-gray-200 
rounded-lg p-4 hover:shadow-md hover:border-blue-300 transition-all"
+                  onClick={() => {
+                    setShowExamplePicker(false);
+                    handleLoadExample(example);
+                  }}
+                >
+                  <h4 className="font-medium 
text-gray-900">{example.title}</h4>
+                  <p className="text-sm text-gray-600 
mt-1">{example.description}</p>
+                  <div className="flex gap-2 mt-2">
+                    <span className="inline-flex items-center px-2 py-0.5 
rounded text-xs border border-gray-300 bg-gray-50 text-gray-600">
+                      {example.nodes.length} nodes
+                    </span>
+                    <span className="inline-flex items-center px-2 py-0.5 
rounded text-xs border border-gray-300 bg-gray-50 text-gray-600">
+                      {example.edges.length} edges
+                    </span>
+                  </div>
+                </button>
+              ))}
+            </div>
+            <div className="mt-4 flex justify-end">
+              <button
+                className="px-4 py-2 text-sm text-gray-600 hover:text-gray-800"
+                onClick={() => setShowExamplePicker(false)}
+              >
+                Cancel
+              </button>
+            </div>
+          </div>
+        </div>
+      )}
+
+      {/* Confirm Clear Dialog */}
+      {confirmClearOpen && (
+        <div
+          className="fixed inset-0 bg-black bg-opacity-50 flex items-center 
justify-center z-50"
+          onClick={() => setConfirmClearOpen(false)}
+        >
+          <div
+            className="bg-white rounded-lg p-6 w-full max-w-sm"
+            onClick={(e) => e.stopPropagation()}
+          >
+            <h2 className="text-lg font-semibold mb-2">Clear canvas?</h2>
+            <p className="text-sm text-gray-600 mb-6">
+              This will remove all nodes and edges. This cannot be undone.
+            </p>
+            <div className="flex justify-end gap-2">
+              <Button outline onClick={() => setConfirmClearOpen(false)}>
+                Cancel
+              </Button>
+              <Button color="red" onClick={handleClearCanvas}>
+                Clear
+              </Button>
+            </div>
+          </div>
+        </div>
+      )}
+
+      {/* Confirm Load Example Dialog */}
+      <ConfirmLoadExampleDialog
+        open={confirmDialogOpen}
+        onClose={handleCancelLoadExample}
+        onConfirm={handleConfirmLoadExample}
+        exampleTitle={selectedExample?.title || ''}
+        hasExistingContent={hasExistingContent}
+      />
+    </div>
+  );
+};
+
+export default GraphBuilder;
diff --git a/telemetry/ui/src/components/routes/graph-builder/data/examples.ts 
b/telemetry/ui/src/components/routes/graph-builder/data/examples.ts
new file mode 100644
index 00000000..528600dc
--- /dev/null
+++ b/telemetry/ui/src/components/routes/graph-builder/data/examples.ts
@@ -0,0 +1,564 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you 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.
+ */
+
+export interface ExampleGraph {
+  id: string;
+  title: string;
+  description: string;
+  nodes: Array<{
+    id: string;
+    label: string;
+    nodeType: 'input' | 'action';
+    isAsync?: boolean;
+    isStreaming?: boolean;
+    position: { x: number; y: number };
+    description?: string;
+  }>;
+  edges: Array<{
+    id: string;
+    source: string;
+    target: string;
+    condition?: string;
+    isConditional: boolean;
+  }>;
+}
+
+export const multiModalChatbotWorkflow: ExampleGraph = {
+  id: 'multi-modal-chatbot',
+  title: 'MultiModal Chatbot',
+  description: 'A ChatGPT-like bot which supports multiple response modes, 
with regular actions.',
+  nodes: [
+    {
+      id: 'node_prompt',
+      label: 'prompt',
+      nodeType: 'action',
+      position: { x: 761, y: 91 }
+    },
+    {
+      id: 'node_check_openai_key',
+      label: 'check_openai_key',
+      nodeType: 'action',
+      position: { x: 591, y: 193 }
+    },
+    {
+      id: 'node_check_safety',
+      label: 'check_safety',
+      nodeType: 'action',
+      position: { x: 384, y: 342 }
+    },
+    {
+      id: 'node_decide_mode',
+      label: 'decide_mode',
+      nodeType: 'action',
+      position: { x: 245, y: 441 }
+    },
+    {
+      id: 'node_prompt_for_more',
+      label: 'prompt_for_more',
+      nodeType: 'action',
+      position: { x: 25, y: 640 }
+    },
+    {
+      id: 'node_generate_image',
+      label: 'generate_image',
+      nodeType: 'action',
+      position: { x: 237, y: 673 }
+    },
+    {
+      id: 'node_generate_code',
+      label: 'generate_code',
+      nodeType: 'action',
+      position: { x: 470, y: 747 }
+    },
+    {
+      id: 'node_answer_question',
+      label: 'answer_question',
+      nodeType: 'action',
+      position: { x: 1019, y: 724 }
+    },
+    {
+      id: 'node_response',
+      label: 'response',
+      nodeType: 'action',
+      position: { x: 883, y: 909 }
+    },
+    {
+      id: 'input_prompt',
+      label: 'input: prompt',
+      nodeType: 'input',
+      position: { x: 790, y: -29 }
+    },
+    {
+      id: 'input_model',
+      label: 'input: model',
+      nodeType: 'input',
+      position: { x: 884, y: 549 }
+    },
+    {
+      id: 'input_display_type',
+      label: 'input: display_type',
+      nodeType: 'input',
+      position: { x: 1086, y: 551 }
+    }
+  ],
+  edges: [
+    {
+      id: 'e-input_prompt-node_prompt',
+      source: 'input_prompt',
+      target: 'node_prompt',
+      isConditional: false
+    },
+    {
+      id: 'e-node_prompt-node_check_openai_key',
+      source: 'node_prompt',
+      target: 'node_check_openai_key',
+      isConditional: false
+    },
+    {
+      id: 'e-node_check_openai_key-node_response',
+      source: 'node_check_openai_key',
+      target: 'node_response',
+      isConditional: false
+    },
+    {
+      id: 'e-node_check_openai_key-node_check_safety',
+      source: 'node_check_openai_key',
+      target: 'node_check_safety',
+      condition: 'has_openai_key=True',
+      isConditional: true
+    },
+    {
+      id: 'e-node_check_safety-node_decide_mode',
+      source: 'node_check_safety',
+      target: 'node_decide_mode',
+      condition: 'safe=True',
+      isConditional: true
+    },
+    {
+      id: 'e-node_check_safety-node_response',
+      source: 'node_check_safety',
+      target: 'node_response',
+      isConditional: false
+    },
+    {
+      id: 'e-node_decide_mode-node_prompt_for_more',
+      source: 'node_decide_mode',
+      target: 'node_prompt_for_more',
+      isConditional: false
+    },
+    {
+      id: 'e-node_decide_mode-node_generate_image',
+      source: 'node_decide_mode',
+      target: 'node_generate_image',
+      condition: 'mode="generate_image"',
+      isConditional: true
+    },
+    {
+      id: 'e-node_decide_mode-node_generate_code',
+      source: 'node_decide_mode',
+      target: 'node_generate_code',
+      condition: 'mode="generate_code"',
+      isConditional: true
+    },
+    {
+      id: 'e-node_decide_mode-node_answer_question',
+      source: 'node_decide_mode',
+      target: 'node_answer_question',
+      condition: 'mode="answer_question"',
+      isConditional: true
+    },
+    {
+      id: 'e-node_answer_question-node_response',
+      source: 'node_answer_question',
+      target: 'node_response',
+      isConditional: false
+    },
+    {
+      id: 'e-node_generate_code-node_response',
+      source: 'node_generate_code',
+      target: 'node_response',
+      isConditional: false
+    },
+    {
+      id: 'e-node_generate_image-node_response',
+      source: 'node_generate_image',
+      target: 'node_response',
+      isConditional: false
+    },
+    {
+      id: 'e-node_prompt_for_more-node_response',
+      source: 'node_prompt_for_more',
+      target: 'node_response',
+      isConditional: false
+    },
+    {
+      id: 'e-node_response-node_prompt',
+      source: 'node_response',
+      target: 'node_prompt',
+      isConditional: false
+    },
+    {
+      id: 'e-input_model-node_generate_code',
+      source: 'input_model',
+      target: 'node_generate_code',
+      isConditional: false
+    },
+    {
+      id: 'e-input_model-node_answer_question',
+      source: 'input_model',
+      target: 'node_answer_question',
+      isConditional: false
+    },
+    {
+      id: 'e-input_model-node_generate_image',
+      source: 'input_model',
+      target: 'node_generate_image',
+      isConditional: false
+    },
+    {
+      id: 'e-input_display_type-node_answer_question',
+      source: 'input_display_type',
+      target: 'node_answer_question',
+      isConditional: false
+    },
+    {
+      id: 'e-input_display_type-node_generate_code',
+      source: 'input_display_type',
+      target: 'node_generate_code',
+      isConditional: false
+    }
+  ]
+};
+
+export const streamingChatbotWorkflow: ExampleGraph = {
+  id: 'streaming-chatbot',
+  title: 'Streaming Chatbot',
+  description: 'A ChatGPT-like bot which supports multiple response modes, 
with streaming actions.',
+  nodes: [
+    {
+      id: 'input_prompt',
+      label: 'input: prompt',
+      nodeType: 'input',
+      position: { x: 500, y: 50 }
+    },
+    {
+      id: 'input_model',
+      label: 'input: model',
+      nodeType: 'input',
+      position: { x: 84, y: 436 }
+    },
+    {
+      id: 'prompt',
+      label: 'prompt',
+      nodeType: 'action',
+      position: { x: 500, y: 200 }
+    },
+    {
+      id: 'check_safety',
+      label: 'check_safety',
+      nodeType: 'action',
+      position: { x: 500, y: 350 }
+    },
+    {
+      id: 'decide_mode',
+      label: 'decide_mode',
+      nodeType: 'action',
+      position: { x: 400, y: 500 }
+    },
+    {
+      id: 'unsafe_response',
+      label: 'unsafe_response',
+      nodeType: 'action',
+      isAsync: true,
+      isStreaming: true,
+      position: { x: 761, y: 496 }
+    },
+    {
+      id: 'generate_code',
+      label: 'generate_code',
+      nodeType: 'action',
+      isAsync: true,
+      isStreaming: true,
+      position: { x: 90, y: 635 }
+    },
+    {
+      id: 'answer_question',
+      label: 'answer_question',
+      nodeType: 'action',
+      isAsync: true,
+      isStreaming: true,
+      position: { x: 299, y: 713 }
+    },
+    {
+      id: 'generate_poem',
+      label: 'generate_poem',
+      nodeType: 'action',
+      isAsync: true,
+      isStreaming: true,
+      position: { x: 565, y: 780 }
+    },
+    {
+      id: 'prompt_for_more',
+      label: 'prompt_for_more',
+      nodeType: 'action',
+      isAsync: true,
+      isStreaming: true,
+      position: { x: 730, y: 677 }
+    }
+  ],
+  edges: [
+    {
+      id: 'e-input_prompt-prompt',
+      source: 'input_prompt',
+      target: 'prompt',
+      isConditional: false
+    },
+    {
+      id: 'e-input_model-generate_code',
+      source: 'input_model',
+      target: 'generate_code',
+      isConditional: false
+    },
+    {
+      id: 'e-input_model-answer_question',
+      source: 'input_model',
+      target: 'answer_question',
+      isConditional: false
+    },
+    {
+      id: 'e-input_model-generate_poem',
+      source: 'input_model',
+      target: 'generate_poem',
+      isConditional: false
+    },
+    {
+      id: 'e-prompt-check_safety',
+      source: 'prompt',
+      target: 'check_safety',
+      isConditional: false
+    },
+    {
+      id: 'e-check_safety-decide_mode',
+      source: 'check_safety',
+      target: 'decide_mode',
+      condition: 'safe=True',
+      isConditional: true
+    },
+    {
+      id: 'e-check_safety-unsafe_response',
+      source: 'check_safety',
+      target: 'unsafe_response',
+      isConditional: false
+    },
+    {
+      id: 'e-decide_mode-generate_code',
+      source: 'decide_mode',
+      target: 'generate_code',
+      condition: 'mode="generate_code"',
+      isConditional: true
+    },
+    {
+      id: 'e-decide_mode-answer_question',
+      source: 'decide_mode',
+      target: 'answer_question',
+      condition: 'mode="answer_question"',
+      isConditional: true
+    },
+    {
+      id: 'e-decide_mode-generate_poem',
+      source: 'decide_mode',
+      target: 'generate_poem',
+      condition: 'mode="generate_poem"',
+      isConditional: true
+    },
+    {
+      id: 'e-decide_mode-prompt_for_more',
+      source: 'decide_mode',
+      target: 'prompt_for_more',
+      isConditional: false
+    },
+    {
+      id: 'e-generate_code-prompt',
+      source: 'generate_code',
+      target: 'prompt',
+      isConditional: false
+    },
+    {
+      id: 'e-answer_question-prompt',
+      source: 'answer_question',
+      target: 'prompt',
+      isConditional: false
+    },
+    {
+      id: 'e-generate_poem-prompt',
+      source: 'generate_poem',
+      target: 'prompt',
+      isConditional: false
+    },
+    {
+      id: 'e-unsafe_response-prompt',
+      source: 'unsafe_response',
+      target: 'prompt',
+      isConditional: false
+    },
+    {
+      id: 'e-prompt_for_more-prompt',
+      source: 'prompt_for_more',
+      target: 'prompt',
+      isConditional: false
+    }
+  ]
+};
+
+export const adaptiveCRAGWorkflow: ExampleGraph = {
+  id: 'adaptive-crag',
+  title: 'Adaptive CRAG',
+  description:
+    'A system that dynamically selects the most suitable route for a given 
user query and self-reflects on retrieved documents to improve response 
quality.',
+  nodes: [
+    {
+      id: 'node_router',
+      label: 'router',
+      nodeType: 'action',
+      position: { x: 821, y: 177 }
+    },
+    {
+      id: 'node_terminate',
+      label: 'terminate',
+      nodeType: 'action',
+      position: { x: 1115, y: 339 }
+    },
+    {
+      id: 'node_rewrite_query',
+      label: 'rewrite_query_for_lancedb',
+      nodeType: 'action',
+      position: { x: 570, y: 343 }
+    },
+    {
+      id: 'node_search_lancedb',
+      label: 'search_lancedb',
+      nodeType: 'action',
+      position: { x: 395, y: 474 }
+    },
+    {
+      id: 'node_remove_irrelevant',
+      label: 'remove_irrelevant_lancedb_results',
+      nodeType: 'action',
+      position: { x: 222, y: 598 }
+    },
+    {
+      id: 'node_extract_keywords',
+      label: 'extract_keywords_for_exa_search',
+      nodeType: 'action',
+      position: { x: 862, y: 755 }
+    },
+    {
+      id: 'node_search_exa',
+      label: 'search_exa',
+      nodeType: 'action',
+      position: { x: 1014, y: 900 }
+    },
+    {
+      id: 'node_ask_assistant',
+      label: 'ask_assistant',
+      nodeType: 'action',
+      position: { x: 664, y: 1050 }
+    },
+    {
+      id: 'input_query',
+      label: 'input: query',
+      nodeType: 'input',
+      position: { x: 822, y: 26 }
+    }
+  ],
+  edges: [
+    {
+      id: 'e-input_query-node_router',
+      source: 'input_query',
+      target: 'node_router',
+      isConditional: false
+    },
+    {
+      id: 'e-node_router-node_rewrite_query',
+      source: 'node_router',
+      target: 'node_rewrite_query',
+      isConditional: false
+    },
+    {
+      id: 'e-node_rewrite_query-node_search_lancedb',
+      source: 'node_rewrite_query',
+      target: 'node_search_lancedb',
+      isConditional: false
+    },
+    {
+      id: 'e-node_search_lancedb-node_remove_irrelevant',
+      source: 'node_search_lancedb',
+      target: 'node_remove_irrelevant',
+      isConditional: false
+    },
+    {
+      id: 'e-node_remove_irrelevant-node_ask_assistant',
+      source: 'node_remove_irrelevant',
+      target: 'node_ask_assistant',
+      isConditional: false
+    },
+    {
+      id: 'e-node_remove_irrelevant-node_extract_keywords',
+      source: 'node_remove_irrelevant',
+      target: 'node_extract_keywords',
+      condition: 'len(lancedb_results) < docs_limit',
+      isConditional: true
+    },
+    {
+      id: 'e-node_extract_keywords-node_search_exa',
+      source: 'node_extract_keywords',
+      target: 'node_search_exa',
+      isConditional: false
+    },
+    {
+      id: 'e-node_search_exa-node_ask_assistant',
+      source: 'node_search_exa',
+      target: 'node_ask_assistant',
+      isConditional: false
+    },
+    {
+      id: 'e-node_router-node_ask_assistant',
+      source: 'node_router',
+      target: 'node_ask_assistant',
+      condition: 'route="assistant"',
+      isConditional: true
+    },
+    {
+      id: 'e-node_router-node_extract_keywords',
+      source: 'node_router',
+      target: 'node_extract_keywords',
+      condition: 'route="web_search"',
+      isConditional: true
+    },
+    {
+      id: 'e-node_router-node_terminate',
+      source: 'node_router',
+      target: 'node_terminate',
+      condition: 'route="terminate"',
+      isConditional: true
+    }
+  ]
+};
+
+export const examples = [multiModalChatbotWorkflow, adaptiveCRAGWorkflow, 
streamingChatbotWorkflow];
diff --git 
a/telemetry/ui/src/components/routes/graph-builder/utils/BurrCodeGenerator.ts 
b/telemetry/ui/src/components/routes/graph-builder/utils/BurrCodeGenerator.ts
new file mode 100644
index 00000000..16004ce4
--- /dev/null
+++ 
b/telemetry/ui/src/components/routes/graph-builder/utils/BurrCodeGenerator.ts
@@ -0,0 +1,321 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you 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.
+ */
+
+import { BurrGraphJSON } from './GraphExporter';
+
+type NodeRef = BurrGraphJSON['nodes'][0];
+
+export class BurrGraphCodeGenerator {
+  static generatePythonCode(graphData: BurrGraphJSON): string {
+    const actionNodes = graphData.nodes.filter((n) => n.nodeType !== 'input');
+    const hasAsync = actionNodes.some((n) => n.isAsync);
+    const hasStreaming = actionNodes.some((n) => n.isStreaming);
+
+    const imports = this.generateImports(actionNodes, hasAsync, hasStreaming);
+    const actions = this.generateActions(graphData.nodes, graphData.edges);
+    const graphFunction = this.generateGraphFunction(graphData);
+    const main = this.generateMain(hasAsync, hasStreaming);
+
+    return [imports, actions, graphFunction, main].join('\n\n');
+  }
+
+  private static generateImports(
+    actionNodes: NodeRef[],
+    hasAsync: boolean,
+    hasStreaming: boolean
+  ): string {
+    // Decorator imports
+    const decorators: string[] = ['action'];
+    if (hasStreaming) decorators.push('streaming_action');
+    const actionImports = `from burr.core.action import ${decorators.join(', 
')}`;
+
+    // Typing imports
+    const typingParts: string[] = ['Tuple'];
+    if (hasStreaming) {
+      typingParts.push('Optional');
+      if (hasAsync) typingParts.push('AsyncGenerator');
+      else typingParts.push('Generator');
+    }
+    const typingImports = `from typing import ${typingParts.join(', ')}`;
+
+    const asyncioImport = hasAsync ? '\nimport asyncio' : '';
+
+    return `${typingImports}
+from burr.core import State, default, when
+${actionImports}
+from burr.core.graph import GraphBuilder${asyncioImport}`;
+  }
+
+  private static generateActions(
+    nodes: BurrGraphJSON['nodes'],
+    edges: BurrGraphJSON['edges']
+  ): string {
+    const processNodes = nodes.filter((node) => node.nodeType !== 'input');
+
+    const actionFunctions = processNodes.map((node) => {
+      return this.generateAction(node, nodes, edges);
+    });
+
+    return actionFunctions.join('\n\n');
+  }
+
+  private static generateAction(
+    node: NodeRef,
+    nodes: BurrGraphJSON['nodes'],
+    edges: BurrGraphJSON['edges']
+  ): string {
+    const functionName = this.sanitizeNodeName(node.label);
+    const inputParams = this.getInputParameters(node.id, nodes, edges);
+    const paramString =
+      inputParams.length > 0 ? `state: State, ${inputParams.join(', ')}` : 
'state: State';
+
+    const isAsync = node.isAsync || false;
+    const isStreaming = node.isStreaming || false;
+
+    const decorator = isStreaming
+      ? '@streaming_action(reads=[], writes=[])'
+      : '@action(reads=[], writes=[])';
+    const asyncKeyword = isAsync ? 'async ' : '';
+
+    let returnType: string;
+    let body: string;
+    let docKind: string;
+
+    if (isStreaming && isAsync) {
+      returnType = 'AsyncGenerator[Tuple[dict, Optional[State]], None]';
+      body = '    yield {}, state';
+      docKind = 'async streaming action';
+    } else if (isStreaming) {
+      returnType = 'Generator[Tuple[dict, Optional[State]], None, None]';
+      body = '    yield {}, state';
+      docKind = 'streaming action';
+    } else if (isAsync) {
+      returnType = 'Tuple[dict, State]';
+      body = '    return {}, state';
+      docKind = 'async action';
+    } else {
+      returnType = 'Tuple[dict, State]';
+      body = '    return {}, state';
+      docKind = 'action';
+    }
+
+    const docstring = node.description
+      ? `\n    """${node.description}\n\n    This is a stub ${docKind}. Please 
complete with your business logic.\n    """`
+      : `\n    """Stub ${docKind}. Please complete with your business 
logic."""`;
+
+    return `${decorator}
+${asyncKeyword}def ${functionName}(${paramString}) -> 
${returnType}:${docstring}
+${body}`;
+  }
+
+  private static getInputParameters(
+    nodeId: string,
+    nodes: BurrGraphJSON['nodes'],
+    edges: BurrGraphJSON['edges']
+  ): string[] {
+    const inputParams: string[] = [];
+
+    edges.forEach((edge) => {
+      if (edge.target === nodeId) {
+        const sourceNode = nodes.find((n) => n.id === edge.source);
+        if (sourceNode && sourceNode.nodeType === 'input') {
+          const paramName = sourceNode.label.replace(/^input:\s*/, '').trim();
+          const sanitizedParam = this.sanitizeParameterName(paramName);
+          inputParams.push(`${sanitizedParam}: str`);
+        }
+      }
+    });
+
+    return inputParams;
+  }
+
+  private static sanitizeParameterName(name: string): string {
+    return (
+      name
+        .toLowerCase()
+        .replace(/[^a-z0-9]/g, '_')
+        .replace(/_+/g, '_')
+        .replace(/^_|_$/g, '') || 'param'
+    );
+  }
+
+  private static deduplicateNames(names: string[]): string[] {
+    const seen = new Map<string, number>();
+    return names.map((name) => {
+      const count = seen.get(name) || 0;
+      seen.set(name, count + 1);
+      return count > 0 ? `${name}_${count}` : name;
+    });
+  }
+
+  private static generateGraphFunction(graphData: BurrGraphJSON): string {
+    const processNodes = graphData.nodes.filter((node) => node.nodeType !== 
'input');
+    const actionNames = this.deduplicateNames(
+      processNodes.map((node) => this.sanitizeNodeName(node.label))
+    );
+    const transitions = this.generateTransitions(graphData);
+
+    const actionsString = actionNames.map((name) => `          
${name},`).join('\n');
+
+    return `def create_burr_graph():
+    """Create the Burr graph for the project."""
+    return (
+        GraphBuilder()
+        .with_actions(
+${actionsString}
+        )
+        .with_transitions(
+${transitions}
+        )
+        .build()
+    )`;
+  }
+
+  private static generateTransitions(graphData: BurrGraphJSON): string {
+    const transitions: string[] = [];
+
+    const processEdges = graphData.edges.filter((edge) => {
+      const sourceNode = graphData.nodes.find((n) => n.id === edge.source);
+      const targetNode = graphData.nodes.find((n) => n.id === edge.target);
+      return sourceNode?.nodeType !== 'input' && targetNode?.nodeType !== 
'input';
+    });
+
+    const edgesBySource = new Map<string, typeof processEdges>();
+    processEdges.forEach((edge) => {
+      if (!edgesBySource.has(edge.source)) {
+        edgesBySource.set(edge.source, []);
+      }
+      edgesBySource.get(edge.source)!.push(edge);
+    });
+
+    const allTransitions: Array<{ source: string; target: string; condition: 
string }> = [];
+
+    edgesBySource.forEach((edges, sourceId) => {
+      const sourceNode = graphData.nodes.find((n) => n.id === sourceId);
+      if (!sourceNode || sourceNode.nodeType === 'input') return;
+
+      const sourceName = this.sanitizeNodeName(sourceNode.label);
+
+      if (edges.length === 1) {
+        const edge = edges[0];
+        const targetNode = graphData.nodes.find((n) => n.id === edge.target);
+        if (targetNode && targetNode.nodeType !== 'input') {
+          const targetName = this.sanitizeNodeName(targetNode.label);
+          allTransitions.push({ source: sourceName, target: targetName, 
condition: 'default' });
+        }
+      } else {
+        const conditionalEdges = edges.filter((e) => e.isConditional && 
e.condition);
+        const defaultEdges = edges.filter((e) => !e.isConditional || 
!e.condition);
+
+        conditionalEdges.forEach((edge) => {
+          const targetNode = graphData.nodes.find((n) => n.id === edge.target);
+          if (targetNode && targetNode.nodeType !== 'input' && edge.condition) 
{
+            const targetName = this.sanitizeNodeName(targetNode.label);
+            allTransitions.push({
+              source: sourceName,
+              target: targetName,
+              condition: `when(${edge.condition})`
+            });
+          }
+        });
+
+        if (defaultEdges.length > 0) {
+          const defaultEdge = defaultEdges[0];
+          const targetNode = graphData.nodes.find((n) => n.id === 
defaultEdge.target);
+          if (targetNode && targetNode.nodeType !== 'input') {
+            const targetName = this.sanitizeNodeName(targetNode.label);
+            allTransitions.push({ source: sourceName, target: targetName, 
condition: 'default' });
+          }
+        }
+      }
+    });
+
+    // Group default transitions by target so multiple sources going to the 
same
+    // target can be collapsed into a single list-form transition.
+    const defaultsByTarget = new Map<string, string[]>();
+    const conditionalTransitions: Array<{ source: string; target: string; 
condition: string }> = [];
+
+    allTransitions.forEach(({ source, target, condition }) => {
+      if (condition === 'default') {
+        if (!defaultsByTarget.has(target)) {
+          defaultsByTarget.set(target, []);
+        }
+        defaultsByTarget.get(target)!.push(source);
+      } else {
+        conditionalTransitions.push({ source, target, condition });
+      }
+    });
+
+    // Emit conditional transitions first
+    conditionalTransitions.forEach(({ source, target, condition }) => {
+      transitions.push(`            ("${source}", "${target}", 
${condition}),`);
+    });
+
+    // Emit default transitions (possibly grouped)
+    defaultsByTarget.forEach((sources, target) => {
+      if (sources.length === 1) {
+        transitions.push(`            ("${sources[0]}", "${target}", 
default),`);
+      } else {
+        const sourceList = sources.map((s) => `                    
"${s}"`).join(',\n');
+        transitions.push(
+          `            (\n                [\n${sourceList}\n                
],\n                "${target}",\n            ),`
+        );
+      }
+    });
+
+    return transitions.join('\n');
+  }
+
+  private static generateMain(hasAsync: boolean, hasStreaming: boolean): 
string {
+    const needsAsyncMain = hasAsync || hasStreaming;
+    if (needsAsyncMain) {
+      return `graph = create_burr_graph()
+
+
+async def main():
+    """Run the Burr application.
+
+    Uses async execution which handles both sync and async actions seamlessly.
+    """
+    # action_, result, state = await app.arun(halt_after=[...])
+    print("Burr graph created successfully.")
+    print(graph)
+
+
+if __name__ == "__main__":
+    asyncio.run(main())`;
+    }
+    return `graph = create_burr_graph()
+
+if __name__ == "__main__":
+    print("Burr graph created successfully.")
+    print(graph)
+    # You can now use \`graph\` in your Burr application.`;
+  }
+
+  private static sanitizeNodeName(label: string): string {
+    return (
+      label
+        .toLowerCase()
+        .replace(/[^a-z0-9]/g, '_')
+        .replace(/_+/g, '_')
+        .replace(/^_|_$/g, '') || 'unnamed_node'
+    );
+  }
+}
diff --git 
a/telemetry/ui/src/components/routes/graph-builder/utils/ExampleLoader.ts 
b/telemetry/ui/src/components/routes/graph-builder/utils/ExampleLoader.ts
new file mode 100644
index 00000000..0492c47b
--- /dev/null
+++ b/telemetry/ui/src/components/routes/graph-builder/utils/ExampleLoader.ts
@@ -0,0 +1,93 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you 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.
+ */
+
+import { Node, Edge, MarkerType } from '@xyflow/react';
+import { ExampleGraph } from '../data/examples';
+
+export class ExampleLoader {
+  static convertToReactFlow(example: ExampleGraph): { nodes: Node[]; edges: 
Edge[] } {
+    const nodes: Node[] = example.nodes.map((node, index) => ({
+      id: node.id,
+      type: 'custom',
+      position: node.position,
+      data: {
+        label: node.label,
+        description: node.description || '',
+        nodeType: node.nodeType,
+        isAsync: node.isAsync || false,
+        isStreaming: node.isStreaming || false,
+        icon: 'settings',
+        colorIndex: index % 10
+      }
+    }));
+
+    const edges: Edge[] = example.edges.map((edge) => ({
+      id: edge.id,
+      source: edge.source,
+      target: edge.target,
+      type: 'custom',
+      markerEnd: {
+        type: MarkerType.ArrowClosed,
+        width: 15,
+        height: 15,
+        color: '#429dbce6'
+      },
+      data: {
+        condition: edge.condition,
+        isConditional: edge.isConditional,
+        label: edge.condition
+      }
+    }));
+
+    return { nodes, edges };
+  }
+
+  static validateExample(example: ExampleGraph): string[] {
+    const errors: string[] = [];
+
+    const nodeIds = new Set(example.nodes.map((n) => n.id));
+
+    example.edges.forEach((edge) => {
+      if (!nodeIds.has(edge.source)) {
+        errors.push(`Edge ${edge.id} references non-existent source node: 
${edge.source}`);
+      }
+      if (!nodeIds.has(edge.target)) {
+        errors.push(`Edge ${edge.id} references non-existent target node: 
${edge.target}`);
+      }
+    });
+
+    const uniqueNodeIds = new Set<string>();
+    example.nodes.forEach((node) => {
+      if (uniqueNodeIds.has(node.id)) {
+        errors.push(`Duplicate node ID: ${node.id}`);
+      }
+      uniqueNodeIds.add(node.id);
+    });
+
+    const uniqueEdgeIds = new Set<string>();
+    example.edges.forEach((edge) => {
+      if (uniqueEdgeIds.has(edge.id)) {
+        errors.push(`Duplicate edge ID: ${edge.id}`);
+      }
+      uniqueEdgeIds.add(edge.id);
+    });
+
+    return errors;
+  }
+}
diff --git 
a/telemetry/ui/src/components/routes/graph-builder/utils/GraphExporter.ts 
b/telemetry/ui/src/components/routes/graph-builder/utils/GraphExporter.ts
new file mode 100644
index 00000000..0959b3d6
--- /dev/null
+++ b/telemetry/ui/src/components/routes/graph-builder/utils/GraphExporter.ts
@@ -0,0 +1,83 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you 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.
+ */
+
+import { Node, Edge } from '@xyflow/react';
+
+/**
+ * Interchange format for Burr graphs. This is the central data model that 
connects:
+ * - Visual graph editing (Graph Builder canvas)
+ * - Python code generation (BurrCodeGenerator)
+ * - Example loading (ExampleLoader)
+ * - Future: Pyodide-based Python AST parsing (code -> visual)
+ * - Future: Tracking API import (ApplicationModel -> BurrGraphJSON)
+ */
+export interface BurrGraphJSON {
+  version: string;
+  metadata: {
+    created: string;
+    title?: string;
+    description?: string;
+  };
+  nodes: Array<{
+    id: string;
+    label: string;
+    description?: string;
+    nodeType: 'input' | 'action';
+    isAsync?: boolean;
+    isStreaming?: boolean;
+    position: { x: number; y: number };
+  }>;
+  edges: Array<{
+    id: string;
+    source: string;
+    target: string;
+    condition?: string;
+    isConditional: boolean;
+  }>;
+}
+
+export class GraphExporter {
+  static exportToJSON(nodes: Node[], edges: Edge[]): BurrGraphJSON {
+    return {
+      version: '1.0.0',
+      metadata: {
+        created: new Date().toISOString(),
+        title: 'Burr Graph',
+        description: 'Generated from Burr Graph Builder'
+      },
+      nodes: nodes.map((node) => ({
+        id: node.id,
+        label: (node.data.label as string) || 'Unnamed Node',
+        description: (node.data.description as string) || undefined,
+        nodeType:
+          (node.data.nodeType as string) === 'input' ? ('input' as const) : 
('action' as const),
+        isAsync: Boolean(node.data.isAsync) || undefined,
+        isStreaming: Boolean(node.data.isStreaming) || undefined,
+        position: node.position
+      })),
+      edges: edges.map((edge) => ({
+        id: edge.id,
+        source: edge.source,
+        target: edge.target,
+        condition: (edge.data?.label as string) || (edge.data?.condition as 
string) || undefined,
+        isConditional: Boolean(edge.data?.isConditional) || false
+      }))
+    };
+  }
+}

Reply via email to