Build ComfyUI Custom Node 2026 | Apatero
/ ComfyUI / Build a ComfyUI Custom Node 2026: V3 Schema From Scratch
ComfyUI 18 min read

Build a ComfyUI Custom Node 2026: V3 Schema From Scratch

V3 froze V1 in 2026. New nodes ship as proper extension classes with async entrypoints. Full code walkthrough plus Vue widget integration.

Build a ComfyUI Custom Node 2026: V3 Schema From Scratch

Some models you cannot download at any VRAM

Veo, Kling and Nano Banana have closed weights. There is no local build, no quantization, no 24GB workaround. Run them in the browser instead. First generation free.

Build a ComfyUI custom node in 2026 and you will run into something that did not exist a year ago. The V3 schema. ComfyUI froze the V1 node API earlier this year and made V3 the official path for any new custom node. If you are still writing nodes the old way you are building on legacy infrastructure. This is the from-scratch walkthrough for the V3 schema, the async entrypoint pattern, and the Vue widget integration that makes a custom node feel like a first-class part of ComfyUI.

Quick Answer: V3 custom nodes are Python classes inheriting from io.ComfyNode. They define inputs and outputs using typed schema objects like io.Image.Input() and io.Int.Input(). The execute method is a stateless classmethod that can be async for any I/O work. Nodes are registered through an async comfy_entrypoint() function instead of NODE_CLASS_MAPPINGS. Optional Vue widgets give you custom frontend UI beyond the default sliders and dropdowns. The whole API is versioned so future ComfyUI updates do not break your node.

Key Takeaways:
  • V1 node API is frozen as of 2026, V3 is the only path for new nodes
  • Inherit from io.ComfyNode, use typed io.Schema for inputs and outputs
  • execute method is a stateless classmethod, can be async for I/O work
  • Return io.NodeOutput instead of tuples
  • Test locally with comfy-cli, publish to the official ComfyUI Registry

Why V1 Is Frozen and What V3 Actually Solves

I had to migrate four of my own custom nodes from V1 to V3 in the first quarter of 2026. The process taught me what V3 was actually designed to fix.

V1 had four big problems. First, the API was loosely typed. INPUT_TYPES returned a dictionary of dictionaries with magic strings. RETURN_TYPES was a tuple. There was no formal type system. Mistakes manifested as runtime errors deep in execution rather than at node-definition time.

Second, V1 was synchronous. If your node needed to make an HTTP call, read a file, or do anything I/O-bound, you blocked the entire ComfyUI execution thread. The recommended workaround was to spawn a thread inside your node, which worked but was awkward and error-prone.

Third, V1 had no versioning. The API was defined by convention and informal contract. ComfyUI updates could and did break custom nodes without warning, because there was no formal API surface to maintain compatibility against.

Fourth, V1 had no clean path for custom frontend. Custom widgets required hand-rolled JavaScript that hooked into ComfyUI's frontend internals. The internals changed across ComfyUI versions. Widgets broke.

V3 fixes all four. The schema is typed objects. The execute method is async-native. The API surface is versioned through comfy_api.latest. Custom widgets use the new Vue-based frontend architecture with a proper API.

Per the official ComfyUI V3 Migration documentation, V1 nodes will keep working for a deprecation period, but no new ComfyUI features will be available to V1 nodes. New work should target V3.

The V3 Architecture: Extension Class, Schema, Async Entrypoint

The V3 architecture has three pillars. Understanding these three things means you understand V3.

Pillar one, the ComfyNode class. Every custom node inherits from io.ComfyNode. The class defines the schema, the execute method, and any optional metadata. There is no NODE_CLASS_MAPPINGS dictionary. The class itself carries everything.

Pillar two, the typed schema. Inputs and outputs use io.Schema with typed input objects like io.Image.Input(), io.Int.Input(), io.String.Input(). Each input type has its own validation rules, default values, and frontend behavior. Outputs are declared with io.NodeOutput-compatible types.

Pillar three, the async entrypoint. The whole extension is registered through an async function called comfy_entrypoint(). Inside that function you yield your node classes. The async pattern means the entrypoint can do its own initialization, fetch remote resources, set up connection pools, all without blocking ComfyUI startup.

The shape of the simplest possible V3 node looks like this:

from comfy_api.latest import io

class MyCustomNode(io.ComfyNode):
    @classmethod
    def define_schema(cls):
        return io.Schema(
            node_id="MyCustomNode",
            display_name="My Custom Node",
            category="example",
            inputs=[
                io.Image.Input("image"),
                io.Int.Input("strength", default=10, min=0, max=100),
            ],
            outputs=[io.Image.Output()],
        )
    
    @classmethod
    def execute(cls, image, strength):
        result = image * (strength / 100.0)
        return io.NodeOutput(result)

