TypeScript SDK for running ComfyUI workflows via the Comfy API v2. The
same code runs against three surfaces — a self-hosted ComfyUI (through
comfy-api-proxy), Comfy
Cloud, or a serverless deployment — changing only the base URL and an
optional API key. It mirrors the behavior of the
Python SDK (comfy-sdk):
upload/dedup inputs, submit a workflow, wait for it, download the outputs —
collapsed here to a single async client (no separate sync/async API;
JavaScript is async-native).
import { Comfy } from "@comfyorg/sdk";
const client = new Comfy("http://127.0.0.1:8189"); // local proxy, no key needed
// const client = new Comfy("https://api.comfy.org", { apiKey: "..." }); // Comfy Cloud
const wf = await client.workflows.fromFile("workflow_api.json");
const asset = client.assets.fromFile("photo.png"); // lazy; hashed + uploaded on first use
wf.setInput("10", "image", asset);
const job = await client.run(wf); // submit, then poll to a terminal state
await job.getOutputs("13")[0].toFile("out.png");-
Node >=22. Node 20 reached end-of-life; browser support is out of scope for v1.
-
Install:
npm i @comfyorg/sdk pnpm add @comfyorg/sdk yarn add @comfyorg/sdk
Releases are published to npm from a GitHub Release (tag
vX.Y.Z) by.github/workflows/publish.yml. To build from source instead (for local development, or to track an unreleased commit), clone this repo,pnpm install,pnpm build, and reference the builtdist/.
| Surface | Auth |
|---|---|
Self-hosted proxy (comfy-api-proxy in front of your own ComfyUI) |
none — do not pass apiKey |
| Comfy Cloud | new Comfy(baseUrl, { apiKey: "comfyui-..." }) |
| Serverless | new Comfy(baseUrl, { apiKey: "comfyui-..." }) |
The client only attaches the Authorization header to requests aimed at its
own baseUrl's origin. If the server hands back an absolute URL on a
different host (for example a job's events/cancel link, or a redirect on
an asset download), the key is not sent there — see "Typed errors" below for
the exception classes this surface can raise.
The SDK identifies itself via User-Agent (for support + usage analytics);
no other data is collected. Pass clientInfo to new Comfy(baseUrl, { ... })
to append your own app's name to it, for example when attributing traffic
from a Worker built on top of this SDK.
import { Comfy } from "@comfyorg/sdk";
const client = new Comfy("http://127.0.0.1:8189");
const wf = await client.workflows.fromFile("workflow_api.json");
const job = await client.run(wf); // submit + poll to a terminal state; throws on failure
const outputs = job.getOutputs("13"); // outputs produced by node "13"
await outputs[0].toFile("out.png");run() submits and polls to completion in one call. If you want to act on
the job in between (read job.status, stream progress, cancel it), use
submit() and drive the job yourself:
const job = await client.submit(wf);
await job.wait(); // poll to terminal (adaptive backoff); or call job.refresh() yourself
console.log(job.status, job.outputs);Workflows that use partner/API nodes (Gemini, etc.) need a Comfy API key to
authenticate them. Pass it per submit with apiKey. This is not the same as
the credential you construct Comfy with: the constructor key authenticates
you to the server, while this one authenticates the partner nodes inside the
workflow (it is often the same comfyui-… key):
const job = await client.run(wf, { apiKey: "comfyui-…" });
// or: await client.submit(wf, { apiKey: "comfyui-…" });The SDK sends it once as extra_data.api_key_comfy_org alongside the workflow —
one key authenticates every partner node in the graph. It is never logged or
persisted by the SDK. Omit apiKey and no extra_data is sent at all.
client.assets.fromFile(path) / client.assets.fromBytes(data, options)
return a lazy asset handle: nothing is hashed or uploaded until the
handle is actually used. Embed the handle directly in a workflow input with
wf.setInput(...):
const asset = client.assets.fromFile("photo.png");
wf.setInput("10", "image", asset);On submit, the SDK walks the workflow graph, finds every embedded handle,
and for each one: hashes the bytes locally (blake3, via
hash-wasm — pure WebAssembly,
no native addon), probes the server's dedup fast path, and only streams a
full upload if the server does not already have those bytes. Each handle is
then substituted in place with a core/ASSET reference
({ __type: "core/ASSET", info: { id, hash, file_path } }) before the
workflow is sent. Re-running a script against unchanged files re-uploads
nothing.
client.assets also has fromStream, fromUrl, and get(assetId) (to
rehydrate a handle for an asset that is already committed) for less common
cases — see the type definitions for details.
job.events() is a typed, auto-reconnecting async iterator over the job's
live event stream:
const job = await client.submit(wf);
for await (const event of job.events()) {
switch (event.kind) {
case "progress":
console.log(event.value);
break;
case "outputReady":
await event.output.toFile(`${event.output.name}`);
break;
case "statusChange":
if (event.status === "succeeded") break;
}
}The stream carries no replay cursor, so a dropped connection is reconnected
from "now," not replayed from the start. Polling stays the source of truth
for whether the job is actually done: if the stream is throttled, drops
permanently, or never even connects, events() falls back to polling
GET /jobs/{id} to detect the terminal state, so consumers never hang
waiting on a stream that isn't coming back. If you only care about the
final result, run()/job.wait() (poll-only, no SSE) is simpler.
submit, run, wait, events, and cancel all accept an AbortSignal,
which stops both the in-flight request and any internal wait (the queue-full
retry pause, the poll backoff, the SSE reconnect pause) — an abort takes
effect immediately rather than only after the current network call returns:
const controller = new AbortController();
setTimeout(() => controller.abort(), 30_000); // give up after 30s
const job = await client.submit(wf, { signal: controller.signal });
await job.wait(undefined, controller.signal);run() also takes a plain timeoutMs if you just want a deadline without
managing an AbortController yourself:
await client.run(wf, { timeoutMs: 60_000 });A finished job exposes its results as output handles — job.outputs, or
job.getOutputs(nodeId) to filter to one node. Each is an asset you can pull
down whichever way suits the caller:
const out = job.getOutputs("13")[0];
await out.toFile("result.png"); // stream to disk
const data = await out.toBytes(); // buffer into memory
await out.toFile("head.png", { range: [0, 1023] }); // range-aware: first 1 KiB onlygetDownloadUrl() hands back a fetchable URL instead of streaming the bytes
through your process — give it to a browser, a CDN, or another service:
const { url, expiresAt } = await out.getDownloadUrl();On Comfy Cloud / serverless it's a short-lived, self-authorizing signed
storage URL: whoever holds it can read the asset until expiresAt with no API
key of their own. On a self-hosted proxy it's the content endpoint (normal auth
still applies) and expiresAt is null. It works on every backend and never
downloads the bytes first.
Protocol-level failures are raised as one exception class per error code, so you can catch what you actually expect instead of string-matching messages:
Unauthorized,Forbidden,NotFoundInvalidWorkflow(andWorkflowFormatUi, for submitting a UI-export instead of an API-format graph)MissingAsset— acore/ASSETreference the server couldn't resolveHashMismatch— uploaded bytes didn't match the declared hashBlobNotFoundIdempotencyKeyReuse— theIdempotency-Keywas reused.submit()(andrun()) attach a fresh key to every call, so an accidental exact resend never runs the workflow twice. Keys are single-use (reject-on-duplicate, no replay), so reusing your own explicitidempotencyKeythrows this. After an ambiguous failure, poll or list your jobs instead of resubmitting with the same key.InsufficientCreditsQueueFull(carriesretryAfter;submit()already retries this one transparently)JobFailed— a job reached a non-succeededterminal state (carries the node-levelerrordetail when the platform provided one)
All extend a shared ComfyError (code, httpStatus, details).
import { JobFailed, MissingAsset } from "@comfyorg/sdk";
try {
await client.run(wf);
} catch (err) {
if (err instanceof JobFailed) {
console.error(err.error); // { code, message, node_id, class_type, traceback } | null
} else if (err instanceof MissingAsset) {
console.error("asset reference was not usable:", err.details);
} else {
throw err;
}
}@comfyorg/sdk— the idiomatic client above: asset dedup/upload,core/ASSETsubstitution, idempotent submit with queue-full backoff, poll-authoritative job completion, typed SSE events, range-aware downloads, and typed errors.@comfyorg/sdk/low— generated types + Zod schemas plus a hand-writtenfetchtransport (ComfyLow) with one method per API operation and the escape hatches the SDK layer is built on: rawResponseaccess, unbuffered streaming bodies (for SSE and range downloads), a streaming multipart upload body, and per-requestAbortSignal/timeout. Use this directly if you need lower-level control.
The generated part of low (src/low/generated/*) is produced by
@hey-api/openapi-ts from spec/openapi.yaml, a
vendored, filtered copy of the canonical Comfy API v2 contract (see
spec/README.md). Regenerate it with pnpm generate after the spec
changes; CI fails if the generated code has drifted from the spec.
pnpm install --frozen-lockfile
pnpm lint # oxlint
pnpm format:check # oxfmt --check
pnpm typecheck # tsc --noEmit
pnpm test # vitest run
pnpm build # tsc -> dist/Other useful scripts:
pnpm generate # regenerate src/low/generated/* from spec/openapi.yaml
pnpm format # oxfmt --write
pnpm test:coverage # vitest run --coverage
pnpm check:spec-drift # fails if src/low/generated/* is stale vs. the spec