Skip to main content
This guide answers “I used to do X through LiteGraph or app; how do I preserve that behavior through the published API?” It is based on the custom-node conversion corpus, including cases where the correct replacement was not the API with the most similar name. The published entry point is always:
Use this guide for live frontend behavior. If an object came from JSON.parse, a workflow file, graphToPrompt, or a backend response, it is data rather than a live node. Do not replace fields inside serialized data with handle methods.

Start with the behavior

Before choosing a replacement, state what the old code accomplishes for the user. One old mechanism often served several unrelated purposes: Do not migrate by spelling alone. Trace what the callback reads, what it writes, when it runs, and which saved or queued bytes it affects.

Quick lookup by legacy surface

This index gives the usual destination. Follow the detailed recipe whenever the row names more than one destination or changes saved workflow topology.

Registration and lifecycle

How do I replace app.registerExtension()?

Split the registration by behavior. The old extension object combined node definition hooks, commands, settings, custom widgets, and application lifecycle. The published API gives each one an explicit owner. Old:
Published API:
Definition callbacks compose. Do not capture and invoke a previous prototype method.

How do I run code after a node is restored?

Use onCreated for a live, addressable node and inspect its event:
restored covers nodes carrying saved state, including duplicate and paste. loading distinguishes a workflow load. Use onConfigured when the behavior needs the saved node record.

How do I keep private state on a node?

Do not assign custom fields to LGraphNode or NodeHandle. Keep pack state in a map and include graph scope in its key:

Nodes and graphs

How do I read or change node state?

Handles use methods so reads can resolve current store state and writes can use host mutation behavior. Assigning a new property to a closed handle is not a mutation API. Use the setter, or a pack-owned map when the value is not entity state.

How do I enumerate or find nodes?

These replace app.graph._nodes, app.graph.getNodeById(), canvas.selected_nodes, and renderer selection stores for node selection. Returned arrays are frozen snapshots. comfy.graph is the graph currently visible. For document-wide work, preserve scope rather than flattening IDs:

How do I add, clone, remove, or replace a node?

replace() carries compatible state and links and groups the operation into one undo step on the visible graph. A defensive write such as node.type = node.type ?? undefined has no user behavior and should be removed instead of converted.

How do I make several edits one undo step?

Replace graph.beforeChange() / graph.afterChange() pairs with a synchronous scope:
Do not hold a batch across await.

How do I respond to graph changes without polling?

Choose the narrowest semantic signal: Do not increment graph._version yourself. Published mutations and committed widget values advance host change state. Treat graph.version as an opaque token, not a counter or event log.

How do I accept a file dropped from the browser?

Register the behavior on the node definition instead of replacing renderer drop methods:
Returning true from onDragOver asks both renderers to present and route the drop. Returning true from onDrop claims it; handlers after the claimant do not run. The host gets the first opportunity, so this extends file handling rather than replacing behavior another pack or core already owns.

How do I inspect an input connection?

Use the physical source for graph editing and the resolved source for reasoning about execution.

How do I connect or disconnect slots?

These replace editing input.link, pushing into output.links, and mutating the graph’s link map. Normal compatibility checks and connection-veto hooks still run.

How do I add or remove dynamic inputs and outputs?

Use node.inputs.add/remove and node.outputs.add/remove instead of addInput, removeInput, addOutput, or array mutation. Removing a slot disconnects its links through the host.

How do I rename or retype a slot?

Use one atomic patch:
This replaces direct writes to slot.name, slot.type, slot.label, and renderer shape fields. Existing links remain attached.

How do I reorder slots safely?

The list must be a complete permutation. The host updates serialized endpoint indexes while preserving which logical slots the links reach.
Use this instead of disconnecting and reconnecting. It preserves link IDs, which is required for workflow wire compatibility.

How do I veto or observe a proposed connection?

Use onBeforeConnect only when the old hook can refuse:
If the old onConnectInput or onConnectOutput never returned false, it was an observer. Use onConnectionsChanged instead.

