Source profileQuality 94/100Review permissions

laurigates/claude-plugins/comfyui-plugin/skills/comfyui-node-authoring/SKILL.md

comfyui-node-authoring

ComfyUI frontend/backend authoring facts: hiding/serializing widgets, DOM event isolation, endpoints, tooltip lookup, canvas hit-testing, sourcemap verification. Use when writing or patching a custom node's code.

Source repository stars
54
Declared platforms
0
Static risk flags
2
Last source update
2026-08-28
Source checked
2026-08-28

Decision brief

What it does: where it fits

Facts about how ComfyUI's frontend and backend actually behave, gathered from reverse-engineering the (minified) frontend bundle and from real bugs that shipped despite green tests. These apply to any custom-node pack regardless of build system — hand-authored web/js/.js, or a T…

Best for

  • Use when writing or patching a custom node's code.

Not for

  • Tasks that require unconfirmed production actions or broad system permissions.
  • Environments where the pinned source and install steps cannot be inspected.

Compatibility matrix

Platform support, with evidence labels

PlatformStatusEvidenceWhat to check
CodexNot declaredNo explicit evidencePortability before use
Claude CodeNot declaredNo explicit evidencePortability before use
CursorNot declaredNo explicit evidencePortability before use
Gemini CLINot declaredNo explicit evidencePortability before use
Open the compatibility checker

Installation

Inspect first. Install second.

The source command is displayed only when detected. A safe inspection prompt is always available so your agent can explain every action before execution.

Source-detected install commandSource
npx skills add https://github.com/laurigates/claude-plugins --skill "comfyui-plugin/skills/comfyui-node-authoring"
Safe inspection promptEditorial

Inspect the Agent Skill "comfyui-node-authoring" from https://github.com/laurigates/claude-plugins/blob/c056e44b978db58648ad20440dc1515cb09af09d/comfyui-plugin/skills/comfyui-node-authoring/SKILL.md at commit c056e44b978db58648ad20440dc1515cb09af09d. List every install step, command, network request, credential, file read/write, external action, and rollback step. Explain whether it fits my task. Do not install or execute anything until I approve.

Workflow

