Skip to content

Repository files navigation

Comfy

comfy-python-sdk

The Python client for the Comfy API v2.
Submit a workflow, stream its progress, get your outputs — against self-hosted ComfyUI, Comfy Cloud, or serverless.

PyPI Python 3.10+ License: MIT Comfy Cloud


Python SDK for running ComfyUI workflows via the Comfy API v2. The same code runs against Comfy Cloud, a serverless deployment, or a self-hosted ComfyUI instance — only the COMFY_BASE_URL environment variable and an optional API key change.

Requirements and install

Requires Python 3.10+. Dependencies: httpx, blake3, pydantic (v2).

pip install comfy-sdk

To install from source instead (for local development, or to track an unreleased commit):

git clone https://github.com/Comfy-Org/comfy-python-sdk
cd comfy-python-sdk
pip install -e .

# To install everything needed to lint/type-check/test locally
pip install -e ".[dev]"

Optional dependencies

Install the optional pil extra with pip install -e ".[pil]" to use Preview.to_pil() for decoding an in-progress output preview to a PIL.Image.

For local

The SDK works against a ComfyUI instance with Comfy API v2. Comfy Cloud and serverless instances deployed from our developer platform already use Comfy API v2. For local or self-hosted instances, Comfy API v2 can be setup using the comfy-api-proxy.

Getting started

from comfy_sdk import Comfy

client = Comfy(api_key="comfyui-...")   # Comfy Cloud

wf = client.workflows.from_file("workflow_api.json")

# Input assets are hashed locally with blake3
# If the server already has an identical copy we reuse it, if not we upload the asset
# The workflow is updated with the core/ASSET reference instead of a local file path
asset = client.assets.from_file("photo.png")
wf.set_input("10", "image", asset)

# Run workflow
# Get outputs using the output node Id as a reference
job = client.run(wf)
for output in job.get_outputs("9"):
    output.to_file(output.name)

Constructing a workflow

client.workflows builds a Workflow from wherever your API-format graph already lives. All three constructors are local — none of them touches the network:

Constructor Takes
from_file(path) a path to a workflow_api.json on disk
from_json(graph) a graph already in memory, as a dict
from_str(text) the JSON text of a graph

Callers that assemble the graph in code — a service or a cron with no JSON file to ship alongside it — want from_json:

graph = {                                   # API format, same shape as workflow_api.json (abridged)
    "3": {"class_type": "KSampler", "inputs": {"seed": 0, "steps": 20}},
    "9": {"class_type": "SaveImage", "inputs": {"images": ["8", 0]}},
}

wf = client.workflows.from_json(graph)      # or from_str(json.dumps(graph))
wf.set_input("3", "seed", 42)               # sugar for graph["3"]["inputs"]["seed"] = 42

job = client.run(wf)
data = job.get_outputs("9")[0].to_bytes()   # bytes in memory, no file written

from_json wraps the dict you hand it rather than copying, and wf.json is that graph — still a plain, freely-mutable dict if you'd rather edit it directly than go through set_input. AsyncComfy exposes the same three constructors on client.workflows.

Authentication — one client, per-surface key