How do I preserve widget-backed input constraints?

A socket converted from a widget carries the declaration a connected Primitive node should render. Read or intersect that declaration through the input handle:
mergeWidgetConfig() updates the declaration only when the two widget types are compatible. It returns undefined and leaves the input unchanged when they are not. For a dynamic socket that represents one of the node’s widgets, pass both widget and widgetConfig to inputs.add(), or apply them together with input.modify(). The widget name must already exist on that node. Use onUnplacedLink when the node understands a bundle or pipe and can expand one gesture into its own connections:
The callback must make the connections through slot handles and return true only when it placed the link. Both ends can be offered the gesture and the first claimant wins. This replaces a global connectByType patch without changing link-routing behavior for every other node in the document.

Widgets

How do I read and write a widget value?

setValue() replaces both a bare widget.value = value and the common pair widget.value = value; widget.callback?.(value). It uses the host’s commit protocol: property synchronization, host callback behavior, node change behavior, one change event, and graph change tracking. An equal value is a no-op. A programmatic write does not fire activate.

How do I replace a chained widget.callback?

Observe values additively:
For an action widget such as a button:
Do not capture, replace, or call through another callback. If old code invoked its own handler once to initialize state, call that handler directly after subscribing.

How do I hide a converted widget?

This replaces widget.type = 'converted-widget' and the bookkeeping that restored the old type and size function. Hiding and serialization are separate: setHidden() retains the widget’s existing saved and prompt behavior. Audit any old serializeValue override separately.

How do I add, remove, or reorder widgets?

reorder() requires every current widget name exactly once. It cannot silently drop widgets or bypass teardown.

How do I change widget options?

getOptions() returns a frozen view. Do not mutate it or remove and reinsert a widget to make the renderer notice changes.

How do I replace addDOMWidget() or widget.inputEl?

For a pack-owned control on one node, mount into a host-owned container:
The host controls mounting, zoom visibility, and removal. Clean up listeners, timers, and observers in destroy(). Keep one mount per serialized legacy widget cell unless you have proved that consolidating them preserves widgets_values. For behavior on a host-owned multiline editor, do not mount a duplicate merely to access its element. Subscribe to textInteraction:

How do I replace getCustomWidgets for a backend input type?

Register the renderer for that type before nodes are constructed:
Do not add a second mounted widget for the same input. A type renderer preserves the original positional widgets_values cell and keeps the input a widget instead of turning it into a socket.

How do I replace widget.serializeValue?

Classify the behavior:
The three contexts are workflow, prompt, and embedded. The handler is synchronous and changes only that write, not the live value.

How do I migrate a name-keyed widgets_values override?

Delete the live override. Runtime widget identity is already the widget name:
The positional array remains the workflow wire format and is host-owned. Do not replace it with a record, rewrite it during serialization, or mirror live values into it. Keep compatibility with workflows the old pack already saved. The host applies normal positional values before onConfigured, and that hook also receives the original saved node data. Read the pack’s old record there, translate renamed or retired keys, and commit the recovered values through named widget handles. That migration is user-data compatibility; the live serialization override is not.

How do I migrate a custom upload widget?

Inspect the Python input declaration first. If its options already include image_upload, animated_image_upload, video_upload, or audio_upload, the host supplies the chooser, upload, value commit, and preview. Remove the duplicate frontend widget. Do not replace it with widgets.mount() merely because the original used DOM. An extra widget adds a positional widgets_values cell and can shift every later value. Mount only when the pack provides behavior beyond the declared upload contract, and preserve the original serialized-cell count.

Drawing, geometry, and editor interaction

How do I replace an onDraw* callback?

Inspect its body first.

It draws a small status label

Use a badge when the content belongs in node chrome rather than in an interactive widget:
The function is evaluated while drawing, so it must return quickly. Keep the returned removal function when the badge has a shorter lifetime than the node.

It actually draws

Move the drawing and hit testing to a pack-owned canvas widget:
This renders under both the legacy renderer and Nodes 2.0. Use the supplied theme instead of LiteGraph color constants.