That is a working V3 node. Define the schema, implement execute, return a NodeOutput. The framework handles the rest.

For an overview of V3 schema changes in production environments, the ComfyUI V3 schema development guide covers the migration patterns and gotchas.

Project Setup with comfy_api.latest

Project setup in V3 is cleaner than V1 because the API surface is explicit. Here is the actual setup I run for any new custom node project.

Directory structure:

my-custom-node/
├── pyproject.toml
├── __init__.py
├── nodes/
│   ├── __init__.py
│   └── my_node.py
├── web/
│   └── widgets/
│       └── my-widget.vue
└── README.md

pyproject.toml:

[project]
name = "my-custom-node"
version = "0.1.0"
description = "Example V3 custom node"
requires-python = ">=3.10"

[project.entry-points."comfyui.extensions"]
my-custom-node = "my_custom_node:comfy_entrypoint"

[tool.comfy]
PublisherId = "your-publisher-id"
DisplayName = "My Custom Node"

Root init.py with the entrypoint:

from comfy_api.latest import io

async def comfy_entrypoint():
    from .nodes.my_node import MyCustomNode
    yield MyCustomNode

The async generator pattern is the key. ComfyUI awaits the entrypoint, iterates the yielded nodes, registers them. If your extension needs to do startup work (fetch config, validate dependencies, initialize a connection), you do that before yielding nodes.

The pyproject.toml carries the registration metadata. ComfyUI discovers extensions through Python entry points rather than file-system scanning. This is more robust than V1's manifest-based discovery.

Defining Input and Output Types

V3 has a typed input system. Each input type carries validation, default values, and frontend behavior. The common ones are these.

Primitive types:

  • io.Int.Input(name, default, min, max, step, tooltip)
  • io.Float.Input(name, default, min, max, step, round, tooltip)
  • io.String.Input(name, default, multiline, tooltip)
  • io.Boolean.Input(name, default, tooltip)

Choice types:

  • io.Combo.Input(name, options, default, tooltip) for dropdown menus

Image and tensor types:

  • io.Image.Input(name, tooltip)
  • io.Mask.Input(name, tooltip)
  • io.Latent.Input(name, tooltip)

Model and conditioning types:

  • io.Model.Input(name, tooltip)
  • io.Conditioning.Input(name, tooltip)
  • io.Clip.Input(name, tooltip)
  • io.Vae.Input(name, tooltip)

File and path types:

  • io.File.Input(name, accept, tooltip) for file uploads

Outputs use the same type system but with .Output() instead of .Input():

outputs=[
    io.Image.Output(name="result"),
    io.String.Output(name="metadata"),
]

Per the Comfy-Org V3 schema issue and discussion, additional types like Audio, Video, and complex compound types continue to be added. The pattern is consistent across all of them.

The advantage over V1. Your IDE knows what every input is. Refactoring is safer. The framework validates types at node-definition time rather than execution time. If you typo a parameter name or pass the wrong type, you find out immediately rather than three minutes into a generation.

The execute Classmethod and Stateless Design

V3's execute method is a stateless classmethod. This is a meaningful change from V1, where execute was an instance method on a node class that could carry state between calls.

The stateless requirement is intentional. It enables ComfyUI to parallelize execution, cache results, and route nodes across distributed workers without worrying about hidden state. Your execute method receives all inputs as arguments and returns all outputs through NodeOutput. No self, no instance variables that persist.

Free ComfyUI Workflows

Find free, open-source ComfyUI workflows for techniques in this article. Open source is strong.

100% Free MIT License Production Ready Star & Try Workflows

Synchronous execute:

@classmethod
def execute(cls, image, strength):
    result = image * (strength / 100.0)
    return io.NodeOutput(result)

Async execute for I/O work:

@classmethod
async def execute(cls, image, api_key):
    import httpx
    async with httpx.AsyncClient() as client:
        response = await client.post(
            "https://api.example.com/process",
            json={"image": image.tolist(), "key": api_key}
        )
        result = response.json()["processed_image"]
    return io.NodeOutput(result)

The async pattern lets your node make HTTP calls, read large files, or do any I/O-bound work without blocking ComfyUI's execution loop. Other nodes can run in parallel while your async node waits on its I/O.