What the source asks the agent to do

  1. 01

    When to Use This Skill

    Review the “When to Use This Skill” section in the pinned source before continuing.

    Review and apply the “When to Use This Skill” source section.
  2. 02

    Pack layout

    The pack directory name becomes the served URL segment (/extensions//...). Keep it lowercase-kebab so the path is predictable.

    The pack directory name becomes the served URL segment (/extensions//...). Keep it lowercase-kebab so the path is predictable.web/dist ships only in the registry tarball, never a bare git clone, for TS-built packs — it's git-ignored. git clone/nightly installs land main without it (the extension is dead until bun run build runs locally); only…
  3. 03

    Frontend import paths

    From a vanilla web/js/.js, reach the comfy frontend exports with 3 ups:

    From a vanilla web/js/.js, reach the comfy frontend exports with 3 ups:Two ups (the rgthree convention) only works for files at web/.js.
  4. 04

    Hiding a widget while keeping it serializable

    To take over a node's input UI with a DOM widget while leaving the underlying widget's value reachable by the backend, set BOTH:

    To take over a node's input UI with a DOM widget while leaving the underlying widget's value reachable by the backend, set BOTH:The hidden / options.hidden pair is what the frontend reads internally. Setting only widget.type = "hiddensomething" (the old pattern) is insufficient — STRING widgets create a DOM input element positioned by canvas coo…
  5. 05

    A non-serializable widget must set widget.serialize = false AND be appended last

    When a pack adds a helper widget to a node — a "button" opener, a label, any control the user shouldn't have persisted — it MUST both (1) set widget.serialize = false on the widget object itself and (2) be appended to the end of node.widgets. Getting either wrong silently corrup…

    addWidget(type, name, value, cb, { serialize: false }) isPosition matters even with serialize = false. Save is index-basedWhen a pack adds a helper widget to a node — a "button" opener, a label, any control the user shouldn't have persisted — it MUST both (1) set widget.serialize = false on the widget object itself and (2) be appended to t…

Permission review

Static risk signals and limitations

Reads files

low · line 221

The documentation asks the agent to read local files, directories, or repositories.

`PIL.Image.open(path)` is lazy — only the file header is decoded until pixel

Runs scripts

medium · line 303

The documentation asks the agent to run terminal commands or scripts.

python3 - <<'PY'

Evidence record

Why each signal appears

EvidenceSourceComputedTestedEditorial
SignalValueEvidence typeMeaning
Quality score94/100ComputedDocumentation, specificity, maintenance, and trust rules
Repository stars54SourceRepository attention, not individual Skill quality
Compatibility0 platformsSourceDeclared in the catalog source record
Usage guideautomated source guideEditorialGenerated or reviewed according to the visible evidence level

Pinned source

Provenance and original SKILL.md

Repository
laurigates/claude-plugins
Skill path
comfyui-plugin/skills/comfyui-node-authoring/SKILL.md
Commit
c056e44b978db58648ad20440dc1515cb09af09d
License
MIT
Collected
2026-08-28
Default branch
main
View the original SKILL.md

comfyui-node-authoring

Facts about how ComfyUI's frontend and backend actually behave, gathered from reverse-engineering the (minified) frontend bundle and from real bugs that shipped despite green tests. These apply to any custom-node pack regardless of build system — hand-authored web/js/*.js, or a TypeScript+bun-build pack (the layout comfyui-node-scaffold generates).

When to Use This Skill

Use this skill when...Use instead when...
Writing or patching a custom node's frontend or backend codeSetting up the pack's release/publish pipeline -> comfy-registry-lifecycle
Verifying an undocumented LiteGraph/Vue API shapeSmoke-testing the finished pack live -> comfyui-pack-live-smoke

Pack layout

<pack>/
  __init__.py             # NODE_CLASS_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS, WEB_DIRECTORY
  <name>.py                # backend node(s) + any /<your>/<endpoint> routes
  web/
    js/<name>.js            # vanilla-JS frontend extension (hand-authored layout)
    css/<name>.css          # NOT auto-loaded — inject a <link> from the JS
  # — or, for a TS+bun-build pack (comfyui-node-scaffold) —
  src/index.ts              # TypeScript source; WEB_DIRECTORY = "./web/dist"
  web/dist/                 # built output, committed

The pack directory name becomes the served URL segment (/extensions/<pack-dir>/...). Keep it lowercase-kebab so the path is predictable.

web/dist ships only in the registry tarball, never a bare git clone, for TS-built packs — it's git-ignored. git clone/nightly installs land main without it (the extension is dead until bun run build runs locally); only a correctly-configured registry publish ships a prebuilt frontend. See comfy-registry-lifecycle for the publish-pipeline traps that can silently break this.

Frontend import paths

From a vanilla web/js/<file>.js, reach the comfy frontend exports with 3 ups:

import { app } from "../../../scripts/app.js";

Two ups (the rgthree convention) only works for files at web/<file>.js.

Hiding a widget while keeping it serializable

To take over a node's input UI with a DOM widget while leaving the underlying widget's value reachable by the backend, set BOTH:

widget.hidden = true;
widget.options = widget.options || {};
widget.options.hidden = true;
widget.computeSize = () => [0, -4];
// Belt-and-braces for frontends that position DOM elements regardless of `hidden`:
for (const key of ["element", "inputEl"]) {
    const el = widget[key];
    if (el?.style) el.style.display = "none";
}

The hidden / options.hidden pair is what the frontend reads internally. Setting only widget.type = "hidden_something" (the old pattern) is insufficient — STRING widgets create a DOM input element positioned by canvas coords that ignores the type change.

A non-serializable widget must set widget.serialize = false AND be appended last

When a pack adds a helper widget to a node — a "button" opener, a label, any control the user shouldn't have persisted — it MUST both (1) set widget.serialize = false on the widget object itself and (2) be appended to the end of node.widgets. Getting either wrong silently corrupts the widgets_values of every opened workflow, and the frontend then autosaves the corrupted graph back to disk.

The frontend's save/restore loops key on widget.serialize, not widget.options.serialize:

// save (serialize): index-based, non-compacting
for (const [n, r] of widgets.entries()) { if (r.serialize === false) continue; wv[n] = r.value }
// restore (configure): compacting counter
if (wv) { let t = 0; for (const w of widgets) if (w.serialize !== false) { if (t >= wv.length) break; w.value = wv[t++] } }

Two traps:

  1. addWidget(type, name, value, cb, { serialize: false }) is INEFFECTIVE. addWidget stores the option in widget.options.serialize and never sets widget.serialize — so the loops above still treat the widget as serializable. You must assign widget.serialize = false directly (the frontend's own non-serialized widgets do exactly this).
  2. Position matters even with serialize = false. Save is index-based (wv[rawIndex]) but restore is compacting (wv[t++]). A skipped widget placed before real widgets leaves a hole → a leading null on save → every value shifts by one on the next open. A serializable widget at index 0 (e.g. unshifted in nodeCreated, which runs before configure() restores values) consumes wv[0] outright.
const btn = node.addWidget?.("button", "…", null, cb, { serialize: false });
if (btn) btn.serialize = false;   // the flag the frontend actually checks
// do NOT unshift/splice it to the front — addWidget appends to the end; leave it there

Add a serialize?: boolean field to the pack's local widget interface so this type-checks. To verify live in a devtools console:

const n = app.graph._nodes.find(n => n.widgets?.some(w => w.name.includes("…")));
const b = n.widgets.at(-1); b.serialize === false;   // true
n.serialize().widgets_values;                        // dense, no leading/trailing null

DOM widget event isolation

LiteGraph processes pointer/wheel events on the canvas. To make a DOM widget interactive (scroll, tap, type) without the canvas hijacking or zooming the events:

const stop = (e) => e.stopPropagation();
for (const ev of ["pointerdown","pointermove","pointerup","click",
                  "dblclick","contextmenu","touchstart","touchmove",
                  "touchend","keydown","keyup"]) {
    root.addEventListener(ev, stop, { capture: false });
}
scrollEl.addEventListener("wheel", (e) => {
    scrollEl.scrollTop += e.deltaY;
    e.preventDefault();
    e.stopPropagation();
}, { passive: false });

A widget name is not proof of its option source

Matching widgets by name is what makes a usability pack generic across node packs — any node exposing lora_name gets the LoRA picker, whatever its node type. But a name is a convention, not a contract: a third-party node can hardcode a combo under a canonical name, and its options then have nothing to do with folder_paths.

The split that decides whether you need a gate:

The modal renders…RiskGate needed
The widget's own options.values (reformatted, filtered, searchable)none — you show what the node offersno
Content from an external source (a folder_paths listing, an endpoint, a corpus)you can replace the node's only valid choices with values it rejectsyes

Evidence (2026-08, comfyui-model-gallery #66): ComfyUI-Frame-Interpolation's RIFE VFI node hardcodes ckpt_name to rife47.pth, rife49.pth, rife417.pth, rife426.pth, sudo_rife4_269…pth — weights living in that pack's own ckpts/ dir. The gallery matched the name, listed models/checkpoints instead, and offered two diffusion checkpoints the node cannot load. Zero overlap between the two sets, which is exactly the signal.

The gate: require the widget's own values to overlap the external source.

// names = the external source's contents for this category; null when unknown
function optionsMatchSource(w, names) {
  if (!names) return true;                       // source unknown — stay optimistic
  const values = w?.options?.values;
  if (!Array.isArray(values) || values.length === 0) return true;
  return values.some((v) => names.has((v ?? "").toString()));
}

Four things make it work in practice:

  • Prime the source per category at enhance time (fire-and-forget, once), so the check answers synchronously when the user taps. A gate that has to await a fetch mid-pointer-event is a gate that opens the wrong modal first.
  • Decide at tap time, not patch time. The listing arrives asynchronously and a node's options can be rebuilt by a definition refresh, so test inside the opener, not in the if that decides whether to patch.
  • Decline by returning false from the patchWidgetPointer opener — that falls through to the native control, preserving the additive contract. Do not skip patching entirely; you still want the tooltip/callback enhancements.
  • Stay optimistic on unknown. Unknown source or an empty values array must open the modal, exactly as before the gate existed. A conservative default silently withholds the feature whenever the backend is unreachable — a regression that presents as "the pack stopped working" with no error.

Partial overlap is a pass, not a failure: a stale entry alongside real files (a deleted model still listed in a loaded workflow) is normal.

Reusing core endpoints

  • /api/view?filename=<name>&type=input|output|temp&subfolder=<sub>&preview=webp;75 returns a webp thumbnail; handles subfolder-escape checks. Works only for the three managed roots — arbitrary absolute paths must be served by your own endpoint.
  • folder_paths.annotated_filepath() parses name [input|output|temp].
  • PromptServer.instance.routes.get("/your_pack/something") registers an HTTP endpoint. Call from JS via fetch("/your_pack/...").

Subfolder safety

When accepting a subfolder query param under a managed root:

target = os.path.abspath(os.path.join(root, subfolder or ""))
if os.path.commonpath([target, os.path.abspath(root)]) != os.path.abspath(root):
    return web.json_response({"ok": False, "error": "subfolder escapes root"}, status=400)

Without this, subfolder=../../etc reads anywhere on disk that ComfyUI can reach.

Cheap metadata in listing endpoints

PIL.Image.open(path) is lazy — only the file header is decoded until pixel data is accessed. So .size, .mode, and .format are nearly free and safe to call inside an os.scandir listing loop, even for directories of 100+ images. Wrap in try/except so a single broken file doesn't kill the listing, and do not call im.load() or access pixels in the listing loop — that forces a full decode and turns the loop into a multi-second operation.

width: int | None = None
height: int | None = None
try:
    with Image.open(entry.path) as im:
        width, height = im.size
except Exception:
    pass

Sibling-module imports must be relative

ComfyUI imports each pack as a package (custom_nodes.<pack>) and does not put the pack dir on sys.path. A backend file pulling in a sibling module with a bare absolute import xmp_meta raises ModuleNotFoundError at load time, dropping the whole pack (node + frontend). Use a relative import with an absolute fallback so pytest (which runs with the pack root on sys.path) still works:

try:
    from . import xmp_meta          # ComfyUI runtime: package import
except ImportError:
    import xmp_meta                 # pytest: flat import

The pytest suite hides this bug — guard it with a test that imports the backend as a package submodule with the pack dir removed from sys.path. A bare import also passes a registry security scan, so the only signal is the runtime IMPORT FAILED.

Frontend-bundle reverse-engineering

When a frontend behavior isn't documented (e.g. "how do I hide this widget"), grep the minified frontend bundle for property tokens:

grep -oE ".{60}<token>.{30}" \
  <venv>/lib/python*/site-packages/comfyui_frontend_package/static/assets/core-*.js \
  | head -10

Property names survive minification (only variables are mangled), so grep -oE "[a-zA-Z_]+\.hidden\b" is enough to find that the frontend uses widget.hidden = true / widget.options.hidden = true as the canonical hide toggles.

Verify against the sourcemap for anything non-trivial

For a LiteGraph / canvas API whose shape you need precisely, don't trust a guessed property name or an old tutorial — the shipped bundle renames properties under minification and forks rename further. The frontend ships .js.map files with sourcesContent (the original TypeScript). LiteGraph is bundled in the api-*.js.map chunk:

cd <pack>/.venv/lib/python*/site-packages/comfyui_frontend_package/static/assets
grep -l 'LGraphGroup' *.js.map        # find the chunk (usually api-*.js.map)

This recovers full Vue component source too, not just LiteGraph classes — the original .vue (template + <script setup> + scoped CSS) is in sourcesContent keyed by a ../../src/... path. When a UI behaviour lives in the app itself (a topbar, a tab, a dialog) rather than in a pack, grep the maps for the .vue filename:

grep -l 'WorkflowTabs.vue' *.js.map   # the component's chunk (e.g. GraphView-*.js.map)

To extract a class/value cleanly, load the map as JSON and slice sourcesContent (the minified .js itself is useless for names):

python3 - <<'PY'
import json
m = json.load(open("api-<hash>.js.map"))
for name, src in zip(m["sources"], m["sourcesContent"] or []):
    if src and "class LGraphGroup" in src:
        i = src.index("class LGraphGroup"); print(name); print(src[i:i+2000]); break
PY

Record what you confirm in the pack's CLAUDE.md (a "Verified frontend API" table), and note the comfyui-frontend-package version — re-verify after a bump.

Facts confirmed this way (recheck on version bump)

SymbolFinding
LiteGraph.NODE_TITLE_HEIGHT= 30. A node's pos is the body top-left; the title bar sits above it. A group's pos is the whole-box top-left (title drawn inside) — no title offset.
canvas.selectedItemsSet<Positionable> = all selected nodes, groups, and reroutes. Groups and reroutes are individually selectable here.
canvas.selected_nodesDictionary<LGraphNode> (nodes only).
LGraphGroup.pos / .sizegetters/setters over _pos/_size; the size setter self-clamps to minWidth=140/minHeight=80.
LGraphGroup.recomputeInsideNodes()present — call it after mutating a group's size/pos so membership stays correct.
LGraphGroup.iddefaults to -1, not guaranteed unique → use a selection-index fallback when keying.
Canvas zoomwheel-driven (processMouseWheel → ds.changeScale; browsers send pinch-zoom as ctrl+wheel).

Two implementation gotchas that follow:

  • Discriminate items by shape, not instanceof. The class is renamed under minification (and forks rename further), so x instanceof LGraphGroup is fragile. Filter by structure instead: a node has a computeSize() method; a group has pos+size+a string title but no computeSize; a reroute has no size.
  • Suppress native zoom via a wheel interceptor, not just pointer events. Because zoom is wheel-driven, e.stopImmediatePropagation() on pointerdown/pointermove alone will not stop a pinch-zoom. While a gesture is locked, also intercept wheel in the capture phase with passive: false and preventDefault().

Behavioural / touch / visibility bugs: reproduce live, don't trust a static read

Reading the source tells you what the code says; for an interaction bug — hover-gating, touch reachability, z-index overlap, focus, a tap that "does nothing" — a static CSS/template read is not enough to confirm the mechanism. Reproduce against a live instance (see comfyui-pack-live-smoke) before concluding.

Technique that settled a real case (a workflow-tab close button unreachable on touch — ComfyUI_frontend #13279 / PR #13280):

  • Drive it with the chrome-devtools MCP: emulate a mobile viewport with the touch+mobile flags, then confirm the media state you think you're testingmatchMedia('(hover: none)').matches must be true, else you're not actually testing touch.
  • Prove tap reachability with document.elementFromPoint(cx, cy) at the target control's centre: if it returns an overlay element instead of the button/its child, the control is visually present but tap-intercepted — a failure a CSS read of visibility alone will miss.
  • Mutate-and-recheck live: inject the candidate fix as a <style> and re-run the same elementFromPoint + a real .click(), watching the result, before committing to it.

When a bug is reported as conditional ("works with a few, breaks with many"), treat that as ground truth and reproduce the conditional rather than defending a first theory that only explains part of it.

Reading INPUT_TYPES tooltip metadata from JS

Tooltips declared in a node's Python INPUT_TYPES are surfaced to the frontend at multiple distinct locations — there is no single tooltip field. A JS extension that wants to read them needs to walk this lookup chain:

SourcePathWhen populated
Widget optionwidget.options.tooltipCanvas-rendered widgets (most common)
Input slotnode.inputs[i].tooltipWired-socket inputs that round-tripped through the loader
Raw node defnode.constructor.nodeData.input.required|optional[name][1].tooltipAlways — fallback when neither of the above was populated
Output socketnode.constructor.nodeData.output_tooltips[i]Outputs (array indexed by slot)
Node-levelnode.constructor.nodeData.descriptionWhole-node hover / final fallback

node.constructor.nodeData is the full registered node definition — same shape Python's INPUT_TYPES returned, with [type, opts] tuples preserved. Don't assume widget.options.tooltip exists for every widget; DOM widgets and dynamically-created widgets often don't get it copied over, so the nodeData fallback matters.

Hit-testing the canvas from a frontend extension

To map a pointer event to a node / widget / socket / title region:

const [gx, gy] = canvas.convertEventToCanvasOffset(e);            // screen → graph
const node = canvas.graph.getNodeOnPos(gx, gy, canvas.visible_nodes);
// Socket hit (most precise — uses canonical socket positions):
const p = node.getConnectionPos(/* isInput */ true, slotIndex);   // [x, y] in graph coords
// Widget hit:
//   widget.last_y is the y-offset within the node, set on each draw
//   widget.computeSize(node.size[0]) returns [w, h]; fall back to
//   LiteGraph.NODE_WIDGET_HEIGHT (20) when computeSize is absent
// Title-region hit: ly ∈ [-LiteGraph.NODE_TITLE_HEIGHT, 0]

canvas.visible_nodes is what's currently on-screen — pass it to getNodeOnPos so off-screen / culled nodes don't false-hit. Sockets need a tolerance radius (≈14 px works for touch, ≈8 px for mouse).

Smoke-testing a new pack

from server import PromptServer won't import standalone — the ComfyUI runtime sets up sys.path and package import order specially, so local import tests fail with confusing ModuleNotFoundErrors. Verify syntax only with python -m py_compile, then stand the pack up in a real running instance and drive it end to end — see the comfyui-pack-live-smoke skill for the full recipe (browser-driven and headless-API variants, including how to point it at any host via COMFYUI_HOST).

When to skip

The pack clearly owns the file (its own JS logic, not a LiteGraph call), or you already verified the symbol this session / from a recent one at the same comfyui-frontend-package version. Live reproduction is for behavioural bugs; a pure symbol/shape lookup doesn't need a running server.

Frequently asked questions

What to verify before installation and use

What does the comfyui-node-authoring source document cover?

Facts about how ComfyUI's frontend and backend actually behave, gathered from reverse-engineering the (minified) frontend bundle and from real bugs that shipped despite green tests. These apply to any custom-node pack regardless of build system — hand-authored web/js/.js, or a T…

How do I install comfyui-node-authoring?

The source record exposes this install command: npx skills add https://github.com/laurigates/claude-plugins --skill "comfyui-plugin/skills/comfyui-node-authoring". Inspect the command and pinned source before running it.

Which permission-related actions were detected?

Static rules flagged read-files, exec-script in the source; the page lists the matching lines and excerpts.

Alternatives

Compare before choosing

Computed 10045,960

coreyhaines31/marketingskills

ab-testing

When the user wants to plan, design, or implement an A/B test or experiment, or build a growth experimentation program. Also use when the user mentions "A/B test," "split test," "experiment," "test this change," "variant copy," "multivariate test," "hypothesis," "should I test this," "which version is better," "test two versions," "statistical significance," "how long should I run this test," "growth experiments," "experiment velocity," "experiment backlog," "ICE score," "experimentation program

Computed 10029,236

garrytan/gbrain

bulk-ingestion

End-to-end discipline for turning any large data source (audio libraries, email takeouts, document corpora, chat exports, API dumps) into brain pages at scale. The lifecycle spine: SCHEMA → ACCESS → TRIAL → EVALUATE → IMPROVE → CODIFY → TEST → SKILLIFY → BULK → MONITOR. State is tracked in a durable JSON manifest (see MANIFEST-PATTERN.md) so any crash, session boundary, or subagent fan-out resumes from ground truth instead of memory.

Computed 10025,136

alirezarezvani/claude-skills

app-store-optimization

App Store Optimization (ASO) toolkit for researching keywords, analyzing competitor rankings, generating metadata suggestions, and improving app visibility on Apple App Store and Google Play Store. Use when the user asks about ASO, app store rankings, app metadata, app titles and descriptions, app store listings, app visibility, or mobile app marketing on iOS or Android. Supports keyword research and scoring, competitor keyword analysis, metadata optimization, A/B test planning, launch checklist

Computed 1005,277

dotnet/skills

migrate-vstest-to-mtp

Migrates .NET test projects from VSTest to Microsoft.Testing.Platform (MTP). Use when user asks to "migrate to MTP", "switch from VSTest", "enable Microsoft.Testing.Platform", "use MTP runner", set OutputType=Exe only for test projects in Directory.Build.props, or mentions EnableMSTestRunner, EnableNUnitRunner, or UseMicrosoftTestingPlatformRunner. USE FOR: MTP behavioral differences vs VSTest (exit code 8, zero tests discovered, --ignore-exit-code, TESTINGPLATFORM_EXITCODE_IGNORE); centralizing