The Swift client for Comfy Cloud.
Submit a workflow, stream its events, get your outputs — in a few lines of async/await.
A thin, dependency-free Swift client for the Comfy Cloud API. You hand it a
ComfyUI workflow graph; it submits the job, streams the lifecycle back to you as a typed
AsyncThrowingStream, and the terminal event hands you the finished media. No callbacks, no delegates,
no Combine — just structured concurrency. It powers the Comfy Go iOS app.
- Submit a workflow —
ComfyCloudClient.submit(_:)with aWorkflowRequest - Stream job events —
events(for:)yields.queued→.progress→.finalizing→.complete/.failed/.cancelled - Receive outputs — images arrive inline, videos stream to a temp file, both via
WorkflowOutput - Reattach after a network drop or app relaunch —
reattach(to:)/reattach(promptId:) - Authenticate — an API key, or "Sign in with Comfy" OAuth (authorization-code + PKCE)
| Swift | 5.9+ |
| Platforms | iOS 17+ · macOS 14+ |
| Dependencies | None (Foundation + CryptoKit only) |
Add the package in Xcode (File → Add Package Dependencies…) or in your Package.swift:
.package(url: "https://github.com/Comfy-Org/ComfySwiftSDK.git", from: "0.3.0")…then list ComfySwiftSDK as a dependency of your target.
import ComfySwiftSDK
// 1. Construct a client. (API key shown here; OAuth is also supported — see below.)
let client = ComfyCloudClient(apiKey: "your-api-key")
// 2. Submit a ComfyUI API-format workflow graph. The SDK posts it verbatim.
let request = WorkflowRequest(workflowJSON: myWorkflowGraph)
let job = try await client.submit(request)
// 3. Stream the job's lifecycle until it reaches a terminal event.
for try await event in client.events(for: job) {
switch event {
case .queued:
print("queued")
case .progress(let fraction, let phase):
print("\(phase): \(Int(fraction * 100))%")
case .finalizing:
print("downloading output…")
case .complete(let output):
for file in output.files {
switch file {
case .image(let data, let mimeType):
print("image: \(data.count) bytes (\(mimeType))")
case .video(let url):
print("video: \(url.path)")
}
}
case .failed(let error):
print("failed: \(error)")
case .cancelled:
print("cancelled")
}
}Cancellation is cooperative: cancel the consuming Task and the SDK fires a best-effort server-side
cancel, then yields a final .cancelled event.
Two modes, both behind the same ComfyCloudClient:
// API key
let client = ComfyCloudClient(apiKey: "your-api-key")
// "Sign in with Comfy" OAuth (authorization-code + PKCE, no client secret on device)
let client = ComfyCloudClient(credential: .oauth(tokenProvider: { await myKeychain.accessToken() }))ComfyAuth.signIn runs the whole authorization-code + PKCE handshake in one call — build → present →
verify state → exchange → persist → return a ready, self-refreshing client. The SDK owns everything
except presenting the browser, which you inject through a ComfyWebAuthPresenter so the SDK never
imports AuthenticationServices:
import AuthenticationServices
import UIKit
// A thin app-side adapter over ASWebAuthenticationSession. `@MainActor` because the protocol
// requirement is main-actor-isolated: ASWebAuthenticationSession.start() must run on the main thread.
@MainActor
final class WebAuthPresenter: NSObject, ComfyWebAuthPresenter, ASWebAuthenticationPresentationContextProviding {
// Held for the session's whole lifetime: ASWebAuthenticationSession is not retained by the
// system, so a local-only reference would be deallocated after `start()` returns, cancelling
// the flow and hanging sign-in.
private var session: ASWebAuthenticationSession?
func authenticate(url: URL, callbackURLScheme: String) async throws -> URL {
try await withCheckedThrowingContinuation { continuation in
let session = ASWebAuthenticationSession(url: url, callbackURLScheme: callbackURLScheme) { [weak self] url, error in
self?.session = nil // release the one-shot session once the callback fires
if let url { continuation.resume(returning: url) }
else if (error as? ASWebAuthenticationSessionError)?.code == .canceledLogin {
continuation.resume(throwing: ComfyError.authCancelled) // user dismissed the sheet
} else {
continuation.resume(throwing: error ?? ComfyError.authCancelled)
}
}
session.presentationContextProvider = self
self.session = session
// start() returns false without ever calling the completion handler when the system
// refuses to present (bad anchor, redirect mismatch, missing entitlements). Guard it so
// sign-in fails fast instead of hanging on a continuation that never resumes.
guard session.start() else {
self.session = nil
continuation.resume(throwing: ComfyError.authCancelled)
return
}
}
}
// Present on the app's active window — a detached `ASPresentationAnchor()` has no window scene
// and the auth sheet would fail to display.
func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor {
UIApplication.shared.connectedScenes
.compactMap { $0 as? UIWindowScene }
.flatMap(\.windows)
.first { $0.isKeyWindow } ?? ASPresentationAnchor()
}
}
// One call: the returned client is signed in and refreshes itself; tokens are persisted in `store`.
let client = try await ComfyAuth.signIn(presenter: WebAuthPresenter(), store: myTokenStore)store is your ComfyTokenStore (Keychain, an encrypted file, …). On a later launch,
ComfyAuth.restoreClient(store:) rebuilds the same refreshable client without re-prompting, and
ComfyAuth.signOut(store:) clears it. Prefer a non-default OAuthClientConfig? Pass it as the
config: argument and it is threaded through the exchange and every silent refresh.
Credentials are held privately and are never logged, returned, or interpolated into error messages. If
you need the lower-level primitives, ComfyCloudClient.buildAuthorizationRequest(config:) and
exchangeAuthorizationCode(_:codeVerifier:config:) remain available.
The example above is the bring-your-own tier: depend on ComfySwiftSDK alone and inject your own
ComfyWebAuthPresenter and ComfyTokenStore. The core SDK imports Foundation only — no
AuthenticationServices, no Security — so it stays reusable across surfaces and never forces a
UI/keychain dependency on a consumer that doesn't want one.
If you'd rather not hand-write that boilerplate, add the ComfyAuthKit product. It ships the two
obvious defaults — an ASWebAuthenticationSession-backed presenter and a Keychain-backed token store —
in a separate target that is allowed to import AuthenticationServices and Security:
import ComfySwiftSDK
import ComfyAuthKit
// Default presenter (ASWebAuthenticationSession) + default store (Keychain). No boilerplate.
let presenter = await ASWebAuthPresenter() // @MainActor
let store = try KeychainTokenStore() // stored under your bundle id + ".oauth"
let client = try await ComfyAuth.signIn(presenter: presenter, store: store)
// Later launch: rebuild the signed-in client from the Keychain without re-prompting.
let restored = try await ComfyAuth.restoreClient(store: store)ASWebAuthPresenter retains the session until the callback fires and maps a user dismissal onto
ComfyError.authCancelled; it takes an optional anchor: when the automatic key-window choice isn't
right. KeychainTokenStore persists the three OAuth slots under
kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly (readable by background reattach while the device is
locked, kept out of backups and off other devices). The default init() derives its namespace from
the app's bundle id and throws KeychainError.missingBundleIdentifier in a process without one (e.g. a
CLI) rather than sharing a hard-coded namespace; pass an explicit init(service:) there and for test
isolation. Reach for ComfyAuthKit when you want defaults; depend on ComfySwiftSDK alone when you
want full control.
Pre-1.0. The SDK is battle-tested by the Comfy Go iOS app, builds clean, and ships with a full test suite. The public API may still shift before a tagged 1.0 — pin to an exact version or commit if you need stability today. Feedback on the surface is what we're looking for at this stage.
Contributions are very welcome — issues, bug reports, and PRs all help. If you're building something on Comfy Cloud in Swift and hit a rough edge, open an issue; real-world usage is the best guide for where this SDK should go next.
A few pointers:
swift buildandswift testshould both pass before you open a PR.- Keep the public surface small and
async/await-native — no callbacks, delegates, or Combine. - New error conditions go through the
ComfyErrortaxonomy rather than leaking transport details. - The SDK import boundary (sources import only
Foundation,CryptoKit, andos— neverSwiftUI,SwiftData,Photos, orSecurity) is enforced in-repo byImportBoundaryTestsunderswift test.
For anything substantial, open an issue first so we can talk through the approach.
Apache License 2.0 — see LICENSE. Copyright © Comfy Org.