There is a real gotcha. If your async node holds a connection (database, websocket, persistent HTTP session), the connection has to be managed inside the execute scope. You cannot hold connections in class-level state because the class is stateless by contract. Use context managers inside execute.

The ComfyUI V3 schema custom node skills repo on GitHub has good reference patterns for async nodes that do real I/O work.

Building a Custom Vue Widget for the Frontend

This is the part most V1 developers skipped because the frontend extension model was fragile. V3 makes it actually pleasant.

The default frontend behavior for any input type is a sensible widget. Int inputs render as a number stepper. Combo inputs render as a dropdown. Image inputs render as an image picker. For 90 percent of nodes, the defaults are fine.

But sometimes you want something custom. A color picker for a Hex string. A multi-line code editor for a Python expression. A live preview for a parameter. V3 lets you ship a Vue component that the frontend mounts wherever your input would normally render.

The structure is straightforward. You add a web/ folder to your extension. Inside that folder, you put Vue single-file components or JavaScript modules. You reference them in your input definition.

Example, a color picker widget:

@classmethod
def define_schema(cls):
    return io.Schema(
        node_id="ColorPickerNode",
        display_name="Color Picker",
        category="example",
        inputs=[
            io.String.Input("color", default="#FF0000", widget="color-picker"),
        ],
        outputs=[io.String.Output()],
    )

The widget="color-picker" tells the frontend to use the registered color-picker component instead of the default String input. The registration happens through the comfy_entrypoint or through an extension.js file in the web folder.

<!-- web/widgets/color-picker.vue -->
<template>
  <input 
    type="color" 
    :value="modelValue" 
    @input="$emit('update:modelValue', $event.target.value)"
  />
</template>

<script setup>
defineProps(['modelValue'])
defineEmits(['update:modelValue'])
</script>

The Vue component receives modelValue as a prop and emits update:modelValue to communicate back. This is the standard Vue 3 input pattern. It works because ComfyUI's frontend exposes a stable input contract that custom widgets can hook into.

For Vue widget patterns in more detail, the ComfyUI nodes 2.0 Vue migration guide covers the frontend architecture and the patterns for live-preview widgets.

Testing Locally with comfy-cli

The official testing tool in 2026 is comfy-cli. It is a command-line tool that handles installing custom nodes, validating schemas, running test workflows, and packaging extensions for the Registry.

Want to skip the complexity? Apatero gives you professional AI results instantly with no technical setup required.

Zero setup Same quality Start in 30 seconds Create Your AI Influencer
Plans from $12.99/mo

Install comfy-cli:

pip install comfy-cli

Install your local node for development:

cd my-custom-node
comfy node install-local .

This symlinks your development folder into the ComfyUI custom_nodes directory. Changes to your code are picked up on ComfyUI restart.

Run schema validation:

comfy node validate-schema .

This catches schema definition errors before you try to load the node. If you forgot a required field or used a wrong type, validation flags it.

Run a test workflow:

comfy node test-workflow workflows/test_basic.json

You ship test workflow JSON files in your repo, and comfy-cli runs them headlessly to check that your node executes correctly. This is how you build a regression test suite for your custom node.

The development loop I run looks like this. Make code change. Hit comfy-cli validate-schema. If valid, restart ComfyUI. Test the node in the UI. Once it works, write a workflow JSON that exercises the node and add it to my test suite. Run comfy-cli test-workflow before every commit.

That loop took about 30 minutes to set up the first time. It saves hours over the life of the project. Per the DeepWiki Comfy-Org custom node creation guide, the comfy-cli toolchain is the official supported development path.

Publishing to the ComfyUI Registry

When your node is ready, you publish it to the ComfyUI Registry. The Registry is the canonical place for sharing custom nodes in 2026. It replaces the older ComfyUI Manager approach of pulling nodes directly from GitHub.

Publishing flow:

comfy node publish

The CLI walks you through registering a publisher account, signing your package, and uploading the build. After publication, your node appears in the ComfyUI Manager's Registry tab and users can install it with one click.

The Registry adds three things over raw GitHub installs.

First, it validates your package before publication. Schema errors, dependency conflicts, malformed manifests all get caught. Bad packages do not ship.

Creator Program

Earn Up To $1,250+/Month Creating Content

Join our exclusive creator affiliate program. Get paid per viral video based on performance. Create content in your style with full creative freedom.

$100
300K+ views
$300
1M+ views
$500
5M+ views
Weekly payouts
No upfront costs
Full creative freedom

Second, it signs packages cryptographically. Users can verify that the node they install came from you, not a malicious fork. This addresses a real security gap in the older direct-GitHub model. The ComfyUI custom nodes security guide covers the broader security context.

