comfyui-plugin/skills/comfy-workflow-json/SKILL.md
Programmatically edit ComfyUI workflow JSON: edge-list link rebuilds, link-encoding schemas, preserving the extra block (App Mode config). Use when a workflow JSON is too large to hand-edit or a transform drops state.
npx skillsauth add laurigates/claude-plugins comfy-workflow-jsonInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
Workflow JSONs routinely exceed a Read tool's size limit (a moderately
complex workflow can be 25k+ tokens). Don't chunk-read or hand-edit large
workflow files — write a Python transform script that uses json.load /
json.dump.
| Use this skill when... | Use instead when... |
|---|---|
| A workflow JSON is too large to hand-edit, or you're splicing/rewiring nodes programmatically | Auto-arranging node positions -> comfy-workflow-layout |
| A round-trip transform is silently dropping App Mode or canvas state | Deciding whether to use a subgraph/Blueprint/App Mode at all -> comfy-subgraphs-app-mode |
In-place link surgery (patching the flat links array and every node's
inputs[].link / outputs[].links by hand) is error-prone — the
silent-failure mode is a link id present in one place but not the other.
The robust alternative:
[{src, src_slot, dst, dst_slot, type}] from wf['links'].dst to a new node, drop
edges touching a deleted node, append new edges) and add/remove nodes
from wf['nodes'].inputs[].link → None
and outputs[].links → [], then re-number all edges 1..N into
wf['links'] and re-populate both endpoints' link refs from the edge
list. Set last_link_id / last_node_id.dst_slot indexes the node's inputs array (widget entries included);
src_slot indexes outputs. New nodes get id = max(existing)+1. To
insert node X between src and Target.model: find the edge with
dst==Target, dst_slot==0, repoint its dst to X, then append
X→Target.model. This keeps the socket-array / links-table invariant
correct by construction.
A reference/comparison builder must clone a clean, stable template, not
a workflow the user actively edits in the UI. A file you generated earlier
can be silently reshaped between sessions (extra LoRAs, a swapped
VAE-decode, a converted negative) — basing new variants on it inherits that
drift and breaks structural assumptions a from-scratch builder made (e.g. a
ConditioningZeroOut lookup can raise if the template gained unexpected
nodes). Pick one canonical clean template, hardcode the per-variant deltas,
and never read a user's live working copy as a template.
To execute a workflow JSON headlessly (queue it, poll, collect outputs)
without the browser, see the comfyui-pack-live-smoke skill's
headless-API section. This is the fastest way to verify an edited or built
workflow actually runs — the gold-standard check beyond JSON link-integrity
validation.
The exact format ComfyUI validates against is the frontend's zod
schema — there is no published JSON Schema. If uncertain about a field,
extract the schema from the frontend's own sourcemap rather than guessing
(see comfyui-node-authoring's sourcemap-verification recipe):
FE=<comfyui-root>/.venv/lib/python3.10/site-packages/comfyui_frontend_package/static/assets
python3 -c "import json,glob; m=json.load(open(glob.glob('$FE/api-*.js.map')[0])); i=m['sources'].index('../../src/platform/workflow/validation/schemas/workflowSchema.ts'); print(m['sourcesContent'][i])" > workflowSchema.ts
Re-extract after a frontend bump (the bundle hash changes) and keep the extracted file checked in with a provenance note, so uncertain transforms have a fast source of truth without re-extracting every time.
Schema facts that drive transforms:
version: 0.4 → zComfyWorkflow; version: 1 → zComfyWorkflow1. They differ in link encoding and graph-state
fields (see table below). A frontend can emit either — don't hardcode an
assumption about which one you'll see..passthrough() everywhere. Unknown keys are preserved, not
rejected. json.load→mutate→json.dump is safe; the schema won't strip
fields you don't touch (this is why preserving extra works for free).id/slot fields accept strings or ints (zNodeId, zSlotIndex).
GroupNode hacks node ids to "<id>:<i>" strings, and subgraph slot ids
are UUIDs. Don't assume integer node ids.widgets_values is array or object (zWidgetValues = array | record). Most core nodes use the positional array; some use a keyed
record. Index-based widget edits break on the record form.| Where | Format |
|---|---|
| Top-level links, 0.4 (zComfyLink) | tuple [id, origin_id, origin_slot, target_id, target_slot, type] |
| Top-level links, v1 (zComfyLinkObject) | dict {id, origin_id, origin_slot, target_id, target_slot, type, parentId?} |
| Subgraph-internal links (any host version) | dict zComfyLinkObject — subgraph definitions always follow the v1 schema |
| floatingLinks | dict zComfyLinkObject |
This is why a 0.4 workflow with subgraphs has tuple links at the
top level but dict links inside definitions.subgraphs[]: the
subgraph definition (zSubgraphDefinition) extends zComfyWorkflow1, so
it carries v1 object-links regardless of the host workflow's version. v1
also replaces last_node_id/last_link_id with a state object
({lastNodeId, lastLinkId, lastGroupId, lastRerouteId}).
Newer workflows wrap part of the graph in subgraphs
(wf['definitions']['subgraphs'][i]). Each subgraph has its own nodes,
inputs, outputs, and an internal links table whose format differs
from the top-level:
| Where | Link format |
|---|---|
| Top-level wf['links'] | flat array [id, src, src_slot, dst, dst_slot, type] |
| Subgraph sg['links'] | array of dicts {id, origin_id, origin_slot, target_id, target_slot, type} |
When splicing a node into a subgraph, both the new node's input/output
links arrays and the subgraph's internal links table must
reference the new link ids. A link id present in a socket array but
missing from the links table is the silent-failure mode — the workflow
loads but the splice is invisible.
A subgraph published as a Blueprint (node-library reuse) is a separate
concern from this on-disk shape: the blueprint itself lives server-side via
the userdata API, while each placed instance is a plain embedded
definitions.subgraphs[] copy like any other. Editing the JSON never
touches the blueprint, and there is no live link back to it (instances are
snapshots). See comfy-subgraphs-app-mode.
extra block (App Mode config lives there)Round-trip transforms must carry wf['extra'] through unchanged — it
holds editor and feature state, not just cosmetics:
| Key | What it is |
|---|---|
| extra.ds | canvas pan/zoom |
| extra.frontendVersion | authoring frontend version |
| extra.favoritedWidgets | pinned widgets |
| extra.linearMode / extra.linearData | App Mode input/output bindings + layout |
json.load → mutate nodes → json.dump preserves extra for free. The
failure mode is rebuilding a workflow from scratch in a builder script:
the new dict has no extra, so any App Mode configuration the user set up
in the UI is silently dropped and must be reconfigured. If a workflow has
been App-Mode-configured, prefer an in-place transform over a
from-scratch rebuild, or copy the old extra.linearMode/extra.linearData
across. See comfy-subgraphs-app-mode for the App Mode feature itself.
Symptom: a workflow generates outputs that look like the input image with
only minor changes, even at denoise=1.0. With CFG-distilled/Lightning-
style LoRAs (CFG=1.0) the symptom is especially common because the
negative path is ignored, so an empty positive conditioning falls back to
"reconstruct the reference image."
A real root cause found this way: the node titled positive-prompt and
the node titled negative-prompt were wired into the sampler's
negative and positive inputs respectively. Titles say one thing, links
say the other.
Diagnostic recipe — JSON-only, no UI required:
import json
wf = json.load(open(path))
by_id = {n["id"]: n for n in wf["nodes"]}
ks = next(n for n in wf["nodes"] if n["type"] == "KSampler") # or KSamplerAdvanced
pos_link = next(i for i in ks["inputs"] if i["name"] == "positive")["link"]
neg_link = next(i for i in ks["inputs"] if i["name"] == "negative")["link"]
src_of = lambda lid: next(L[1] for L in wf["links"] if L[0] == lid)
print("positive source:", by_id[src_of(pos_link)].get("title"))
print("negative source:", by_id[src_of(neg_link)].get("title"))
Fix: swap link source nodes in both the links table and the source
nodes' outputs[0].links arrays — a 10-line transform once you know the
link IDs. Same pattern applies to SamplerCustomAdvanced (via
BasicGuider/CFGGuider) and any subgraph-internal sampler; the
link-table layout is what matters, not which sampler family.
Many installs adopt their own save-node filename_prefix convention
(dated buckets, sampler/scheduler/seed tokens, per-workflow kebab-case
node-title slugs) to keep output sortable and runs distinguishable. That
convention — and any audit script enforcing it — is specific to how a
given install organizes its output/ directory, not a portable ComfyUI
fact; design one for your own install rather than importing someone else's
verbatim.
development
Debug HTTP APIs: trace requests, inspect headers. Use when a request fails: check status first.
documentation
Render architecture diagrams from text sources. Use when documenting system topology.
tools
Inspect JSON payloads and extract nested fields. Use when parsing API responses.
tools
--- name: no-description allowed-tools: Read --- # No Description This skill has no description and must be dropped with a warning.