Authoring ComfyUI v2 frontend extensions
ComfyUI v2 Frontend Extension API

Authoring ComfyUI v2 frontend extensions with @comfyorg/extension-api, covering defineNode/defineExtension/defineWidget, shell UI (sidebar tabs, commands, hotkeys), typed events, and handles. Use when writing or editing ComfyUI web-UI…
--- name: comfyui-frontend-extensions description: Authoring ComfyUI v2 frontend extensions with @comfyorg/extension-api, covering defineNode/defineExtension/defineWidget, shell UI (sidebar tabs, commands, hotkeys), typed events, and handles. Use when writing or editing ComfyUI web-UI extension code (custom node JS, sidebar panels, widgets). --- # 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.md`](references/migrate-v1-to-v2.md) for a > pattern-by-pattern mapping. ## Mental model | v1 (legacy) | v2 (`@comfyorg/extension-api`) |
Overview
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.
Manual install (npx, ZIP)
npx skills add artokun/comfyui-mcp --skill comfyui-frontend-extensions -g -a claude-codemkdir -p ~/.claude/skills && curl -sL https://codeload.github.com/artokun/comfyui-mcp/tar.gz/1d5b12b1b499 \
| tar -xz -C ~/.claude/skills --strip-components=3 comfyui-mcp-1d5b12b1b499/plugin/skills/comfyui-frontend-extensionsNew-Item -ItemType Directory -Force "$HOME\.claude\skills" | Out-Null; Invoke-WebRequest "https://m.agentalley.io/zip/21c36c9bde35127ba559/comfyui-frontend-extensions.zip" -OutFile "$env:TEMP\comfyui-frontend-extensions.zip"; Expand-Archive "$env:TEMP\comfyui-frontend-extensions.zip" -DestinationPath "$HOME\.claude\skills" -ForceConnects with Agent Alley MCP · agentalley.io/mcp
Files included
SKILL.md21.0 KBreferences/1 file · 11.4 KB
Read the files here
Show the full text504 lines
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.
import { 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.
import {
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.
import { 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. |
// 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> |
import { 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:
declare 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().
import {
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
import { 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.
import {
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.
import { 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:
import {
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.
const 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.
| 1 | |
| 2 | name comfyui-frontend-extensions |
| 3 | description Authoring ComfyUI v2 frontend extensions with @comfyorg/extension-api, covering defineNode/defineExtension/defineWidget, shell UI (sidebar tabs, commands, hotkeys), typed events, and handles. Use when writing or editing ComfyUI web-UI extension code (custom node JS, sidebar panels, widgets). |
| 4 | |
| 5 | |
| 6 | # ComfyUI v2 Frontend Extension API |
| 7 | |
| 8 | The v2 extension API is the published npm package `@comfyorg/extension-api`. It |
| 9 | replaces the legacy `app.registerExtension()` / `nodeType.prototype` monkey-patching |
| 10 | model with a typed, tree-shakeable, import-based API. |
| 11 | |
| 12 | > If you are converting an existing v1 extension, read |
| 13 | > [`references/migrate-v1-to-v2.md`] for a |
| 14 | > pattern-by-pattern mapping. |
| 15 | |
| 16 | ## Mental model |
| 17 | |
| 18 | | v1 (legacy) | v2 (`@comfyorg/extension-api`) | |
| 19 | |-------------|-------------------------------| |
| 20 | | One giant `app.registerExtension({...})` call | One `defineX` per concern, each independently disposable | |
| 21 | | `window.app` / `app.*` globals | Direct `import` from the package; no `window.app` at module-eval time | |
| 22 | | `nodeType.prototype.onExecuted = ...` patching | `node.on('executed', fn)` on a `NodeHandle` | |
| 23 | | Mutate `widget.value`, assign `widget.callback` | `widget.setValue(v)` / `widget.on('valueChange', fn)` | |
| 24 | | `api.addEventListener('execution_start', fn)` | `execution.on('start', fn)` (typed namespaces) | |
| 25 | | Manual `removeEventListener` bookkeeping | Every subscription returns `Unsubscribe`; every `defineX` returns `DisposableHandle` | |
| 26 | |
| 27 | Core principles baked into the API: |
| 28 | |
| 29 | **Import, don't reach for globals.** `import { defineNode } from '@comfyorg/extension-api'`. No `window.app` dependency at module evaluation time. |
| 30 | **Read via getters, write via command-dispatch setters.** `getValue()` reads; `setValue()` dispatches an undo-able, serializable command. Read-only invariants (set at construction) are `readonly` accessors (`node.type`, `widget.name`). |
| 31 | **Observe via typed `on(...)` subscriptions.** Each returns an `Unsubscribe` cleanup function. No Vue refs/signals are ever exposed; Vue reactivity is the internal engine only. |
| 32 | **Everything is disposable.** Every `defineX` returns a `DisposableHandle` with an idempotent, synchronous `dispose()`. |
| 33 | |
| 34 | ## Registration entry points |
| 35 | |
| 36 | All imported from `@comfyorg/extension-api`: |
| 37 | |
| 38 | | Function | Purpose | Returns | |
| 39 | |----------|---------|---------| |
| 40 | | `defineNode(opts)` | **Primary entry** — react to node lifecycle (replaces prototype patching) | `NodeExtensionOptions` | |
| 41 | | `defineExtension(opts)` | App-scoped lifecycle (`init`/`setup`) + shell UI host | `ExtensionOptions` | |
| 42 | | `defineWidget(opts)` | Register a custom widget type (DOM via `mount`) | `WidgetExtensionOptions` | |
| 43 | | `defineSidebarTab(opts)` | Add a left-sidebar tab (Vue or custom) | `DisposableHandle` | |
| 44 | | `defineBottomPanelTab(opts)` | Add a bottom-panel tab | `DisposableHandle` | |
| 45 | | `defineToolbarButton(opts)` | Add an action-bar button | `DisposableHandle` | |
| 46 | | `defineCommand(opts)` | Register an invokable command | `DisposableHandle` | |
| 47 | | `defineHotkey(opts)` | Bind a key combo to a command id | `DisposableHandle` | |
| 48 | | `defineSetting(opts)` | Add a settings-menu entry | `DisposableHandle` | |
| 49 | | `defineAboutBadge(opts)` | Add a badge to the About page | `DisposableHandle` | |
| 50 | |
| 51 | Imperative carve-outs (fire-and-forget, not `defineX`, no handle): `toast`, `notify`. |
| 52 | |
| 53 | A single extension file typically exports a default `defineExtension`/`defineNode` |
| 54 | result and calls the shell-UI `defineX` functions inside `setup()` or at module scope. |
| 55 | They queue safely before the app boots. |
| 56 | |
| 57 | ## `defineNode` — the primary entry point |
| 58 | |
| 59 | Reacts to node lifecycle. `nodeCreated` fires once per node instance (typed in, pasted, |
| 60 | duplicated, or loaded without an existing workflow). `loadedGraphNode` fires once when a |
| 61 | node is restored from a saved workflow (widget values already populated). Exactly one of |
| 62 | them fires per node entity, never both. |
| 63 | |
| 64 | |
| 65 | import { defineNode, onNodeMounted, onNodeRemoved } from '@comfyorg/extension-api' |
| 66 | |
| 67 | export default defineNode({ |
| 68 | name: 'my-org.executed-logger', |
| 69 | // Filter to specific comfyClass names. Omit to receive every node type. |
| 70 | nodeTypes: ['KSampler', 'KSamplerAdvanced'], |
| 71 | |
| 72 | // MUST be synchronous. Runs inside a Vue EffectScope; everything registered |
| 73 | // here (subscriptions, onNodeMounted) auto-disposes when the node is removed. |
| 74 | nodeCreated(node) { |
| 75 | // Read-only invariants |
| 76 | console.log(node.type, node.comfyClass, node.id) |
| 77 | |
| 78 | // Subscribe to backend execution completion (replaces onExecuted patching) |
| 79 | node.on('executed', (e) => { |
| 80 | console.log('output:', e.output) // Record<string, unknown> |
| 81 | }) |
| 82 | |
| 83 | // Lifecycle hooks — call SYNCHRONOUSLY (never after an await) |
| 84 | onNodeMounted(() => { |
| 85 | // Node fully mounted; DOM/canvas ready. |
| 86 | }) |
| 87 | onNodeRemoved(() => { |
| 88 | // Cleanup: abort fetches, close sockets. Does NOT fire on subgraph promotion. |
| 89 | }) |
| 90 | }, |
| 91 | |
| 92 | loadedGraphNode(node) { |
| 93 | // Node restored from a saved workflow; widget values are already set. |
| 94 | } |
| 95 | }) |
| 96 | |
| 97 | |
| 98 | ### `NodeHandle` surface (Phase A) |
| 99 | |
| 100 | | Member | Kind | Notes | |
| 101 | |--------|------|-------| |
| 102 | | `id: string` | readonly | Opaque token. Compare with `node.equals(other)`, never by slicing. | |
| 103 | | `equals(other)` | method | Canonical identity comparison. | |
| 104 | | `type: string` | readonly | LiteGraph node type. | |
| 105 | | `comfyClass: string` | readonly | Backend class name. | |
| 106 | | `getProperty<T>(key)` / `getProperties()` / `setProperty(key, v)` | methods | Per-instance props (migration shim — prefer widget values). | |
| 107 | | `getInputs()` / `getOutputs()` | methods | `ReadonlyArray<Readonly<SlotInfo>>` — frozen views. | |
| 108 | | `on('executed', fn)` | method | Execution complete → `NodeExecutedEvent { output }`. | |
| 109 | | `on('removed', fn)` | method | Node deleted (not subgraph promotion). | |
| 110 | | `on('configured', fn)` | method | Loaded from saved workflow (after widget values restored). | |
| 111 | | `on('beforeSerialize', fn)` | method | **Deprecated** — use widget-level `beforeSerialize` (ADR-0010). | |
| 112 | |
| 113 | > Position/size/title/mode getters and slot/connection events are deferred in Phase A. Do not rely on `getPosition`, `setSize`, `getMode`, `on('connected')`. |
| 114 | > |
| 115 | > Nodes cannot enumerate or reference their widgets (`node.getWidget(name)` was removed). Use `defineWidget` and the `mount` context's `ctx.widget` handle. |
| 116 | |
| 117 | ## `defineExtension` — app lifecycle + shell UI |
| 118 | |
| 119 | Use for app-wide setup and to host shell-UI registrations. `setup()` runs at the |
| 120 | early registration point; use the imported `onMounted` hook for work that needs the |
| 121 | app fully initialized. |
| 122 | |
| 123 | |
| 124 | import { |
| 125 | defineExtension, |
| 126 | onMounted, |
| 127 | onUnmounted, |
| 128 | execution, |
| 129 | toast |
| 130 | } from '@comfyorg/extension-api' |
| 131 | |
| 132 | export default defineExtension({ |
| 133 | name: 'my-org.my-extension', |
| 134 | setup() { |
| 135 | // Register late-lifecycle work via onMounted (called synchronously here). |
| 136 | onMounted(() => { |
| 137 | const off = execution.on('start', () => { |
| 138 | toast.show({ severity: 'info', summary: 'Run started' }) |
| 139 | }) |
| 140 | onUnmounted(off) // tidy teardown |
| 141 | }) |
| 142 | } |
| 143 | }) |
| 144 | |
| 145 | |
| 146 | > A note on the `setup()` signature. The source-of-truth API uses *implicit-context* |
| 147 | > hooks: import `onMounted`/`onNodeMounted`/etc. and call them synchronously inside |
| 148 | > `setup()` (mirrors Vue's Composition API). An early package draft showed a |
| 149 | > `setup(ctx) { ctx.onNodeMounted(...) }` context-argument style; prefer the imported-hook |
| 150 | > form above, which is what the current API exports. |
| 151 | |
| 152 | Context-scoped lifecycle hooks are imported and called synchronously inside |
| 153 | `defineExtension`'s `setup()`: `onBeforeMount`, `onMounted`, `onUnmounted`, |
| 154 | `onActivated`, `onDeactivated`. `defineSidebarTab`/`defineBottomPanelTab` take no |
| 155 | `setup` field; adding one is a type error. `onActivated`/`onDeactivated` fire |
| 156 | when the surrounding tab or panel is shown or hidden. |
| 157 | |
| 158 | ## `defineWidget` — custom widget types with DOM |
| 159 | |
| 160 | Widgets are declared in the Python node's `INPUT_TYPES`, never created at runtime |
| 161 | (`node.addWidget` is forbidden). `defineWidget` registers a *type* and the DOM `mount` |
| 162 | hook the runtime invokes against a host `<div>` it owns. `mount` is optional; omit it |
| 163 | for value-only widgets that render through the native renderer. |
| 164 | |
| 165 | |
| 166 | import { defineWidget, type WidgetCleanup } from '@comfyorg/extension-api' |
| 167 | |
| 168 | export default defineWidget({ |
| 169 | name: 'my-org.color-picker', |
| 170 | type: 'COLOR_PICKER', // referenced from Python INPUT_TYPES |
| 171 | |
| 172 | // The SOLE DOM seam. Capture host + constructed DOM via closure — there is |
| 173 | // no widget.element accessor. |
| 174 | mount(host, ctx): WidgetCleanup { |
| 175 | const input = document.createElement('input') |
| 176 | input.type = 'color' |
| 177 | input.value = String(ctx.widget.getValue() ?? '#000000') |
| 178 | input.addEventListener('input', () => ctx.widget.setValue(input.value)) |
| 179 | host.appendChild(input) |
| 180 | |
| 181 | // ctx.widget / ctx.node are the only legal handles here. |
| 182 | ctx.widget.on('valueChange', (e) => { |
| 183 | input.value = String(e.newValue ?? '#000000') |
| 184 | }) |
| 185 | |
| 186 | // Optional cleanup — fires once on widget destruction (NOT on host remount). |
| 187 | return () => input.remove() |
| 188 | } |
| 189 | }) |
| 190 | |
| 191 | |
| 192 | `WidgetMountContext` also exposes `onUnmount(fn)`, `onBeforeRemount(fn)`, and |
| 193 | `onAfterRemount(fn => ...)` for host-move scenarios (graph↔app mode, subgraph |
| 194 | promotion). The `mount` body is not re-invoked across a remount; only the |
| 195 | remount hooks fire. |
| 196 | |
| 197 | ### `WidgetHandle` surface |
| 198 | |
| 199 | | Member | Kind | Notes | |
| 200 | |--------|------|-------| |
| 201 | | `id` / `equals(other)` | readonly / method | Opaque identity. | |
| 202 | | `name` / `widgetType` / `label` | readonly | Set from `INPUT_TYPES` schema. | |
| 203 | | `getValue<T>()` / `setValue(v)` | methods | `setValue` dispatches an undo-able command. | |
| 204 | | `options` | readonly | `Readonly<WidgetOptions>` snapshot. Writes raise TS errors. | |
| 205 | | `getOption<K>(key)` / `setOption(key, v)` | methods | Per-instance overrides (e.g. `min`/`max`/`step`). | |
| 206 | | `setHeight(px)` | method | Resize the reserved host height (DOM widgets). | |
| 207 | | `on('valueChange', fn)` | method | `WidgetValueChangeEvent { oldValue, newValue }`. | |
| 208 | | `on('optionChange', fn)` | method | `WidgetOptionChangeEvent { key, oldValue, newValue }`. | |
| 209 | | `on('beforeSerialize', fn)` | method | **Only async-allowed event.** `e.value` + `e.setSerializedValue(v)`. | |
| 210 | | `on('beforeQueue', fn)` | method | Pre-queue validation. Call `e.reject(msg)` to cancel. | |
| 211 | |
| 212 | |
| 213 | // Serialization override (the SOLE serialization interface in v2): |
| 214 | widget.on('beforeSerialize', (e) => { |
| 215 | e.setSerializedValue(processDynamicPrompt(widget.getValue())) |
| 216 | }) |
| 217 | |
| 218 | // Async serialization (e.g. capture a webcam frame before queueing): |
| 219 | widget.on('beforeSerialize', async (e) => { |
| 220 | e.setSerializedValue(await captureFrame()) |
| 221 | }) |
| 222 | |
| 223 | // Pre-queue validation (replaces app.queuePrompt monkey-patching): |
| 224 | widget.on('beforeQueue', (e) => { |
| 225 | if (!widget.getValue()) e.reject('Prompt text is required before queueing.') |
| 226 | }) |
| 227 | |
| 228 | |
| 229 | ## Typed event namespaces |
| 230 | |
| 231 | Four module-level singletons replace `api.addEventListener('...')`. Each `on()` |
| 232 | returns an `Unsubscribe`. Subscriptions made inside a `setup()` body auto-dispose |
| 233 | on unmount; subscriptions made elsewhere are the caller's responsibility. |
| 234 | |
| 235 | | Namespace | Events (canonical) | Wire mapping | |
| 236 | |-----------|--------------------|--------------| |
| 237 | | `execution` | `start`, `end`, `error`, `interrupted`, `cached`, `executing`, `progress`, `preview` | `execution_<evt>` | |
| 238 | | `graph` | `changed`, … | `graph:<evt>` | |
| 239 | | `server` | `status`, `logs`, `reconnected`, `feature_flags`, `assets`, **+ custom-node events** | raw event name | |
| 240 | | `workbench` | `notification`, … | `workbench:<evt>` | |
| 241 | |
| 242 | |
| 243 | import { execution, server } from '@comfyorg/extension-api' |
| 244 | |
| 245 | const off = execution.on('progress', (e) => console.log('progress', e)) |
| 246 | // Custom-node events ride the `server` namespace with arbitrary names: |
| 247 | server.on('my-org.my-node.update', (e) => console.log(e)) |
| 248 | // later: |
| 249 | off() |
| 250 | |
| 251 | |
| 252 | Payloads default to `unknown` today. Narrow them with TS module augmentation: |
| 253 | |
| 254 | |
| 255 | declare module '@comfyorg/extension-api' { |
| 256 | interface ExecutionEventPayloads { |
| 257 | start: { promptId: string } |
| 258 | progress: { value: number; max: number } |
| 259 | } |
| 260 | interface ServerEventPayloads { |
| 261 | 'my-org.my-node.update': { nodeId: string; text: string } |
| 262 | } |
| 263 | } |
| 264 | |
| 265 | |
| 266 | The augmentable interfaces are `GraphEventPayloads`, `ExecutionEventPayloads`, |
| 267 | `ServerEventPayloads`, and `WorkbenchEventPayloads`. |
| 268 | |
| 269 | ## Shell UI registrations |
| 270 | |
| 271 | Each returns a `DisposableHandle`. Safe to call at module scope (they queue until the |
| 272 | app boots) or inside `setup()`. |
| 273 | |
| 274 | |
| 275 | import { |
| 276 | defineCommand, |
| 277 | defineHotkey, |
| 278 | defineToolbarButton, |
| 279 | defineSetting, |
| 280 | defineAboutBadge |
| 281 | } from '@comfyorg/extension-api' |
| 282 | |
| 283 | // Command — id, function, optional label/icon/tooltip. |
| 284 | const cmd = defineCommand({ |
| 285 | id: 'my-org.do-the-thing', |
| 286 | label: 'Do The Thing', |
| 287 | function: () => { /* ... */ } |
| 288 | }) |
| 289 | |
| 290 | // Hotkey — binds a key combo to an already-registered command id. |
| 291 | // `mod` = cmd on macOS, ctrl elsewhere. |
| 292 | defineHotkey({ keys: 'mod+shift+k', commandId: 'my-org.do-the-thing' }) |
| 293 | |
| 294 | // Action-bar button — id (for dispose), icon, onClick. |
| 295 | defineToolbarButton({ |
| 296 | id: 'my-org.help', |
| 297 | icon: 'pi-question-circle', |
| 298 | tooltip: 'Get help', |
| 299 | onClick: () => openHelp() |
| 300 | }) |
| 301 | |
| 302 | // Setting — widen the id when not augmenting the Settings keymap. |
| 303 | defineSetting({ |
| 304 | id: 'my-org.enabled' as never, |
| 305 | name: 'Enable my extension', |
| 306 | type: 'boolean', |
| 307 | defaultValue: false |
| 308 | }) |
| 309 | |
| 310 | // About-page badge. |
| 311 | defineAboutBadge({ |
| 312 | label: 'GitHub', |
| 313 | url: 'https://github.com/me/my-ext', |
| 314 | icon: 'pi-github' |
| 315 | }) |
| 316 | |
| 317 | // Tear down any registration: |
| 318 | cmd.dispose() // idempotent + synchronous |
| 319 | |
| 320 | |
| 321 | `CommandDefinition` fields: `id` (required), `function: (metadata?) => void | Promise<void>` (required), optional `label` / `icon` / `tooltip` (each `string | (() => string)`), `menubarLabel`, `versionAdded`. |
| 322 | |
| 323 | ## `defineSidebarTab` — embedded panels (e.g. a chat panel) |
| 324 | |
| 325 | A sidebar tab hosts a rich embedded UI such as a chat panel. It comes in two flavors: |
| 326 | `type: 'vue'` (mount a Vue component) or `type: 'custom'` (imperative `render(container)` / |
| 327 | `destroy()`). Both share the base fields `id`, `title`, optional `icon`, `iconBadge`, |
| 328 | `tooltip`, `label`. |
| 329 | |
| 330 | ### Vue component tab |
| 331 | |
| 332 | |
| 333 | import { defineSidebarTab } from '@comfyorg/extension-api' |
| 334 | import ChatPanel from './ChatPanel.vue' |
| 335 | |
| 336 | const chatTab = defineSidebarTab({ |
| 337 | id: 'my-org.chat', |
| 338 | title: 'Chat', |
| 339 | type: 'vue', |
| 340 | icon: 'pi-comments', |
| 341 | component: ChatPanel |
| 342 | }) |
| 343 | // chatTab.dispose() removes the tab. |
| 344 | |
| 345 | |
| 346 | ### Custom (framework-free) chat panel |
| 347 | |
| 348 | When you don't want a Vue dependency, use `type: 'custom'` and build the DOM yourself. |
| 349 | `render` receives the container; `destroy` is your teardown. |
| 350 | |
| 351 | |
| 352 | import { |
| 353 | defineExtension, |
| 354 | defineSidebarTab, |
| 355 | execution, |
| 356 | server, |
| 357 | type Unsubscribe |
| 358 | } from '@comfyorg/extension-api' |
| 359 | |
| 360 | export default defineExtension({ |
| 361 | name: 'my-org.chat-panel', |
| 362 | setup() { |
| 363 | const subscriptions: Unsubscribe[] = [] |
| 364 | |
| 365 | defineSidebarTab({ |
| 366 | id: 'my-org.chat', |
| 367 | title: 'Chat', |
| 368 | type: 'custom', |
| 369 | icon: 'pi-comments', |
| 370 | |
| 371 | render(container: HTMLElement) { |
| 372 | const log = document.createElement('div') |
| 373 | log.className = 'chat-log' |
| 374 | |
| 375 | const form = document.createElement('form') |
| 376 | const input = document.createElement('input') |
| 377 | input.placeholder = 'Ask something…' |
| 378 | const send = document.createElement('button') |
| 379 | send.type = 'submit' |
| 380 | send.textContent = 'Send' |
| 381 | form.append(input, send) |
| 382 | |
| 383 | const append = (who: string, text: string) => { |
| 384 | const line = document.createElement('p') |
| 385 | line.textContent = `${who}: ${text}` |
| 386 | log.appendChild(line) |
| 387 | log.scrollTop = log.scrollHeight |
| 388 | } |
| 389 | |
| 390 | form.addEventListener('submit', (ev) => { |
| 391 | ev.preventDefault() |
| 392 | const text = input.value.trim() |
| 393 | if (!text) return |
| 394 | append('You', text) |
| 395 | input.value = '' |
| 396 | // Forward to a backend node/server event, stream the reply, etc. |
| 397 | }) |
| 398 | |
| 399 | container.append(log, form) |
| 400 | |
| 401 | // Stream backend replies via the server namespace (custom-node event). |
| 402 | subscriptions.push( |
| 403 | server.on('my-org.chat.reply', (e) => append('Assistant', String(e))) |
| 404 | ) |
| 405 | // React to runs to show status in the panel. |
| 406 | subscriptions.push( |
| 407 | execution.on('start', () => append('System', 'Run started…')) |
| 408 | ) |
| 409 | }, |
| 410 | |
| 411 | destroy() { |
| 412 | for (const off of subscriptions) off() |
| 413 | subscriptions.length = 0 |
| 414 | } |
| 415 | }) |
| 416 | } |
| 417 | }) |
| 418 | |
| 419 | |
| 420 | `defineBottomPanelTab` has the same `vue` / `custom` shapes (base fields `id`, optional |
| 421 | `title`/`titleKey`, optional `targetPanel: 'terminal' | 'shortcuts'`). |
| 422 | |
| 423 | ## Toasts |
| 424 | |
| 425 | `toast` and `notify` are inline imperative (no `defineX`, no handle). Call from any |
| 426 | `setup()` body or hook closure. |
| 427 | |
| 428 | |
| 429 | import { toast } from '@comfyorg/extension-api' |
| 430 | |
| 431 | toast.show({ severity: 'error', summary: 'Workflow failed', detail: err.message, life: 4000 }) |
| 432 | toast.removeAll() |
| 433 | |
| 434 | |
| 435 | `notify({ kind, message, detail, life })` is a deprecated 1:1 wrapper over `toast.show`. |
| 436 | Prefer `toast.show` directly. |
| 437 | |
| 438 | ## Node identity helpers |
| 439 | |
| 440 | For referencing nodes across subgraph boundaries or execution runs, use the branded |
| 441 | identity types rather than raw integer node IDs: |
| 442 | |
| 443 | |
| 444 | import { |
| 445 | createNodeLocatorId, parseNodeLocatorId, isNodeLocatorId, |
| 446 | createNodeExecutionId, parseNodeExecutionId, isNodeExecutionId, |
| 447 | type NodeLocatorId, type NodeExecutionId |
| 448 | } from '@comfyorg/extension-api' |
| 449 | |
| 450 | const locator: NodeLocatorId = createNodeLocatorId(subgraphUuid, localNodeId) |
| 451 | // NodeExecutionId encodes a node's path through nested subgraphs as an array of node ids |
| 452 | // (joined with ':'). Pass the array, not positional args: |
| 453 | const execId: NodeExecutionId = createNodeExecutionId([localNodeId]) |
| 454 | |
| 455 | if (isNodeLocatorId(maybe)) { |
| 456 | // parseNodeLocatorId returns { subgraphUuid: string | null; localNodeId: NodeId } |
| 457 | const { subgraphUuid, localNodeId } = parseNodeLocatorId(maybe) |
| 458 | } |
| 459 | |
| 460 | |
| 461 | `NodeLocatorId` arrives from workflow JSON; `NodeExecutionId` arrives from websocket |
| 462 | frames. You receive these from event payloads, which is why they're public (unlike the |
| 463 | internal `*EntityId` brands, which are not exported). |
| 464 | |
| 465 | ## Disposal contract |
| 466 | |
| 467 | Every `defineX` returns `DisposableHandle { dispose(): void }`: |
| 468 | |
| 469 | **Idempotent.** Calling `dispose()` again is a safe no-op. |
| 470 | **Synchronous.** Teardown happens synchronously inside `dispose()`. |
| 471 | **Independent.** Disposing handle A does not affect B or C. Sequence calls |
| 472 | explicitly when teardown order matters (e.g. drop a hotkey before its command). |
| 473 | **Pre-mount safe.** Disposing before the app boots removes the spec from the |
| 474 | pending queue so it never mounts. |
| 475 | |
| 476 | |
| 477 | const handles = [ |
| 478 | defineCommand({ id: 'my.cmd', function: () => {} }), |
| 479 | defineHotkey({ keys: 'mod+k', commandId: 'my.cmd' }), |
| 480 | defineSidebarTab({ id: 'my.tab', title: 'Tab', type: 'vue', component: MyTab }) |
| 481 | ] |
| 482 | // Full teardown: |
| 483 | for (const h of handles.reverse()) h.dispose() |
| 484 | |
| 485 | |
| 486 | ## Common mistakes |
| 487 | |
| 488 | **Calling lifecycle hooks after `await`.** `onNodeMounted` / `onMounted` / `onUnmounted` rely on implicit scope context and must be called synchronously inside the `setup()`/`nodeCreated` body. After an `await` the 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. |
| 489 | **Reaching for `window.app` or `app.*`.** v2 has no `window.app` dependency at module-eval time. Import everything from `@comfyorg/extension-api`. |
| 490 | **Patching `nodeType.prototype`.** Replaced by `defineNode` + `node.on(...)`. Prototype patching does not interoperate with the v2 handle model. |
| 491 | **Mutating reads.** `node.getInputs()`, `widget.options`, and `Point`/`Size` tuples are frozen/`Readonly`; assignment raises TS errors. Use the setter methods (`widget.setOption`, `widget.setValue`). |
| 492 | **Assigning `widget.value` / `widget.callback` / `widget.serializeValue`.** Use `setValue()`, `on('valueChange')`, and `on('beforeSerialize')`. `serializeValue` is read-only in v2. |
| 493 | **Trying to disable widget serialization.** There is no `serialize: false` and no `skip()` in v2. If a widget should not contribute to the payload, it should not be a widget. The only serialization interface is `widget.on('beforeSerialize', fn)` + `e.setSerializedValue(v)`. |
| 494 | **Creating widgets at runtime.** `node.addWidget(...)` / `node.addDOMWidget(...)` are removed. Declare widgets in the Python `INPUT_TYPES`; render custom DOM via `defineWidget({ mount })`. |
| 495 | **Enumerating widgets from a node.** `node.getWidget(name)` / `node.getWidgets()` were removed (nodes cannot reference widgets). Use a `defineWidget` mount context's `ctx.widget`, or share state via the `server` event bus. |
| 496 | **Using node-level `beforeSerialize`.** Deprecated (ADR-0010). Store extension state in a widget and use widget-level `beforeSerialize`. |
| 497 | **Forgetting to dispose.** Long-lived subscriptions made outside a `setup()` context, and every `defineX` handle, leak unless you call the returned `Unsubscribe` / `dispose()`. Inside `setup()` they auto-dispose on unmount. |
| 498 | **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. |
| 499 | |
| 500 | ## Sources |
| 501 | |
| 502 | **Official:** npm package `@comfyorg/extension-api` at https://www.npmjs.com/package/@comfyorg/extension-api |
| 503 | **Empirical:** none; the API was transcribed from the published package, not reverse-engineered from a working graph. |
| 504 |
Details
- Updated
- Sep 21, 2026
- License
- MIT
- Stars on the repo
- ★ 759
- Checked
- Source
- artokun/comfyui-mcp ↗
- Creator
- artokun · 44 listings
Similar skills
See all →
SkillMake Japanese TV-anime style videos with codeAnime cel
SkillDirect and produce a short film made entirely in codeLemo opuscar
SkillGenerate Ian-style Xiaohei illustrations for articles, posts
SkillCreating game art through Scenario MCP: spritesScenario game assets
SkillTrain custom LoRAs with ostris AI-ToolkitAI-Toolkit LoRA Trainer (WAN 2.2 & Z-Image)
SkillAnime/illustration text-to-imageANIMA 1.0 (Anima Base Ultra) Text-to-Image WorkflowsMore from artokun
SkillTrain custom LoRAs with ostris AI-ToolkitAI-Toolkit LoRA Trainer (WAN 2.2 & Z-Image)
SkillAnime/illustration text-to-imageANIMA 1.0 (Anima Base Ultra) Text-to-Image Workflows
SkillAuthoring & Publishing ComfyUI Custom Nodes
SkillTrain a custom anime LoRA on the ANIMA base modelCitron Anima LoRA Trainer
SkillDiscover Civitai models with the BUILT-IN downloadmodelCivitai + comfyui-mcp
SkillDiagnose and fix video/image color OBJECTIVELYColor Correction (measure, don't eyeball)