It enforces size

Declare the constraint once:

It polls for state

Move the body to the event that changes the state: widget.on('change'), onPropertyChanged, onConnectionsChanged, onNodeChanged, or onWorkflowLoaded.

It positions DOM over the graph

Prefer a mounted widget. If the UI must remain outside the node, read node.getScreenRect() and update it from comfy.onViewportChanged().

How do I replace renderer geometry constants?

Use the answer to the operation instead of publishing another renderer constant.

How do I request a repaint?

Do not port canvas.setDirty() or node.setDirtyCanvas(). Handle mutations invalidate their own host views. For external data behind a canvas widget, call that surface’s redraw().

How do I rebuild a node-drag editing gesture?

Observe semantic movement rather than document pointer events and canvas drag state:
onNodeMoved works under both renderers. It reports movement, not proof that a person caused it, so guard mutations made by the gesture against re-entry. onNodeDragEnd is Nodes 2.0 only because the legacy renderer has no published drag-completion lifecycle. If the action must work under both renderers, design an explicit command or button rather than pretending release is observable. Use comfy.isInteracting() when the old code read several renderer flags only to ask whether the editor was already in the middle of any gesture.

How do I add a node context-menu item?

This replaces getExtraMenuOptions and node-menu prototype patches. Entries from multiple packs compose. Use when, a dynamic label, or items for a submenu rather than constructing LiteGraph.ContextMenu.

How do I open a menu from my own button or surface?

The triggering MouseEvent gives the host an anchor. This does not add a new hook to a host-owned canvas or slot menu; use it only when the pack owns the gesture that asks for the menu.

How do I replace DOM insertion into ComfyUI chrome?

Contributions are declarative so host layout, theme, accessibility, and lifecycle remain host-owned.

How do I replace extension settings?

Use settings.onChange() instead of polling, including for a core setting the pack does not own. IDs must be namespaced.

How do I set a temporary graph background?

If the old code replaced the canvas background renderer only to show an image, write the core setting instead:
The host owns loading and drawing the image under both renderers. Preserve and restore the user’s previous value when the pack’s temporary mode ends.

How do I use host palette colors?

Resolve design tokens at the point of use:
Use setTypeColor(type, color) only for a link type the pack owns; it refuses core-owned types and returns an unsubscribe that restores the prior mapping. nodeColor() returns the title, body, and group fill colors behind a palette name. Do not cache or copy the host’s internal color tables.

Execution, previews, and backend services

How do I replace api.queuePrompt() wrappers?

Do not rebuild a prompt wrapper when only one stage is needed.

How do I read or change auto-queue and batch settings?

Use the queue service rather than queue stores or settings IDs:
The modes are disabled, change, and instant. Call disableAutoQueue() before a self-interrupting conditional workflow so the automatic runner does not immediately submit it again. It does not cancel the run already in progress.

How do I define a frontend-only or virtual node?

Replace extends LGraphNode, isVirtualNode, and prompt mutation with plain data and pure resolution:
A resolver may omit, forward one of its own inputs, or return a literal. It is synchronous and cannot mutate the graph or prompt.

How do I implement “use this value everywhere” behavior?

Use a supplier rather than scanning and editing the built prompt:
Supply resolution is graph-local and does not cross subgraph boundaries. graph.resolvedSupplies() exposes the winning edges when a command needs to materialize the same choices as real links.

How do I correlate execution or preview events with a node?

This replaces global b_preview, b_preview_with_metadata, and module-level “currently executing ID” correlation for a node’s own frames. For global execution UI, use comfy.executingNode(), comfy.executionNode(id), and comfy.onExecutingNodeChanged().

How do I read images produced by another node?

Definition callbacks are intentionally correlated only with the node type they extend. For a command, panel, or overlay that inspects an arbitrary node, read that node’s output state directly:
The image list is a frozen URL snapshot. The displayed index is the image the user selected or is hovering, and can be undefined when no image is singled out.

