---
url: /apps/reference/generation/bodies.md
description: >-
  The WorkflowBody union and its text-to-image, ComfyUI and orchestrator-step
  members, with their parameter types. Part of the Generation bridge reference.
---

> Documentation index: https://developer.civitai.com/llms.txt — every page on this site has a markdown version at its URL + `.md`.

# Workflow body types

The `WorkflowBody` union and its text-to-image, ComfyUI and orchestrator-step members, with their parameter types.

This page documents 9 of the 27 types in the [Generation bridge reference](https://developer.civitai.com/apps/reference/generation.md), which indexes every type by name. Generated from `@civitai/app-sdk@0.71.0` and `@civitai/blocks-react@0.73.0`.

Other pages: [useBuzzWorkflow lifecycle and snapshot types](https://developer.civitai.com/apps/reference/generation/lifecycle.md) · [Training and multi-step workflow types](https://developer.civitai.com/apps/reference/generation/training-and-multi-step.md)

**`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. - {@link WorkflowBodyTraining} (`kind: 'training'`) — an ai-toolkit LoRA run on a server-prepared dataset of the viewer's own images. ESTIMATE ONLY through `useBuzzWorkflow`; it runs through `useRunTraining()`, whose host-chrome consent dialog is the only way to confirm its quote. - {@link WorkflowBodyWorkflow} (`kind: 'workflow'`) — up to {@link BLOCK\_WORKFLOW\_MAX\_STEPS} orchestrator steps submitted as one workflow, a later step consuming an earlier one's result through a {@link WorkflowStepRef}. Its estimate is not a maximum — read its doc. `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.

* `WorkflowBodyTextToImage`
* `WorkflowBodyCustomComfy`
* `WorkflowBodyStep`
* `WorkflowBodyPassThroughStep`
* `WorkflowBodyTraining`
* `WorkflowBodyWorkflow`

**`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.

* `WorkflowBodyCustomComfyRecipe`
* `WorkflowBodyCustomComfyInline`

**`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} and a per-request orchestrator `whatif` quote replace them — see `maxBuzz` below for how the two combine. 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. `training` and `imageResourceTraining` are deliberately NOT on it (a host operator decision): both may be submitted here, on the same terms as every other `$type`. If the orchestrator quotes the run, the host reserves the quote (or `maxBuzz`, if larger), gated by the token's per-call budget, and stamps no timeout; if it does not, `maxBuzz` is both the reservation and the timeout — see `maxBuzz` below. Whether a given training input is quoted, and at what price, is the orchestrator's answer, not this package's: read it from `estimate`. A host that supports it reports the trained epochs on {@link BlockWorkflowSnapshot.trainedEpochs} without exposing the checkpoint itself. - `$type` is bounded to **1…64 characters** and `input` to **262144 bytes** (256 KB) serialized. Both REJECT; neither truncates. - **`repeat` is refused on this arm**, outright: a `repeat` may only iterate an array in a body's own `arguments`, and this arm has none. Use a `kind: 'workflow'` body ({@link WorkflowBodyWorkflow}) to repeat a step. - `maxBuzz` is the app's spend knob, and the host's quote can raise the reservation above it — 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 app's declared per-job Buzz amount: the least the host reserves, and — only when unquoted — the step timeout in seconds. NOT a spend ceiling (see below). Required, an integer in **1…250**. 🔴 **WHAT IT BOUNDS DEPENDS ON WHETHER THE HOST GOT A QUOTE** — this is NOT the mechanism of {@link WorkflowBodyCustomComfyInline.maxBuzz}, which is always the timeout. On both `estimate` and `submit` the host first asks the orchestrator for a `whatif` quote of this exact step, then: - **Quoted** — the host reserves `max(maxBuzz, quote)` and stamps NO step timeout. That reservation can exceed 250: it is gated against the token's per-call budget (`token.buzzBudget` — the app's per-generation budget, i.e. `page.buzzBudgetPerGen` for a page app: a platform default when nothing declares one, and clamped to a platform cap on every mint), and a submit whose reservation exceeds that budget comes back `failed` with an `insufficient buzz budget` error before any Buzz is reserved. The current default and cap are in the pinned limits table, https://developer.civitai.com/apps/guide/text-to-image.md#limits-table. - **Unquoted** (the orchestrator returned no price, or the quote could not be had) — the host reserves `maxBuzz` and stamps it as the step timeout, in seconds. `maxBuzz: 10` then buys a job that is KILLED after 10 seconds and comes back `expired`, so size it to the wall-clock time the step actually needs. The same per-call budget gate applies. The timeout bounds wall-clock time; it bounds spend only for a step billed by compute time. A step priced per unit can bill ABOVE `maxBuzz`. `estimate` on a pass-through body returns, as `cost.total`, the number the submit would reserve: `max(maxBuzz, quote)`, or `maxBuzz` when unquoted. It is a reservation, NOT a guaranteed ceiling: the terminal settle refunds any unused remainder, but nothing on this arm stops a job billing above the reservation (see the unquoted case above). Do not present it to the viewer as a maximum. |
| `quoteId?` | `string` | The viewer-confirmable quote this job is charged against — {@link BlockStepQuote.quoteId} from this body's own estimate (`sq_` + 32 lowercase hex characters, {@link BLOCK\_STEP\_QUOTE\_ID\_REGEX}). OMIT it everywhere except a CONFIRMED run: an estimate never carries it, and neither does an ordinary submit. It is REQUIRED on `RUN_CONFIRMED_STEP` (`useRunConfirmedStep()` in `@civitai/blocks-react`), the only message that can run a job priced above the token's per-call budget — see {@link BlockWorkflowSnapshot.stepQuote} for the flow. 🔴 A body carrying it is NOT runnable through `SUBMIT_WORKFLOW`. The server charges a confirmed job only against a quote the viewer confirmed in Civitai's own dialog from a signed-in session, so the same body sent as a plain submit is refused. Change nothing else in the body between the estimate and the run: the server checks it against the quoted one (`quoteId` itself is excluded from that comparison). |