Third, the Registry handles dependency resolution. If your node requires specific versions of other packages, the Registry knows and installs them in the right order. Per the ComfyUI V3 dependency resolution specification, the resolution algorithm is similar to npm or pip but tuned for the ComfyUI ecosystem.

I publish updates to my own nodes maybe once a month. The publish flow takes about two minutes. Updates appear in the Manager within 15 minutes. Users see the update notification on their next ComfyUI launch.

For the related front-end-only development path, the build ComfyUI custom nodes JavaScript frontend guide covers the legacy JavaScript widget approach that V3 Vue components replaced.

Maintenance: API Versioning and Backward Compatibility

V3 introduced explicit API versioning. The import is from comfy_api.latest import io. The latest is a moving target that always points to the most recent API version. If you want to lock to a specific version for stability, you can import from a pinned version like from comfy_api.v3_0 import io.

The versioning is meaningful. ComfyUI updates can extend the API (adding new types, new methods on existing types) but cannot remove or change existing API surface within a major version. If your node imports from comfy_api.v3_0, you are guaranteed your node will keep working through the entire V3 lifecycle.

The maintenance pattern I follow for my own nodes. Develop against comfy_api.latest for new development. Pin to a specific version like comfy_api.v3_0 before release. Update the pin in a controlled way when I am ready to adopt new features.

The benefit. My nodes do not silently break when ComfyUI ships an update. The framework guarantees backward compatibility within a major API version. Hot take. This is the single most important change in V3. It transforms custom-node maintenance from constant firefighting to predictable scheduled updates.

For deploying ComfyUI workflows as production APIs, the ComfyUI API deploy to RunPod serverless guide covers the headless deployment path that pairs naturally with V3 custom nodes.

Real Production Example: A Working V3 Node

Here is a complete working V3 node that does something useful. It takes an image, calls an external watermarking API, and returns the watermarked image. This is a real-world async node that demonstrates the V3 patterns end-to-end.

from comfy_api.latest import io
import httpx
import torch
import numpy as np
from PIL import Image
import io as bytesio
import base64

class C2PAWatermarkNode(io.ComfyNode):
    @classmethod
    def define_schema(cls):
        return io.Schema(
            node_id="C2PAWatermarkNode",
            display_name="C2PA Provenance Watermark",
            category="watermark",
            description="Embeds C2PA provenance metadata into an image",
            inputs=[
                io.Image.Input("image", tooltip="Image to watermark"),
                io.String.Input(
                    "creator_name", 
                    default="", 
                    tooltip="Name to embed as content creator"
                ),
                io.String.Input(
                    "claim_url", 
                    default="", 
                    tooltip="URL claiming authorship"
                ),
                io.String.Input(
                    "api_key", 
                    default="", 
                    tooltip="C2PA service API key"
                ),
            ],
            outputs=[
                io.Image.Output(name="watermarked_image"),
                io.String.Output(name="metadata"),
            ],
        )
    
    @classmethod
    async def execute(cls, image, creator_name, claim_url, api_key):
        # Convert tensor to PNG bytes
        img_array = (image[0].cpu().numpy() * 255).astype(np.uint8)
        pil_img = Image.fromarray(img_array)
        buf = bytesio.BytesIO()
        pil_img.save(buf, format='PNG')
        img_bytes = buf.getvalue()
        img_b64 = base64.b64encode(img_bytes).decode()
        
        # Call watermark API
        async with httpx.AsyncClient(timeout=30.0) as client:
            response = await client.post(
                "https://api.c2pa-service.example/watermark",
                headers={"Authorization": f"Bearer {api_key}"},
                json={
                    "image_b64": img_b64,
                    "creator": creator_name,
                    "claim_url": claim_url,
                }
            )
            response.raise_for_status()
            result = response.json()
        
        # Decode watermarked image
        watermarked_bytes = base64.b64decode(result["watermarked_b64"])
        watermarked_pil = Image.open(bytesio.BytesIO(watermarked_bytes))
        watermarked_array = np.array(watermarked_pil).astype(np.float32) / 255.0
        watermarked_tensor = torch.from_numpy(watermarked_array).unsqueeze(0)
        
        metadata_str = result.get("metadata_json", "{}")
        
        return io.NodeOutput(watermarked_tensor, metadata_str)

This node demonstrates the full V3 pattern. Typed schema. Async execute. External API call. Multiple typed outputs. Real error handling through httpx's raise_for_status. The kind of node you would actually use in a production workflow.