How do I replace a graphToPrompt wrapper used for a sidecar cache?

Trace the Python first. A wrapper that only chose a cache filename does not require access to the built prompt:
  1. Use the pack’s authenticated route through comfy.backend.fetch() to read or refresh the sidecar.
  2. If the backend needs connected tensors, run that node and its dependencies with comfy.queue.run({ nodes: [node] }).
  3. Refresh correlated state from the definition’s onExecuted callback and comfy.backend.on('execution_cached', ...) when the cached-result path also matters.
  4. Use a backend hidden UNIQUE_ID input when simultaneous node instances need distinct identity.
A hidden string whose default is the same for every node is not a frontend API gap; it is insufficient backend identity. State the simultaneous-node limitation instead of restoring prompt mutation.

How do I call my Python routes or receive custom messages?

Use backend.fetch() rather than plain fetch(backend.url(...)) when the request needs host credentials. Use new URL('./asset.css', import.meta.url) for a file shipped beside the current module.

How do I load a workflow or store pack documents?

workflow.open() replaces app.loadGraphData() for an explicit user action. comfy.storage replaces direct user-data APIs or localStorage for named server-side presets, prompts, and templates.

How do I apply ComfyUI filename and workflow tokens?

This uses the active root graph and the same token language as core. It throws when no graph is active. Do not copy the token parser or read workflow widgets through renderer objects.

Things not to translate

Some old code should disappear rather than acquire a new spelling: Other mechanisms have no sanctioned equivalent and must not be recreated with an internal escape hatch:
  • patching LGraph, LGraphNode, LGraphCanvas, renderer, or widget prototypes;
  • mutating the built prompt or core workflow snapshot;
  • selecting and restyling host-owned DOM with global selectors;
  • publishing or consuming live internal stores and mutable link records;
  • making a supplier or resolver cross a subgraph boundary;
  • using a per-frame callback as a general tick.
If a user capability remains after checking the supported alternatives, name that capability precisely as an API gap. Do not invent a plausible member or reintroduce the old object.

How do I document a refusal?

A refusal is an architectural decision, not shorthand for “the conversion was hard” or “I did not find a method.” It must answer why the old mechanism or requested capability is outside the published contract. Every refusal record must include:
  1. User behavior: what the feature does from the user’s point of view.
  2. Mechanism: the exact live object, prototype, store, DOM, prompt, or renderer operation the original used.
  3. Reason: which ownership, determinism, wire-format, renderer-independence, scope, or lifecycle guarantee that mechanism violates.
  4. Outcome: the supported replacement and remaining loss. State explicitly when the loss is nothing.
  5. Boundary: the precise published capability or policy change that would make the refused remainder supportable, or why it must remain host-owned.
Keep those facts in one adjacent comment block. Use the existing terminal markers for the outcome rather than inventing another marker:
When behavior is genuinely lost, name the loss rather than softening it:
These are not valid reasons:
  • “not supported”;
  • “no API”;
  • “renderer internals are unavailable”;
  • “cannot be converted”;
  • a list of removed property names without the behavior they implemented.
Those statements describe absence or effort, not a decision. If the behavior is acceptable but the public surface is merely missing, classify it as an API gap. If another published mechanism preserves it, convert through that mechanism and record the refused technique only as localized rationale.

Migration safety checklist

  • Trace every inputs, outputs, links, widgets, and properties value to determine whether it is live state or serialized data.
  • State the user-visible behavior before selecting a replacement.
  • Preserve workflow and prompt wire format, including positional widget cells and link IDs.
  • Use the narrowest semantic event instead of polling or draw-time work.
  • Keep frontend resolvers and suppliers pure and graph-scoped.
  • Use methods for handle reads and writes; do not attach arbitrary properties.
  • Probe optional behavior with comfy.supports().
  • Verify both behavioral equivalence and the specific failure the migration is intended to fix.
For the detailed contracts behind these recipes, continue with Nodes, Graphs, Slots, Widgets, Execution, and Application services.