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.
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.
Requires Python 3.10+. Dependencies: httpx, blake3, pydantic (v2).
pip install comfy-sdkTo 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]"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.
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.
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)| 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 CloudAsyncComfy 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.
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: 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")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.
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:
- hashes the bytes locally with blake3;
- probes the server's dedup fast-path — a
HEADexistence check by hash, then a cheapfrom-hashmint if the server already has those bytes; - 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.)
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 failurejob.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).
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 intactformat 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.)
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
data = out.to_bytes() # buffer into memory
out.to_file("head.png", range=(0, 1023)) # range-aware: first 1 KiB onlyEvery 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. (AsyncOutput mirrors all of the
above with await.)
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")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:
Unauthorized,Forbidden,NotFound— auth and lookup failures.InvalidWorkflow,WorkflowFormatUi— the graph itself was rejected;WorkflowFormatUispecifically 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— acore/ASSETreference could not be resolved.HashMismatch,BlobNotFound— asset upload/dedup failures.IdempotencyKeyReuse— 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, there is no replay — so if you pass your ownidempotency_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_afterseconds.client.submitalready retries this automatically for a bounded budget before giving up and raising it.JobFailed— a job reached a non-succeededterminal state;.errorcarries 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")-
comfy_low— generated protocol bindings. Pydantic v2 models generated fromspec/openapi.yaml(src/comfy_low/models/_generated.py, committed; regenerate withscripts/gen_models.sh, CI fails on drift) plus a thin hand-writtenhttpxtransport (sync + async), one function peroperationId, 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/ASSETsubstitution, idempotent submit, live SSE with reconnect, poll-authoritativerun(), 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.
Clients for the same Comfy API v2 contract:
| Project | Language | Package |
|---|---|---|
| comfy-python-sdk | Python | comfy-sdk |
| comfy-typescript-sdk | TypeScript | @comfyorg/sdk |
pip install -e ".[dev]"
ruff check .
ruff format --check .
mypy src
pytest -vRegenerating and checking the vendored protocol layer (a separate CI job):
pip install -e ".[codegen]"
python 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 driftedReleases 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.