Skip to content

Your First Plugin

github-actions[bot] edited this page Oct 14, 2026 · 3 revisions

Your first plugin

A ZenKit plugin is an ordinary ComfyUI custom node folder whose frontend calls registerZenPlugin. This page builds one with a single Vue panel.

Note: create the folder outside this repository. A folder under the monorepo's plugins/ is a pnpm workspace member, so its @nynxz/zenkit-* dependencies resolve to the workspace packages instead of npm. Only put a plugin there when you are contributing it to ZenKit.

Layout

ComfyUI-MyPlugin/
├── __init__.py          # V3 entrypoint, WEB_DIRECTORY = "./js"
├── pyproject.toml
├── package.json
├── vite.config.mts
├── tsconfig.json
├── src/
│   ├── main.ts          # registers with ZenKit
│   └── Hello.vue        # the panel body
└── js/                  # build output (gitignore it)

pyproject.toml

[project]
name = "comfyui-myplugin"
version = "0.1.0"
description = "My first ZenKit plugin. Requires ComfyUI-ZenKit."

[tool.comfy]
PublisherId = "you"
DisplayName = "ComfyUI-MyPlugin"
includes = ["js"]            # ship the built frontend in the registry archive

[tool.zenkit]
id = "myplugin"              # same id you pass to registerZenPlugin

[tool.zenkit] id is read by the Zen Inspector to join your Python nodes/routes with what the frontend registered. Keep it identical to the id in registerZenPlugin.

__init__.py

from comfy_api.latest import ComfyExtension, io


class MyPluginExtension(ComfyExtension):
    async def get_node_list(self) -> list[type[io.ComfyNode]]:
        return []  # a frontend-only plugin ships no nodes


async def comfy_entrypoint() -> ComfyExtension:
    return MyPluginExtension()


WEB_DIRECTORY = "./js"

__all__ = ["MyPluginExtension", "comfy_entrypoint", "WEB_DIRECTORY"]

Note: do not also define NODE_CLASS_MAPPINGS. ComfyUI checks for it first and returns early, so comfy_entrypoint is never called and nothing registers.

Server routes go in their own module, imported at the top of __init__.py inside a try/except so a broken route never stops the plugin from loading.

package.json

{
  "name": "comfyui-myplugin",
  "private": true,
  "type": "module",
  "scripts": { "build": "vite build", "dev": "vite build --watch" },
  "dependencies": {
    "@nynxz/zenkit-client": "^0.3.0",
    "@nynxz/zenkit-ui": "^0.3.0",
    "vue": "^3.5.0"
  },
  "devDependencies": {
    "@nynxz/zenkit-nodekit": "^0.3.0",
    "@vitejs/plugin-vue": "^5.2.3",
    "vite": "^6.3.5"
  }
}

vite.config.mts

import { zenPluginConfig } from '@nynxz/zenkit-nodekit/vite'

export default zenPluginConfig({
  configUrl: import.meta.url,
  sharedRuntime: true,
})

zenPluginConfig builds src/main.ts to js/main.js, inlines CSS into it, aliases @ to src/, and keeps @comfy/app / @comfy/api external.

All options are listed in Nodekit pack pattern.

With sharedRuntime: true the page has one Vue and one copy of the UI library, and your bundle stays small, but the plugin requires ComfyUI-ZenKit. Leave it off if the plugin must work without ZenKit (see Working without ZenKit).

tsconfig.json

{
  "extends": "@nynxz/zenkit-nodekit/tsconfig.pack.json",
  "compilerOptions": { "paths": { "@/*": ["./src/*"] } },
  "files": ["./node_modules/@nynxz/zenkit-nodekit/comfy.d.ts"],
  "include": ["src"]
}

comfy.d.ts declares the ambient @comfy/app and @comfy/api modules. It goes in files, not include: include entries are filtered by exclude, which skips node_modules by default.

src/Hello.vue

<script setup lang="ts">
import type { PanelContext } from '@nynxz/zenkit-client'
import { ZenButton } from '@nynxz/zenkit-ui'
import { ref } from 'vue'

const props = defineProps<{ ctx?: PanelContext }>()
const count = ref((props.ctx?.state as number | undefined) ?? 0)

function bump() {
  count.value++
  props.ctx?.setState(count.value) // persisted per panel, restored next session
}
</script>

<template>
  <div style="padding: 14px; color: var(--zen-text)">
    <ZenButton icon="mdi mdi-plus" @click="bump">Clicked {{ count }}</ZenButton>
  </div>
</template>

src/main.ts

import { app } from '@comfy/app'
import { mountVue, registerZenPlugin } from '@nynxz/zenkit-client'
import { version } from '../package.json'
import Hello from '@/Hello.vue'

app.registerExtension({
  name: 'you.myplugin',
  setup() {
    void registerZenPlugin({
      id: 'myplugin',
      plugin: 'My Plugin',
      version,
      description: 'A first ZenKit plugin.',
      panels: [
        {
          id: 'hello', // becomes 'myplugin:hello'
          title: 'Hello',
          icon: 'mdi mdi-hand-wave',
          width: 360,
          height: 240,
          render: mountVue(Hello),
        },
      ],
    })
  },
})

Call registerZenPlugin from the extension's setup(). ComfyUI-ZenKit sets window.ZenKitPending as soon as its script loads, so registerZenPlugin waits for the runtime regardless of which extension loads first.

mountVue(component, props?) turns a component into a panel render. It passes the panel's PanelContext as the ctx prop only if the component declares a ctx prop, and unmounts the app when ZenKit tears the panel down.

Build and run

pnpm install
pnpm build          # or: pnpm dev (rebuilds on save)

Link or copy the folder into ComfyUI/custom_nodes, restart ComfyUI, reload. "Hello" appears in the Start menu under My Plugin, and the console logs My Plugin → connected (1 panel).

registerZenPlugin at a glance

Field Purpose Page
id Canonical kebab id. Defaults to a slug of plugin ("Zen Suite" → zensuite).
plugin Display name; groups entries in the Start menu.
namespace Route namespace for apps. Defaults to id. Apps and routing
version, logo, description Shown in Zen Settings and the Inspector.
panels ZenPanelDef[] Panels and workspaces
apps ZenAppDef[] Apps and routing
taskbarWidgets TaskbarWidget[] Panels and workspaces
themes ThemePack[] Themes
backgrounds ZenBackground[] Backgrounds
channels Channel names to declare Channels and media
capabilities Capability[] Capabilities
slotLinks Middle-click a slot to spawn and wire a node API reference
setup(zen, { id, namespace }) Imperative escape hatch; may return a cleanup
fallback, sidebarFallback, app, timeout Behaviour when ZenKit is absent Working without ZenKit

Ids follow one rule: a short panel or taskbar-widget id becomes <id>:<short>, a short capability id <id>.<short>; an id that already has the separator is kept. Channel, theme and background ids are global and never prefixed.

It resolves to { connected, zen, unregister }. unregister() removes panels, apps, taskbar widgets, slot links and capabilities and runs the setup cleanup. Themes, backgrounds and channels have no unregister and stay for the session.

Clone this wiki locally