@comfyorg/sdk
v0.1.7
Published
TypeScript SDK for running ComfyUI workflows via the Comfy API v2 (self-hosted, Comfy Cloud, serverless).
Downloads
18,451
Readme
TypeScript SDK for running ComfyUI workflows via the Comfy API v2. The
same code runs against three surfaces — Comfy Cloud, a serverless
deployment, or a self-hosted ComfyUI (through
comfy-api-proxy) — changing
only the COMFY_BASE_URL environment variable 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({ apiKey: "..." }); // Comfy Cloud
// COMFY_BASE_URL=http://127.0.0.1:8189 targets a local proxy instead (no key needed)
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");Requirements
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/sdkReleases 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/.
Auth, per surface
| Surface | Auth |
| ------------------------------------------------------------------ | -------------------------------------- |
| Self-hosted proxy (comfy-api-proxy in front of your own ComfyUI) | none — do not pass apiKey |
| Comfy Cloud | new Comfy({ apiKey: "comfyui-..." }) |
| Serverless | new Comfy({ apiKey: "comfyui-..." }) |
The client only attaches the Authorization header to requests aimed at its
own target deployment'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({ ... })
to append your own app's name to it, for example when attributing traffic
from a Worker built on top of this SDK.
Targeting another deployment
new Comfy() points at Comfy Cloud and takes no base-URL argument. To run
against a serverless deployment or a self-hosted instance behind
comfy-api-proxy, set COMFY_BASE_URL in the environment:
export COMFY_BASE_URL="https://<deployment>.run.comfy.app" # serverless
export COMFY_BASE_URL="http://127.0.0.1:8189" # self-hosted proxyIt is read each time a client is constructed, must be an http(s) URL, and
an unset or blank value (including whitespace-only) means Comfy Cloud.
Upgrading from an earlier version: new Comfy(url, opts) becomes
new Comfy(opts) with COMFY_BASE_URL set. A positional string now throws a
TypeError rather than being silently ignored.
Quickstart
import { Comfy } from "@comfyorg/sdk";
const client = new Comfy({ apiKey: "comfyui-..." });
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);Partner (API) node auth
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.
Assets and core/ASSET
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.
Live progress
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.
Cancellation and timeouts
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 });Downloading outputs
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.
Typed errors
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;
}
}Two layers
@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.
Related projects
Clients for the same Comfy API v2 contract:
| Project | Language | Package |
| ------------------------------------------------------------------------- | ---------- | --------------- |
| comfy-python-sdk | Python | comfy-sdk |
| comfy-typescript-sdk | TypeScript | @comfyorg/sdk |
comfy-api-proxy fronts a
self-hosted ComfyUI with this same v2 contract (it is the comfy-api-proxy
entry in the servers list of spec/openapi.yaml).
Development
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