Skip to content

Build-time vs runtime

You do not need to add Node as a daemon to a Rust or Python application merely because Comfy Workflows authored its workflows.

Option A — JavaScript/TypeScript application

The app can:

  • build / instantiate
  • compile
  • createClient
  • run

SDK in process. Comfy still executes.

Option B — non-Node product

TypeScript at BUILD TIME

 generated IR / prompt template

 application runtime binds values

 Comfy
ts
/**
 * Build-time authoring. A non-Node runtime later binds {$param} values in the
 * generated IR / compiled prompt. It does not reimplement the compiler.
 */
import { serializeGraph, workflow } from "@stepupgaming/comfy-workflows";
import { conditioning, image, latent, loaders, sampling } from "@stepupgaming/comfy-workflows/nodes";

export function buildTemplate() {
  const g = workflow("product-demo");
  const prompt = g.param("prompt", { type: "string" });
  const seed = g.param("seed", { type: "int", default: 42n });
  const ckpt = g.add(loaders.CheckpointLoaderSimple, {
    ckpt_name: "v1-5-pruned-emaonly.safetensors",
  });
  const positive = g.add(conditioning.CLIPTextEncode, { text: prompt, clip: ckpt.CLIP });
  const negative = g.add(conditioning.CLIPTextEncode, { text: "", 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,
    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: "product" });
  return g.toGraph();
}

export function emitArtifacts(): { ir: string } {
  return { ir: serializeGraph(buildTemplate(), { pretty: true }) };
}

The binder on the other side of that wall should stay narrow and topology-free. Replace {"$param":"seed"} with a value. Do not create a KSampler. Do not decide that "continue" means a different class_type.

A tiny JS illustration of that binder (the same idea in Python or Rust):

js
/**
 * Tiny non-Node-style binder. It only replaces {$param} placeholders in a
 * compiled prompt template. It does not invent nodes or rewrite topology.
 */
/** @param {any} template @param {Record<string, unknown>} params */
export function bindParams(template, params) {
  const walk = (value) => {
    if (Array.isArray(value)) return value.map(walk);
    if (value && typeof value === "object") {
      const keys = Object.keys(value);
      if (keys.length === 1 && keys[0] === "$param") {
        const name = value.$param;
        if (!(name in params)) {
          throw new Error(`unbound parameter: ${name}`);
        }
        return params[name];
      }
      const out = {};
      for (const [k, v] of Object.entries(value)) out[k] = walk(v);
      return out;
    }
    return value;
  };
  return walk(template);
}

Authority

The Comfy Workflows compiler is the authority for lowering Graph IR to API JSON.

Other runtimes may consume:

  • compiled artifacts
  • generated templates
  • narrow parameter binding

They should not silently fork compiler semantics (bypass lowering, lossless ints, slot indexes).

If you think you need a second compiler, you probably need another ir.build.ts instead.

Node at runtime?

SituationNeed Node?
Authoring / CIYes
TS app calling createClientYes
Python/Rust posting compiled JSONNo
cwf run on a workstationYes (the CLI)

No second compiler

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