SDK
Go SDK
How to use the Go SDK against a local comfy-api-proxy, from creating a client through submitting workflows, waiting on jobs, downloading outputs, and handling errors. The same code runs against Comfy Cloud with a different base URL and key.
The package github.com/regiellis/comfy-api-proxy-go/sdk is a Go client for the Comfy API v2.
It talks to this proxy, to Comfy Cloud, or to any other host that serves the same contract, so
an app written against the local proxy moves to Comfy Cloud by changing its base URL and key.
It is a port of the Python comfy-sdk, and its calls take a context.Context where the Python
SDK has sync and async twins. This page uses the local proxy throughout. Comfy Cloud and
Router covers what changes for the hosted service.
Install
The module needs Go 1.26 or newer.
go get github.com/regiellis/comfy-api-proxy-go/sdk
import "github.com/regiellis/comfy-api-proxy-go/sdk"
The proxy must be running and reachable. See Quickstart for starting it.
Create a client
sdk.New takes functional options and checks them before any request is sent.
client, err := sdk.New(
sdk.WithBaseURL("http://127.0.0.1:8189"),
sdk.WithClientInfo("my-app/1.0"),
)
if err != nil {
return err
}
The base URL is resolved in this order:
sdk.WithBaseURL(url)- the
COMFY_BASE_URLenvironment variable sdk.CloudBaseURL(https://cloud.comfy.org)
The API key is resolved the same way: sdk.WithAPIKey(key), then COMFY_API_KEY. A blank or
whitespace key counts as unset at both sources. With no key the client sends no
Authorization header, which is what a proxy bound to 127.0.0.1 without a token expects. A
proxy started with a token (required for any non-loopback bind, see Remote
access) needs that token as the key:
client, err := sdk.New(
sdk.WithBaseURL("http://192.168.1.20:8189"),
sdk.WithAPIKey(os.Getenv("PROXY_TOKEN")),
)
The base URL is the host root, without /api/v2; the client adds that prefix. It must be an
http or https URL with no query or fragment, and New returns an error otherwise.
| Option | Effect |
|---|---|
WithBaseURL(u string) |
Target deployment. Wins over COMFY_BASE_URL. An empty value counts as not given. |
WithAPIKey(key string) |
Bearer token. Wins over COMFY_API_KEY. An empty value counts as not given. |
WithProxyMetadata() |
Sends job labels the way comfy-api-proxy stores them. See Labels. |
WithTimeout(d time.Duration) |
Connect, response-header, and idle-body limit. Default 30 s. See Timeouts. |
WithClientInfo(info string) |
Appends app/<info> to the User-Agent. |
WithHTTPClient(c *http.Client) |
Replaces the HTTP client and its timeouts. |
WithRouterBaseURL(u string) |
Comfy Router target. See Comfy Cloud and Router. |
WithRetry(p sdk.RetryPolicy) |
Retry policy for Router calls only. |
The bearer token is sent only to the base URL's origin (scheme, host, and port) and to the
Comfy Router origin. It is removed on any redirect that leaves them. client.String() and
client.BaseURL() never print the key, and redact credentials embedded in the URL.
Workflows
A *sdk.Workflow holds an API-format graph: a map of node id to {class_type, inputs}. Export
it from ComfyUI with "Export (API)". The editor's normal save format (with nodes, links,
and last_node_id) is refused by Submit with a workflow_format_ui error before anything is
sent.
wf, err := sdk.WorkflowFromFile("workflow_api.json")
if err != nil {
return err
}
wf.SetInput("3", "seed", int64(1234567890123))
wf.SetInput("6", "text", "a lighthouse at dusk")
wf.RemoveNode("12") // drop a preview node; links through it are rewired
sdk.WorkflowFromJSON(data) parses bytes, and sdk.NewWorkflow(graph) wraps a map you built.
SetInput creates the node and its inputs map when they are missing. RemoveNode rewires
links that pointed at the removed node to its single linked input, when it had exactly one, and
drops them otherwise.
WorkflowFromJSON and WorkflowFromFile decode numbers as json.Number, so a 64-bit seed such
as 18446744073709551615 reaches the server exactly. Decoding the same file with a plain
json.Unmarshal into map[string]any turns it into a float64 and changes the seed.
Input files
Pass an *sdk.Asset as an input value and Submit uploads it, then replaces it in a copy of
the graph with a core/ASSET reference. Your workflow is left unchanged.
wf.SetInput("10", "image", client.AssetFromFile("photo.png"))
Assets are lazy. Nothing is read until submit, when the SDK hashes the bytes with blake3, asks
the server whether it already holds them (HEAD /assets/by-hash/{hash}), and uploads only when
it does not. client.AssetFromBytes(data, filename, contentType),
client.AssetFromReader(r, filename, contentType), and client.AssetFromURL(ctx, url) build
assets from other sources. asset.Commit(ctx) uploads ahead of time and returns the asset id;
asset.CreatedNew() then reports whether new bytes were stored.
Submit a job
client.Submit sends the workflow and returns as soon as the job is queued.
job, err := client.Submit(ctx, wf, sdk.SubmitOptions{})
if err != nil {
return err
}
fmt.Println(job.ID(), job.Status()) // e.g. "3f1c..." queued
sdk.SubmitOptions fields, all optional:
| Field | Meaning |
|---|---|
IdempotencyKey string |
Key for this submission. Minted (a random UUID) when empty. |
Metadata map[string]string |
Job labels. See Labels. |
PartnerAPIKey string |
Key for partner (API) nodes in the graph, sent as extra_data.api_key_comfy_org. Unrelated to the client's own key. |
Idempotency keys
Every submit carries an Idempotency-Key header. Keys are single-use: the server rejects a
second submit under the same key with HTTP 422 idempotency_key_reuse. It does not replay the
first response. A key you supply must be 1 to 255 printable ASCII characters; the SDK checks
that locally with sdk.ValidateIdempotencyKey before sending.
Supply your own key when a submit must not run twice, for example one derived from an order id. When a submit fails without a clear answer (a timeout, a dropped connection), the job may or may not exist. Every error a submit returns carries the key it was sent under, so you can look the job up instead of guessing:
job, err := client.Submit(ctx, wf, sdk.SubmitOptions{IdempotencyKey: "order-1234"})
var apiErr *sdk.APIError
var tErr *sdk.TransportError
switch {
case errors.Is(err, sdk.ErrIdempotencyKeyReuse):
// Already submitted under this key. Find the job rather than resubmitting.
case errors.As(err, &tErr):
log.Printf("unknown outcome for key %s: %v", tErr.IdempotencyKey, err)
case errors.As(err, &apiErr):
log.Printf("submit refused (%s), key %s", apiErr.Code, apiErr.IdempotencyKey)
}
The proxy releases a key when ComfyUI definitely created no job, so a key whose submit ComfyUI rejected can be used again. On an unknown outcome it keeps the key held.
Labels and WithProxyMetadata
SubmitOptions.Metadata attaches string labels to a job. The SDK contract carries them as a JSON
object, while comfy-api-proxy stores metadata as one opaque string and answers an object with
HTTP 422 invalid_request. Create the client with sdk.WithProxyMetadata() when targeting the
proxy:
client, err := sdk.New(
sdk.WithBaseURL("http://127.0.0.1:8189"),
sdk.WithProxyMetadata(),
)
if err != nil {
return err
}
job, err := client.Submit(ctx, wf, sdk.SubmitOptions{
Metadata: map[string]string{"project": "demo", "shot": "12"},
})
if err != nil {
return err
}
fmt.Println(job.Metadata()) // map[project:demo shot:12]
With the option, the map is sent as its JSON encoding ("{\"project\":\"demo\",...}"), and the
proxy's 1024-byte metadata limit applies to that encoded string. job.Metadata() and
JobSummary.Metadata decode labels from either an object or a JSON-encoded object, and ignore
opaque text and non-string values. Leave the option off for any deployment that accepts metadata
objects. Comfy Cloud does not take labels at all yet. See
Comfy Cloud and Router.
Wait for the result
There are two ways to wait: polling, and the live event stream.
Polling
client.Run is Submit followed by job.Result:
ctx, cancel := context.WithTimeout(ctx, 10*time.Minute)
defer cancel()
job, err := client.Run(ctx, wf, sdk.SubmitOptions{})
var failed *sdk.JobFailedError
if errors.As(err, &failed) {
if failed.Err != nil {
return fmt.Errorf("job %s %s: %s", failed.JobID, failed.Status, failed.Err.Message)
}
return err
}
if err != nil {
return err
}
For a job you already hold, job.Wait(ctx) polls until the status is terminal and
job.Result(ctx) also turns a non-success into *sdk.JobFailedError. Polls start at half a
second and grow to one every five seconds. Neither has a built-in deadline, so bound them with
the context. job.Refresh(ctx) reads the job once; client.GetJob(ctx, id) fetches one by id.
Statuses are queued, running, canceling, succeeded, failed, canceled, and expired,
available as sdk.Status* constants. sdk.IsTerminal(status) is true for the last four.
Between polls, job.Progress(), job.QueuePosition(), and job.Failure() return copies of the
last snapshot.
Events
job.Events(ctx) streams progress, previews, outputs, and status changes as they happen. It is
a Go iterator:
for ev, err := range job.Events(ctx) {
if err != nil {
return err
}
switch e := ev.(type) {
case *sdk.StatusChange:
fmt.Println("status", e.Status)
case *sdk.Progress:
fmt.Printf("progress %.0f%%\n", e.Value*100)
case *sdk.Preview:
fmt.Println("preview", e.ContentType, len(e.Data))
case *sdk.OutputReady:
fmt.Println("output", e.Output.Name())
case *sdk.Log:
fmt.Println(e.Level, e.Message)
}
}
if err := job.Result(ctx); err != nil { // reads final state, outputs included
return err
}
The stream is backed by polling. A dropped connection reconnects; a stream that closes without
a terminal status falls back to reading the job; a server without the endpoint (HTTP 501) ends
the loop quietly. Unless an error ends it, the last event is always a terminal
*sdk.StatusChange. Treat OutputReady as an early notice and read the full list from
job.Outputs() after the loop, since a deployment may not send an event for every output. See
Events for the wire format.
Read outputs
job.Outputs() lists every output of the job; job.GetOutputs(nodeID) lists one node's.
Each *sdk.Output carries NodeID(), Name(), Type() (image, video, audio, text, file, or
latent), ContentType(), SizeBytes(), and ID().
for i, o := range job.GetOutputs("9") {
dest := filepath.Join("out", fmt.Sprintf("%d_%s", i, filepath.Base(o.Name())))
if err := o.ToFile(ctx, dest, nil); err != nil {
return err
}
}
| Method | Effect |
|---|---|
ToFile(ctx, path, rng) |
Streams to a file, creating or truncating it. A failed download removes the partial file. |
ToWriter(ctx, w, rng) |
Streams into an io.Writer and returns the byte count. |
Bytes(ctx, rng) |
Reads into memory. |
DownloadURL(ctx) |
Resolves a URL for the bytes without downloading them. |
Pass nil for the whole file, or a *sdk.Range for an inclusive byte range:
head, err := o.Bytes(ctx, &sdk.Range{Start: 0, End: 1023})
On the proxy, DownloadURL returns the content endpoint itself, which needs the same bearer
token as any other call. On Comfy Cloud it returns a signed URL and its expiry. Output asset
ids from the proxy are signed and stateless; see Assets.
List jobs
client.ListJobs walks jobs newest first and yields sdk.JobSummary values. Break out of the
loop to stop early.
for s, err := range client.ListJobs(ctx, sdk.ListOptions{Limit: 20}) {
if err != nil {
return err
}
fmt.Println(s.ID, s.Status, s.Metadata)
}
ListOptions.Metadata keeps only jobs carrying every given label. On the proxy the list is one
page: Limit is between 1 and 100 (default 50 when zero) and the proxy sends no cursor. With
WithProxyMetadata, label filtering runs in the SDK over that page, so a filter sees at most
Limit of the most recent jobs, not every job the proxy knows.
Cancel
if err := job.Cancel(ctx); err != nil {
return err
}
if err := job.Wait(ctx); err != nil {
return err
}
fmt.Println(job.Status()) // canceled, or succeeded if it finished first
A running job reads canceling until ComfyUI's interrupt lands at the next node boundary,
which can take seconds, so wait for the terminal status rather than reading it from the cancel
reply. After a
cancel, job.Result returns a *sdk.JobFailedError with Status canceled.
Errors
Every failure the API reports is an *sdk.APIError:
| Field | Meaning |
|---|---|
Code |
The wire error code, such as not_found, or http_<status> when the server named none the SDK knows. |
Message |
The server's message. |
HTTPStatus |
The response status. Zero for a check the SDK made before sending. |
Details |
The envelope's details object, when present. |
RequestID |
The X-Comfy-Request-Id header, cleaned for logging. |
RetryAfter |
Retry-After in seconds, nil when absent. |
IdempotencyKey |
The key a failed submit used. |
Match a kind of failure with errors.Is against a sentinel, and read the fields with
errors.As:
_, err := client.GetJob(ctx, id)
switch {
case errors.Is(err, sdk.ErrNotFound):
// unknown job id
case errors.Is(err, sdk.ErrUnauthorized):
// missing or wrong token
case err != nil:
var apiErr *sdk.APIError
if errors.As(err, &apiErr) {
log.Printf("%d %s: %s (request %s)", apiErr.HTTPStatus, apiErr.Code, apiErr.Message, apiErr.RequestID)
}
return err
}
Sentinels for the v2 API: ErrNotFound, ErrUnauthorized, ErrForbidden,
ErrInvalidWorkflow, ErrWorkflowFormatUI (also matches ErrInvalidWorkflow),
ErrMissingAsset, ErrHashMismatch, ErrBlobNotFound, ErrIdempotencyKeyReuse,
ErrQueueFull, ErrInsufficientCredits, ErrMissingAPIKey, and ErrInvalidResponse. A code
with no sentinel (the proxy's invalid_request, upstream_unreachable, and others listed in
API) still arrives as an *APIError; compare its Code.
Two other error types:
*sdk.TransportErroris a failure that produced no response: a refused connection, a timeout, a stream cut mid-body. Whether the server acted on the request is unknown. It unwraps to the cause, soerrors.Is(err, context.DeadlineExceeded)anderrors.Is(err, sdk.ErrIdleTimeout)work through it.*sdk.JobFailedErrorreports a job that endedfailed,canceled, orexpired.errors.Is(err, sdk.ErrJobFailed)matches it, and itsErrfield holds the server's*sdk.JobError(code, message, node id, class type, traceback) when one was given.
Retries and timeouts
Submit and ListJobs retry an HTTP 429 that carries Retry-After, or a 429 with the legacy
queue_full code, for up to 60 seconds. Each wait is at least one second and never longer than
the budget left. Nothing else on the v2 surface is retried, because a repeated submit could run
a job twice; retry other failures yourself under the same idempotency key, after checking
whether the first attempt created the job.
WithTimeout (default 30 seconds) bounds three phases separately: connecting, waiting for
response headers, and a streamed body that sends nothing. It is not a cap on a whole request, so
a large download is never cut off for taking long while bytes still arrive. The idle clock runs
only while the SDK is waiting on the network, so a slow io.Writer does not trip it. A stalled
body surfaces as sdk.ErrIdleTimeout inside a *sdk.TransportError. The event stream uses its
own 45 second idle limit, since the server sends heartbeats.
To bound a whole operation, use the context:
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
defer cancel()
job, err := client.Run(ctx, wf, sdk.SubmitOptions{})
WithHTTPClient replaces the client and all of these limits with its own settings.
Concurrency
A *sdk.Client is safe for concurrent use; create one and share it. A *sdk.Job is too: its
getters return copies of the last snapshot, and Refresh, Wait, and Cancel update it under
a lock. A *sdk.Asset serializes Commit, Delete, and Reference, so several submits that
share one asset upload it once. A *sdk.Workflow is a plain map with no locking: build or
modify it in one goroutine, or give each goroutine its own. Submit reads it and does not
change it.
g, ctx := errgroup.WithContext(ctx)
for _, prompt := range prompts {
g.Go(func() error {
wf, err := sdk.WorkflowFromFile("workflow_api.json")
if err != nil {
return err
}
wf.SetInput("6", "text", prompt)
_, err = client.Run(ctx, wf, sdk.SubmitOptions{})
return err
})
}
return g.Wait()
The example uses golang.org/x/sync/errgroup; any pattern that gives each goroutine its own
workflow works.
The demo program
cmd/comfy-demo in this repository drives the SDK end to end: it submits a workflow, follows
it by polling or by events, and downloads the outputs.
task build:demo
bin/comfy-demo -base http://127.0.0.1:8189 -events
bin/comfy-demo -base http://127.0.0.1:8189 -workflow my_api.json -out ./outputs
Runnable examples for New, Client.Submit, Client.Run, Job.Events, WithProxyMetadata,
and APIError are in the package documentation on pkg.go.dev.