Independent project. Not affiliated with or endorsed by Comfy Org.

All pages

SDK

Comfy Cloud and Router

How to point the same Go SDK client at Comfy Cloud for workflows and at Comfy Router for hosted models, and what changes from the local proxy. Covers model runs, queued requests, schemas, retries, and which calls are billed.

View as Markdown Source on GitHub

The client from the Go SDK page reaches Comfy Cloud with a different base URL and a key. The workflow code itself does not change. The same client also runs hosted models on Comfy Router through client.Models.

What changes from the local proxy

Local proxy Comfy Cloud
Base URL http://127.0.0.1:8189 (or your host) https://cloud.comfy.org, the default
Key Optional; the proxy's token when it has one Required, your Comfy API key
Job labels WithProxyMetadata() Leave the option off
Job list Served by the proxy https://platformapi.comfy.org (see below)
Output.DownloadURL The proxy's content endpoint A signed URL with an expiry
Cost Free Billed per workflow run

With no base URL set, sdk.New targets Comfy Cloud and requires a key. A missing key fails at construction rather than as a 401 on the first call:

// COMFY_API_KEY must be set in the environment.
client, err := sdk.New()
if errors.Is(err, sdk.ErrMissingAPIKey) {
	log.Fatal("set COMFY_API_KEY")
}
if err != nil {
	log.Fatal(err)
}

Keep the key in the environment rather than in source. sdk.WithAPIKey(key) wins over COMFY_API_KEY when you load it from somewhere else. A blank value counts as unset.

To switch one program between the proxy and Cloud, set COMFY_BASE_URL and COMFY_API_KEY and call sdk.New() with no target options. Unset COMFY_BASE_URL means Cloud.

Workflows on Comfy Cloud

Submitting, waiting, and downloading are the calls from the Go SDK page:

wf, err := sdk.WorkflowFromFile("workflow_api.json")
if err != nil {
	return err
}
wf.SetInput("10", "image", client.AssetFromFile("photo.png"))
job, err := client.Run(ctx, wf, sdk.SubmitOptions{})
if err != nil {
	return err
}
for _, o := range job.Outputs() {
	if err := o.ToFile(ctx, filepath.Base(o.Name()), nil); err != nil {
		return err
	}
}

Differences seen on Cloud:

  • Every run is billed. A retry under a fresh key is a second run. Supply an IdempotencyKey you can recompute, and on an ambiguous failure look for the job before submitting again.
  • Models must exist on Cloud. A graph that loads a checkpoint only your local ComfyUI has fails there.
  • The event stream sent queued then succeeded, with no live output event, in the runs checked so far. Read outputs from job.Outputs() after job.Result, which works on both.
  • Download URLs are signed URLs on another host. Output.DownloadURL returns one with its ExpiresAt and never contacts that host, so the bearer token does not reach it. ToFile, ToWriter, and Bytes follow the redirect for you and drop the token on the way.
  • No labels yet. Cloud refuses any SubmitOptions.Metadata with HTTP 422 and code metadata_not_supported ("Job metadata is kept only for jobs sent to a deployment for now"), checked before the workflow, so no job is created and nothing is billed. Submit to Cloud without labels, and match the case with errors.As(err, &apiErr) and apiErr.Code == "metadata_not_supported". Checked against cloud.comfy.org on 2026-10-27.
  • Partner nodes in the graph take SubmitOptions.PartnerAPIKey.

Listing jobs

https://cloud.comfy.org answers GET /api/v2/jobs with HTTP 501. The workspace address https://platformapi.comfy.org serves it with the same key, so list through a second client:

jobs, err := sdk.New(sdk.WithBaseURL("https://platformapi.comfy.org"))
if err != nil {
	return err
}
for s, err := range jobs.ListJobs(ctx, sdk.ListOptions{Limit: 10}) {
	if err != nil {
		return err
	}
	fmt.Println(s.ID, s.Status)
}

