# ComfyUI node graphs in ten minutes Read this first if you have not converted a pack before. It exists because the alternative is inferring the model from the pack's source, which is slow and tends to produce a plausible but wrong mental picture. ## What the thing is ComfyUI is a **node graph editor whose graph is a program**. The user wires nodes together; the frontend compiles that wiring into a JSON payload; a Python backend executes it and streams results back. ``` user edits graph → graphToPrompt() → POST /prompt → backend executes ▲ │ └────────────── node.onExecuted(message) ◀───────────────┘ ``` Two artifacts come out of the same graph, and confusing them is the single most common conversion error: | | **Workflow** | **Prompt** | | ------------------- | ---------------------------------------------------------- | -------------------------- | | Purpose | what the user saves and reloads | what the backend runs | | Contains | positions, colours, titles, collapsed state, widget values | node inputs and links only | | Produced by | `graph.serialize()` | `graphToPrompt()` | | Frontend-only nodes | present | must be resolved away | **Both must come out byte-identical after a conversion.** That is the hard constraint: a user's saved file and the job they queue cannot change. ## What a node actually is A node exists in two halves. **The backend half** is a Python class. It declares its inputs, outputs and category, and the server sends that declaration to the frontend as a **node definition** — `nodeData` in the old hook. This is a _description_, not a node. **The frontend half** is a JavaScript class generated _from_ that definition, registered by type name. Every node the user drops on the canvas is an instance of it. ``` node definition (from backend) → generated class → instances on the canvas "KSampler", inputs, outputs KSampler node #7, node #12 ``` So there are three distinct things, and packs act on all three: - **the definition** — before any class exists - **the class** — affects every instance of that type - **the instance** — one node on one canvas A conversion that moves code between these levels changes behaviour. Watch for it: `nodeData.name` is a _definition_; `this` inside `onNodeCreated` is an _instance_. ## The lifecycle Everything a pack hooks hangs off this sequence: 1. **Definitions arrive** from the backend. 2. **`beforeRegisterNodeDef`** — for each definition, every extension gets a chance to modify the definition and patch the about-to-be-registered class. _This is where nearly half of all packs do their work._ 3. **`registerNodeType`** puts the class in the registry under its type name. 4. **Instance created** — user drops a node, or a workflow loads one. `onNodeCreated` fires (before the node has an id or a graph). 5. **`onAdded`** — the node joins the graph. Now it has an id and is addressable. _This is where the published `onCreated` fires, deliberately._ 6. **`onConfigure`** — only for nodes loaded from a saved workflow; restores widget values and any pack-specific state. 7. **`onExecuted(message)`** — backend produced output for this node. 8. **`onRemoved`** — node deleted. ## Inputs, outputs, widgets - **Inputs / outputs** are sockets. Links connect an output to an input. - **Widgets** are the controls drawn _on_ the node — a seed number, a sampler dropdown, a text box. A widget holds a value that becomes an input at execution time. The wrinkle: a widget can be **promoted to a socket** so another node can drive it. Historically the frontend faked this by setting `widget.type = 'converted-widget'` and stashing the old type — a hack that packs learned to detect and imitate. It is now a real property. When you see `'converted-widget'`, the pack is almost always trying to _hide_ a widget, not change its kind. `widgets_values` in a saved workflow is a **positional array** — index matters, names are not stored. This is why widget order and count are part of the wire format, and why removing a widget is not a cosmetic change. ## Why packs customise the frontend at all Packs are not being gratuitous. There are a handful of recurring motives, and recognising which one you are looking at usually tells you the replacement. Counts are sites across the ~5,000-pack registry corpus; a pack often has several motives at once. | Motive | What it looks like | Scale | | --------------------------------------------------------------- | ---------------------------------------------------- | ------------------- | | **Show backend output on the node** — text, previews, progress | patch `onExecuted`, create a display widget | 497 packs | | **Set up per-instance state** — dynamic inputs, defaults, DOM | patch `onNodeCreated` | 943 packs | | **Restore that state on load** | patch `onConfigure` | 429 packs | | **React to wiring** — add a slot when the last one fills | patch `onConnectionsChange` | 223 packs | | **Draw on the node** — badges, overlays, custom controls | patch `onDrawForeground` | 199 packs | | **Define frontend-only nodes** — reroutes, switches, note nodes | `registerCustomNodes`, `isVirtualNode` | 86 packs | | **Change what gets saved or queued** | patch `serialize`, `serializeValue`, `graphToPrompt` | fewer, highest risk | The last row is where conversions do damage, because it is the row that touches the wire format. ## Why any of this needs converting Almost all of the above is done by **monkey-patching the generated class's prototype**: ```js const original = nodeType.prototype.onExecuted nodeType.prototype.onExecuted = function (message) { original?.apply(this, arguments) // ← if you forget this, you break other packs myBehaviour.call(this, message) } ``` Three things are wrong with this, and they motivate the whole published API: 1. **It reaches into internals.** `nodeType.prototype`, `node.widgets`, `link.origin_id` are implementation, and they are being reshaped. 2. **It does not compose.** Whether your handler survives depends on every other pack remembering to call through. One that forgets silently disables yours, and load order decides who wins. 3. **It cannot be undone.** There is no unpatch, so nothing can be torn down. The published API replaces each of these with something registered rather than patched: handlers are additive, ordered, and individually removable, and the entity classes stay closed. ## The mental model to convert with > A pack **declares** what it wants for **which node types**, and **reacts** to > a small set of lifecycle events using **handles** that expose behaviour but > not internals. Concretely, the shape of nearly every conversion is: ```js // before: run for every type, filter, patch the prototype app.registerExtension({ name: 'x', beforeRegisterNodeDef(nodeType, nodeData) { if (nodeData.name !== 'MyNode') return // ← the selector const orig = nodeType.prototype.onExecuted // ← the chaining nodeType.prototype.onExecuted = function (m) { orig?.apply(this, arguments) populate.call(this, m.text) // ← the actual behaviour } } }) // after: the selector is declared, the chaining disappears, behaviour is unchanged comfy.defs.extend('MyNode', (b) => { b.onExecuted((node, result) => populate(node, result.text)) }) ``` The guard clause becomes the selector. The capture-and-chain boilerplate goes away entirely. What is left is the behaviour, which you should be changing as little as possible. ## Things that will mislead you - **`this` is not always a node.** Inside a patched prototype method it is an instance; inside `beforeRegisterNodeDef` it is not. - **`nodeData` is a definition, not a node.** It has no widgets and no id. - **A widget named the same is not the same widget.** Packs remove and recreate readout widgets on every execution. - **`app` is the whole application.** A pack reaching for `app.graph` usually wants its own node's graph, and often only needs the node. - **Absence of a hook means nothing.** Plenty of packs put their logic in a module-level side effect that runs at import.