Generation bridge reference
This is the field-level contract for spending a viewer's Buzz on a generation from a block: the WorkflowBody your block sends, the useBuzzWorkflow() lifecycle that carries it (estimate → submit → watch → cancel), and the BlockWorkflowSnapshot you get back.
The field tables below are generated from the published @civitai/app-sdk and @civitai/blocks-react type definitions — the same JSDoc your editor shows — so they can't drift from the packages you install. For the narrative version (with worked img2img / LoRA examples and the page-vs-model rules) start with the text-to-image generation guide; for the ComfyUI recipe path see Comfy on Civitai.
Before you design against the field tables, read what the bridge can and cannot do — the bridge is a narrower surface than the orchestrator, so orchestrator step JSON can't be sent from a block, and reaching a model is a matter of naming the right modelVersionId rather than describing an engine.
Trust model
useBuzzWorkflow() is host-mediated: the host resolves the viewer from the block token and runs the estimate/submit/cancel on Civitai's side of the iframe boundary, re-checking scope + budget every time. Your block never holds an orchestrator credential.
useBuzzWorkflow() lifecycle
useBuzzWorkflow(): UseBuzzWorkflowOrchestrates the estimate → confirm → submit → poll dance through the host-mediated `postMessage` path. The host enforces budget rules (`cost_estimate <= token.buzzBudget`) before forwarding to the orchestrator. 🔴 A BUDGET REFUSAL DOES **NOT** REJECT — IT RESOLVES. It comes back as a snapshot with `status: 'failed'`, an `error` string and the `cost` the server declined to charge, and THAT resolved shape is the cue to call `useBuzzPurchase().openPurchaseModal()`. What DOES reject is a submit with no usable outcome — see {@link WorkflowSubmitError}. Routing a rejection into a top-up sells Buzz for a failure Buzz cannot fix. 🔴 NOR IS EVERY RESOLVED `'failed'` AN AFFORDABILITY PROBLEM. The per-app velocity limit, the per-app aggregate daily cap, a fail-closed "temporarily unavailable" deny and a missing price quote are all priced, resolving outcomes too. Branch on the message/your own policy before offering to sell anything. AFTER `submit` FLIPS `status` TO `'polling'`, USE `watch(workflowId)`. It owns the loop, resolves on the terminal snapshot, and pushes every intermediate one to an `onUpdate` callback — so a block consumes a promise/callback rather than running its own timer. `poll(workflowId)` remains the single-round-trip primitive for callers that genuinely want to drive their own cadence; the hand-written `useEffect` + backoff around it that this docstring used to prescribe is no longer the recommended shape. `status === 'confirming'` is IDLE (estimate landed, user reviewing cost) — keep the Generate button enabled. `estimate`/`submit` take a full {@link WorkflowBody} — the discriminated union keyed by `kind`, never a bare `{ prompt }`. The hook forwards the body to the host verbatim and never reads variant-specific fields, so every member flows through unchanged, including any member added later. 🔴 THIS COMMENT DELIBERATELY DOES NOT SAY HOW MANY MEMBERS THERE ARE, OR NAME THEM (#381). It used to open "with THREE members as of `@civitai/app-sdk@0.30.0`" and close by certifying the list "otherwise unchanged" — while the union had FOUR, `WorkflowBodyPassThroughStep` having arrived in 583e8ba (#310). The sentence whose only job was to vouch for the list was the sentence that went stale. `{@link WorkflowBody}`'s own docblock is the single description of the member set; the machine-checked copy is {@link WorkflowBodyArms} below, which fails `tsc` when the union changes in either direction. A count re-typed here could only ever repeat the defect. 🔴 `customComfy` IS ITSELF A UNION, on `mode` — an app CAN ship its own ComfyUI graph, which is a CAPABILITY claim, not a member count, and so is stated here. `WorkflowBodyCustomComfyRecipe` (`mode` omitted or `'recipe'`) names a server-registered recipe; `WorkflowBodyCustomComfyInline` (`mode: 'inline'`) carries the graph itself, plus its declared AIR `resources` and a `maxBuzz` bound. The inline arm is LIVE in production (page-tokens-only, and NOT developer-only — this parenthetical said "developer-only", which is false: no `customComfy` arm runs an app-developer check. See `WorkflowBodyCustomComfyInline` for the refusals that DO run) and this comment used to describe `customComfy` as a recipe-only `{ kind, recipe, params }` shape — written when that was true and never revisited once the arm shipped. A developer working against the live feature read the equivalent claim on the type, believed it over their own instinct, and concluded the capability did not exist. That is the SAME defect the paragraph above records, one member over: an incomplete description of a union, trusted because it read as authoritative.
| member | type | notes |
|---|---|---|
estimate | (body: WorkflowBody) => Promise<BlockWorkflowSnapshot> | Price a workflow without queueing it. Resolves ONLY with a snapshot that carries a numeric `cost.total`. 🔴 REJECTS with {@link WorkflowEstimateError} when the reply is unusable — the estimate errored server-side, or it came back with no numeric cost. Wrap every call in `try/catch`; see that class for why resolving such a reply was civitai/civitai#4159. 🔴 THIS INCLUDES MODERATOR REVIEW PREVIEW, a behaviour change worth knowing before you ship. While an app is under review the host short-circuits every workflow request with `failureSnapshot('not available in review preview')`, so `estimate()` now rejects there where it used to resolve. That is the correct reading — no estimate happened — and it is why the catch is not optional: a block without one turns the reviewer's first click into an unhandled rejection, at exactly the moment it is meant to look healthy. A block that catches shows the reviewer the reason instead. `result` is updated to the returned snapshot BEFORE any rejection, so a failed estimate can never leave a previous, differently-configured estimate's price sitting in `result` for a Confirm gate to read. 🔴 NO AUTOMATIC CONSENT PROMPT HERE, unlike {@link UseBuzzWorkflow.submit}. Blocks call `estimate()` from an effect keyed on the generation form, so it fires on mount and on every parameter change — prompting there would open a consent dialog with no user gesture behind it, once per edit. A missing scope surfaces as an ordinary rejection; show no price and let `submit()` do the asking. |
submit | (body: WorkflowBody, options?: SubmitWorkflowOptions) => Promise<BlockWorkflowSnapshot> | Queue a workflow. Resolves ONLY with a reply that represents a real workflow OUTCOME — one that was queued, or one the server priced and then refused. 🔴 REJECTS with {@link WorkflowSubmitError} when the reply is failure-shaped and carries no price. Wrap every call in `try/catch`; see that class for why resolving such a reply was the `submit` half of civitai/civitai#4159. **Check `err.code` before saying anything about money** — and note that NEITHER code guarantees nothing was spent. `'exception'` usually means nothing was queued or charged, but a lost response or an in-progress idempotency conflict reaches it too; `'workflow-failed'` means a workflow PROBABLY exists (the `'whatif'` sentinel lands here too and has nothing to poll — guard before polling) and its spend may already be committed. Do not tell the viewer it was free, and on either code prefer reusing the same {@link SubmitWorkflowOptions.idempotencyKey}. 🔴 A BUDGET / SPEND-CAP REJECTION STILL RESOLVES, and that is deliberate. It is a documented outcome, not an error: the server quotes what it refused to charge, so the resolved snapshot has `status === 'failed'` AND a numeric `cost.total`. THAT is the shape to branch on when offering a top-up — `useBuzzPurchase().openPurchaseModal()` — not a `catch`. 🔴 BUT A RESOLVED `'failed'` IS NOT ALWAYS AN AFFORDABILITY PROBLEM, so do not wire every one of them to a purchase modal. The per-app **velocity** limit, the per-app **aggregate daily** cap, a fail-closed "temporarily unavailable" deny and a **missing price quote** are all priced, resolving outcomes that buying Buzz cannot fix. 🔴 THIS ALSO INCLUDES MODERATOR REVIEW PREVIEW. While an app is under review the host short-circuits every workflow request with `failureSnapshot('not available in review preview')`, so `submit()` rejects there where it used to resolve — the correct reading (no workflow was queued), and why the catch is not optional. `result` is updated to the returned snapshot BEFORE any rejection, so a failed submit can never leave a previous submit's workflow in `result`. 🔴 CONSENT IS HANDLED FOR YOU. When the token lacks `ai:write:budgeted`, this opens the host's consent dialog, waits for the grant, and re-sends the submit ONCE — with the SAME {@link SubmitWorkflowOptions.idempotencyKey}, so the two attempts are one reservation, not two. Nothing else changes: a failure while the token DOES hold the scope is untouched, a `CONSENT_UNAVAILABLE` environment is never retried, and a second consent failure reaches you unchanged. Opt out with {@link ConsentRetryOptions.autoRequestConsent}`: false`. |
poll | (workflowId: string) => Promise<BlockWorkflowSnapshot> | ONE host round-trip. The low-level pull primitive — you almost certainly want {@link UseBuzzWorkflow.watch} instead, which owns the loop. |
watch | (workflowId: string, options?: WatchWorkflowOptions) => Promise<BlockWorkflowSnapshot> | Watch a workflow to completion. Resolves with the TERMINAL snapshot; calls `onUpdate` with every intermediate snapshot along the way. This is the replacement for the `useEffect` + `setTimeout` backoff every block used to hand-write around {@link UseBuzzWorkflow.poll}. The app consumes a promise and/or a callback; the loop lives here. 🔴 THE LOOP IS SEQUENTIAL AND NON-OVERLAPPING BY CONSTRUCTION — each poll is awaited before the next is scheduled, so exactly one request per watched workflow is ever in flight. That is not tidiness: it is the property that makes a long hold SAFE. A caller-written `setInterval(poll, 2000)` against a host holding 15s would stack ~7 concurrent requests per workflow, and that is precisely why long polling is opt-in on the wire rather than switched on for every deployed block. |
cancel | (workflowId: string) => Promise<BlockWorkflowSnapshot> | Cancel a running workflow on the orchestrator (a real server-side stop, not just client-side untracking). The host re-derives ownership from the viewer's orchestrator token, so this can only cancel workflows the viewer owns; the orchestrator rejects others. Resolves with the workflow's (now-canceled) snapshot. |
status | WorkflowStatus | |
result | BlockWorkflowSnapshot | null | |
error | Error | null |
WorkflowBody union
Body the block sends to `useBuzzWorkflow().{submit,estimate}`. A real discriminated union keyed by `kind`: - {@link WorkflowBodyTextToImage} (`kind: 'textToImage'`) — the original checkpoint/LoRA/img2img generation body (unchanged, back-compatible). - {@link WorkflowBodyCustomComfy} (`kind: 'customComfy'`) — post-paid ComfyUI, itself a union on `mode`: a bounded, server-registered {@link WorkflowBodyCustomComfyRecipe} (the default), or a {@link WorkflowBodyCustomComfyInline} graph the block ships itself (`mode: 'inline'`; page-tokens-only, and NOT developer-only — that claim used to be here and is false). - {@link WorkflowBodyStep} (`kind: 'step'`, `step` PRESENT) — a bounded, server-registered orchestrator step (the host's step registry; billing mode and moderation posture are declared per entry). - {@link WorkflowBodyPassThroughStep} (`kind: 'step'`, `step` ABSENT) — names an orchestrator `$type` directly and has the host forward `input` unmodified. Bounded by a platform-internal denylist and by `maxBuzz`, not by a registry. `kind: 'step'` is therefore itself a union, discriminated on the PRESENCE of `step` — the same nesting {@link WorkflowBodyCustomComfy} has on `mode`. Narrowing on `kind === 'step'` alone leaves both arms in play; narrow further with `'$type' in body` (or `body.step === undefined`) before touching arm-specific fields. New kinds extend this union as the host gains support for them. Narrow on `body.kind` before touching member-specific fields (e.g. `modelId`/`params` live only on the `textToImage` member). ⚠️ Adding a member is additive for PRODUCERS (every existing body still satisfies the union) but narrowing for CONSUMERS that `switch` exhaustively over `kind`. Host code that must handle every member gets a compile error pointing at the new one, which is the intended behaviour.
WorkflowBodyTextToImageWorkflowBodyCustomComfyWorkflowBodyStepWorkflowBodyPassThroughStep
WorkflowBodyTextToImage object
The text-to-image member of the {@link WorkflowBody} discriminated union (`kind: 'textToImage'`). This is the original, single-member shape — kept byte-identical for backward compatibility. An existing `{ kind: 'textToImage', modelId, modelVersionId, params }` body must continue to satisfy {@link WorkflowBody} unchanged. Both `modelId` and `modelVersionId` are required even though they're conceptually redundant — the host validates that `modelId` matches the JWT's `ctx.modelId` (context binding) AND that the version belongs to that model (DB lookup). The block always has both values from `useBlockContext().context as ModelSlotContext`.
| field | type | notes |
|---|---|---|
kind | 'textToImage' | |
modelId | number | |
modelVersionId | number | |
additionalResources? | Array<{ modelVersionId: number; strength?: number; }> | Optional additional generation resources (LoRAs) layered on top of the checkpoint (`modelVersionId`). Mirrors civitai's `blockWorkflowBodySchema`: - max 5 entries - each: { modelVersionId: positive int, strength?: number in [-1, 2], default 1 } - the server is LoRA-only for additional resources (non-LoRA versions are rejected) and enforces base-model-family compatibility with the checkpoint + per-resource entitlement (early-access/Private) before any Buzz spend. Omit for a checkpoint-only generation (backward compatible). |
sourceImage? | BlockSourceImage | Optional img2img init/source image (App Blocks IMAGE bridge). When present, the block bridge emits an img2img graph workflow instead of `txt2img`; omit for a plain text-to-image generation (backward compatible). Constraints (all SERVER-ENFORCED — mirrors civitai's `blockTextToImageBodySchema`): - `url` must be a Civitai-hosted https image (an uploaded image from {@link BlockUploadedImageInfo.url} qualifies) — never an arbitrary remote URL. - The checkpoint's ecosystem must support an img2img variant — SD-family → `img2img`, edit-capable (OpenAI / Qwen / Flux Kontext / …) → `img2img:edit`. An ecosystem supporting NEITHER is rejected fail-closed. - PAGE apps only — the server rejects source images on a model-bound token. |
sourceImages? | BlockSourceImage[] | Optional img2img/edit source images — multi-image conditioning (App Blocks IMAGE bridge). The current field; supersedes the deprecated singular {@link WorkflowBodyTextToImage.sourceImage}. Omit BOTH for a plain text-to-image generation (backward compatible). Constraints (all SERVER-ENFORCED — mirrors civitai's `blockTextToImageBodySchema.sourceImages`, which is `z.array(blockSourceImageSchema).min(1).max(BLOCK_SOURCE_IMAGES_WIRE_MAX)`): - **Every element is validated individually** — each `url` must be a Civitai-hosted **https** image (an image from {@link BlockUploadedImageInfo.url}, or a {@link BlockGenerationSourceImageInfo} from a `'generationSource'` upload, qualifies) and each `width`/`height` must be within 64–2048. A bad element ANYWHERE rejects the whole body — there is no "first element only" path. - **The maximum count is PER-ECOSYSTEM, not a constant.** It is derived from the checkpoint's own generation-graph `images` node, so it tracks what the ecosystem actually supports: SD-family / Flux.1 Kontext / Boogu / MAI **1**; Qwen / Qwen2 / MageFlow **3**; Reve / HiDream-O1 **4**; WanImage **5**; Flux.2 / Flux.2 Klein / OpenAI / NanoBanana / Seedream / Grok **7**. Exceeding the checkpoint's cap is REJECTED (never silently truncated), and the error names both the count sent and the ecosystem's limit. A flat wire bound of 10 additionally rejects an oversized array before the body is parsed — it is NOT the product cap. - **An empty array is REJECTED** (`.min(1)`); it is NOT treated as "no source image". Omit the field entirely for text-to-image. - **PAGE apps only** — the server rejects source images on a model-bound token, for this array form as well as the singular one. - Sending **both** `sourceImage` and `sourceImages` is REJECTED as ambiguous rather than resolved to a winner. TypeScript cannot express that mutual exclusion here (both are independently optional on this member), so it surfaces as a server-side validation error — send exactly one. - Element ORDER is preserved into the graph's `images` input. 🔴 Requires a host running civitai/civitai#3518 or later. The text-to-image body schema is NOT `.strict()`, so a host predating #3518 does not error on this field — it SILENTLY STRIPS it and runs (and bills) a plain text-to-image generation with no image conditioning at all. There is no client-side way to detect that, so until #3518 is deployed everywhere you target, send the singular `sourceImage` (which works on both). |
sharedContentKey? | string | Optional shared-storage key of the published content this generation runs on behalf of. The server resolves it to the content's author for attribution (see the G5 civitai PR). Opaque string — the block passes back the `key` it got from `useSharedStorage()` for the content being generated against; omit when not applicable. |
accountType? | BuzzAccountType | Optional preferred Buzz pool to fund this generation from — a *preference*, not a guarantee. The host clamps it server-side to what the viewer actually holds and to the domain-allowed pools (a `blockWorkflowBodySchema` field on civitai/civitai; preferred-first, then falls back). Omit for today's default host-chosen funding order (backward compatible). Whichever pool ended up the primary funder is echoed back on {@link BlockWorkflowSnapshot.spentAccountType}. |
params | BlockTextToImageParams |
BlockTextToImageParams object
Generation parameters a block can override. All optional — the host fills sensible defaults (sampler='Euler', steps=25, dimensions from the base-model family) when omitted, so the simplest block can submit `{ kind: 'textToImage', modelId, modelVersionId, params: { prompt } }`. Bounds mirror civitai/civitai's `blockWorkflowBodySchema` zod gate; over- limit values are rejected server-side before reaching the orchestrator.
| field | type | notes |
|---|---|---|
prompt | string | |
negativePrompt? | string | |
cfgScale? | number | Range 1–30. |
sampler? | string | Sampler name (e.g. 'Euler', 'DPM++ 2M Karras'). Defaults to 'Euler'. |
steps? | number | Range 1–50. |
seed? | number | null | `null` lets the orchestrator pick. |
width? | number | Range 64–2048. Defaults to 1024 for SDXL/Flux, 512 for SD1/SD2. |
height? | number | Range 64–2048. Same defaults as width. |
clipSkip? | number | Per-resource CLIP layer skip count (SD1/SDXL). Range 0–12. Flux ignores it. |
quantity? | number | Range 1–4. Defaults to 1. |
BlockSourceImage object
A Civitai-hosted source image for an img2img generation. Mirrors civitai's `blockSourceImageSchema` (`{ url, width, height }` — all three REQUIRED), the element type of BOTH `blockTextToImageBodySchema.sourceImages` (current) and its deprecated singular alias `.sourceImage`. `url` MUST resolve to a Civitai-controlled host — the server rejects an arbitrary remote URL (SSRF / arbitrary-fetch). An image obtained from {@link BlockUploadedImageInfo.url} (via `useImageUpload`) satisfies this. `width`/`height` are the intrinsic dimensions the graph uses for its denoise/aspect derivation; the server bounds each to 64–2048. In the array form EVERY element is validated individually against exactly these rules — there is no "first element only" shortcut server-side.
| field | type | notes |
|---|---|---|
url | string | |
width | number | |
height | number |
WorkflowBodyCustomComfy union
The `customComfy` member of the {@link WorkflowBody} discriminated union (`kind: 'customComfy'`) — itself a discriminated union on `mode`, mirroring the host's `blockCustomComfyMemberSchema`: - {@link WorkflowBodyCustomComfyRecipe} (`mode` omitted, or `'recipe'`) — names a server-registered, code-reviewed recipe. The default. - {@link WorkflowBodyCustomComfyInline} (`mode: 'inline'`) — ships the ComfyUI graph itself. Page-tokens-only, and fenced by three server-side gates instead of code review. 🔴 NOT developer-only — this line said it was, and no `customComfy` arm runs an app-developer check. See that type's own doc comment for the refusals that DO run. Both arms are `.strict()` server-side, so a body naming BOTH `recipe` and `workflow` is rejected by both rather than resolved to a winner. Narrow on `body.mode === 'inline'` — NOT on the presence of a `mode` key, and not on the presence of a `workflow` key.
WorkflowBodyCustomComfyRecipeWorkflowBodyCustomComfyInline
WorkflowBodyCustomComfyRecipe object
The RECIPE arm of {@link WorkflowBodyCustomComfy} — runs a **server-registered, code-reviewed ComfyUI recipe** end-to-end. The block sends only a registered `recipe` id plus a small, per-recipe-validated `params` object; the civitai server owns the entire graph (built by object construction, so the prompt is a leaf value that cannot perturb graph topology). This is the DEFAULT arm. `mode` is optional here and a body that omits it lands on this arm, so every body written before the inline arm existed is unchanged and keeps working byte-for-byte. Trust / safety model (all SERVER-ENFORCED — mirrors civitai's `blockCustomComfyBodySchema`): - `recipe` is a **registered recipe id** resolved against a code-reviewed, in-repo recipe registry. An unknown id is **rejected server-side, fail- closed** (the schema enum is derived from the registry keys). - `params` are **bounded and validated per-recipe** by the server's `.strict()` Zod param schema (extra fields rejected); each recipe pins its own resources (checkpoint/LoRA/diffusion AIRs) — the block cannot supply them. Billing is **post-paid** (the underlying orchestrator `customComfy` step bills measured GPU runtime at a fixed rate, so there is NO exact pre-price): - `estimate` returns a per-recipe **display estimate**, not a firm quote — surface it as an estimate; the actual cost is known only on terminal. - Each recipe declares a hard per-job `maxBuzz` ceiling backed by an aggressive step `timeout`; the orchestrator physically caps the job at that ceiling server-side (worst-case Buzz = the timeout in seconds), and the host gates `maxBuzz <= token.buzzBudget` before submit. A single job therefore cannot exceed the recipe's declared ceiling no matter what.
| field | type | notes |
|---|---|---|
kind | 'customComfy' | |
mode? | 'recipe' | The arm discriminator, OPTIONAL on this arm. Omit it (the normal case) or set it to `'recipe'`; both land here. It exists so the inline arm can be selected explicitly with `mode: 'inline'`. 🔴 The host's schema declares this `z.literal('recipe').optional()` — NOT `.default('recipe')` — precisely so a body with no `mode` key still parses as a recipe. Do not "helpfully" send `mode: undefined` as a spread from an optional variable if you can avoid it; it parses fine today, but omitting the key entirely is the shape every deployed block sends. |
recipe | string | A **registered recipe id** (e.g. `'seamless-pano-360'`). Resolved server- side against the code-reviewed recipe registry; an unknown id is rejected fail-closed. The recipe fixes the graph, the resource allowlist, the checkpoint policy, and the `maxBuzz`/`timeout` budget ceiling — none of which the block can influence beyond selecting the recipe + `params`. |
params | { prompt: string; seed?: number | null; engine?: string; accountType?: BuzzAccountType; } | Bounded, per-recipe-validated parameters. Only the fields a recipe's `.strict()` Zod schema accepts are honored; extra fields are rejected server-side. The common shape: - `prompt` — the generation prompt (a leaf string; injected into the server-built graph by object construction, never string-templated). - `seed` — optional; `null`/omitted lets the orchestrator pick. - `engine` — optional recipe engine-variant selector (e.g. a DiT engine); the recipe defaults it when omitted. - `accountType` — optional preferred Buzz pool (a preference, clamped server-side to pools the viewer actually holds; see {@link BuzzAccountType}). |
WorkflowBodyCustomComfyInline object
The INLINE-GRAPH arm of {@link WorkflowBodyCustomComfy} (`kind: 'customComfy'`, `mode: 'inline'`) — the block ships **the ComfyUI graph itself**, so it can run a workflow the server's recipe registry does not contain. 🔴 THIS IS LIVE IN PRODUCTION. An earlier revision of this doc comment asserted that "there is no way for a block to run an arbitrary/unreviewed graph". That is FALSE and was removed: it predates the inline arm and cost a developer a dogfooding session, who trusted it over a working feature. 🔴 IT IS NOT "APP DEVELOPERS ONLY", AND THAT CLAIM USED TO BE HERE. This block read "**App developers only** — the host runs `assertViewerIsAppDeveloper` on every `customComfy` estimate AND submit. A non-developer viewer of your published block cannot submit one." Both sentences are FALSE: NEITHER `customComfy` arm runs any app-developer check, on the estimate or on the submit. The host's own schema module records the same retraction. Do not reintroduce it, in any wording — it is a SECURITY claim, and believing it is what makes an author ship an inline graph they would not ship to every viewer. An ordinary viewer of your published block CAN reach this arm once the refusals below pass. WHO CAN USE IT — stated as the refusals the host actually runs, before either arm's body is inspected. Each is a path your block has to handle: - **Page tokens only** — a model-bound token is rejected. - The token must carry the **`ai:write:budgeted`** consent scope. - The viewer must be **signed in** — a token whose subject does not resolve is refused. - The viewer must be **enabled for Apps** (the runtime kill-switch, evaluated on the token's subject; this is a closed beta). - On **submit** only: the token must carry a **positive per-call Buzz budget**. That budget is minted from YOUR OWN manifest (`page.buzzBudgetPerGen`) — it is not a property of the viewer. A registered recipe is how you get a reviewed graph you do not have to ship in the body. It is not a way onto a surface the inline arm cannot reach. WHAT REPLACED CODE REVIEW. A recipe is reviewed in-repo, and that review was the trust root. An inline graph has none, so the host substitutes three mechanical, fail-closed gates that all run BEFORE any spend or orchestrator call (a rejection therefore costs nothing): 1. **AIR containment.** Every AIR URN appearing anywhere in the graph — including as an object KEY — must also appear in {@link resources}. The match is whole-string (trimmed, case-insensitive), so an AIR embedded in a longer string does not count as declared and the body is rejected. 2. **Entitlement**, over the declared `resources`. Stricter than the onsite generator: early-access `hasAccess` and Private/epoch subscription are both folded in, an unresolvable version id is a hard `FORBIDDEN`, and a resource the site would silently SUBSTITUTE with a sibling version is REJECTED instead — your graph names one specific AIR and nothing rewrites it. 3. **Moderation sweep.** The audit reads the declared `prompt` AND every distinct string leaf in the graph, because a real graph carries its prompts inside `CLIPTextEncode` nodes. A clean declared `prompt` cannot launder a graph prompt.
| field | type | notes |
|---|---|---|
kind | 'customComfy' | |
mode | 'inline' | The arm discriminator. REQUIRED and exactly `'inline'` — omitting it does not "default to inline because a `workflow` is present"; it routes the body to the recipe arm, which then rejects it for a missing `recipe`. |
workflow | Record<string, InlineComfyNode> | The ComfyUI `/prompt` graph, keyed by node id (the same object shape ComfyUI's "Save (API Format)" export produces). Server-enforced bounds — all three REJECT, none truncates: - at least **1** node, at most **300**; - the serialized graph must be at most **262144 bytes** (256 KB); - nesting at most **128** levels deep (the gate walks the graph, and a graph it cannot finish walking is one it cannot prove safe). |
resources | string[] | The DECLARED DOWNLOAD MANIFEST: every resource the graph needs, as an AIR URN. This is the array the entitlement gate runs over, and gate 1 above is what makes gating it sufficient. 🔴 **Declaring is not optional and it is not inferred.** Every AIR your graph names must ALSO be listed here or the submit is rejected — and anything the graph references that is missing from this list would fail at load time inside ComfyUI anyway. At most **24** entries, each at most 512 characters. A `civitai`-sourced AIR MUST carry a model VERSION id (`urn:air:<ecosystem>:<type>:civitai:<modelId>@<versionId>`) — without one there is no version to check entitlement against and the body is rejected. The AIR grammar is read strictly here: all of `urn:air:<ecosystem>:<type>:<source>:<id>[@<version>]` is required, unlike the lenient parser used elsewhere. The permitted AIR `type`s are model WEIGHTS — `checkpoint`, `diffusion_model`, `unet`, `lora`, `lycoris`, `dora`, `embedding`, `hypernet`, `controlnet`, `vae`, `upscaler`, `clip`, `clipvision`, `text_encoders`, `motion` and a few siblings. An `oci:image` container AIR is NOT permitted. Anything the allowlist does not name is rejected rather than passed through. Duplicate entries are rejected, not deduped. |
prompt? | string | The prompt to surface and to audit alongside the graph sweep. Optional (defaults to `''` server-side) and at most 1500 characters. It is NOT the moderation surface on its own — the graph's own strings are swept too — and it is NOT what generates: the text that generates is whatever your `CLIPTextEncode` node carries. Set it to the same text so what you show the user matches what ran. |
negativePrompt? | string | Optional declared negative prompt, at most 1500 characters. Same posture as {@link prompt}. |
maxBuzz | number | The per-job Buzz ceiling. Required, an integer in **1…250**. 🔴 **IT IS ALSO THE STEP TIMEOUT, IN SECONDS.** The host stamps `stepTimeoutSeconds = maxBuzz` — there is only one number, which is exactly what makes the ceiling physically enforceable rather than merely asserted. So `maxBuzz: 10` does not buy a cheap generation; it buys a job that is KILLED after 10 seconds and comes back `expired`. Size it to the wall-clock time your graph actually needs, not to what you hope to pay. You are billed the REAL cost. Billing is post-paid against measured GPU seconds and settles to actual, refunding the unused remainder of the ceiling — so a generous `maxBuzz` costs nothing extra when the job finishes early. The only thing a low value guarantees is a timeout. `estimate` on an inline body echoes this number back as `cost.total`. That is an upper bound, not a price — surface it as "up to N Buzz". There is no honest per-graph estimate: the orchestrator forwards the graph opaquely and cannot price it. The host additionally requires `maxBuzz <= token.buzzBudget` before submit; over-budget comes back as a `failed` snapshot naming both numbers. |
WorkflowBodyStep object
The REGISTRY ARM of the `kind: 'step'` member — a **registered orchestrator step**, submitted through the host's step registry, the uniform bridge for step types that are not full generation recipes (image conversion, chat completion, captioning, …). 🔴 `kind: 'step'` HAS TWO ARMS, discriminated by whether `step` is present. This one names a REGISTERED id and the host translates it; the other is {@link WorkflowBodyPassThroughStep}, which omits `step`, names an orchestrator `$type` directly and has the host forward `input` unmodified. The trust model below describes THIS arm only — the pass-through arm deliberately drops four of these controls, and its own doc comment enumerates which. Mirrors the host's `blockStepBodySchema` element-for-element. That schema is `.strict()` with exactly these three fields, so anything else on this object is REJECTED server-side rather than dropped. Trust / safety model (all SERVER-ENFORCED, same posture as {@link WorkflowBodyCustomComfy}): - `step` is a **registered step id** resolved against a code-reviewed, non-DB-editable registry. THIS ARM's wire enum is DERIVED from the registry keys, so an unregistered id is rejected **fail-closed at the schema**, before any translator, any spend reservation, or any orchestrator call. 🔴 That is a statement about the REGISTRY ARM, not about `kind: 'step'` as a whole: a body that omits `step` lands on {@link WorkflowBodyPassThroughStep} instead, where an id the registry has never heard of is exactly the supported case. - `params` are **bounded and validated per-step** by that step's own `.strict()` Zod schema. They are deliberately opaque on the wire (the host keeps the transport step-agnostic), so this field is `Record<string, unknown>` here and the host's per-step schema is the authority for what a given step accepts. An unknown param is a `BAD_REQUEST`, never a silent drop. - Each entry declares its own billing mode and moderation posture in that registry; a step that produces free text has its output scanned before it can reach the block. Registered ids at the time of writing: `'convert-image'` (fixed-price image format conversion + resize) and `'chat-completion'` (fixed-price LLM chat completion over a server-pinned model allowlist). The set grows additively — `step` is typed `string` rather than a literal union on purpose, because the registry lives on the host and a pinned union here would go stale against any host deploy that adds one.
| field | type | notes |
|---|---|---|
kind | 'step' | |
step | string | A **registered step id** (e.g. `'chat-completion'`). Resolved server-side against the code-reviewed step registry; on THIS arm an unregistered id is rejected fail-closed at the wire schema. Its presence is also the ARM DISCRIMINATOR. Omit it and the body is read as a {@link WorkflowBodyPassThroughStep} instead, where an unregistered id is not rejected because there is no registry lookup at all. |
params | Record<string, unknown> | Bounded, per-step-validated parameters. Only the fields that step's `.strict()` schema accepts are honored; anything else is rejected. For `'chat-completion'` the accepted shape is `{ model: string; messages: Array<{ role: 'system' | 'user' | 'assistant'; content: string }>; maxTokens: number; temperature?: number }`, where `model` must be one of the host's allowlisted models and `maxTokens` is REQUIRED and bounded. Documented rather than typed here: the host's schema is the single source of truth, and a hand-mirrored param type in this package would drift against it silently. |
WorkflowBodyPassThroughStep object
The PASS-THROUGH ARM of the `kind: 'step'` member — the block names an ORCHESTRATOR `$type` directly and the host forwards `input` **unmodified**. No registry entry, no server-side translator, no per-step param schema. Mirrors the host's `blockPassThroughStepBodySchema`. That schema is `.strict()` with exactly these fields, so anything else on this object is REJECTED server-side rather than dropped. 🔴 **`step` IS THE ARM DISCRIMINATOR AND IT MUST BE ABSENT.** Send `{ kind: 'step', $type, input, maxBuzz }` with no `step` key at all. It is typed `step?: undefined` here so that omitting it satisfies the type while setting it to anything is a compile error — the host's discriminated union uses `z.undefined()` for the same job, and a body carrying both `step` and `$type` is rejected by BOTH arms (each is `.strict()`) rather than resolved to a winner. WHAT THIS ARM GIVES UP relative to {@link WorkflowBodyStep}. Four of the registry's controls are deliberately absent, by operator decision — this is documented so it reads as a decision rather than an oversight: 1. **No per-step `.strict()` param schema.** `input` is opaque; the orchestrator's own per-`$type` validation is the only shape gate, and it runs AFTER the spend reservation rather than at the wire. 2. **No moderation posture and no prompt audit.** Nothing audits `input`. Moderation moved to the PUBLISH boundary — nothing a block generates is public until published, and that path is moderated. 3. **No resource policy / `urn:air:` scan.** AIR resources are ALLOWED here. Spend, not entitlement, is the binding control. 4. **No `billingMode` and no load-time price invariant.** {@link maxBuzz} replaces them, exactly as on {@link WorkflowBodyCustomComfyInline}. WHAT STILL BOUNDS IT, all server-side: - A **denylist** of platform-internal `$type`s (scanners, moderation classifiers, hashing/model-ingestion, web egress) is refused by the host router before any spend reservation or orchestrator call. It is a DENYLIST, not an allowlist: a `$type` the host has never heard of is allowed through by construction, which is the point of this arm. - `$type` is bounded to **1…64 characters** and `input` to **262144 bytes** (256 KB) serialized. Both REJECT; neither truncates. - `maxBuzz` is the single spend knob — see its own note below.
| field | type | notes |
|---|---|---|
kind | 'step' | |
step? | undefined | 🔴 THE ARM DISCRIMINATOR — **omit this key**. It exists in the type only so that a body which sets it cannot be mistaken for a pass-through body: the only assignable value is `undefined`, and the wire payload carries no `step` key at all (JSON cannot express `undefined`). To name a REGISTERED step id instead, you want {@link WorkflowBodyStep}. |
$type | string | The ORCHESTRATOR step type to run, verbatim — e.g. `'imageBackgroundRemoval'`. Required, 1…64 characters. This is the orchestrator's own `$type` discriminator, NOT a Civitai step- registry id, and it is not resolved against any allowlist. It is refused only if it names a platform-internal type (the denylist above; the match is case-insensitive). The host records the submitted value as the subtype of the generation it stamps, so a `$type` longer than the cap is rejected rather than silently degraded. |
input | Record<string, unknown> | The orchestrator step's own input object, **forwarded unmodified**. The host does not read, rewrite, merge or default any field in here — that is the whole point of this arm, and the step the orchestrator receives carries an `input` byte-identical to this value. Consequently the orchestrator's per-`$type` schema is the ONLY authority for what a given `$type` accepts; nothing in this package or on the host mirrors it. Bounded only by size: at most **262144 bytes** (256 KB) serialized, which is a payload-DoS bound and not a shape gate. |
maxBuzz | number | The per-job Buzz ceiling. Required, an integer in **1…250**. 🔴 **IT IS ALSO THE STEP TIMEOUT, IN SECONDS** — identical mechanism to {@link WorkflowBodyCustomComfyInline.maxBuzz}. The host stamps `stepTimeoutSeconds = maxBuzz`; there is only one number, which is what makes the ceiling physically enforceable rather than merely asserted. So `maxBuzz: 10` does not buy a cheap job; it buys one that is KILLED after 10 seconds and comes back `expired`. Size it to the wall-clock time the step actually needs. You are billed the REAL cost: post-paid against measured GPU seconds, refunding the unused remainder of the ceiling, so a generous `maxBuzz` costs nothing extra when the job finishes early. `estimate` on a pass-through body echoes this number back as `cost.total` — an upper bound, not a price; surface it as "up to N Buzz". The host additionally requires `maxBuzz <= token.buzzBudget` before submit. |
BlockWorkflowSnapshot object
The host-mediated view of an orchestrator workflow that an iframe block receives over `postMessage`. This is intentionally a flattened **subset** of `WorkflowSnapshot` from `../orchestrator/` — the host (civitai.com) maps the full orchestrator payload down to this shape before forwarding. Notable differences: - `workflowId` here = orchestrator's `id` - `imageUrls` here = flattened from `steps[].output.images[].url` - `cost.total` is the host-attested total (not the raw orchestrator field) - the status union omits orchestrator-internal states like `unassigned` If the orchestrator gains a status the host doesn't recognize, the host is responsible for mapping it to one of the values here (typically `processing` or `failed`).
| field | type | notes |
|---|---|---|
workflowId | string | |
status | 'pending' | 'processing' | 'succeeded' | 'failed' | 'expired' | 'canceled' | |
cost? | { total: number; } | |
imageUrls? | string[] | |
error? | string | |
spentAccountType? | BuzzAccountType | The Buzz pool that was the PRIMARY FUNDER of this generation — i.e. the account with the LARGEST debit, which the host stamps onto the workflow snapshot server-side. This is NOT necessarily "the paid account": a generation covered mostly by free/earned Buzz reports `spentAccountType: 'blue'`. Populated by the host from the Phase-1 backend `spentAccountType` field; absent when the host predates it or no spend occurred. Informational only — surface it (e.g. "funded from your yellow balance") but don't gate on it. |
autoClaim? | { type: 'dailyBoost'; amount: number; accountType: 'yellow' | 'blue' | 'red' | 'green'; } | Set when the host opportunistically claimed a Buzz reward on the user's behalf during submit. Currently the host only fires this for the daily boost (25 blue Buzz, one per UTC day) when the user's balance would otherwise have been short by less than the boost amount. Informational only — the block has no obligation to reconcile state (the claim already settled in the orchestrator). A typical block UX surfaces a small "+25 daily boost claimed" notice next to the succeeded result. |
AppWorkflow object
The clean, wire-stable projection of ONE orchestrator workflow in the calling app's own generator SUBQUEUE — what `QUERY_APP_WORKFLOWS` / `CANCEL_APP_WORKFLOW` return. Mirrors civitai/civitai's `AppWorkflow` / `projectAppWorkflow` (`src/server/services/blocks/workflow.service.ts`, PR #3164) EXACTLY — KEEP IN LOCKSTEP; a drift here silently strands the reply's transport validator. The host deliberately DROPS every internal/sensitive workflow field (steps, params, prompts, resources, tokens, transactions, metadata, tags) so a block can never read generation internals of a queue it only owns by tag. status: the block-contract status — the orchestrator's `unassigned`/`preparing`/`scheduled` all collapse to `pending`. images: only `available` blobs with a non-null url (see {@link AppWorkflowImage}). cost: the workflow's realized/estimated buzz total, or `null` when absent. createdAt: ISO-8601 string.
| field | type | notes |
|---|---|---|
workflowId | string | |
status | 'pending' | 'processing' | 'succeeded' | 'failed' | 'expired' | 'canceled' | |
images | AppWorkflowImage[] | |
cost | number | null | |
createdAt | string | ISO-8601. |
AppWorkflowImage object
One result image on an {@link AppWorkflow}. Mirrors civitai/civitai's `AppWorkflowImage` projection (`src/server/services/blocks/workflow.service.ts`, PR #3164) — keep in lockstep. Only `available` blobs with a non-null url are surfaced by the host (pending/blocked blobs are dropped rather than handing the block dead links). `width`/`height` are `null` until the orchestrator populates them; `nsfwLevel` is the numeric civitai browsing-level bitflag (1/2/4/8/16), `null` for an unrated blob.
| field | type | notes |
|---|---|---|
url | string | |
width | number | null | |
height | number | null | |
nsfwLevel | number | null |
SubmitWorkflowOptions object
Optional per-submit controls.
| field | type | notes |
|---|---|---|
idempotencyKey? | string | A STABLE idempotency key for this logical submit. Reuse the SAME value when RETRYING a submit whose response was lost (timeout / network drop) so the host+orchestrator collapse it to ONE Buzz charge instead of double-charging. Omit → the hook generates a fresh key per `submit()` call (each call is a new logical submit); pass a stable id (e.g. a grid-cell id) to make a retry safe. 🔴 THIS IS THE FIELD THAT MAKES A RETRY AFTER A `'workflow-failed'` REJECTION SAFE. That code means a workflow probably exists and its spend may already be committed server-side; retrying WITHOUT reusing the key mints a fresh one and therefore a SECOND reservation. See {@link WorkflowSubmitError.code}. The SDK's own automatic consent retry obeys this: whichever value ends up here — yours, or the one `submit()` mints — is the value BOTH of its attempts carry. So an error you receive may already be a second attempt's; if you then retry a third time by hand, reuse this key for that too. 🔴 **FORMAT: `^[A-Za-z0-9_-]{1,64}$` — letters, digits, `_` and `-` only, at most 64 characters, and NO COLONS.** The host rejects anything else before the procedure runs: `BAD_REQUEST` / **400** on `blocks.submitWorkflow`, with `{"code":"invalid_format","pattern":"/^[A-Za-z0-9_-]{1,64}$/","path":["idempotencyKey"]}`. So a composite key like `sheetId:panelId:nonce` fails every time — that is the exact value that broke a shipped app, with 201 local tests green. The colon is excluded deliberately, not cosmetically: the host composes its per-`(user, app, key)` dedupe key with `:` as the delimiter and relies on the key being colon-free for that to stay injective. The 64 bound is derived from the orchestrator's 128-char `externalId` ceiling, which the host builds by substringing this key. 🔴 A key that fails this is **REFUSED before anything is sent**, with `InvalidIdempotencyKeyError` — nothing was sent and nothing was spent, which is why it is NOT a {@link WorkflowSubmitError} (every code on that class is money-ambiguous by design). The key is never sanitised for you: rewriting an idempotency key would break the identity it exists to carry — two distinct logical submits could collapse onto one slot, or a retry could be normalised differently from its first attempt and mint a SECOND reservation. Validate with `isValidBlockIdempotencyKey` from `@civitai/app-sdk/blocks` if you compose keys dynamically. |
WatchWorkflowOptions object
Optional controls for {@link UseBuzzWorkflow.watch}.
| field | type | notes |
|---|---|---|
onUpdate? | (snapshot: BlockWorkflowSnapshot) => void | Called with EVERY snapshot the host returns, intermediate ones included, in order. This is the push side of the API: render from here instead of re-reading `result` on a timer. A throw from this callback is not caught — it rejects the `watch` promise. |
signal? | AbortSignal | Stop watching. The promise RESOLVES with the last snapshot seen rather than rejecting: an abort is the caller's own decision, not a failure, and the common case (a component unmounting) has nobody left to catch a rejection. 🔴 This does NOT cancel the workflow — it stops watching it. Buzz is already spent and the orchestrator keeps running. To actually stop the work, call {@link UseBuzzWorkflow.cancel}. |
waitSeconds? | number | Orchestrator-side hold per poll, in **seconds**. Default {@link DEFAULT_WATCH_WAIT_SECONDS}. 🔴 THE HOST HONOURS AND CLAMPS THIS — it is NOT advisory (#388). This docblock said "CURRENTLY ADVISORY … a host that does not yet read the field simply answers immediately, exactly as today", which was true when written and is not true of the deployed host. The contract, read off `civitai/civitai` at **`b0eb2820b5`** (5.1.120, `main`) — two files, because a constant that nothing calls is not a contract: - `src/server/services/blocks/workflow.service.ts:1263` declares `MAX_BLOCK_POLL_WAIT_SECONDS = 15`. - `:1286-1292` `resolveBlockPollWaitSeconds(waitSeconds?: number)` applies it, and `src/server/routers/blocks.router.ts:3965` is the call site. What that function does, in its own order: 1. a non-`number` or non-finite value → `undefined`, i.e. NO HOLD. 2. `Math.floor` FIRST. `0.9` is therefore not "a short hold" — it floors to `0` and becomes no hold at all, which the host's own comment calls out explicitly. 3. floored `<= 0` → `undefined` (no hold). So `0` — and any fraction below 1 — disables long polling and `watch` falls back to a plain read per `intervalMs`. 4. otherwise `Math.min(floored, 15)`. Asking for 60 gets you 15. Consequences worth planning for: only whole seconds are expressible, and no value above `MAX_BLOCK_POLL_WAIT_SECONDS` buys anything — `intervalMs` is still what bounds request rate, because a clamped hold returns sooner than the caller asked for. 🔴 THIS IS PROSE ABOUT ANOTHER REPO, AND NO GUARD IN THIS ONE CAN CHECK IT. The host checkout is not present in CI, so the honest check is a human reading the two files above at a named revision — which is why the sha is quoted rather than "as of today". A later reader comparing against a newer host should update the sha along with whatever moved. The `POLL_WORKFLOW` field's own note on `BlockToParentMessage` in `@civitai/app-sdk` covers the wire shape and the older-host case; this covers what the current host does with the value. |
intervalMs? | number | Delay between polls, in ms. Default 1500. 🔴 NOT REDUNDANT WITH `waitSeconds`. When the host long-polls, the hold dominates and this is a few percent of overhead. When it does NOT — an older host, or `waitSeconds: 0` — this is the only thing standing between this loop and a request storm. |
timeoutMs? | number | Give up and resolve with the last snapshot after this long. Default 10min. |
maxRetries? | number | Consecutive transport failures to absorb before rejecting. Default 3. A poll can fail for reasons that have nothing to do with the workflow (a pod rolling, a network blip). Because `watch` OWNS the loop, a single blip would otherwise end a generation the caller's own retry loop used to survive. The counter RESETS on any successful poll, so this bounds a burst, not a lifetime. |
What the bridge can and cannot do
The generation bridge is a deliberately narrower surface than the orchestrator, not a thin proxy in front of it. The body your block sends is a discriminated union keyed by kind, and anything outside those shapes is rejected at the wire schema — in the host, before any orchestrator call is made.
There are three kind values, and kind: 'step' is itself two arms — four members, and they are the whole surface:
kind | what it addresses | how you name the model |
|---|---|---|
textToImage | a Civitai checkpoint | numeric modelId + modelVersionId |
customComfy | a server-registered ComfyUI recipe, or your own graph | a registered recipe id — or, with mode: 'inline', the graph itself plus a declared resources manifest |
step (step present) | a server-registered orchestrator step (convert-image, chat-completion) | a registered step id |
step (step omitted) | an orchestrator step type named directly, with input forwarded unmodified | the orchestrator's own $type — not a Civitai id |
The registry arm of step (added in @civitai/app-sdk@0.30.0) carries a registered step id plus bounded params validated per-step by the host's own .strict() schema. Like recipes, the step registry is server-side and code-reviewed: an unregistered id is rejected fail-closed at the wire schema.
Registered ids as of the pinned SDK (the registry arm only — the pass-through arm has no registry): convert-image (fixed-price image format conversion + resize) and chat-completion (fixed-price LLM chat completion over a server-pinned model allowlist). So step is not the "non-image" arm — one of the two entries today is an image operation. The set grows additively on the host, which is why step is typed string rather than a literal union: a union pinned in the SDK would go stale against any host deploy that adds one, so treat the host's registry, not this list, as authoritative. The full field table for this arm is at WorkflowBodyStep; the other arm's is at WorkflowBodyPassThroughStep.
Orchestrator step JSON is not a bridge body as it stands
If you have been handed an orchestrator step — a $type object shaped like this:
{
"$type": "imageGen",
"input": {
"engine": "sdcpp",
"ecosystem": "zImage",
"model": "turbo",
"operation": "createImage"
}
}— that is correct for the orchestrator and is still not a bridge body. It carries no kind and no maxBuzz, so it fails the wire schema before the host does anything else, and none of ecosystem / model / operation / engine is how a block names a Civitai model.
What is new: the kind: 'step' member has a second, pass-through arm that does carry a $type. It is a host capability and is already live; @civitai/app-sdk@0.43.0 is the release that gives it a type (WorkflowBodyPassThroughStep), so that is the floor for expressing one in typed code. A body of { kind: 'step', $type, input, maxBuzz } — with the step key omitted — has the host forward input unmodified. The two arms are discriminated on whether step is present, so a body carrying both is rejected by both. The registry arm described above is unchanged. Field-by-field bounds are in WorkflowBodyPassThroughStep.
That arm is deliberately less bounded than the registry, not unbounded. $types the platform reserves for its own internal use are refused server-side, with their own error and a case-insensitive match. A $type being absent from that set is not a promise that it will run — only that the host will forward it; the orchestrator's own per-$type validation is what decides, and it runs after the spend reservation rather than at the wire.
Below 0.43.0 the SDK has no type for this arm, so a pass-through body cannot be expressed without casting — that is a typing floor, not a host one.
If you send a body the wire schema rejects — the raw orchestrator step above, or a kind: 'step' body carrying both step and $type — the symptom is distinctive: every generation fails identically, on every model, with no per-model variation, because nothing model-specific ever ran. If you are seeing "it fails on anything", check the body shape first.
Either way, a block never holds an orchestrator Bearer token. The full orchestrator contract, with you as the principal, is the Orchestration REST API — see not to be confused with orchestration recipes.
"The model I want isn't reachable" — what to do
Most of the time it is reachable, and the fix is naming the right modelVersionId. Work down this path:
Is it a Civitai checkpoint? — if it has a Civitai model version, use
textToImagewith itsmodelId+modelVersionId. Models that feel "orchestrator-only" usually aren't: Z-Image and Qwen are ordinary checkpoints with ordinary ids.Do you need an edit? — pass a source image (
sourceImage, orsourceImagesfor more than one) and name the edit version (see the worked example). The variant is chosen by the presence of a source image, not by the version id — this is the single most common mistake on the bridge.Is it genuinely outside the union? — before you conclude that, check every arm.
Asking for a platform request is still the route to the bounded, registered treatment — say so explicitly when you ask, and note that both
customComfyrecipes today are prompt-only txt2img, so anything taking an image input is new ground rather than a variation on an existing one.Recipes are not self-serve, but an inline graph is. The recipe registry is server-side and code-reviewed; there is no runtime, manifest, or dashboard way to add one, so adding a recipe is a platform request — see requesting a new recipe. You do not have to wait for one to run a graph, though: the inline arm (
mode: 'inline') lets a page app ship the ComfyUI graph in the body today. Ask for a recipe when you want a reviewed graph you do not have to ship in the body. Neither arm reaches a model-slot block:customComfyis page-token-only on both.
The ids you probably want
These are the checkpoints developers most often assume are out of reach. They are not — they are normal textToImage targets:
| model | modelId | modelVersionId | use it for |
|---|---|---|---|
| Z Image Turbo | 2168935 | 2442439 | txt2img |
| Qwen — "Image Edit 2511" | 2268063 | 2558804 | edit (send a sourceImage) |
| Qwen — "fp8_e4m3fn" | 2268063 | 2552908 | txt2img |
The Qwen model name and its edit version disagree
Both Qwen versions live under one modelId (2268063), and the model is named "Qwen-Image-2512" while its edit version is named "Image Edit 2511". Reading 2512 off the model and treating it as the version you want lands you on the txt2img version. The edit version is 2558804.
A version the resolved workflow doesn't offer can still be swapped silently
The workflow variant is derived from whether a source image is present (sourceImage, or sourceImages), not from the version id you name — so the version you send and the workflow you get are decided independently.
The Qwen case in the table above now fails, loudly. Name the edit version 2558804, leave the source image off, the bridge resolves txt2img, and the request is rejected with BAD_REQUEST:
modelVersion 2558804 is not available for 'txt2img' on the Qwen ecosystem — it is offered for img2img:edit only. Either send a
sourceImageto run it as an image edit, or use modelVersionId 2552908 for txt2img.
That check only fires on a version the ecosystem's config lists for some other workflow, and only in the txt2img direction. Two narrower cases are still substituted silently:
- A version id the ecosystem lists nowhere — a community checkpoint, or a version retired since your app shipped. Most image ecosystems lock their checkpoint, and on those an unlisted id is replaced with the workflow's default version and the generation succeeds:
987654321on Qwentxt2imgcomes back as2552908. (On an ecosystem that does not lock its checkpoint, the id survives.) - The reverse direction — a
txt2img-only version sent with a source image. It is substituted the same way and is not rejected:2552908sent with a source image on Qwen comes back as2558804.
In both, it looks like success: the workflow succeeds, images render, and you are billed for a checkpoint you did not ask for. The swap is recorded server-side on the workflow snapshot as modelSubstitutions (requested / applied / reason), but that field is not part of the SDK's BlockWorkflowSnapshot type yet — so don't build on reading it. The tell is behavioural: the output ignores your source image, or doesn't look like the version you named.
The fix is in your body, not in a support request: send a source image whenever you mean to edit, and name the version that belongs to the mode you want — 2558804 for a Qwen edit, 2552908 for Qwen txt2img.
Worked example: Qwen single-image edit
The case people get wrong. Note both halves: the edit version id and the source image. This is a page app — source images are rejected on a model-bound token.
This example sends the singular sourceImage, which is the right choice for one image today even though the SDK marks it @deprecated — see which field to send today.
import { useBuzzWorkflow, useImageUpload } from '@civitai/blocks-react';
import type { WorkflowBodyTextToImage } from '@civitai/app-sdk/blocks';
// PAGE APP ONLY — `sourceImage` is rejected fail-closed on a model-bound token.
export function QwenEdit() {
const { submit } = useBuzzWorkflow();
const { open } = useImageUpload({ purpose: 'generationSource' });
const run = async () => {
const source = await open(); // Civitai-hosted { url, width, height }
if (!source) return;
const body: WorkflowBodyTextToImage = {
kind: 'textToImage',
modelId: 2268063,
modelVersionId: 2558804, // "Image Edit 2511" — NOT the model's default
sourceImage: { url: source.url, width: source.width, height: source.height },
params: { prompt: 'make the sky stormy' },
};
await submit(body);
};
return <button onClick={run}>Edit image</button>;
}Drop the sourceImage line and this silently becomes a txt2img generation.
What the source-image fields can and cannot do
A textToImage body carries its img2img / edit input in one of two fields, and the SDK ships both:
| field | shape | status |
|---|---|---|
sourceImage | a single { url, width, height } | @deprecated — but supported indefinitely, and the right thing to send today for one image |
sourceImages | { url, width, height }[] (min 1) | the current field; the only way to express more than one image |
Read which field to send today before you pick — the answer is not simply "the newer one".
These limits are structural, not tuning knobs, and apply to both fields:
- Civitai-hosted
httpsURLs only.civitai.com,civitai.red,civitai.greenand their subdomains. An arbitrary remote URL is rejected. In the array form every element is validated individually — there is no "first element only" path, and one bad element rejects the whole body. width/heightare bounded to 64–2048 per image, and are required.- Page apps only. Source images are rejected fail-closed on a model-bound token, the same restriction
additionalResourcescarries — for the array form as well as the singular one. See page-vs-model constraints. - Never send both fields.
sourceImageandsourceImagestogether is rejected as ambiguous rather than resolved to a winner. TypeScript cannot express that mutual exclusion (both are independently optional), so it surfaces as a server-side validation error — send exactly one. - An empty
sourceImages: []is rejected, not read as "no source image". Omit the field entirely for plain text-to-image. - You do not choose edit vs img2img — the checkpoint's ecosystem does. Edit-capable ecosystems (Qwen, Qwen2, Seedream, NanoBanana, OpenAI, Flux.1 Kontext, Flux2 and the Flux2-Klein variants) get an
img2img:editgraph. SD-family ecosystems get plainimg2img("Image Variations") instead. A checkpoint whose ecosystem supports neither is rejected fail-closed. There is no body field that overrides this.
How many images you may send
The cap is per-ecosystem, not a constant. It is derived from the checkpoint's own generation-graph images node, so it tracks what the ecosystem actually supports:
| max images | ecosystems |
|---|---|
| 1 | SD-family, Flux.1 Kontext, Boogu, MAI |
| 3 | Qwen, Qwen2, MageFlow |
| 4 | Reve, HiDream-O1 |
| 5 | WanImage |
| 7 | Flux.2, Flux.2 Klein, OpenAI, NanoBanana, Seedream, Grok |
Exceeding the checkpoint's cap is rejected, never silently truncated, and the error names both the count you sent and the ecosystem's limit. A flat wire bound of 10 additionally rejects an oversized array before the body is even parsed — that number is a wire guard, not the product cap, so never design against it.
Element order is preserved into the graph's images input.
Note the consequence: on an ecosystem capped at 1 (SD-family, Flux.1 Kontext, Boogu, MAI) multi-image editing is still not reachable — not because the field can't express it, but because that checkpoint's graph has one image slot. Pick the checkpoint accordingly. Being edit-capable and accepting several images are separate properties: Flux.1 Kontext is an edit ecosystem with a cap of 1.
Which field to send today
sourceImages is the current field, but "always use the current field" is the wrong rule here, because of one asymmetry:
An old host STRIPS sourceImages and still bills you
sourceImages requires a host running civitai/civitai#3518 or later. The text-to-image body schema is not .strict(), so a host that predates #3518 does not error on the field — it silently strips it and runs, and bills, a plain text-to-image generation with no image conditioning at all. There is no client-side way to detect that: you get a successful workflow, real images, and a real Buzz charge for a generation that ignored your input.
sourceImage (singular) is understood by both old and new hosts.
So:
- One image → send
sourceImage. It is marked@deprecated, but the SDK states the alias keeps working indefinitely (every deployed block and these docs ship it), and the server normalizes it into a 1-element array, so a 1-elementsourceImagesarray produces a byte-identical generation. The deprecation is a signpost toward the array, not a removal notice. - Two or more images → send
sourceImages, and only against a host you know runs #3518 or later. Until #3518 is deployed everywhere you target, that is the trade you are making:sourceImagesis the only way to express the request, and an older host answers it by silently doing something else. - Never both, in either direction — that is rejected as ambiguous.
These are bounded by the union's shape, not by configuration:
| Constraint | What to do instead |
|---|---|
Source images on a model-bound (model.*) block | build a page app |
| More images than the checkpoint's ecosystem allows | pick a checkpoint whose ecosystem has a higher cap |
| Choosing edit vs img2img yourself | it follows from the checkpoint's ecosystem; pick the checkpoint accordingly |
Note what is not on this list: single-image editing, multi-image editing on a capable ecosystem, and Z-Image all work through textToImage today — see the ids you probably want.
The registered recipes
On its recipe arm customComfy accepts only a registered id — an unregistered one is rejected at the union, before the recipe is resolved. Today there are exactly two. (The inline arm is how you run a graph that is not in this table.)
recipe | what it does | params (.strict()) | per-generation Buzz ceiling |
|---|---|---|---|
seamless-pano-360 | 360° seamless panorama, fixed 2048×1024 | { prompt, seed?, engine?, accountType? } — engine is one of zimage-turbo, flux2-klein, qwen-image | 90 / 150 / 180, by engine |
starter-comfy-txt2img | single-step Z-Image txt2img, fixed 1024×1024 | { prompt, seed?, accountType? } | 90 |
Both param schemas are .strict(): a field that isn't listed is rejected, not ignored. Note what is not exposed — neither recipe takes width / height, steps, or CFG. Those are fixed server-side (the starter recipe runs at the Z-Image turbo defaults).
Don't reach for starter-comfy-txt2img to get Z-Image
It is a fixed-resolution, prompt-and-seed demo starter, not the Z-Image path. For Z-Image generation use textToImage with 2168935 / 2442439, which gives you the full param surface (dimensions, steps, sampler, quantity). Reach for the recipe only when you want exactly what it does.
Retrying a submit() without double-charging
submit(body, options?) takes a second argument — SubmitWorkflowOptions — whose one field is about money:
import { useBuzzWorkflow } from '@civitai/blocks-react';
import type { WorkflowBody } from '@civitai/app-sdk/blocks';
export function useRetryableSubmit() {
const { submit } = useBuzzWorkflow();
// ONE logical submit → ONE stable key, reused by every retry of it.
// `-` is the separator, not `:` — a colon fails the charset below.
return async (body: WorkflowBody, cellId: string) => {
for (let attempt = 0; attempt < 3; attempt++) {
try {
return await submit(body, { idempotencyKey: `gen-${cellId}` });
} catch (err) {
if (attempt === 2) throw err;
}
}
};
}- Omit
idempotencyKeyand the hook generates a fresh key persubmit()call — correct, because each call is a new logical submit. - Reuse the SAME key when you are retrying a submit whose response was lost (a timeout, a network drop). The host and the orchestrator then collapse the attempts into one Buzz charge instead of charging twice.
A stable key must be stable per submit, not per component
The key identifies one logical submit. Deriving it from something coarser — a component instance, the block id, a user id — means two genuinely different generations share a key and are eligible to be collapsed as if one were a retry of the other. Key it to the unit of work the user asked for. When in doubt, omit the option: the hook's per-call key is the safe default, and only a retry needs to opt out of it.
idempotencyKey — the validated charset
🔴 ^[A-Za-z0-9_-]{1,64}$. Letters, digits, _ and -, 1 to 64 characters — and the host validates it on the way in, before the whatIf preflight prices anything. A key outside that class is refused:
{ "code": "invalid_format", "format": "regex",
"pattern": "/^[A-Za-z0-9_-]{1,64}$/",
"path": ["idempotencyKey"],
"message": "Invalid string: must match pattern /^[A-Za-z0-9_-]{1,64}$/" }returned as BAD_REQUEST / 400. Because validation runs first, nothing is queued and nothing is charged — a block that composes keys outside the class never generates at all, rather than generating and double-charging.
The same charset governs every idempotency key the platform accepts, on all four surfaces that take one: the useBuzzWorkflow().submit() bag documented here, the REST POST /api/v1/blocks/workflows/submit body (where the field is required, not optional), useGoodPurchase().purchase(), and useTip().tip().
The payload above is what the bridge path returns. The three REST routes validate the same pattern but report it in their own envelope — a 400 with { "error": "Invalid request body", "details": { "fieldErrors": { "idempotencyKey": [ … ] } } } — so branch on the status, not on a shared error body.
Why the colon specifically is out. It is excluded on purpose, and knowing why is what stops you reaching for . or / instead. The host composes its internal per-viewer / per-app dedupe and rate-limit keys by joining their parts with :. Those joins are unambiguous only while none of the parts can themselves contain a colon — your key is one of the parts, so a colon-bearing key could cross into a neighbouring key's namespace. Every other separator outside [A-Za-z0-9_-] is rejected for the same reason the class is closed: - and _ are the two you have. Concatenate with those.
Why 64. The host derives the orchestrator-side identifier for the run from your key plus a short prefix, and the orchestrator enforces its own ^[A-Za-z0-9_-]+$ over at most 128 characters. A 64-character ceiling on your half keeps the composed identifier inside that in the worst case.
| Key expression | Verdict |
|---|---|
`gen-${cellId}` | ✅ |
`buy-${goodId}-${attemptId}` | ⚠️ charset-legal, still wrong twice: an attempt-varying part defeats the replay the key exists for, and a good id may itself be 64 chars, which breaks the length bound below. Prefer `buy-${crypto.randomUUID()}` |
crypto.randomUUID() | ✅ — 36 chars, [0-9a-f-] |
`gen:${cellId}` | ❌ 400 — colon |
React.useId() | ❌ 400 — returns a colon-wrapped id such as :R0: |
`gen.${cellId}` / `gen/${cellId}` | ❌ 400 — . and / are outside the class |
| a key longer than 64 chars | ❌ 400 |
See also
- What the bridge can and cannot do — the boundary vs the orchestrator, the ids for Z-Image and Qwen edit, and the one gap that needs a platform request.
- Retrying a
submit()without double-charging —idempotencyKey. - Generation guide — the narrative walkthrough (img2img, LoRAs, page-vs-model).
- Comfy on Civitai (customComfy) — the recipe-gated ComfyUI path.
- Hooks reference — every
@civitai/blocks-reacthook. - Messages reference — the
postMessageprotocol these hooks sit on.