When a page carries next_cursor, ListJobs follows it until the last page or until you break out of the loop.

Comfy Router

Comfy Router runs hosted models (image, video, and audio providers) addressed by a model id of the form provider/model. It lives on a separate host from the v2 API: https://api.comfy.org by default, or COMFY_ROUTER_BASE_URL, or sdk.WithRouterBaseURL(u). client.Models uses the client's key, which is sent to exactly the v2 origin and the Router origin.

Catalog and schemas

Reading the catalog and schemas is free.

for m, err := range client.Models.List(ctx, sdk.ListModelsOptions{}) {
	if err != nil {
		return err
	}
	fmt.Println(m.ID, m.Billing)
}

List walks every page. Models.Page(ctx, opts) fetches one page, with HasMore and NextCursor for walking it yourself; a Limit above 100 is clamped by the server. CatalogModel.Billing holds per-model billing facts as the server sent them, not prices.

Models.Schema returns a model's OpenAPI document. Pass the ETag from an earlier call to get Unchanged instead of the document again:

res, err := client.Models.Schema(ctx, "bfl/flux-pro-1.1", "")
if err != nil {
	return err
}
// later
again, err := client.Models.Schema(ctx, "bfl/flux-pro-1.1", res.ETag)
if err != nil {
	return err
}
if again.Unchanged {
	// res.Document is still current
}

Running a model

There are two ways to run a model. Both are billed.

Synchronous: Run and RunDetailed. One request that returns when the generation is done. The response-header limit for these calls is 600 seconds, independent of WithTimeout.

res, err := client.Models.RunDetailed(ctx, "bfl/flux-pro-1.1",
	map[string]any{"prompt": "a red cube", "width": 512, "height": 512},
	sdk.RunOptions{})
if err != nil {
	return err
}
switch {
case res.Output.Binary != nil:
	if err := os.WriteFile("out.bin", res.Output.Binary.Content, 0o644); err != nil {
		return err
	}
case res.Output.JSON != nil:
	fmt.Println(res.Output.JSON) // often a short-lived signed URL to the result
}
if res.CreditsUsed != nil {
	fmt.Println("credits:", *res.CreditsUsed)
}