Surface api_key
Comfy Cloud (https://cloud.comfy.org) — the default Required
Serverless deployment Required
Self-hosted ComfyUI (behind the API proxy) Omit — no key is sent, even implicitly
client = Comfy(api_key="comfyui-...")   # Comfy Cloud

AsyncComfy takes the same arguments. A key is only ever attached to requests aimed at the target deployment's own origin — a server-returned follow-up link (job.urls.self/cancel/events, or a redirected asset download) pointing anywhere else never receives it.

Where the key comes from

Each client resolves its credential once, at construction, in a fixed order:

  1. the explicit api_key= argument — it always wins;
  2. the COMFY_API_KEY environment variable, when no argument was passed;
  3. neither → MissingApiKey, raised locally against Comfy Cloud, which always requires a key. Nothing is sent, so you find out from the constructor rather than from a 401 on your first call.
export COMFY_API_KEY="comfyui-..."
client = Comfy()                        # uses COMFY_API_KEY
client = Comfy(api_key="comfyui-...")   # this key, whatever the environment says

Both sources are read fresh on every construction (so a process can build successive clients under different credentials), and both are trimmed — leading and trailing whitespace is stripped, and a blank value counts as unset, so COMFY_API_KEY= in a shell profile and a key read from a file with a trailing newline both do the obvious thing.

Step 3 applies only to Comfy Cloud, recognized by its normalized origin and path rather than by the exact string — https://cloud.comfy.org:443/ is the same deployment and gets the same local error. A deployment named by COMFY_BASE_URL may have no auth at all, so if nothing resolves there the client is built without a credential and sends none — the self-hosted row of the table above is unchanged. A serverless deployment does require a key, and picks up COMFY_API_KEY the same way; supply one or the server will answer 401.

The key is write-only from the outside: it is never logged, never rendered by a client's repr()/str() (they report authenticated=True|False, never the key), and never placed in an exception message. The same holds for a credential carried in the base URL itself — COMFY_BASE_URL=https://user:token@proxy.example reaches a deployment behind an authenticating proxy, and every repr() renders it as https://***@proxy.example while requests still use the URL as given.

MissingApiKey is a ComfyError like every other SDK exception, and is distinct from Unauthorized — no key at all, versus a key the server rejected:

from comfy_sdk import Comfy, MissingApiKey

try:
    client = Comfy()
except MissingApiKey as exc:
    print(exc)   # names COMFY_API_KEY and the api_key= argument

The low-level comfy_low.ComfyLow transport is unaffected: it takes the key it is handed and reads no environment, since resolution is a comfy_sdk concern.

Targeting another deployment

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 proxy

It 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: Comfy("<url>", "<key>") becomes Comfy(api_key="<key>") with COMFY_BASE_URL set. api_key is keyword-only, so the old positional call raises TypeError rather than reading a URL as a key.

The SDK identifies itself via a User-Agent header (for support and usage analytics) — this is request metadata only; no other data is collected. Pass client_info="my-app" to append an app/my-app token so an integration can attribute its own traffic:

client = Comfy(api_key="comfyui-...", client_info="my-app")

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 api_key=. This is not the same as the api_key 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):

