diff --git a/custom-nodes/backend/expansion.mdx b/custom-nodes/backend/expansion.mdx
index 0b147e71..6144be77 100644
--- a/custom-nodes/backend/expansion.mdx
+++ b/custom-nodes/backend/expansion.mdx
@@ -46,3 +46,8 @@ Even if you don't want to use the `GraphBuilder` for actually building the graph
### Efficient Subgraph Caching
While you can pass non-literal inputs to nodes within the subgraph (like torch tensors), this can inhibit caching *within* the subgraph. When possible, you should pass links to subgraph objects rather than the node itself. (You can declare an input as a `rawLink` within the input's [Additional Parameters](./datatypes#additional-parameters) to do this easily.)
+
+## Related
+
+- [Subgraphs (Python)](/custom-nodes/backend/subgraphs) — How `UNIQUE_ID` behaves inside subgraphs
+- [Subgraphs (Developer Guide)](/custom-nodes/js/subgraphs) — Full guide for extension authors
diff --git a/custom-nodes/backend/subgraphs.mdx b/custom-nodes/backend/subgraphs.mdx
new file mode 100644
index 00000000..dfe74eb7
--- /dev/null
+++ b/custom-nodes/backend/subgraphs.mdx
@@ -0,0 +1,40 @@
+---
+title: "Subgraphs (Python)"
+description: "How UNIQUE_ID and execution behave when your Python node runs inside a subgraph."
+---
+
+## UNIQUE_ID in Subgraphs
+
+When a Python node executes inside a subgraph, its `UNIQUE_ID` becomes a colon-separated
+execution path instead of a simple number.
+
+| Context | UNIQUE_ID | Meaning |
+|---------|-----------|---------|
+| Root graph | `"789"` | Node 789 at top level |
+| One level deep | `"45:789"` | Node 789 inside subgraph-node 45 |
+| Two levels deep | `"12:45:789"` | Node 789 → subgraph 45 → subgraph 12 |
+
+```python
+def process(self, **kwargs):
+ unique_id = kwargs.get('unique_id', '')
+ # Always get local ID from the last segment
+ local_id = unique_id.split(':')[-1]
+
+ # Use the full unique_id for cache keys and logging
+ cache_key = f"my_node_{unique_id}"
+```
+
+
+Never assume `UNIQUE_ID` is a simple integer. Always treat it as a string and split on `:`
+when you need the local ID.
+
+
+## Execution Flattening
+
+At execution time, the frontend flattens the subgraph hierarchy into a flat list of
+`ExecutableNodeDTO` objects. Each gets a compound execution ID. The backend sees only
+the flattened graph — it has no awareness of the subgraph structure.
+
+For runtime subgraph creation in Python, see [Node Expansion](/custom-nodes/backend/expansion).
+
+For the full subgraph developer guide (frontend/TypeScript), see [Subgraphs](/custom-nodes/js/subgraphs).
diff --git a/custom-nodes/js/subgraphs.mdx b/custom-nodes/js/subgraphs.mdx
new file mode 100644
index 00000000..92e5e6cd
--- /dev/null
+++ b/custom-nodes/js/subgraphs.mdx
@@ -0,0 +1,221 @@
+---
+title: "Subgraphs"
+description: "Developer guide for working with subgraphs programmatically in ComfyUI extensions."
+---
+
+
+The subgraph API is under active development. Some internal APIs referenced here may change.
+See [GitHub Issue #8137](https://github.com/Comfy-Org/ComfyUI_frontend/issues/8137) for planned improvements.
+
+
+## Overview
+
+Subgraphs let users group nodes into reusable components. This guide covers what extension
+developers need to know when their code interacts with nodes inside subgraphs.
+
+For the user-facing subgraph guide, see [Subgraph - Simplify your workflow](/interface/features/subgraph).
+For backend node expansion (runtime subgraph creation in Python), see [Node Expansion](/custom-nodes/backend/expansion).
+
+## Node ID Types
+
+Nodes in subgraphs have three different identifiers. Using the wrong one is the most common source of bugs.
+
+| Type | Format | Example | Scope | Where you see it |
+|------|--------|---------|-------|-----------------|
+| **Local Node ID** | `number` | `123` | Unique within one graph level | `node.id`, `this.id` |
+| **Node Locator ID** | `uuid:localId` | `"a1b2…:123"` | Globally unique, stable across blueprint instances | Frontend node identification |
+| **Node Execution ID** | `path:path:id` | `"45:14:789"` | Globally unique per execution instance | `UNIQUE_ID` in Python, `send_sync` events |
+
+### Local Node ID
+
+The basic `node.id` — a number unique only within its immediate graph level. Two nodes in different
+subgraphs can share the same local ID.
+
+### Node Locator ID
+
+Combines the subgraph's UUID with the local node ID: `:`.
+Root graph nodes use just their local ID as a string. Stable across all instances of the same
+subgraph blueprint.
+
+```typescript
+// Parsing a locator ID
+const parts = locatorId.split(':')
+if (parts.length === 2) {
+ const [subgraphUuid, localNodeId] = parts
+ // Node is inside a subgraph
+} else {
+ // Node is in the root graph
+}
+```
+
+### Node Execution ID
+
+A colon-separated path of local node IDs from root to target: `"45:14:789"` means node 789
+inside subgraph-node 14 inside subgraph-node 45.
+
+This is what Python nodes receive as `UNIQUE_ID` and what `send_sync` events use.
+
+
+Custom nodes currently cannot determine their own execution ID from within the subgraph.
+This is a known limitation tracked in [#8137](https://github.com/Comfy-Org/ComfyUI_frontend/issues/8137).
+
+
+```python
+# In a Python node's execute method:
+def process(self, **kwargs):
+ unique_id = kwargs.get('unique_id', '')
+ # If inside subgraphs: "45:14:789"
+ # Local ID is the last segment
+ local_id = unique_id.split(':')[-1]
+```
+
+## Traversing Nodes
+
+### Current Layer Only
+
+To iterate nodes in the current graph level (non-recursive):
+
+```typescript
+for (const node of graph.nodes) {
+ // Only nodes at this level — does NOT enter subgraphs
+}
+```
+
+### All Nodes Recursively
+
+To find nodes across all subgraph levels, use recursive descent:
+
+```typescript
+function forEachNodeRecursive(graph, fn) {
+ for (const node of graph.nodes) {
+ fn(node)
+ if (node.isSubgraphNode?.() && node.subgraph) {
+ forEachNodeRecursive(node.subgraph, fn)
+ }
+ }
+}
+
+// Usage
+forEachNodeRecursive(app.graph, (node) => {
+ console.log(node.id, node.type)
+})
+```
+
+
+The `isSubgraphNode()` method is the reliable way to detect subgraph nodes. Check for `.subgraph`
+property to access the inner graph.
+
+
+
+Internal utilities in `graphTraversalUtil.ts` (like `forEachNode`, `getNodeByExecutionId`) exist
+but are **not importable** from custom node extensions. The pattern above is the extension-safe equivalent.
+
+
+## Subgraph Events
+
+The `Subgraph` class fires typed events via `CustomEventTarget`. Listen using standard
+`addEventListener` with `AbortController` for cleanup.
+
+| Event | When Fired | Cancellable |
+|-------|-----------|-------------|
+| `adding-input` | Before input slot added | No |
+| `input-added` | After input slot added | No |
+| `removing-input` | Before input slot removed | Yes |
+| `adding-output` | Before output slot added | No |
+| `output-added` | After output slot added | No |
+| `removing-output` | Before output slot removed | Yes |
+| `renaming-input` | Input slot renamed | No |
+| `renaming-output` | Output slot renamed | No |
+| `widget-promoted` | Widget promoted to subgraph surface | No |
+| `widget-demoted` | Promoted widget removed | No |
+| `input-connected` | SubgraphInput connected internally | No |
+| `input-disconnected` | SubgraphInput disconnected | No |
+
+### Listening Pattern
+
+```typescript
+const controller = new AbortController()
+
+subgraph.events.addEventListener('input-added', (e) => {
+ const { input } = e.detail
+ console.log(`New input: ${input.name} (${input.type})`)
+}, { signal: controller.signal })
+
+// Cleanup — removes all listeners attached with this controller
+controller.abort()
+```
+
+## Widget Promotion
+
+When a `SubgraphInput` connects to a node input that has an associated widget, the widget
+is "promoted" — a copy appears on the SubgraphNode's surface.
+
+
+
+ SubgraphInput connects to a node input with an attached widget.
+
+
+ `SubgraphNode._setWidget()` creates a copy via `widget.createCopyForNode()`.
+ The promoted widget's name/label become read-only, delegating to the SubgraphInput.
+
+
+ `widget-promoted` fires. On disconnect: `widget-demoted` fires and the widget is removed.
+
+
+
+Widget type compatibility is validated by `SubgraphInput.matchesWidget()`, checking type, min, max,
+step, and precision.
+
+
+The widget promotion API is still evolving. PR #8352 (dynamic widget promotion) was closed without merging.
+
+
+## Cleanup Patterns
+
+Subgraph nodes use `AbortController`-based lifecycle management. Follow this pattern in extensions
+that interact with subgraph events.
+
+### The Pattern
+
+```typescript
+class MyExtensionState {
+ private _controller = new AbortController()
+
+ setup(subgraphNode) {
+ const { signal } = this._controller
+
+ subgraphNode.subgraph.events.addEventListener('input-added', (e) => {
+ // Handle event
+ }, { signal })
+
+ subgraphNode.subgraph.events.addEventListener('widget-promoted', (e) => {
+ // Handle event
+ }, { signal })
+ }
+
+ cleanup() {
+ // One call removes ALL listeners
+ this._controller.abort()
+ }
+}
+```
+
+### What SubgraphNode.onRemoved() Does
+
+1. Aborts the main `_eventAbortController` (removes all subgraph event listeners)
+2. Dispatches `widget-demoted` for all promoted widgets
+3. Aborts per-input listener controllers
+4. Fires `onRemoved` for inner nodes and `onNodeRemoved` callbacks
+
+
+If you store state keyed by node ID, remember that local IDs are not globally unique.
+Use execution IDs or locator IDs as keys when tracking nodes across subgraph boundaries.
+
+
+## Known Limitations
+
+- **No self-identification:** Nodes inside subgraphs cannot determine their own execution ID or locator ID ([#8137](https://github.com/Comfy-Org/ComfyUI_frontend/issues/8137))
+- **Internal-only traversal utilities:** `graphTraversalUtil.ts` functions are not importable from extensions
+- **Widget promotion evolving:** Dynamic promotion not yet stable
+- **Linked subgraphs broken:** Editing one instance can affect others ([#6639](https://github.com/Comfy-Org/ComfyUI_frontend/issues/6639))
+- **Widget renaming inconsistent:** Names may differ inside vs. outside subgraphs ([#7739](https://github.com/Comfy-Org/ComfyUI_frontend/issues/7739))
diff --git a/docs.json b/docs.json
index a9cc70c7..c1bf66d6 100644
--- a/docs.json
+++ b/docs.json
@@ -605,6 +605,7 @@
"custom-nodes/backend/more_on_inputs",
"custom-nodes/backend/lazy_evaluation",
"custom-nodes/backend/expansion",
+ "custom-nodes/backend/subgraphs",
"custom-nodes/backend/lists",
"custom-nodes/backend/snippets",
"custom-nodes/backend/tensors"
@@ -627,6 +628,7 @@
"custom-nodes/js/javascript_commands_keybindings",
"custom-nodes/js/javascript_topbar_menu",
"custom-nodes/js/context-menu-migration",
+ "custom-nodes/js/subgraphs",
"custom-nodes/js/javascript_examples",
"custom-nodes/i18n"
]
diff --git a/interface/features/subgraph.mdx b/interface/features/subgraph.mdx
index 830b0bcc..898c087e 100644
--- a/interface/features/subgraph.mdx
+++ b/interface/features/subgraph.mdx
@@ -11,6 +11,10 @@ icon: "share-nodes"
- Some features like converting subgraph back to nodes will be supported in the future
+
+For developer documentation on working with subgraphs programmatically, see the [Subgraph Developer Guide](/custom-nodes/js/subgraphs).
+
+