Repository navigation
Releases: Comfy-Org/comfy-typescript-sdk
Release list
v0.5.0 — Job labels
Adds job labels: submit takes metadata to label a job, and listJobs finds jobs by those labels. The full list of changes since 0.4.0 is below (from CHANGELOG.md).
Added
- The rest of a job's state is readable off the handle.
Jobheld the
whole v2 job model privately and re-exported four fields, so a caller who
wanted a run's duration had to cast pastprivateto reach the timestamps.
Eight read-only accessors now cover the remainder of the wire contract:
createdAtandexpiresAt(Date),startedAtandcompletedAt
(Date | null— both are nullable on the wire, and a duration is
completedAtminusstartedAt),progress,queuePosition,metricsand
urls. They read whatever state the handle currently holds, exactly like
id/status— nothing re-fetches implicitly — and the object-valued three
hand back a snapshot copy so editing the result cannot rewrite the handle's
own links.progressis the sameProgressthe event stream yields (which
now also carriescurrentNodeClass). Comfy Cloud's poll response has been
reported to carryprogress: nulleven for a running job, so
job.events()remains the live-progress source there. Where the wire field is nullable, an absent or unusable value
reads as "none" rather than as anInvalid Dateor an empty snapshot; where
it is required and non-nullable (createdAt,expiresAt,urls) a
response that omits it raisesComfyError(unexpected_response) instead
of handing back aDatethat silently compares false against everything, or
a{}typed as a full set of links. - Job labels:
submit()takesmetadata, andclient.listJobs()finds
jobs by it.metadatais a map of your own string keys to string values,
sent as the body'smetadata; a submit without it sends the same request
as before.job.metadatareturns the labels (an empty object when there
are none).client.listJobs({ metadata, limit, signal })lists your jobs
newest first, filtered by up to three labels, as an async iterator that
fetches page by page; a 429 on a page is retried assubmit()retries one.
The SDK also checks each listed job against the filter, so a server that
ignores it yields only matching jobs. Anext_cursorthe walk has already
followed raisesComfyErrorwith codeunexpected_responseinstead of
fetching the same pages forever.
A rejected map raisesComfyErrorwith codemetadata_invalid. Needs a
server that supports job metadata: Comfy Cloud answers a labelled submit
withmetadata_not_supportedand the list withnot_implemented(501),
and a self-hosted proxy keeps no labels (a filtered list yields nothing
there). RunJsonResult.replayed/RunBinaryResult.replayed—truewhen
Router served the call from itsIdempotency-Keyrecord (Idempotent-Replayed)
rather than by running the model again, so a replayed result can be told from
a fresh charge: a replay restates the original run and is not billed a second
time, so a spend tracker must skip it rather than add it up again. Derived
from the header's PRESENCE, because Router omits it on a fresh run rather
than sendingfalse. It happens on this SDK's own collect loop (the same-key
re-send after a paced409/504) and on a caller's own retry under a
suppliedidempotencyKey. AlwaysfalsefromRequestHandle.get()— the
queued result route carries no replay marker — so deduplicate re-collection
byRequestHandle.requestIdinstead. OPTIONAL on both interfaces for the
same source-compatibility reason ascreditsUsedbelow; every result this
SDK returns sets it.creditsUsedon a run result — what Router priced the call at. Both
arms ofRunResult(RunJsonResultandRunBinaryResult) now carry the
X-Comfy-Credits-Usedresponse header. The queued result
(RequestHandle.get(), and socomfy.models.subscribe) reads it too, but
the contract does not declare it on the queued result route, so there it is
unpinned: expectnull, and reconcile spend against the workspace ledger if
it matters (callingrunfor the price is a second, billed generation).
Typedstring | null, verbatim off the wire:
the value is decimal and a caller reconciling money should parse it
deliberately rather than receive a float this SDK chose the rounding of.
Three caveats it is worth reading the TSDoc for — it is a price rather than
a settled ledger entry,nullmeans "not reported" and never "free", and a
reported"0"is a real cost, so branch on presence (creditsUsed != null)
rather than on the value being non-zero. A blank header is normalized to
nullfor that last reason: passed through,""would clear a presence
check and then read as a cost of zero. The field is OPTIONAL on both
interfaces so that a consumer's ownRunJsonResult/RunBinaryResult
literal — a test double written against0.4.0, which shipped these
interfaces without it — keeps compiling; every result this SDK returns sets
it. Success-only, per the Router contract: the header is written on the path
that returns a result, so a Router refusal carries no cost. A priced200
that the SDK then refuses client-side (response_too_large, a body-read
timeout, an empty or non-JSON body) throws aComfyErrorwith no cost on
it, so reconcile those failures against the workspace ledger.routerErrors.QueueBacklogFullfor thequeue_backlog_fullbucket: a
queued submit refused with429because you already have too many queued
requests waiting. Nothing was submitted or charged; submit again once some
of your queued requests finish. It shares429with
ConcurrencyLimitExceeded, anderrorTypetells them apart.
Changed
- Breaking:
new Comfy()now resolvesCOMFY_API_KEY, matching the Python
SDK. The class client takes its credential from the explicitapiKeyoption,
then fromCOMFY_API_KEYin the environment (trimmed; blank counts as unset,
and it is read per construction), then — targeting Comfy Cloud, which always
requires one — throwsMissingCredentialsat construction naming both ways to
supply it, before any request.comfy.models.*already read that variable;
the class client did not, sonew Comfy()used to send noAuthorization
header and come back with a bare401from the server. Pointing
COMFY_BASE_URLat another deployment keeps the keyless flow: an unresolved
key there is not an error and means "send no credentials". ACOMFY_API_KEY
set in the environment, though, is now sent to that deployment too, as the
Python SDK does — unset it for a keyless target. The
new Comfy({ apiKey: process.env.COMFY_API_KEY })workaround in the README
is gone. Note for callers who relied on the old behaviour:new Comfy()
against Comfy Cloud with no key anywhere now throws locally instead of failing
on the first call, and anapiKeythat is not a string — includingnull, as
a JSON config file spells "absent" — now throws aTypeErrorat
construction. Passundefined(or omit the field) to fall back to the
environment.
Fixed
idempotencyKeyis now stamped onto every errorcomfy.models.runandcomfy.models.submitthrow, including raw transport failures and aborts. Previously only therunresponse-pathComfyErrorcarried it; undici's transport-failureTypeError("fetch failed"), an already-aborted signal'sAbortError, and the queue path'sRouterError(e.g. a bare502ProviderError) all escaped without it. On a transport failure the server never minted anX-Comfy-Request-Id, so the key is the only value that correlates the failure to the server-side record. It is now attached as an ownidempotencyKeyproperty on the raw throwable (its class,name, message and stack are otherwise untouched), androuterErrors.RouterErrorgained a typedidempotencyKeyfield. A failure to collect a generation additionally carries theRetry-Afterpace Router named, rather than reporting none. Where the throwable is one the caller owns and other calls share — anAbortController'ssignal.reason, whichfetchhands to every concurrent call on that controller — each call receives an equivalent per-call error carrying ITS OWN key instead, so no caller reads a key belonging to another generation andcontroller.signal.reasonis left unmodified. Mirrors the Python SDK'sexceptions.translating(idempotency_key=…).- The
droppedParamsdoc comments now match the vendored Router contract.
The TSDoc onparseDroppedParamsandRunJsonResult.droppedParamsstill
described the pre-sync spec: it called the declared
X-Comfy-Router-Dropped-Paramsschema a defect that would be reverted, and
said only an explicitmodelProvidertranslation could populate the field.
The spec declares that header as one JSON-encoded string deliberately — each
entry is a sentence carrying commas of its own — and names an automatic
fallback_providerretry as a second producer, so a call that never set
modelProvidercan still come back with a non-nulldroppedParams. Comments
only; the parsing and the header handling are unchanged. retryAfteron aComfyErrorfrom aComfymethod (submit(),
client.jobs.get(), asset and output calls) now carries the server's
Retry-Afteron every error. OnlyQueueFullkept it before; every other
error hadnulleven when the header was sent.submit()waits at least one second before re-sending after a 429. A
Retry-After: 0used to re-send at once, over and over, for the whole
one-minute retry budget.
v0.4.0 — Router alt-provider controls
Adds the Comfy Router alt-provider controls to models.run, and the disclosure needed to tell an alt-provider run from a native one. Twin of comfy-sdk v0.4.0 (Python).
Added
modelProvider,strictModeandfallbackProvideronRunOptions— sent only when set, so a call that names none appends no query at all and is byte-for-byte the request this route always made.servingProvideranddroppedParamsonRunResult, besiderequestId, populated on every path including the queued one.parseDroppedParams,FALLBACK_PROVIDER_HEADERandDROPPED_PARAMS_HEADERexported.
Why the new RunResult fields
With modelProvider, the response is translated back to the model's own native contract — so data is identical whether an alternate or the native provider served the call. X-Comfy-Router-Fallback-Provider is the only disclosure that they differed, and X-Comfy-Router-Dropped-Params the only disclosure that translating the request onto the alternate's schema could not carry a field.
Fixed
strict_modewas rendered with a truthiness test, so the string"false"— truthy in JS, and the exact spelling the siblingfallbackProvideroption asks for — inverted the flag that decides whether the body is translated or passed through raw. Now an explicit=== true.fallbackProvidernow acceptsboolean | stringand normalises the boolean. The server reads any value other thanfalseas fallback ON, so"False","0","no"and"off"all type-checked and silently did the opposite.queue_timeoutis now inTERMINAL_ERROR_TYPES. It arrives on a504, which thestatus >= 500rule retried, so an admission timeout burned the whole retry budget re-asking the queue that had just said it could not admit the work.
Note for callers
Recovering a lost generation must resend these controls alongside idempotencyKey. A key's identity covers the query, so replaying without them presents the same key under a different query and leaves the generation uncollectable.
v0.3.0
comfy.models gains queued delivery and model discovery, and run no longer destroys a binary generation on the way back.
⚠️ Breaking
Types. RunResult is now a discriminated union: RunJsonResult (kind: "json") and the new RunBinaryResult (kind: "binary", data a Uint8Array, contentType the partner's own media type). A JSON result's runtime shape only gains kind, so existing code keeps running — but types need a narrowing:
const result = await comfy.models.run("bfl/flux-2-pro", { prompt });
if (result.kind === "json") result.data; // your TData againRuntime. models.run now caps the response body it will buffer at 64 MiB by default, where it was previously unbounded — a larger result raises response_too_large instead of resolving. Nothing in the catalog returns that much today; if yours does, pass maxBytes: <larger> or maxBytes: null.
Added
comfy.models.submit/subscribe/handle— queue a request and collect it later, instead of holding one connection open for the whole generation.submitresolves to aRequestHandlewithstatus(),get(),cancel()and async-iterableevents();subscribeis submit + poll + collect in one call. Preview-gated server-side — outside it,403 not_enabledarrives asrouterErrors.NotEnabled. (#133)comfy.models.schema()/comfy.models.list()— ask Router what it runs and what each model takes.schema()sends a heldetagasIf-None-Matchand resolves a304as an explicit{ unchanged: true };list()is async-iterable over the whole catalog, withlist().page()for callers driving their own pagination. (#138)maxBytesonmodels.run— per-call control of the cap above (see Breaking).DEFAULT_MAX_RESPONSE_BYTESis exported;nulldisables it. A breach raisescode: "response_too_large"and is deliberately not retried. (#145)routerErrors.errorFromCompletion(body, requestId)— the typed error a completed queued request reports, ornull. (#133)
Fixed
- A binary
200no longer throwsunexpected_response. The ElevenLabs audio models (elevenlabs/eleven_v3,elevenlabs/eleven_sfx_v2) were unusable: their bytes were UTF-8-decoded andJSON.parsed, destroying the generation after the server had run and billed it.runnow readsContent-Typebefore touching the body. (#139)
Changed
- A
200declaring a non-JSONContent-Typeis a binary result rather than an error. A200declaring none is parsed as JSON if it decodes and parses, and is binary otherwise. (#139) models.runsendsAccept: application/json, */*;q=0.9— JSON still ranked first, but the client no longer claims to reject the binary branch its own contract declares. (#139)
npm i @comfyorg/sdk@0.3.0Full changelog: v0.2.0...v0.3.0
v0.2.0
comfy.models.run now collects a generation it already dispatched instead of losing it, uploaded assets get a directly-fetchable URL, and a job's execution log can be read on demand.
Highlights
A generation that outlives the connection is collected, not lost. Comfy answers two failures with a Retry-After that means "the generation your Idempotency-Key already names has not finished — wait, then ask again for that one": a 409 carrying concurrency_limit_exceeded (an earlier attempt of this same call is still in flight, which is what a re-send after a dropped connection meets) and a 504 carrying deadline_exceeded (Comfy stopped holding the connection at its own bound while the provider carried on). Both used to be terminal, so a caller lost a generation that had already been dispatched and paid for. run now waits the interval the server named, re-sends the same key, and resolves with the result: no second dispatch, no second charge, nothing to enable. The collect loop has its own budget, retry.collectBudgetMs (default 20 minutes); collectBudgetMs: 0 switches it off alone, retry: false switches off retries and collection together.
The default comfy.models.run deadline is 20 minutes, up from 10. The deadline covers every attempt of a call, and Comfy's own deadline is 10 minutes — so the old default was exactly spent at the moment a deadline_exceeded 504 arrived, and the collect above could never start under it. Successful calls are unaffected; the deadline is a ceiling, not a wait. Pass timeoutMs: 600_000 for the old bound.
Every ComfyError carries retryAfter and idempotencyKey. The server's Retry-After in seconds (null when none was sent), and the key the failed call went out under — including a key the SDK minted for you, which was previously visible nowhere. Together they are what a manual re-ask needs once the collect budget is spent. RouterError gains retryAfter for the same reason.
Asset.getDownloadUrl(). A directly-fetchable URL for an uploaded asset's bytes, mirroring Output.getDownloadUrl(). On Comfy Cloud it is a short-lived signed URL any fetcher can read until expiresAt, which is what lets a local image be passed to a URL-taking image-to-image model: upload it as an asset, resolve its URL, put the URL in the model's input. Matches Asset.get_download_url() in the Python SDK.
Job.getLogs(). The run's captured execution log via GET /api/v2/jobs/{id}/logs, or null when the job has none — today only a job run on a serverless deployment has one; Comfy Cloud captures none. The text is untrusted workflow output and should be rendered as plain text.
Upgrading
No API removals. Two defaults changed on the comfy.models.run path: a 409/504 carrying Retry-After is now collected rather than thrown, and the default deadline is 20 minutes. If you relied on the previous behaviour, pass retry: { collectBudgetMs: 0 } and/or timeoutMs: 600_000.
Full detail in CHANGELOG.md.
v0.1.9
One fix: a timeoutMs longer than five minutes is now actually honoured.
Highlights
Long requests no longer die at 300 s. On Node — the only runtime this
package supports — fetch is undici, and undici keeps two timers of its own on
the dispatcher, where no AbortSignal can reach them: headersTimeout and
bodyTimeout, both defaulting to 300 s. Anything asked to wait longer was
silently capped there, and it failed in the worst shape for a caller:
TypeError: fetch failed, with no HTTP status and no server request id to
quote in a support ticket, while the generation had already been dispatched and
billed.
comfy.models.run felt this hardest. It holds one request open for the whole
generation — the server does the provider-side polling inside the call, so
nothing arrives on the connection until the model finishes — and its own
default deadline is 600 s. The default path was capped at half its own
default; you did not have to ask for anything unusual to hit it.
Both limits are now derived per request from the deadline the caller actually
asked for, and timeoutMs: null disables them the same way it already disabled
the deadline.
Your transport stays yours. A dispatcher already on the request — a
ProxyAgent, an mTLS agent, an egress policy — is delegated to rather than
replaced, so a host that installed one keeps it. A client constructed with its
own fetch is left alone entirely.
No API changes, no breaking changes. Upgrading is a version bump.
Full detail in CHANGELOG.md.
v0.1.8
The release that makes comfy.models.run work against the live Comfy Router service.
Highlights
models.run reaches Router's real route. POST {routerBaseUrl}/v2/models/{provider}/{model} — the Router service moved its model routes from /v1/models to /v2/models, and against the live service the old path answered a bare 404. The vendored spec/router-openapi.yaml is synced to the same contract and the router-spec contract test re-pins the two together.
One call, native payloads. comfy.models.run(model, input) takes the canonical {provider}/{model} id and the model's own native JSON input, and resolves only when the generation is complete (server-side provider polling included) to { data, requestId }: data is the provider's payload untouched, typed unknown and narrowable with run<T>(...); requestId is the server's X-Comfy-Request-Id, present on thrown ComfyErrors too. An Idempotency-Key goes out on every call. Errors carry Router's own error_type on ComfyError.code — branch on the code.
Full detail in CHANGELOG.md.
v0.1.7
Added
-
job.getWorkflow()— fetch the workflow behind a job, including one rehydrated by id. Returns the graph and aformatdiscriminator:save— the authoring workflow at the version the job ran, with canvas layout and editor-only nodes intactapi— the executed API-format graph
Branch on
format; which shape comes back depends on how the job was submitted, not on anything the caller controls. Jobs submitted through this SDK always getapitoday. -
Asset deletion —
Asset.delete()andassets.delete(id), matching the Python SDK. -
jobIdon outputs and assets — get from an output file back to the job that produced it, without a side table. Absent for uploaded assets, which have no producing job. -
expiresAton assets.
Fixed
-
jobIdandexpiresAtwere present on the wire but not exposed by the public wrapper classes, so they were unreachable. Found by end-to-end testing against Comfy Cloud and a self-hosted proxy. -
getJobWorkflowgiven a job URL rather than a bare id fetched the job resource instead of its workflow, returningworkflowandformatasundefinedwith no error. UnlikegetJob/cancelJob/getJobEvents, which receive a pre-built link, there is nourls.workflow— so the sub-resource has to be appended.
Verified
End to end against Comfy Cloud staging and a self-hosted comfy-api-proxy driving a real workflow: submit, poll, jobId on outputs, output download, getWorkflow returning the submitted graph with no extra_data, and the job-URL form above.
Requirements
assets.delete() needs backend support. Comfy Cloud has it. Self-hosted needs a comfy-api-proxy new enough to serve DELETE /api/v2/assets/{id} — older proxies return 405 Method Not Allowed.
v0.1.6
Breaking: the base URL moves from a constructor argument to COMFY_BASE_URL
new Comfy() targets Comfy Cloud by default. To point the client at another deployment, set the COMFY_BASE_URL environment variable — an arbitrary endpoint is no longer part of the call surface.
- const client = new Comfy("https://my-deployment.example.com", opts)
+ // COMFY_BASE_URL=https://my-deployment.example.com
+ const client = new Comfy(opts)TypeScript callers get a compile error on the old form; untyped JavaScript callers get a TypeError rather than a silently ignored argument.
The variable is read on each construction (not at module load), must be an http(s) URL, and unset-or-blank means Comfy Cloud.
ComfyLow (@comfyorg/sdk/low), the documented escape hatch the client is built on, still takes a base URL directly and is unchanged.
Full changelog: v0.1.5...v0.1.6
v0.1.5
Maintenance release. No API changes — existing code needs no updates.
Packaging
- Ship an MIT license (the package previously declared none) and add
keywordsfor discoverability. - Publish source maps and declare
sideEffects: false, so bundlers can tree-shake the SDK and consumers get usable stack traces.
Docs
- TSDoc for the public API members that had none.
- README now leads with the same branded header and linked "Related projects" table as the Python and Swift SDKs, so the three SDK READMEs are consistent.
Repo rename
The repository moved from Comfy-Org/ComfyTypeScriptSDK to Comfy-Org/comfy-typescript-sdk, matching the org's comfy-lower-kebab-case convention. GitHub redirects the old URLs.
The npm package name is unchanged (@comfyorg/sdk) — npm i @comfyorg/sdk is unaffected. This release is the first to carry the corrected repository/homepage/bugs URLs in its published metadata.
Internal
- Added a vitest config so coverage measures hand-written code rather than generated output.
v0.1.4
Comfy Cloud now serves the v2 API on cloud.comfy.org. api.comfy.org continues to serve the node registry.
Breaking
api.comfy.org/api/v2/* no longer responds. If you pass that host explicitly, requests will 404 until you update.
Changes
baseUrlnow defaults tohttps://cloud.comfy.org, added as a constructor overload so the options-only form reads naturally.COMFY_CLOUD_BASE_URLis exported for callers who want the value.- Spec server URL, the regenerated
baseUrltype union, README, and doc comments updated to the new host. - Passing an explicit
baseUrlstill wins — self-hosted and serverless callers are unaffected.
Upgrading
// before
const client = new Comfy("https://api.comfy.org", { apiKey: "..." });
// after — the default is correct, so the host can be dropped
const client = new Comfy({ apiKey: "..." });Backward compatible for anyone already passing their own host; only api.comfy.org callers must change.