job = client.run(wf, api_key="comfyui-...")
# or drive it yourself:
job = client.submit(wf, api_key="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 api_key and no extra_data is sent at all.

Assets and core/ASSET

client.assets.from_file(...) / from_bytes(...) / from_stream(...) / from_url(...) return a lazy asset handle immediately — no network call yet. Embed it directly into the workflow graph:

asset = client.assets.from_file("photo.png")
wf.set_input("10", "image", asset)

On first use (submitting the workflow, or an explicit asset.commit()), the SDK:

  1. hashes the bytes locally with blake3;
  2. probes the server's dedup fast-path — a HEAD existence check by hash, then a cheap from-hash mint if the server already has those bytes;
  3. only streams a full multipart upload on a miss.

At submit time, every asset handle found anywhere in the graph is replaced by a core/ASSET reference object ({"__type": "core/ASSET", "info": {"id": ..., "hash": ..., "file_path": ...}}), which the server resolves back to the uploaded asset when it runs the workflow.

Once committed, an asset also carries job_id — the id of the job that produced it, or None for an asset with no producing job (e.g. a plain upload) — and expires_at, its retention deadline, or None if it doesn't expire.

Delete an asset with asset.delete(), or by id alone with client.assets.delete(asset_id):

asset.delete()
# or, without holding a handle:
client.assets.delete(asset_id)

Deletion needs a proxy new enough to serve DELETE /api/v2/assets/{id} — an older comfy-api-proxy returns 405 instead. (AsyncAsset.delete() / AsyncAssetFactory.delete() mirror both with await.)

Live progress

job = client.submit(wf)
for event in job.events():          # SSE; live, auto-reconnecting (no replay)
    match event:
        case Progress() as p:       print(f"{p.value:.0%} {p.message}")
        case Preview() as pv:       show(pv.to_pil())
        case OutputReady() as o:    o.output.to_file(f"partial/{o.output.name}")
        case StatusChange(status="succeeded"): break
result = job.result()               # raises JobFailed with node details on failure

job.events() reconnects automatically if the stream drops, but never replays a frame you've already seen (the stream carries no cursor). That's why polling stays authoritative: job.wait() / job.result() (and client.run(), which is submit() + result()) always fall back to GET /jobs/{id} to decide when a job is really done — use events() for live UI feedback, and wait()/result()/run() for the definitive answer. job.status is the current status string; job.outputs is the full list of output handles regardless of which node produced them (job.get_outputs(node_id) filters to one node, as in the quickstart above).

Getting a job's workflow back

The SDK only holds the workflow it submitted for as long as the originating Job handle stays alive — for a job rehydrated purely by id (client.jobs.get(job_id)), get_workflow() is the only way to see the graph:

job = client.jobs.get(job_id)
wf = job.get_workflow()
match wf.format:
    case "api":   ...   # the executed graph; frontend-only nodes already resolved away
    case "save":  ...   # the authoring workflow at the pinned version, canvas layout intact

format discriminates the shape of wf.graph, so branch on it rather than assume one. It depends on how the job was submitted, not on anything a caller controls — jobs submitted through this SDK always get "api" today, since v2 submission has no version-pinning fields yet. (AsyncJob.get_workflow() mirrors this with await.)

Downloading outputs

A finished job exposes its results as Output handles — job.outputs, or job.get_outputs(node_id) to filter to one node. Each output is an asset you can pull down whichever way suits the caller:

out = job.get_outputs("13")[0]
out.to_file("result.png")                   # stream to disk in chunks
with open("result.bin", "wb") as stream:
    written = out.to_stream(stream)          # write to an already-open binary stream
data = out.to_bytes()                       # buffer into memory
out.to_file("head.png", range=(0, 1023))    # range-aware: first 1 KiB only

Every output also carries job_id, the id of the job that produced it — so a caller holding just an output can get back to the job that made it.

get_download_url() hands back a fetchable URL instead of transferring the bytes through your process — give it to a browser, a CDN, or another service:

link = out.get_download_url()               # DownloadUrl(url=..., expires_at=...)

On Comfy Cloud / serverless the URL is a short-lived, self-authorizing signed storage URL: whoever holds it can read the asset until expires_at with no API key of their own. On a self-hosted proxy it's the content endpoint (normal auth still applies) and expires_at is None. It works on every backend and never downloads the bytes first.

Outputs are kind-typed

Outputs aren't assumed to be images. output.type is the normalized kind of what the node produced — one of image, video, audio, text, file, latent — and sits alongside output.content_type (the exact MIME type), output.name and output.size_bytes. Branch on it rather than sniffing the filename:

for out in job.outputs:
    match out.type:
        case "image":
            out.to_file(out.name)                        # stream straight to disk
        case "audio":
            transcode(out.to_bytes(), out.content_type)  # bytes in memory, nothing written
        case "video":
            enqueue(out.get_download_url().url)          # hand the URL off, transfer nothing
        case _:
            print(out.type, out.name, out.size_bytes)

(AsyncOutput mirrors all of the above with await.)

The models namespace

Model operations live in a namespace on the client you already constructed — client.models — rather than in a second client object:

client = Comfy(api_key="comfyui-...")

client.models.base_url   # the client's own base URL, where model requests go
client.models.timeout    # the client's own HTTP timeout

The namespace is bound to that client's transport, so it uses the client's credentials, base URL, connection pool and timeout, and a configuration change made on the client afterwards applies through models as well — there is no second set of settings to keep in sync. AsyncComfy carries the same models namespace, and nothing extra is imported or constructed for it: from comfy_sdk import Comfy stays the only entry point.

base_url and timeout are a read-only view of that shared configuration; model operations are added to this namespace as they land.

models.run — one call, one result

result = client.models.run("acme/flux/dev", {"prompt": "a cat", "steps": 4})
result["images"][0]["url"]

run returns when the generation is complete. There is no submit step and nothing to poll: where the platform has to submit-and-poll an upstream provider, that happens server side inside this one call. The value you get back is the provider's own payload — decoded JSON, handed over as-is, with no wrapper class between you and the fields the provider documented.

The awaitable form is the async client, not a differently-named method:

async with AsyncComfy(api_key="comfyui-...") as client:
    result = await client.models.run("acme/flux/dev", {"prompt": "a cat"})

There is no run_async(), and there will not be one — one operation, one name, and await is what makes it asynchronous.

Because the server may legitimately hold the connection for minutes, run uses its own 10-minute timeout rather than the client's (which is sized for ordinary API calls). Pass timeout= seconds, an httpx.Timeout, or None to wait indefinitely. Each call also sends a fresh Idempotency-Key, so an accidental exact resend is rejected by the server instead of billing a second generation; pass idempotency_key= to choose the value yourself.

Sync and async

Comfy and AsyncComfy expose the identical surface — swap the import and add await / async for:

from comfy_sdk import AsyncComfy

async def main() -> None:
    async with AsyncComfy(api_key="comfyui-...") as client:
        wf = client.workflows.from_file("workflow_api.json")
        job = await client.run(wf)
        await job.outputs[0].to_file("out.png")
        with open("out.bin", "wb") as stream:
            await job.outputs[0].to_stream(stream)

Typed errors

comfy_sdk translates the API's error envelope into a small set of exceptions, all importable from the top-level package and all subclasses of ComfyError:

Catch these SDK-level exceptions around Comfy/AsyncComfy methods. Public asset, job, event, and output helpers translate protocol errors, so catches of comfy_low.errors.* belong only around direct low-level transport calls.

  • Unauthorized, Forbidden, NotFound — auth and lookup failures.
  • InvalidWorkflow, WorkflowFormatUi — the graph itself was rejected; WorkflowFormatUi specifically means a UI-export (nodes/links/ last_node_id) was submitted instead of the API-format graph — the SDK catches this locally before it ever reaches the server.
  • MissingAsset — a core/ASSET reference could not be resolved.
  • HashMismatch, BlobNotFound — asset upload/dedup failures.
  • IdempotencyKeyReuse — the Idempotency-Key was reused. submit() (and run()) attach a fresh key to every call, so an accidental exact resend never runs the workflow twice. Keys are single-use — reject-on-duplicate, there is no replay — so if you pass your own idempotency_key= and reuse it, the second call raises this. After an ambiguous failure (e.g. a timeout where you don't know if the job was created), poll or list your jobs rather than resubmitting with the same key.
  • InsufficientCredits — the account can't afford the job.
  • QueueFull — backpressure; carries .retry_after seconds. client.submit retries 429 responses with Retry-After for a bounded budget (including deployment warm-up), then raises the translated error if backpressure remains.
  • JobFailed — a job reached a non-succeeded terminal state; .error carries node-level detail when the platform provided one.
from comfy_sdk import JobFailed, QueueFull, Unauthorized

try:
    result = client.run(wf)
except JobFailed as e:
    print(e.error)
except Unauthorized:
    print("check your api_key")

Architecture — two layers

  • comfy_low — generated protocol bindings. Pydantic v2 models generated from spec/openapi.yaml (src/comfy_low/models/_generated.py, committed; regenerate with scripts/gen_models.sh, CI fails on drift) plus a thin hand-written httpx transport (sync + async), one function per operationId, with the mandatory escape hatches: raw response access, unbuffered/streaming bodies, all headers, and per-request timeout/abort. Boring and replaceable.

  • comfy_sdk — the idiomatic layer integrators import. This is where the value lives: blake3 content-addressed dedup-upload, core/ASSET substitution, idempotent submit, live SSE with reconnect, poll-authoritative run(), range-aware downloads, and typed exceptions mapping the error envelope.

spec/openapi.yaml is a one-way vendored copy of the canonical Comfy API v2 contract — do not hand-edit it (see spec/README.md). It's synced periodically from that canonical contract, stripped of anything tagged internal, and pinned by spec/VERSION.

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

Development

See CONTRIBUTING.md for the uv-based setup, the full list of checks CI requires, and why src/comfy_low/models/_generated.py must never be hand-edited.

pip install -e ".[dev]"
ruff check .
ruff format --check .
mypy src
pytest -v

Regenerating and checking the vendored protocol layer (a separate CI job):

pip install -e ".[codegen]"
bash scripts/gen_models.sh       # regenerate comfy_low models from spec/openapi.yaml
python scripts/check_drift.py    # same check CI runs; fails if committed models drifted

Releases

Releases are published to PyPI from a GitHub Release (tag vX.Y.Z) by .github/workflows/publish.yml, using PyPI's Trusted Publishing (OIDC) — no API token is stored in this repo.

About

Python client SDK for ComfyUI (self-hosted) and Comfy Cloud

Resources

Contributing

Stars

9 stars

Watchers

1 watching

Forks

Releases

Packages

Contributors

Languages