Skip to content

Build a graph

workflow(name) returns a GraphBuilder. g.add(spec, params) adds a typed node. g.toGraph() is Graph IR.

ts
/**
 * Code-first quickstart graph. Edit this file; do not hand-edit compiled JSON.
 */
import { compile, workflow, type Graph } from "@stepupgaming/comfy-workflows";
import { conditioning, image, latent, loaders, sampling } from "@stepupgaming/comfy-workflows/nodes";

export function build(): Graph {
  const g = workflow("docs-t2i");

  const ckpt = g.add(loaders.CheckpointLoaderSimple, {
    ckpt_name: "v1-5-pruned-emaonly.safetensors",
  });
  const positive = g.add(conditioning.CLIPTextEncode, {
    text: "a red cube on a table, still camera",
    clip: ckpt.CLIP,
  });
  const negative = g.add(conditioning.CLIPTextEncode, {
    text: "blurry, text, watermark",
    clip: ckpt.CLIP,
  });
  const empty = g.add(latent.EmptyLatentImage, {
    width: 512,
    height: 512,
    batch_size: 1,
  });
  const sampled = g.add(sampling.KSampler, {
    model: ckpt.MODEL,
    positive: positive.CONDITIONING,
    negative: negative.CONDITIONING,
    latent_image: empty.LATENT,
    seed: 42n,
    steps: 8,
    cfg: 7,
    sampler_name: "euler",
    scheduler: "normal",
    denoise: 1,
  });
  const decoded = g.add(latent.VAEDecode, {
    samples: sampled.LATENT,
    vae: ckpt.VAE,
  });
  g.add(image.SaveImage, {
    images: decoded.IMAGE,
    filename_prefix: "docs-t2i",
  });
  g.output(decoded.IMAGE, { name: "image" });
  return g.toGraph();
}

export function compiledJson(): string {
  const result = compile(build());
  if (!result.ok) {
    throw new Error(result.errors.map((e) => `${e.code}: ${e.message}`).join("\n"));
  }
  return result.json;
}

Creating a graph

ts
import { workflow } from "@stepupgaming/comfy-workflows";
const g = workflow("mine");

name is metadata. It shows up in explainGraph / cwf explain.

Adding typed nodes

params is checked against the spec:

  • Widget inputs: string, number, bigint, boolean, combo strings
  • Connection inputs: an output handle of the declared socket type
  • Everything: a ParamRef from g.param(...)

A MODEL wired into a CLIP input is a type error.

You can import category namespaces or named specs:

ts
import { loaders, sampling } from "@stepupgaming/comfy-workflows/nodes";
import { KSampler } from "@stepupgaming/comfy-workflows/nodes";

Widget vs connection

The builder splits params for you. If the value is a node output handle, it becomes inputs[name] = { node, out }. Otherwise it becomes params[name].

Node ids and titles

ts
const ks = g.add(sampling.KSampler, { /* ... */ }, { id: "sampler" });
g.setTitle(ks.id, "Main pass");

Imported graphs pass { id } so Comfy errors map back to the original ids. Fresh graphs get n1, n2, … unless you set an id.

Graph outputs

ts
g.output(decoded.IMAGE, { name: "image" });

Without an explicit list, some recipes declare a sensible default (for example the decoded image). Runtime artifact fetch follows graph.outputs.

Modes

ts
g.setMode(ks.id, "bypassed"); // active | bypassed | muted
g.setBypassMap(ks.id, { 0: "model" });

Bypass lowering is conservative. No map → E_UNRESOLVED_BYPASS. Muted node with a consumer → E_MUTED_CONSUMED. Bypass.

Serialization

ts
import { parseGraph, serializeGraph } from "@stepupgaming/comfy-workflows";
const text = serializeGraph(g.toGraph(), { pretty: true });
const again = parseGraph(text);

Next: Connections · Parameters

Released under the MIT License. Unofficial project. Not affiliated with or endorsed by Comfy Org.