Install
$ agentstack add skill-artokun-comfyui-mcp-comfyui-frontend-extensions ✓ scanned · ✓ verified — works with Claude Code, Cursor, and more.
Security review
✓ PassedNo issues found. Passed automated security review. · v0.1.0 How review works →
- ✓ Prompt-injection patterns
- ✓ Secret / credential exfiltration
- ✓ Dangerous shell & filesystem operations
- ✓ Untrusted network calls
- ✓ Known-malicious package signatures
What it can access
- ● Network access Used
- ✓ Filesystem access No
- ✓ Shell / process execution No
- ✓ Environment & secrets No
- ✓ Dynamic code execution No
From automated source analysis of v0.1.0. “Used” means the capability is present in the source — more access means more to trust, not that it’s unsafe.
About
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) | |-------------|-------------------------------| | 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
})
// 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(key) / getProperties() / setProperty(key, v) | methods | Per-instance props (migration shim — prefer widget values). | | getInputs() / getOutputs() | methods | ReadonlyArray> — 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 (excluded per the project's AXIOMS). Do not rely on getPosition, > setSize, getMode, on('connected'), etc. — they are not yet on the surface.
> Nodes cannot enumerate or reference their widgets (node.getWidget(name) was > removed). To attach per-widget behavior, register a widget type with defineWidget > and use the mount context's ctx.widget handle.
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
})
}
})
> setup() signature note. The source-of-truth surface uses implicit-context > hooks: import onMounted/onNodeMounted/etc. and call them synchronously inside > setup() (mirrors Vue's Composition API). An early package draft showed a > setup(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. Note: defineSidebarTab/defineBottomPanelTab take no setup field — don't add one (it's a type error). onActivated/onDeactivated fire when the surrounding tab/panel is shown/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 ` 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() / setValue(v) | methods | setValue dispatches an undo-able command. | | options | readonly | Readonly snapshot. Writes raise TS errors. | | getOption(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_ | | graph | changed, … | graph: | | server | status, logs, reconnected, feature_flags, assets, + custom-node events | raw event name | | workbench | notification, … | workbench: |
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 (required), optional label / icon / tooltip (each string | (() => string)), menubarLabel, versionAdded.
defineSidebarTab — embedded panels (e.g. a chat panel)
A sidebar tab is the substrate for a rich embedded UI such as a chat panel. 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(
…
## Source & license
This open-source skill is cataloged on AgentStack and links to its original source — we do not rehost the code.
- **Author:** [artokun](https://github.com/artokun)
- **Source:** [artokun/comfyui-mcp](https://github.com/artokun/comfyui-mcp)
- **License:** MIT
- **Homepage:** https://comfyui-mcp.artokun.io/docs
Install and usage instructions live in the source repository linked above.
Reviews
No reviews yet — be the first.
Write a review
Versions
- v0.1.0 Imported from the upstream source.