RunOutput holds exactly one of JSON (for a provider that answers JSON) and Binary (for one that answers the generated bytes). Run returns only the output. RunDetailed adds what Router disclosed: RequestID, Replayed (the answer came from the key's earlier record and was not run or billed again), ServingProvider (set when a fallback provider served the call), DroppedParams, and CreditsUsed. A nil CreditsUsed means not reported, which is not the same as free.

Queued: Submit, then Updates, Get, or Cancel. Use this for long generations, or to show queue position.

h, err := client.Models.Submit(ctx, "bfl/flux-pro-1.1", map[string]any{"prompt": "a red cube"}, sdk.RunOptions{})
if err != nil {
	return err
}
log.Println("request", h.RequestID()) // keep it to collect later with Models.Handle
for u, err := range h.Updates(ctx) {
	if err != nil {
		return err
	}
	fmt.Println(u.Status, u.QueuePosition)
}
out, err := h.Get(ctx)
if err != nil {
	return err
}
fmt.Println(out.JSON)

Updates yields each change of status or queue position and ends after COMPLETED (sdk.Completed), pacing its polls by the server's Retry-After. Get waits for completion and collects the output; a completion that names an error type comes back as that Router error. h.Cancel(ctx) asks the queue to stop. client.Models.Handle(model, requestID) rebuilds a handle in another process.

Models.Subscribe(ctx, model, args, opts, onUpdate) does submit, follow, and collect in one call. If ctx ends first, it cancels the request on a best-effort basis before returning, so an abandoned wait does not leave a generation running.

sdk.RunOptions fields: IdempotencyKey (minted when empty), ModelProvider (route to an alternate provider, translating the body), StrictMode (refuse a translation that would drop fields), and FallbackProvider ("true", "false", or a provider name).

Retries

Router calls follow a sdk.RetryPolicy, sdk.DefaultRetry unless replaced with sdk.WithRetry. It does not apply to workflow Submit and Run.

  • A 429 is retried only when it carries Retry-After.
  • A failure that never reached the server (DNS, refused connection, connect timeout) is retried.
  • A 5xx or a connection dropped mid-request may have started a billed run, so it is retried only when RetryPossiblyInFlight is true. It is false in DefaultRetry.
  • A generation still running (409 concurrency_limit_exceeded or 504 deadline_exceeded, each with Retry-After) is collected under the same key for up to CollectMaxElapsed (20 minutes by default) when RetryCollectable is true.
  • Everything else, and anything after MaxElapsed (60 seconds by default), is returned.

Catalog and schema reads also retry in-flight failures, since repeating a read changes nothing. sdk.NoRetry makes every call a single attempt.

policy := sdk.DefaultRetry
policy.RetryPossiblyInFlight = true // accept the billing risk of a resend after a 5xx
client, err := sdk.New(sdk.WithRetry(policy))

All retries of one run share one Idempotency-Key, so Router can recognize a resend. If a resend is refused as a key reuse after an earlier attempt may have claimed the key, the SDK returns that earlier failure with ResendRefused set on the *sdk.APIError or *sdk.TransportError. The generation may exist and may have been billed: collect it under the key rather than running a new one.

Router errors

Router failures are *sdk.APIError values with ErrorType set to the Router's bucket. errors.Is(err, sdk.ErrRouter) matches any of them. Each bucket has a sentinel: ErrInvalidInput, ErrContentPolicyViolation, ErrProviderError, ErrProviderTimeout, ErrInsufficientCredits, ErrModelNotFound, ErrUnauthorized, ErrForbidden, ErrConcurrencyLimitExceeded, ErrClientDisconnected, ErrInternalError, ErrDeadlineExceeded, ErrNotEnabled, ErrServiceUnavailable, ErrRateLimited, ErrCancelled, ErrQueueTimeout, ErrRequestNotFound, and ErrQueueBacklogFull. A refused cancel matches ErrCancelRefused, and ErrAlreadyCompleted when the request had finished.

_, err := client.Models.Run(ctx, model, args, sdk.RunOptions{})
var apiErr *sdk.APIError
switch {
case errors.Is(err, sdk.ErrInvalidInput) && errors.As(err, &apiErr):
	for _, v := range apiErr.Validation {
		fmt.Println(v.Location(), v.Msg)
	}
case errors.Is(err, sdk.ErrInsufficientCredits):
	// top up before retrying
case errors.As(err, &apiErr) && apiErr.ResendRefused:
	// collect under apiErr.IdempotencyKey; do not start a new run
}

Billing

These calls cost money: Models.Run, Models.RunDetailed, Models.Submit, Models.Subscribe, and any workflow Submit or Run against Comfy Cloud. A content-policy rejection may be charged for some models; the catalog's Billing map flags those with charges_on_policy_rejection. CreditsUsed reports what Router priced a run at when it says so, and nil otherwise. The SDK does not estimate or cap spending. The catalog, schemas, job reads, job lists, and downloads are free.

Trying it from a clone

A clone of this repository has task targets that drive cmd/comfy-demo against Cloud. Copy .env.example to .env and set COMFY_API_KEY; only these targets load it. The billed ones ask for confirmation first.

task cloud:jobs                                            # free: recent Cloud jobs, proves the key
task cloud:models                                          # free: the Router catalog
task cloud:schema -- bfl/flux-pro-1.1                      # free: one model's input schema
task cloud:run -- bfl/flux-pro-1.1 '{"prompt":"a red cube"}'   # billed: one synchronous run
task cloud:workflow -- -workflow my_api.json -events       # billed: a workflow on Cloud