FAQ

Do I need to migrate my V1 custom nodes to V3 now? V1 still works in 2026 but is frozen. No new ComfyUI features ship to V1. If your node is in active development, migrate. If it works and you do not need new features, you have a deprecation runway, but you should plan migration within 12 months.

Can a V3 node coexist with V1 nodes in the same custom_nodes folder? Yes. The two schemas are detected separately by ComfyUI's loader. You can ship a mix during a migration period.

Is the Vue widget system mandatory? No. The default widgets work for most node types. Vue widgets are an option for custom UI. If your node only needs standard inputs, you do not need to ship any frontend code.

What about TypeScript instead of Vue? The Vue widget system uses TypeScript under the hood. You can write your widget components in TypeScript and they integrate naturally. Plain JavaScript also works.

Do async nodes run slower than sync nodes? For CPU-bound work, slightly slower due to event loop overhead, but the difference is negligible (under 1 percent). For I/O-bound work, async is dramatically faster because other nodes can run during your I/O wait.

How do I handle exceptions in async execute? Standard Python try/except. The framework catches uncaught exceptions and surfaces them as workflow execution errors. Wrap risky operations with try/except and return a meaningful error message via NodeOutput when appropriate.

Can a V3 node depend on other custom nodes? Yes, through the Registry's dependency declaration system. You declare your dependencies in pyproject.toml and the Registry handles resolution. Avoid circular dependencies. Avoid depending on unreleased or development-only nodes.

What is the smallest valid V3 node? A class inheriting from io.ComfyNode with define_schema and execute methods. About 15 lines of code. Add an entrypoint function in your package's init.py and you have a working extension.

How do I migrate a complex V1 node with many inputs? Map each V1 input type to its V3 equivalent (INT to io.Int.Input, STRING to io.String.Input, etc). The hardest part is usually the optional inputs, which V3 handles differently. Mark them with default values in the new schema rather than the V1 optional dictionary.

Where do I learn more about advanced patterns? The official V3 migration docs cover the canonical patterns. The Apatero blog ComfyUI section has practical articles on specific node-development scenarios. GitHub repos of well-maintained custom nodes are good references.

Where Apatero Fits Into the Custom Node Story

Full disclosure. I help build Apatero. So I am biased about why this matters.

Apatero uses ComfyUI workflows under the hood for image generation. Custom nodes that work in ComfyUI work in Apatero. When you build a V3 custom node, you can use it in your local ComfyUI for development and also expose it through Apatero Realms for team access. The V3 schema's stateless execute pattern is exactly what Apatero needs to scale node execution across distributed workers.

If you build custom nodes for niche use cases, Apatero is one way to get them in front of more users than just installing them on your own ComfyUI. We expose vetted custom nodes through our Realm system. Users who would never install a custom node manually can still benefit from the work you put into building it.

This is not the only way to distribute your custom nodes. The ComfyUI Registry is the primary channel and remains the canonical place. The Apatero distribution layer is a complementary path for nodes that fit production-grade use cases.

Real Notes from Six Custom Nodes in V3

I have shipped six custom nodes in V3 over the last quarter. The patterns that worked.

Start with the schema. Get the inputs and outputs right before writing any execute logic. Iterate on the schema definition until it feels clean. The schema is the API contract for users of your node.

Use async for any I/O. Even if your initial implementation is sync, if you plan to add HTTP calls, file reads, or database access later, declaring execute async from the start saves migration pain later.

Pin to a specific API version before publishing. comfy_api.latest is fine during development but you want a stable pin in your published package so users do not get surprised by API changes.

Ship test workflows. A test_basic.json that exercises your node and runs through comfy-cli test-workflow catches regressions before users find them. Treat the test workflow as part of your shipped artifact.

Document the parameters. Every input should have a meaningful tooltip. ComfyUI displays the tooltip when the user hovers, and a good tooltip is the difference between "I understand this node" and "I have to read the README to use this."

V3 custom node development in 2026 is genuinely pleasant. The schema typing catches mistakes early. The async support makes I/O work natural. The Vue widget system makes custom UI possible without fighting the frontend. The Registry handles distribution. Five hours of upfront learning pays back across the entire lifetime of every node you build.

Start a new project. Read the docs once. Ship something small. The rest builds from there.

Some models you cannot download at any VRAM

Veo, Kling and Nano Banana have closed weights. There is no local build, no quantization, no 24GB workaround. Run them in the browser instead. First generation free.

#comfyui-development #custom-nodes #v3-schema #comfyui-extension #vue