ComfyUI v2 Frontend Extension API
The v2 extension API is the published npm package @comfyorg/extension-api. It
replaces the legacy app.registerExtension() / nodeType.prototype monkey-patching
model with a typed, tree-shakeable, import-based API.
If you are converting an existing v1 extension, read
references/migrate-v1-to-v2.mdfor a pattern-by-pattern mapping.
Mental model
| v1 (legacy) | v2 (@comfyorg/extension-api) |
|---|---|
One giant app.registerExtension({...}) call | One defineX per concern, each independently disposable |
window.app / app.* globals | Direct import from the package; no window.app at module-eval time |
nodeType.prototype.onExecuted = ... patching | node.on('executed', fn) on a NodeHandle |
Mutate widget.value, assign widget.callback | widget.setValue(v) / widget.on('valueChange', fn) |
api.addEventListener('execution_start', fn) | execution.on('start', fn) (typed namespaces) |
Manual removeEventListener bookkeeping | Every subscription returns Unsubscribe; every defineX returns DisposableHandle |
Core principles baked into the API:
- Import, don't reach for globals.
import { defineNode } from '@comfyorg/extension-api'. Nowindow.appdependency at module evaluation time. - Read via getters, write via command-dispatch setters.
getValue()reads;setValue()dispatches an undo-able, serializable command. Read-only invariants (set at construction) arereadonlyaccessors (node.type,widget.name). - Observe via typed
on(...)subscriptions. Each returns anUnsubscribecleanup function. No Vue refs/signals are ever exposed; Vue reactivity is the internal engine only. - Everything is disposable. Every
defineXreturns aDisposableHandlewith an idempotent, synchronousdispose().
Registration entry points
All imported from @comfyorg/extension-api:
| Function | Purpose | Returns |
|---|---|---|
defineNode(opts) | Primary entry — react to node lifecycle (replaces prototype patching) | NodeExtensionOptions |
defineExtension(opts) | App-scoped lifecycle (init/setup) + shell UI host | ExtensionOptions |
defineWidget(opts) | Register a custom widget type (DOM via mount) | WidgetExtensionOptions |
defineSidebarTab(opts) | Add a left-sidebar tab (Vue or custom) | DisposableHandle |
defineBottomPanelTab(opts) | Add a bottom-panel tab | DisposableHandle |
defineToolbarButton(opts) | Add an action-bar button | DisposableHandle |
defineCommand(opts) | Register an invokable command | DisposableHandle |
defineHotkey(opts) | Bind a key combo to a command id | DisposableHandle |
defineSetting(opts) | Add a settings-menu entry | DisposableHandle |
defineAboutBadge(opts) | Add a badge to the About page | DisposableHandle |
Imperative carve-outs (fire-and-forget, not defineX, no handle): toast, notify.
A single extension file typically exports a default defineExtension/defineNode
result and calls the shell-UI defineX functions inside setup() or at module scope.
They queue safely before the app boots.
defineNode — the primary entry point
Reacts to node lifecycle. nodeCreated fires once per node instance (typed in, pasted,
duplicated, or loaded without an existing workflow). loadedGraphNode fires once when a
node is restored from a saved workflow (widget values already populated). Exactly one of
them fires per node entity, never both.
tsimport { defineNode, onNodeMounted, onNodeRemoved } from '@comfyorg/extension-api' export default defineNode({ name: 'my-org.executed-logger', // Filter to specific comfyClass names. Omit to receive every node type. nodeTypes: ['KSampler', 'KSamplerAdvanced'], // MUST be synchronous. Runs inside a Vue EffectScope; everything registered // here (subscriptions, onNodeMounted) auto-disposes when the node is removed. nodeCreated(node) { // Read-only invariants console.log(node.type, node.comfyClass, node.id) // Subscribe to backend execution completion (replaces onExecuted patching) node.on('executed', (e) => { console.log('output:', e.output) // Record<string, unknown> }) // Lifecycle hooks — call SYNCHRONOUSLY (never after an await) onNodeMounted(() => { // Node fully mounted; DOM/canvas ready. }) onNodeRemoved(() => { // Cleanup: abort fetches, close sockets. Does NOT fire on subgraph promotion. }) }, loadedGraphNode(node) { // Node restored from a saved workflow; widget values are already set. } })
NodeHandle surface (Phase A)
| Member | Kind | Notes |
|---|---|---|
id: string | readonly | Opaque token. Compare with node.equals(other), never by slicing. |
equals(other) | method | Canonical identity comparison. |
type: string | readonly | LiteGraph node type. |
comfyClass: string | readonly | Backend class name. |
getProperty<T>(key) / getProperties() / setProperty(key, v) | methods | Per-instance props (migration shim — prefer widget values). |
getInputs() / getOutputs() | methods | ReadonlyArray<Readonly<SlotInfo>> — frozen views. |
on('executed', fn) | method | Execution complete → NodeExecutedEvent { output }. |
on('removed', fn) | method | Node deleted (not subgraph promotion). |
on('configured', fn) | method | Loaded from saved workflow (after widget values restored). |
on('beforeSerialize', fn) | method | Deprecated — use widget-level beforeSerialize (ADR-0010). |
Position/size/title/mode getters and slot/connection events are deferred in Phase A. Do not rely on
getPosition,setSize,getMode,on('connected').Nodes cannot enumerate or reference their widgets (
node.getWidget(name)was removed). UsedefineWidgetand themountcontext'sctx.widgethandle.
defineExtension — app lifecycle + shell UI
Use for app-wide setup and to host shell-UI registrations. setup() runs at the
early registration point; use the imported onMounted hook for work that needs the
app fully initialized.
tsimport { defineExtension, onMounted, onUnmounted, execution, toast } from '@comfyorg/extension-api' export default defineExtension({ name: 'my-org.my-extension', setup() { // Register late-lifecycle work via onMounted (called synchronously here). onMounted(() => { const off = execution.on('start', () => { toast.show({ severity: 'info', summary: 'Run started' }) }) onUnmounted(off) // tidy teardown }) } })
A note on the
setup()signature. The source-of-truth API uses implicit-context hooks: importonMounted/onNodeMounted/etc. and call them synchronously insidesetup()(mirrors Vue's Composition API). An early package draft showed asetup(ctx) { ctx.onNodeMounted(...) }context-argument style; prefer the imported-hook form above, which is what the current API exports.
Context-scoped lifecycle hooks are imported and called synchronously inside
defineExtension's setup(): onBeforeMount, onMounted, onUnmounted,
onActivated, onDeactivated. defineSidebarTab/defineBottomPanelTab take no
setup field; adding one is a type error. onActivated/onDeactivated fire
when the surrounding tab or panel is shown or hidden.
defineWidget — custom widget types with DOM
Widgets are declared in the Python node's INPUT_TYPES, never created at runtime
(node.addWidget is forbidden). defineWidget registers a type and the DOM mount
hook the runtime invokes against a host <div> it owns. mount is optional; omit it
for value-only widgets that render through the native renderer.
tsimport { defineWidget, type WidgetCleanup } from '@comfyorg/extension-api' export default defineWidget({ name: 'my-org.color-picker', type: 'COLOR_PICKER', // referenced from Python INPUT_TYPES // The SOLE DOM seam. Capture host + constructed DOM via closure — there is // no widget.element accessor. mount(host, ctx): WidgetCleanup { const input = document.createElement('input') input.type = 'color' input.value = String(ctx.widget.getValue() ?? '#000000') input.addEventListener('input', () => ctx.widget.setValue(input.value)) host.appendChild(input) // ctx.widget / ctx.node are the only legal handles here. ctx.widget.on('valueChange', (e) => { input.value = String(e.newValue ?? '#000000') }) // Optional cleanup — fires once on widget destruction (NOT on host remount). return () => input.remove() } })
WidgetMountContext also exposes onUnmount(fn), onBeforeRemount(fn), and
onAfterRemount(fn => ...) for host-move scenarios (graph↔app mode, subgraph
promotion). The mount body is not re-invoked across a remount; only the
remount hooks fire.
WidgetHandle surface
| Member | Kind | Notes |
|---|---|---|
id / equals(other) | readonly / method | Opaque identity. |
name / widgetType / label | readonly | Set from INPUT_TYPES schema. |
getValue<T>() / setValue(v) | methods | setValue dispatches an undo-able command. |
options | readonly | Readonly<WidgetOptions> snapshot. Writes raise TS errors. |
getOption<K>(key) / setOption(key, v) | methods | Per-instance overrides (e.g. min/max/step). |
setHeight(px) | method | Resize the reserved host height (DOM widgets). |
on('valueChange', fn) | method | WidgetValueChangeEvent { oldValue, newValue }. |
on('optionChange', fn) | method | WidgetOptionChangeEvent { key, oldValue, newValue }. |
on('beforeSerialize', fn) | method | Only async-allowed event. e.value + e.setSerializedValue(v). |
on('beforeQueue', fn) | method | Pre-queue validation. Call e.reject(msg) to cancel. |
ts// Serialization override (the SOLE serialization interface in v2): widget.on('beforeSerialize', (e) => { e.setSerializedValue(processDynamicPrompt(widget.getValue())) }) // Async serialization (e.g. capture a webcam frame before queueing): widget.on('beforeSerialize', async (e) => { e.setSerializedValue(await captureFrame()) }) // Pre-queue validation (replaces app.queuePrompt monkey-patching): widget.on('beforeQueue', (e) => { if (!widget.getValue()) e.reject('Prompt text is required before queueing.') })
Typed event namespaces
Four module-level singletons replace api.addEventListener('...'). Each on()
returns an Unsubscribe. Subscriptions made inside a setup() body auto-dispose
on unmount; subscriptions made elsewhere are the caller's responsibility.
| Namespace | Events (canonical) | Wire mapping |
|---|---|---|
execution | start, end, error, interrupted, cached, executing, progress, preview | execution_<evt> |
graph | changed, … | graph:<evt> |
server | status, logs, reconnected, feature_flags, assets, + custom-node events | raw event name |
workbench | notification, … | workbench:<evt> |
tsimport { execution, server } from '@comfyorg/extension-api' const off = execution.on('progress', (e) => console.log('progress', e)) // Custom-node events ride the `server` namespace with arbitrary names: server.on('my-org.my-node.update', (e) => console.log(e)) // later: off()
Payloads default to unknown today. Narrow them with TS module augmentation:
tsdeclare module '@comfyorg/extension-api' { interface ExecutionEventPayloads { start: { promptId: string } progress: { value: number; max: number } } interface ServerEventPayloads { 'my-org.my-node.update': { nodeId: string; text: string } } }
The augmentable interfaces are GraphEventPayloads, ExecutionEventPayloads,
ServerEventPayloads, and WorkbenchEventPayloads.
Shell UI registrations
Each returns a DisposableHandle. Safe to call at module scope (they queue until the
app boots) or inside setup().
tsimport { defineCommand, defineHotkey, defineToolbarButton, defineSetting, defineAboutBadge } from '@comfyorg/extension-api' // Command — id, function, optional label/icon/tooltip. const cmd = defineCommand({ id: 'my-org.do-the-thing', label: 'Do The Thing', function: () => { /* ... */ } }) // Hotkey — binds a key combo to an already-registered command id. // `mod` = cmd on macOS, ctrl elsewhere. defineHotkey({ keys: 'mod+shift+k', commandId: 'my-org.do-the-thing' }) // Action-bar button — id (for dispose), icon, onClick. defineToolbarButton({ id: 'my-org.help', icon: 'pi-question-circle', tooltip: 'Get help', onClick: () => openHelp() }) // Setting — widen the id when not augmenting the Settings keymap. defineSetting({ id: 'my-org.enabled' as never, name: 'Enable my extension', type: 'boolean', defaultValue: false }) // About-page badge. defineAboutBadge({ label: 'GitHub', url: 'https://github.com/me/my-ext', icon: 'pi-github' }) // Tear down any registration: cmd.dispose() // idempotent + synchronous
CommandDefinition fields: id (required), function: (metadata?) => void | Promise<void> (required), optional label / icon / tooltip (each string | (() => string)), menubarLabel, versionAdded.
defineSidebarTab — embedded panels (e.g. a chat panel)
A sidebar tab hosts a rich embedded UI such as a chat panel. It comes in two flavors:
type: 'vue' (mount a Vue component) or type: 'custom' (imperative render(container) /
destroy()). Both share the base fields id, title, optional icon, iconBadge,
tooltip, label.
Vue component tab
tsimport { defineSidebarTab } from '@comfyorg/extension-api' import ChatPanel from './ChatPanel.vue' const chatTab = defineSidebarTab({ id: 'my-org.chat', title: 'Chat', type: 'vue', icon: 'pi-comments', component: ChatPanel }) // chatTab.dispose() removes the tab.
Custom (framework-free) chat panel
When you don't want a Vue dependency, use type: 'custom' and build the DOM yourself.
render receives the container; destroy is your teardown.
tsimport { defineExtension, defineSidebarTab, execution, server, type Unsubscribe } from '@comfyorg/extension-api' export default defineExtension({ name: 'my-org.chat-panel', setup() { const subscriptions: Unsubscribe[] = [] defineSidebarTab({ id: 'my-org.chat', title: 'Chat', type: 'custom', icon: 'pi-comments', render(container: HTMLElement) { const log = document.createElement('div') log.className = 'chat-log' const form = document.createElement('form') const input = document.createElement('input') input.placeholder = 'Ask something…' const send = document.createElement('button') send.type = 'submit' send.textContent = 'Send' form.append(input, send) const append = (who: string, text: string) => { const line = document.createElement('p') line.textContent = `${who}: ${text}` log.appendChild(line) log.scrollTop = log.scrollHeight } form.addEventListener('submit', (ev) => { ev.preventDefault() const text = input.value.trim() if (!text) return append('You', text) input.value = '' // Forward to a backend node/server event, stream the reply, etc. }) container.append(log, form) // Stream backend replies via the server namespace (custom-node event). subscriptions.push( server.on('my-org.chat.reply', (e) => append('Assistant', String(e))) ) // React to runs to show status in the panel. subscriptions.push( execution.on('start', () => append('System', 'Run started…')) ) }, destroy() { for (const off of subscriptions) off() subscriptions.length = 0 } }) } })
defineBottomPanelTab has the same vue / custom shapes (base fields id, optional
title/titleKey, optional targetPanel: 'terminal' | 'shortcuts').
Toasts
toast and notify are inline imperative (no defineX, no handle). Call from any
setup() body or hook closure.
tsimport { toast } from '@comfyorg/extension-api' toast.show({ severity: 'error', summary: 'Workflow failed', detail: err.message, life: 4000 }) toast.removeAll()
notify({ kind, message, detail, life }) is a deprecated 1:1 wrapper over toast.show.
Prefer toast.show directly.
Node identity helpers
For referencing nodes across subgraph boundaries or execution runs, use the branded identity types rather than raw integer node IDs:
tsimport { createNodeLocatorId, parseNodeLocatorId, isNodeLocatorId, createNodeExecutionId, parseNodeExecutionId, isNodeExecutionId, type NodeLocatorId, type NodeExecutionId } from '@comfyorg/extension-api' const locator: NodeLocatorId = createNodeLocatorId(subgraphUuid, localNodeId) // NodeExecutionId encodes a node's path through nested subgraphs as an array of node ids // (joined with ':'). Pass the array, not positional args: const execId: NodeExecutionId = createNodeExecutionId([localNodeId]) if (isNodeLocatorId(maybe)) { // parseNodeLocatorId returns { subgraphUuid: string | null; localNodeId: NodeId } const { subgraphUuid, localNodeId } = parseNodeLocatorId(maybe) }
NodeLocatorId arrives from workflow JSON; NodeExecutionId arrives from websocket
frames. You receive these from event payloads, which is why they're public (unlike the
internal *EntityId brands, which are not exported).
Disposal contract
Every defineX returns DisposableHandle { dispose(): void }:
- Idempotent. Calling
dispose()again is a safe no-op. - Synchronous. Teardown happens synchronously inside
dispose(). - Independent. Disposing handle A does not affect B or C. Sequence calls explicitly when teardown order matters (e.g. drop a hotkey before its command).
- Pre-mount safe. Disposing before the app boots removes the spec from the pending queue so it never mounts.
tsconst handles = [ defineCommand({ id: 'my.cmd', function: () => {} }), defineHotkey({ keys: 'mod+k', commandId: 'my.cmd' }), defineSidebarTab({ id: 'my.tab', title: 'Tab', type: 'vue', component: MyTab }) ] // Full teardown: for (const h of handles.reverse()) h.dispose()
Common mistakes
- Calling lifecycle hooks after
await.onNodeMounted/onMounted/onUnmountedrely on implicit scope context and must be called synchronously inside thesetup()/nodeCreatedbody. After anawaitthe scope is gone: it throws in dev and is a silent no-op in prod. Kick off async work in the body, but register hooks first. - Reaching for
window.apporapp.*. v2 has nowindow.appdependency at module-eval time. Import everything from@comfyorg/extension-api. - Patching
nodeType.prototype. Replaced bydefineNode+node.on(...). Prototype patching does not interoperate with the v2 handle model. - Mutating reads.
node.getInputs(),widget.options, andPoint/Sizetuples are frozen/Readonly; assignment raises TS errors. Use the setter methods (widget.setOption,widget.setValue). - Assigning
widget.value/widget.callback/widget.serializeValue. UsesetValue(),on('valueChange'), andon('beforeSerialize').serializeValueis read-only in v2. - Trying to disable widget serialization. There is no
serialize: falseand noskip()in v2. If a widget should not contribute to the payload, it should not be a widget. The only serialization interface iswidget.on('beforeSerialize', fn)+e.setSerializedValue(v). - Creating widgets at runtime.
node.addWidget(...)/node.addDOMWidget(...)are removed. Declare widgets in the PythonINPUT_TYPES; render custom DOM viadefineWidget({ mount }). - Enumerating widgets from a node.
node.getWidget(name)/node.getWidgets()were removed (nodes cannot reference widgets). Use adefineWidgetmount context'sctx.widget, or share state via theserverevent bus. - Using node-level
beforeSerialize. Deprecated (ADR-0010). Store extension state in a widget and use widget-levelbeforeSerialize. - Forgetting to dispose. Long-lived subscriptions made outside a
setup()context, and everydefineXhandle, leak unless you call the returnedUnsubscribe/dispose(). Insidesetup()they auto-dispose on unmount. - Relying on deferred Phase A exports. Position/size/title/mode getters and slot/connection events are not yet exported. Don't write code against them.
Sources
- Official: npm package
@comfyorg/extension-apiat https://www.npmjs.com/package/@comfyorg/extension-api - Empirical: none; the API was transcribed from the published package, not reverse-engineered from a working graph.


