This skill teaches you how to build modern (V3 Schema) custom nodes and frontend JavaScript extensions for ComfyUI. Always prefer V3 patterns over legacy V1 unless maintaining existing code.
1. The Modern V3 Python Schema
Use comfy_api.latest for all new nodes. The V3 API uses object-oriented definitions with io.ComfyNode, io.Schema, and io.NodeOutput.
Basic Node Anatomy
from comfy_api.latest import ComfyExtension, io, ui
class MyCustomNode(io.ComfyNode): @classmethod def define_schema(cls) -> io.Schema: return io.Schema( node_id="MyPrefix_MyCustomNode", display_name="My Custom Node", category="my_category/sub_category", description="What this node does.", inputs=[ io.Image.Input("image", tooltip="Input image"), io.Int.Input("strength", default=1, min=0, max=10), io.String.Input("text_prompt", multiline=True) ], outputs=[ io.Image.Output(display_name="processed_image") ] )
@classmethod
def execute(cls, image, strength, text_prompt) -> io.NodeOutput:
processed = image * strength
return io.NodeOutput(processed, ui=ui.PreviewImage(processed, cls=cls))
**Key differences from V1:**
- Inherit from `io.ComfyNode` instead of a plain class
- Use `define_schema()` instead of `INPUT_TYPES` classmethod
- `execute` is always a `@classmethod` (no `self`, use `cls`)
- Return `io.NodeOutput(...)` instead of bare tuples
- Node IDs should be globally unique — prefix them (e.g., `AuthorName_NodeAction`)
### Extension Entrypoint
Replace `NODE_CLASS_MAPPINGS` / `NODE_DISPLAY_NAME_MAPPINGS` with `ComfyExtension` and `comfy_entrypoint`:
```python
class MyExtension(ComfyExtension):
async def get_node_list(self) -> list[type[io.ComfyNode]]:
return [MyCustomNode]
async def comfy_entrypoint() -> MyExtension:
return MyExtension()
V1 to V3 Property Mapping
| V1 Property | V3 Schema Field | Notes |
|---|---|---|
RETURN_TYPES |
outputs in Schema |
List of Output objects |
RETURN_NAMES |
display_name in Output |
Per-output display names |
FUNCTION |
Always execute |
Method name is standardized |
CATEGORY |
category in Schema |
String value |
OUTPUT_NODE |
is_output_node in Schema |
Boolean flag |
DEPRECATED |
is_deprecated in Schema |
Boolean flag |
IS_CHANGED |
fingerprint_inputs() |
Renamed for clarity |
2. Data Types and Tensors
ComfyUI relies on PyTorch tensors. Shape mismatches are the most common bug source.
Critical tensor shapes:
- IMAGE:
torch.Tensorshape[B, H, W, C]— channel-last (C=3 for RGB) - MASK:
torch.Tensorshape[B, H, W]— often needsunsqueeze(-1)to[B, H, W, 1] - LATENT:
dictwith key"samples", tensor shape[B, C, H, W]— channel-first (C=4)
V3 Input Types Reference:
| Type | Example |
|---|---|
io.Int.Input("count", default=1, min=0, max=100) |
Integer with optional min/max/step |
io.Float.Input("strength", default=1.0, min=0.0, max=10.0) |
Float with optional bounds |
io.String.Input("text", default="", multiline=True) |
String; multiline for text areas |
io.Boolean.Input("enabled", default=True) |
Boolean toggle |
io.Combo.Input("mode", options=["A", "B", "C"]) |
Dropdown selector |
io.Image.Input("image") |
ComfyUI IMAGE tensor |
io.Mask.Input("mask", optional=True) |
ComfyUI MASK tensor |
io.Latent.Input("latent") |
ComfyUI LATENT dict |
io.Model.Input("model") |
Model object |
io.CLIP.Input("clip") |
CLIP object |
io.VAE.Input("vae") |
VAE object |
io.Conditioning.Input("positive") |
Conditioning object |
io.Custom("MY_TYPE").Input("data") |
Custom datatype |
All input types share these parameters: id (required), display_name, optional, tooltip, lazy, raw_link, advanced.
3. Advanced Node Features
Hidden Inputs
Access execution context like node ID and prompt metadata. In V3, use io.Hidden in the schema and cls.hidden in execute:
@classmethod
def define_schema(cls) -> io.Schema:
return io.Schema(
node_id="MyNode",
inputs=[...],
hidden=[io.Hidden.unique_id, io.Hidden.prompt],
)
@classmethod
def execute(cls, ...) -> io.NodeOutput:
node_id = cls.hidden.unique_id
prompt_data = cls.hidden.prompt
return io.NodeOutput(result)
Available hidden values: unique_id, prompt, extra_pnginfo, dynprompt, auth_token_comfy_org, api_key_comfy_org.
Lazy Evaluation
Skip evaluating inputs that aren't needed. Mark inputs as lazy=True and implement check_lazy_status:
@classmethod
def define_schema(cls) -> io.Schema:
return io.Schema(
node_id="MySwitch",
inputs=[
io.Boolean.Input("switch"),
io.Image.Input("on_false", lazy=True),
io.Image.Input("on_true", lazy=True),
],
outputs=[io.Image.Output()],
)
@classmethod
def check_lazy_status(cls, switch, on_false=None, on_true=None):
if switch and on_true is None:
return ["on_true"]
if not switch and on_false is None:
return ["on_false"]
return []
@classmethod
def execute(cls, switch, on_false, on_true) -> io.NodeOutput:
return io.NodeOutput(on_true if switch else on_false)
Output Nodes
Mark nodes that save files or trigger execution:
io.Schema(
node_id="MySaveNode",
is_output_node=True,
...
)
DynamicCombo (Dropdown-Dependent Inputs)
Show/hide inputs based on a dropdown selection:
io.DynamicCombo.Input("resize_type", options=[
io.DynamicCombo.Option("scale by dimensions", [
io.Int.Input("width", default=512, min=0, max=8192),
io.Int.Input("height", default=512, min=0, max=8192),
]),
io.DynamicCombo.Option("scale by multiplier", [
io.Float.Input("multiplier", default=1.0, min=0.01, max=8.0),
]),
]),
In execute, the DynamicCombo parameter becomes a dict containing the selected key and its sub-inputs.
MatchType (Generic Type Matching)
Create inputs/outputs that dynamically snap to the same connected type:
template = io.MatchType.Template("switch")
io.MatchType.Input("on_false", template=template, lazy=True),
io.MatchType.Input("on_true", template=template, lazy=True),
# ...
io.MatchType.Output(template=template, display_name="output"),
Autogrow (Variable Number of Inputs)
Automatically add input sockets as the user connects nodes:
template = io.Autogrow.TemplatePrefix(
input=io.Image.Input("image"),
prefix="image",
min=2,
)
io.Schema(
inputs=[io.Autogrow.Input("images", template=template)],
outputs=[io.Image.Output()],
)
# In execute, images is a dict mapping input names to their values
@classmethod
def execute(cls, images: io.Autogrow.Type) -> io.NodeOutput:
image_list = list(images.values())
return io.NodeOutput(torch.cat(image_list, dim=0))
Async Execution and Progress Reporting
from comfy_api.latest import ComfyAPI
api = ComfyAPI()
class AsyncNode(io.ComfyNode):
@classmethod
async def execute(cls, images) -> io.NodeOutput:
total = len(images)
for i, img in enumerate(images):
# process img...
await api.execution.set_progress(
value=i + 1,
max_value=total,
preview_image=img,
)
return io.NodeOutput(result)
4. UI Helpers (Preview and Save)
V3 provides built-in helpers in the ui module for common output patterns:
from comfy_api.latest import ui
# Preview image in node
return io.NodeOutput(images, ui=ui.PreviewImage(images, cls=cls))
# Preview mask (auto-converts to 3-channel)
return io.NodeOutput(mask, ui=ui.PreviewMask(mask, cls=cls))
# Preview audio
return io.NodeOutput(audio, ui=ui.PreviewAudio(audio, cls=cls))
# Preview text
return io.NodeOutput(ui=ui.PreviewText("Result text"))
# Save image with metadata
return io.NodeOutput(ui=ui.ImageSaveHelper.get_save_images_ui(
images=images, filename_prefix=prefix, cls=cls
))
# Pass cls=cls to embed workflow metadata when using hidden prompt/extra_pnginfo
5. Node Replacement (Migration)
If renaming or restructuring nodes, register replacements so existing workflows auto-upgrade. Use ComfyAPI in your extension's on_load:
from comfy_api.latest import ComfyAPI, ComfyExtension, io
api = ComfyAPI()
class MyExtension(ComfyExtension):
async def on_load(self) -> None:
await api.node_replacement.register(io.NodeReplace(
new_node_id="MyPrefix_NewNode",
old_node_id="OldNodeName",
old_widget_ids=["param1", "param2"],
input_mapping=[
{"new_id": "image", "old_id": "input_image"},
{"new_id": "method", "set_value": "lanczos"},
],
output_mapping=[
{"new_idx": 0, "old_idx": 0},
],
))
6. Frontend JavaScript Extensions
Declare the web directory in your Python module:
WEB_DIRECTORY = "./js"
Place .js files in that directory. Use modern extension hooks, not legacy prototype monkey-patching.
Basic Extension
import { app } from "../../scripts/app.js";
app.registerExtension({
name: "MyNamespace.MyExtension",
async setup() {
console.log("Extension loaded");
},
});
Context Menus
app.registerExtension({
name: "MyNamespace.MyMenus",
getCanvasMenuItems(canvas) {
return [
null,
{
content: "My Canvas Action",
callback: () => {
/* ... */
},
},
];
},
getNodeMenuItems(node) {
if (node.comfyClass === "MyPrefix_MyNode") {
return [
{
content: "Do Something",
callback: () => {
/* ... */
},
},
];
}
return [];
},
});
Commands, Keybindings, and Selection Toolbox
app.registerExtension({
name: "MyNamespace.Commands",
commands: [
{
id: "myExt.doAction",
label: "Do Action",
function: () => {
/* ... */
},
},
],
keybindings: [
{
combo: { key: "k", ctrl: true, shift: true },
commandId: "myExt.doAction",
},
],
getSelectionToolboxCommands: (selectedItem) => {
const count = app.canvas.selectedItems?.size || 0;
if (count > 1) return ["myExt.doAction"];
return [];
},
});
Sidebar and Bottom Panel Tabs
app.extensionManager.registerSidebarTab({
id: "mySidebar",
icon: "pi pi-list",
title: "My Tools",
type: "custom",
render: (el) => {
el.innerHTML = "<div>Content</div>";
},
});
app.registerExtension({
name: "MyNamespace.BottomTab",
bottomPanelTabs: [
{
id: "myLogs",
title: "Custom Logs",
type: "custom",
render: (el) => {
el.innerHTML = "<div>Log data</div>";
},
},
],
});
Dialogs, Toasts, Settings
// Prompt dialog
app.extensionManager.dialog
.prompt({ title: "Input", message: "Enter value:" })
.then((result) => {
/* result is string or null */
});
// Confirm dialog
app.extensionManager.dialog
.confirm({ title: "Confirm", message: "Continue?" })
.then((result) => {
/* result is boolean or null */
});
// Toast notification
app.extensionManager.toast.add({
severity: "success",
summary: "Done!",
life: 3000,
});
// Settings
app.registerExtension({
name: "MyNamespace.Settings",
settings: [
{
id: "MyExt.enableDebug",
name: "Enable Debug Mode",
type: "boolean",
defaultValue: false,
onChange: (newVal) => {
console.log("Debug:", newVal);
},
},
],
});
About Page Badges and Top Bar Menus
app.registerExtension({
name: "MyNamespace.Badges",
aboutPageBadges: [
{
label: "GitHub",
url: "https://github.com/my/repo",
icon: "pi pi-github",
},
],
menuCommands: [
{ path: ["Extensions", "My Extension"], commands: ["myExt.doAction"] },
],
});
For deeper frontend APIs (subgraphs, i18n, node docs, detailed JS object reference), see references/advanced_frontend_and_i18n.md.
7. Directory Structure
Use this standard layout for ComfyUI Manager compatibility:
my-custom-nodes/
├── __init__.py # Exports WEB_DIRECTORY and comfy_entrypoint
├── nodes.py # V3 ComfyNode definitions
├── requirements.txt # Pip dependencies (Manager auto-installs)
├── install.py # Optional: run by Manager on install
├── js/ # WEB_DIRECTORY for frontend extensions
│ └── my_extension.js
├── subgraphs/ # .json files become available as subgraph blueprints
└── example_workflows/ # .json + .jpg files appear in Workflow Templates browser
8. Checklist: AI Instructions
When building ComfyUI custom nodes:
- Ask clarifying questions if data types or tensor shapes are ambiguous
- Always use V3 API (
from comfy_api.latest import io). Only generate V1INPUT_TYPESdicts if maintaining existing legacy code - Prefix node IDs to ensure global uniqueness (e.g.,
AuthorName_NodeAction) - Remember: Images are
[B, H, W, 3](channel-last), Latents are{"samples": [B, 4, H, W]}(channel-first), Masks are[B, H, W] - For UI modifications, use modern extension hooks (
getCanvasMenuItems,getNodeMenuItems,commands,settings) rather thanLGraphCanvas.prototypemonkey-patching - When scaffolding a new package, include
example_workflows/andrequirements.txtfor Manager compatibility - Return
io.NodeOutput(...)from execute, always — evenio.NodeOutput()for no-output nodes - Use
ui.PreviewImage/ui.ImageSaveHelperfor common output patterns instead of manual file handling - For lazy evaluation, add
lazy=Trueto input definitions and implementcheck_lazy_status - Use
io.DynamicComboinstead of manual JS widget toggling for mode-dependent inputs