# Comfyui Frontend Extensions

> Authoring ComfyUI v2 frontend extensions with @comfyorg/extension-api — 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).

- **Type:** Skill
- **Install:** `agentstack add skill-artokun-comfyui-mcp-comfyui-frontend-extensions`
- **Verified:** Yes — security-reviewed for prompt injection and unsafe behavior
- **Seller:** [artokun](https://agentstack.voostack.com/s/artokun)
- **Installs:** 0
- **Category:** [Content & Media](https://agentstack.voostack.com/c/content-and-media)
- **Latest version:** 0.1.0
- **License:** MIT
- **Upstream author:** [artokun](https://github.com/artokun)
- **Source:** https://github.com/artokun/comfyui-mcp/tree/main/plugin/skills/comfyui-frontend-extensions
- **Website:** https://comfyui-mcp.artokun.io/docs

## Install

```sh
agentstack add skill-artokun-comfyui-mcp-comfyui-frontend-extensions
```

Requires the [AgentStack CLI](https://agentstack.voostack.com/docs/cli). Works with Claude Code, Cursor, and any MCP-compatible agent.

## 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'`. No `window.app` dependency 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) are `readonly` accessors (`node.type`, `widget.name`).
- **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.
- **Everything is disposable.** Every `defineX` returns a `DisposableHandle` with an idempotent, synchronous `dispose()`.

## 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.

```ts
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.

```ts
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.

```ts
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. |

```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_` |
| `graph` | `changed`, … | `graph:` |
| `server` | `status`, `logs`, `reconnected`, `feature_flags`, `assets`, **+ custom-node events** | raw event name |
| `workbench` | `notification`, … | `workbench:` |

```ts
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**:

```ts
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()`.

```ts
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

```ts
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.

```ts
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.

## Pricing

- **Free** — Free

## Security capabilities

Automated source analysis of v0.1.0 — what this tool can access:

- **Network access:** yes
- **Filesystem access:** no
- **Shell / process execution:** no
- **Environment & secrets:** no
- **Dynamic code execution:** no

*"Yes" means the capability is present in the source — more access means more to trust, not that it is unsafe.*


## Versions

- **0.1.0** — security scan: passed — Imported from the upstream source.

## Links

- Listing page: https://agentstack.voostack.com/l/skill-artokun-comfyui-mcp-comfyui-frontend-extensions
- Seller: https://agentstack.voostack.com/s/artokun
- Browse the marketplace: https://agentstack.voostack.com/browse

---
Listed on AgentStack — the marketplace for AI agent skills and MCP servers. Every listing is security-reviewed. Creators keep 70%.
