Skip to main content
The subgraph API is under active development. Details on this page may change. See #8137 for status.

Overview

Subgraphs let users group nodes into reusable, nestable components. Each subgraph is its own LGraph with a UUID. For the user-facing guide, see Subgraphs. For backend expansion, see Node Expansion.

Node Identifiers

ComfyUI uses three distinct node identifier types. Using the wrong one causes silent failures.
TypeFormatUsed for
node.id42 (number)Local to its immediate graph level. graph.getNodeById(id)
Execution ID"1:2:3" (colon-separated string)Backend progress messages, UNIQUE_ID
Locator ID"<uuid>:<localId>" or "<localId>"UI state: badges, errors, images
Nodes cannot currently discover their own execution or locator ID from within a subgraph (#8137).

Traversing Nodes

Current layer only

for (const node of app.graph.nodes) {
  console.log(node.id, node.type)
}

All nodes recursively

To walk into nested subgraphs, use a simple recursive helper:
function walkGraph(graph, visit) {
  for (const node of graph.nodes) {
    visit(node, graph)
    if (node.subgraph) walkGraph(node.subgraph, visit)
  }
}
Full example:
import { app } from "../../scripts/app.js"

function walkGraph(graph, visit) {
  for (const node of graph.nodes) {
    visit(node, graph)
    if (node.subgraph) walkGraph(node.subgraph, visit)
  }
}

app.registerExtension({
  name: "MyExtension.SubgraphWalker",
  async afterConfigureGraph() {
    walkGraph(app.graph, (node, graph) => {
      console.log(`[${graph.id ?? "root"}] node ${node.id}: ${node.type}`)
    })
  }
})

Root vs Active Graph

You want to…Use
Operate on all nodes in the workflowapp.graph (root)
Operate on only the visible layerapp.canvas?.graph
Access a specific subgraphsomeNode.subgraph
// All nodes (including nested subgraphs)
walkGraph(app.graph, (node) => { /* ... */ })

// Only nodes the user currently sees
for (const node of app.canvas?.graph?.nodes ?? []) { /* ... */ }

Events

Subgraph-level events

Dispatched on subgraph.events:
EventPayloadWhen
widget-promoted{ widget, subgraphNode }Widget promoted to parent node
widget-demoted{ widget, subgraphNode }Widget removed from parent node
input-added{ input }Input slot added
removing-input{ input, index }Input slot being removed
output-added{ output }Output slot added
removing-output{ output, index }Output slot being removed
renaming-input{ input, index, oldName, newName }Input slot renamed
renaming-output{ output, index, oldName, newName }Output slot renamed

Canvas-level events

Dispatched on app.canvas.canvas (the HTML canvas element):
EventPayloadWhen
subgraph-opened{ subgraph, closingGraph, fromNode }User navigates into a subgraph
subgraph-converted{ subgraphNode }Selection converted to a subgraph

Listening pattern

import { app } from "../../scripts/app.js"

app.registerExtension({
  name: "MyExtension.SubgraphEvents",
  async setup() {
    const controller = new AbortController()
    const { signal } = controller

    app.canvas.canvas.addEventListener("subgraph-opened", (e) => {
      const { subgraph, fromNode } = e.detail
      console.log(`Opened subgraph from node ${fromNode.id}`)
    }, { signal })

    // To tear down later:
    // controller.abort()
  }
})

Widget Promotion

When a SubgraphInput connects to a widget inside a subgraph, a copy of that widget appears on the parent subgraph node. This fires widget-promoted. Removing the connection fires widget-demoted.
import { app } from "../../scripts/app.js"

function walkGraph(graph, visit) {
  for (const node of graph.nodes) {
    visit(node, graph)
    if (node.subgraph) walkGraph(node.subgraph, visit)
  }
}

app.registerExtension({
  name: "MyExtension.WidgetPromotion",
  async afterConfigureGraph() {
    walkGraph(app.graph, (node) => {
      if (!node.subgraph) return
      const controller = new AbortController()
      const { signal } = controller

      node.subgraph.events.addEventListener("widget-promoted", (e) => {
        console.log(`Widget "${e.detail.widget.name}" promoted`)
      }, { signal })

      node.subgraph.events.addEventListener("widget-demoted", (e) => {
        console.log(`Widget "${e.detail.widget.name}" demoted`)
      }, { signal })

      const origRemoved = node.onRemoved
      node.onRemoved = function () {
        controller.abort()
        origRemoved?.apply(this, arguments)
      }
    })
  }
})

Cleanup

Use an AbortController to clean up all event listeners when a node is removed.
import { app } from "../../scripts/app.js"

app.registerExtension({
  name: "MyExtension.Cleanup",
  async nodeCreated(node) {
    if (!node.subgraph) return

    const controller = new AbortController()
    const { signal } = controller

    node.subgraph.events.addEventListener("input-added", (e) => {
      console.log(`Input added: ${e.detail.input.name}`)
    }, { signal })

    node.subgraph.events.addEventListener("removing-input", (e) => {
      console.log(`Input removing: ${e.detail.input.name}`)
    }, { signal })

    const origRemoved = node.onRemoved
    node.onRemoved = function () {
      controller.abort()
      origRemoved?.apply(this, arguments)
    }
  }
})

Known Limitations

  • Nodes cannot discover their own execution or locator ID from within a subgraph (#8137)
  • Widget promotion behavior is still evolving and may change
  • Linked (multi-instance) subgraph editing has known issues (#6639)

See Also