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
createClientrun
SDK in process. Comfy still executes.
Option B — non-Node product
TypeScript at BUILD TIME
↓
generated IR / prompt template
↓
application runtime binds values
↓
Comfy/**
* 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):
/**
* 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?
| Situation | Need Node? |
|---|---|
| Authoring / CI | Yes |
TS app calling createClient | Yes |
| Python/Rust posting compiled JSON | No |
cwf run on a workstation | Yes (the CLI) |