useBuzzWorkflow lifecycle and snapshot types
The useBuzzWorkflow() lifecycle, the submit and watch options, and the workflow snapshots and app-workflow records it returns.
This page documents 7 of the 27 types in the Generation bridge reference, which indexes every type by name. Generated from @civitai/app-sdk@0.71.0 and @civitai/blocks-react@0.73.0.
Other pages: Workflow body types · Training and multi-step workflow types
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. It is NOT a cue to call `useBuzzPurchase().openPurchaseModal()`: every resolving refusal is a cap buying Buzz does not raise, or a reply that is not about the wallet at all — the complete list is on {@link UseBuzzWorkflow.submit}. 🔴 RUNNING OUT OF BUZZ REJECTS, and is not marked as such. The orchestrator's refusal reaches the block as {@link WorkflowSubmitError} code `'exception'`, shared with every other thrown submit. Offer a top-up only after checking the viewer's SPENDABLE Buzz ({@link useBuzzBalance}: blue plus the block's domain pool, `buzz:read:self` declared) against the quoted cost — see {@link UseBuzzWorkflow.submit}, which also holds the complete list of resolved refusals. 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. 🔴 A MULTI-STEP `kind: 'workflow'` BODY CHANGES WHAT THE PRICE MEANS — again a capability claim, so it is stated here. `WorkflowBodyWorkflow` submits several orchestrator steps as one workflow through these same calls, and: - `estimate()`'s `cost.total` is NOT a maximum: the orchestrator re-prices a step once the values it references are known and charges the difference; - a workflow the host cannot price FAILS `estimate()` with a `workflow_quote_*` `errorCode` and no `cost`, yet still proceeds on `submit()`, reserved at the app's per-generation budget × the number of steps (one with a training step, or with a `repeat` step at any depth, is refused on both); an older host returns that ceiling as the estimate; - one author fee applies to the whole workflow, not one per step, and it is reversed whole if the workflow does not succeed; - `repeat` iterates only an array in the body's `arguments`, at most 16 times, nested repeats multiplied; - a `failed` result can carry `cost.total > 0` and usable `imageUrls`, because steps that succeeded before the failing one stay charged — read `result.steps`.
| 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. A `kind: 'training'` body's reply also carries {@link BlockWorkflowSnapshot.trainingQuote} — the `quoteId` to run it with through `useRunTraining()`. A pass-through `step` body's reply carries {@link BlockWorkflowSnapshot.stepQuote} when its FIXED price is above the token's per-call budget (and within the per-job ceiling). That job cannot run through {@link UseBuzzWorkflow.submit}; run it with `useRunConfirmedStep().runConfirmedStep({ ...body, quoteId: stepQuote.quoteId })`, which has the viewer confirm `stepQuote.total`. No `stepQuote` means the ordinary rules apply — it is also what a host without the feature sends. Where a quote would be granted, this REJECTS instead for a training `$type` or an `input` that is not plain JSON (see {@link BlockWorkflowSnapshot.stepQuote} for the two messages). |
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`. 🔴 WHAT A RESOLVED `status: 'failed'` CAN MEAN — THE ONE COMPLETE LIST. The `@civitai/blocks-react` README, the `civitai-block-starter` AGENTS.md and the changesets link here rather than restating it. Every case below RESOLVES (`blocks.submitWorkflow` returns it; the REST route answers it with HTTP 200): 1. **Refused before anything ran — the `'failed'` sentinel `workflowId`, a `cost.total`, nothing charged, every reservation released.** - the per-call budget, `token.buzzBudget` (`insufficient buzz budget: …`); - the viewer's daily Buzz cap — or, in its place, the review-session cap (a moderator's run-for-real) or the private-run cap; - the viewer's consent budget (the ceiling they set when they granted the scope); - the app's velocity limit or aggregate daily cap, or a fail-closed "temporarily unavailable" deny; - the dev-session cap (`dev:tunnel`); - a missing price quote for a step ("generation temporarily unavailable"). A training step with no price quote is refused the same way but with NO `cost`, which on this hook REJECTS as `'exception'` instead. 2. **Not confirmed — Buzz may have been spent.** A training run whose submission the server could not confirm: the sentinel id, a `cost.total`, and `submissionUnconfirmed: true` on the REST reply. The reservation is NOT refunded and the run may be going. 🔴 The host bridge forwards only the snapshot, so on this hook that flag never arrives and the reply looks like case 1 — never tell a viewer "nothing was charged" for a resolved failed `kind: 'training'` submit; send them to their trainings instead. 3. **A run was created and came back failed — a REAL `workflowId`.** The reservation is kept and Buzz may have been spent. Priced, it resolves; unpriced, it rejects as `'workflow-failed'`. So only case 1 may be told "could not start, nothing was charged". 🔴 A RESOLVED `'failed'` IS NEVER A TOP-UP CUE — do not wire it to `useBuzzPurchase().openPurchaseModal()`. Case 1 is a set of caps that buying Buzz does not raise, and cases 2 and 3 are not about the wallet. 🔴 THE CASE A TOP-UP DOES FIX — THE VIEWER IS ACTUALLY OUT OF BUZZ — REJECTS. The orchestrator refuses the run, the server throws, and the host answers with its cost-less `failureSnapshot(err)`, so this rejects with {@link WorkflowSubmitError} code **`'exception'`** — the same code as every other thrown submit (a bad request, an outage, a rate limit, an idempotency conflict). Nothing structural marks it as insufficient funds: the code is shared, and `snapshot.error` is the upstream message, server-authored prose that is not a contract. So decide a top-up from the BALANCE, not from the rejection: on `'exception'`, read {@link useBuzzBalance} and offer `openPurchaseModal()` only if the viewer's SPENDABLE Buzz is below the quoted cost. Two things make that read correct: - `useBuzzBalance` needs `buzz:read:self` declared in `block.manifest.json`, and that scope is consent-gated (it is not consent-exempt), so request it like any other gated scope. An anonymous viewer is refused outright. - A block spends only blue plus ITS DOMAIN'S pool: blue + green under an SFW ceiling, blue + yellow under a mature one. The server keys this on the token's DOMAIN ceiling (`maxBrowsingLevel`), so compute it as `isSfwCeiling(useDomainMaturity().maxBrowsingLevel)` (from `@civitai/app-sdk/blocks`; an unknown ceiling counts as SFW) — NOT `useDomainMaturity().isSfw`, which is narrowed by the viewer's own setting — and compare `blue` plus that one pool, never blue + green + yellow, against the quoted cost. 🔴 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`. 🔴 A `kind: 'training'` BODY IS REFUSED before anything is sent — it runs only through `useRunTraining()`. See {@link TRAINING_BODY_SUBMIT_REFUSAL}. So is a pass-through `step` body that carries a `quoteId` — it runs only through `useRunConfirmedStep()`. See {@link CONFIRMED_STEP_BODY_SUBMIT_REFUSAL}. |
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 |
BlockWorkflowStepSnapshot object
One entry of {@link BlockWorkflowSnapshot.steps}.
| field | type | notes |
|---|---|---|
name | string | The step's `name` from the body, or the name the host gave an unnamed step. |
$type | string | The orchestrator step type that ran. |
status | 'pending' | 'processing' | 'succeeded' | 'failed' | 'expired' | 'canceled' | |
imageUrls? | string[] | This step's own image urls. Omitted when it has none, and always omitted for a step submitted with `suppressOutput: true`. |
output? | unknown | The step's raw orchestrator output, minus its blobs — the same treatment and the same caveats as {@link BlockWorkflowSnapshot.stepOutputs}: its shape is whatever the orchestrator returns for that `$type`, and it is UNSCANNED. |
error? | string | Why this step failed. Diagnostic text; do not render it verbatim. |
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? | { /** Whole Buzz. Includes {@link authorFee} whenever that field is present. */ total: number; /** * The part of `total` that is the app author's per-generation fee, in whole * Buzz — so `total - authorFee` is the generation's own price. Display only: * it is the amount the host added into `total` for this reply. * * - **A number** wherever `total` was built from a fee quote: the * `estimate()` result for a `textToImage` or registered-`step` body, and * the refusal `submit()` resolves for those kinds when a budget, spend cap * or rate limit refuses the quoted price. **`0` means a fee was looked up * and none applies** to this request. * - **Absent** when the total is not itemised: a host that predates the * field; every snapshot of a submitted workflow (`submit` success, `poll`, * `cancel` — its total is the realized generation cost and the fee is a * separate charge); the registered-`step` `submit()` refusal for a missing * orchestrator price quote, which refuses before any fee is looked up; and * the kinds that price no fee (`customComfy`, pass-through `step`, * `training`). **Do not read absent as `0`** — show `total` on its own. * * A `kind: 'workflow'` estimate itemises it too, and it is ONE fee for the * whole workflow however many steps it has. */ authorFee?: number; } | |
imageUrls? | string[] | |
error? | string | |
errorCode? | KnownBlockWorkflowErrorCode | (string & {}) | A stable, machine-readable companion to {@link error}, for a block to branch on instead of matching the prose. OPTIONAL: set only on the refusals that define a code, absent everywhere else (and on every reply from a host that predates the field). WHERE IT APPEARS today: the refusal for a pass-through `step` body ({@link WorkflowBodyPassThroughStep}) whose `$type` is a training type (`'training'` or `'imageResourceTraining'`, in any casing — the host compares `$type` case-insensitively) when the host could not get a price quote for it. That refusal is `{ workflowId: 'failed', status: 'failed', error, errorCode }` with no `cost`, and it is the reply to `estimate()` (which rejects with `WorkflowEstimateError`), to `submit()` (which rejects with `WorkflowSubmitError`, before anything is reserved or charged), and to that body's cell in a batch estimate. Read it from the error's `.snapshot.errorCode`. A `kind: 'training'` ESTIMATE does not set it. The confirmed SUBMIT of a training run or a confirmed step can — see `'daily-limit-exceeded'` below. A refused `kind: 'workflow'` body ({@link WorkflowBodyWorkflow}) sets it in the same shape, on the same three replies — except the three `workflow_quote_*` codes, which are ESTIMATE-ONLY (`estimate()` and a batch-estimate cell): `submit()` of the same body defines no such refusal and proceeds, reserved at the ceiling. A REGISTERED `step` body ({@link WorkflowBodyStep}) the host has no price quote for gets one of the three `step_quote_*` codes, also ESTIMATE-ONLY (`estimate()` and a batch-estimate cell); its `submit()` is unchanged. The pass-through `step` arm does not set them. The values the host sends today ({@link KnownBlockWorkflowErrorCode}): - `'training-quote-rejected'` — the orchestrator refused the request itself; resending the same body will not help. `error` then reads `Training could not be priced: <reason>` when the reason is safe to show. - `'training-quote-unavailable'` — no usable answer (an outage, a timeout, a rate limit, insufficient funds, …). Retrying later may succeed. - `'training-quote-unpriced'` — the orchestrator answered without a price. - `'daily-limit-exceeded'` — a viewer-confirmed job (a `kind: 'training'` run or a confirmed step) would exceed what remains today of the daily Buzz limit THE VIEWER set for this app. Nothing was charged and no job was started; the confirm cannot override it, and only the viewer can raise the limit. This one arrives on the failed snapshot a confirmed SUBMIT resolves (`{ workflowId: 'failed', status: 'failed', cost, error, errorCode }`), not on an estimate. On a confirmed step the host also reports it as the `error` code of `CONFIRMED_STEP_RESULT` — see {@link BlockRunConfirmedStepHostError}. - `'workflow_step_denied'` — a step of a `kind: 'workflow'` body names a platform-internal `$type`. Remove or replace that step. - `'workflow_ref_invalid'` — a {@link WorkflowStepRef} in a step's `input` points at the step itself, a later step, or nothing; or a step `name` breaks the naming rules (see {@link WorkflowBodyWorkflowStep.name}). `error` names the step by its position. - `'workflow_too_many_steps'` — more than {@link BLOCK_WORKFLOW_MAX_STEPS} steps. - `'workflow_repeat_unbounded'` — a `repeat` step iterates something other than an array in the body's `arguments`, or more than {@link BLOCK_WORKFLOW_REPEAT_MAX_ITERATIONS} times, nested repeats multiplied; or the workflow has a `repeat` and the host has no price quote for it. `error` then reads `workflow step N: a workflow with a repeat needs a price quote and <cause>`, where `<cause>` is one of the three in the `workflow_quote_*` messages below. A retry of the same body can succeed only when the cause is `none could be obtained; try again`; otherwise the body is what has to change. - `'workflow_quote_rejected'` — ESTIMATE only. The orchestrator refused the body, so there is no price to show; resending the same body will not help. `error` reads `No price quote for this workflow: the orchestrator rejected the request: <reason>` when the reason is safe to show, else `No price quote for this workflow: the orchestrator rejected the request; check the step inputs and references (retrying the same body will not help)`. - `'workflow_quote_unavailable'` — ESTIMATE only. No usable answer (an outage, a timeout, a rate limit, …). The one `workflow_quote_*` code a retry of the same body can clear. `error` reads `No price quote for this workflow: none could be obtained; try again`. - `'workflow_quote_unpriced'` — ESTIMATE only. The orchestrator answered without a price. `error` reads `No price quote for this workflow: the orchestrator answered without one`. Each `workflow_quote_*` reply is `{ workflowId: 'failed', status: 'failed', error, errorCode }` with NO `cost`. A host that predates these codes resolves the estimate instead, with the spend ceiling as `cost.total`. - `'step_quote_rejected'` — ESTIMATE only, registered `step` body. The orchestrator refused the request; resending the same body will not help. `error` reads `No price quote for this step: the orchestrator rejected the request: <reason>` when the reason is safe to show, else `No price quote for this step: the orchestrator rejected the request; check the step inputs and references (retrying the same body will not help)`. - `'step_quote_unavailable'` — ESTIMATE only, registered `step` body. No usable answer (an outage, a timeout, a rate limit, …). The one `step_quote_*` code a retry of the same body can clear. `error` reads `No price quote for this step: none could be obtained; try again`. - `'step_quote_unpriced'` — ESTIMATE only, registered `step` body. The orchestrator answered without a price. `error` reads `No price quote for this step: the orchestrator answered without one`. Each `step_quote_*` reply is `{ workflowId: 'failed', status: 'failed', error, errorCode }` with NO `cost`. A host that predates these codes resolves the estimate instead, priced at the step's declared estimate, so there a registered-step estimate may not be a quote. 🔴 AN UNKNOWN VALUE IS POSSIBLE: the host may add codes, and the SDK's inbound validator accepts any string here. Handle an unrecognised code as a generic failure and show `error`. |
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. |
modelSubstitutions? | BlockModelSubstitution[] | Checkpoint versions the host SILENTLY replaced before running the generation. `requested` is the version id the block asked for, `applied` is the one that actually ran (and was billed). On an ecosystem whose model is locked to a workflow, the host swaps a version id that is not offered for the current workflow for that workflow's default and still succeeds — graceful degradation that keeps an app pinned to a since-retired version working. This field only REPORTS the swap; the behaviour is unchanged. WHERE IT APPEARS: on the `kind: 'textToImage'` path only — the submit reply, the estimate reply, every submit reply that quotes a cost without submitting, and every later poll/cancel of that workflow (the record is persisted with it, so it sits next to the `imageUrls` it describes). An estimate creates no persisted workflow, so there it exists only on that reply. It does NOT appear for `customComfy` or `step` bodies: neither resolves a checkpoint, so there is nothing to substitute. OMITTED entirely when nothing was substituted. Absent on hosts that predate it. |
textOutputs? | string[] | Generated FREE TEXT produced by a registered text-output step (e.g. `chat-completion`) — every string here has PASSED the host's output moderation scan. WHERE IT APPEARS: the poll and cancel replies (`poll()` / `cancel()` / `watch()` on `useBuzzWorkflow`). NOT on the submit reply — a submit returns a freshly-queued workflow with no output yet, so poll for the text. Also never on {@link AppWorkflow}, which has no text field. OMITTED entirely when there is no text. Independent of {@link BlockWorkflowSnapshot.textOutputWithheld}: a workflow with two text steps can release one and withhold the other, and both fields then appear. |
textOutputWithheld? | { reason: string; } | Set when a text-output step produced nothing the block can be given, with a user-facing sentence saying why. TWO distinct causes land here: 1. WITHHELD — text WAS produced and is being kept back: a content-policy hit, or any other fail-CLOSED scan outcome (scanner error or timeout, no verdict at all, an over-cap payload, or requested labels that came back missing or errored). Every one of those paths returns the SAME message, so this cause is one string with several origins. 2. UNPUBLISHABLE — the step SUCCEEDED and the scan RELEASED, but none of what came back could be published (e.g. a provider tool-call `id` outside the published charset). Nothing was moderated in this case, and the message says so explicitly. `reason` is deliberately generic: it does not name the labels that triggered. 🔴 THE TWO CAUSES ARE NOT MACHINE-SEPARABLE without matching on the string, which is NOT a contract. Do not branch on the text of `reason`. 🔴 ONLY CAUSE 2 IS STATUS-GATED, and that gate reads the STEP's own `succeeded` status — never the workflow's. Cause 1 is not gated at all, so a CANCELLED generation CAN still set this field: the orchestrator returns whatever text it had already produced, that text is still scanned, and a hit still withholds it. A block that skips rendering `reason` on cancel would suppress a real content-policy explanation. A block polling a healthy in-flight generation sees the field absent — not because of its status, but because a step that has produced no text yet can reach neither cause. Independent of {@link BlockWorkflowSnapshot.textOutputs} rather than mutually exclusive, so `reason` is scoped to "a step in this response", never to the response as a whole. Same appearance rules as `textOutputs`. |
toolCalls? | BlockStepToolCall[] | Structured tool calls the model requested on a registered text-output step (e.g. `chat-completion` with tools), which have PASSED the same output moderation scan as {@link BlockWorkflowSnapshot.textOutputs}: every `arguments` string here was part of the text the scan released. The tool `name` is restricted to `[A-Za-z0-9_-]`, so it cannot carry prose. Each entry is fully populated — the host drops a call it cannot fill completely rather than publishing a partial one. Same appearance rules as `textOutputs` (poll and cancel replies only). OMITTED entirely when there are none. |
stepOutputs? | Array<{ $type: string; output: unknown; }> | The raw orchestrator output of each PASS-THROUGH step ({@link WorkflowBodyPassThroughStep}: `kind: 'step'` with a `$type`), as `{ $type, output }`, forwarded as-is — minus its blobs. Blob-SHAPED values are removed from `output`; their available urls go to {@link BlockWorkflowSnapshot.imageUrls} (and to {@link AppWorkflow.images}) instead, so a pass-through step does not open a second image channel. "Blob-shaped" is the exact scope of that: a url held in a plain string field of the output is not a blob and can still appear here. 🔴 UNSCANNED. Unlike `textOutputs`, no moderation scan runs over this field; moderation for the pass-through arm happens at the publish boundary. Treat any text in it accordingly. 🔴 A `$type` that is ALSO a registered step's orchestrator type (e.g. `chatCompletion`, which `chat-completion` uses) is handled by that registered step instead — its text arrives on `textOutputs`, scanned, and it does not appear here. Which channel a pass-through `$type` lands on can therefore change the day the host registers a step for it. `output` is `unknown`: its shape is whatever the orchestrator returns for that `$type`. OMITTED entirely when the workflow has no pass-through step with an unregistered `$type`. |
trainedEpochs? | BlockTrainedEpoch[] | The epochs of a pass-through `training` / `imageResourceTraining` step (see {@link WorkflowBodyPassThroughStep}) that PRODUCED A CHECKPOINT — one entry per epoch, each naming the step's `$type` and its `epochNumber`. Only runs whose training moderation status is approved are listed; an unmoderated or refused run reports no epochs. 🔴 THE CHECKPOINT ITSELF IS DELIBERATELY NOT EXPOSED — this field carries no download url. (An older host may still surface checkpoint urls in `imageUrls` or `stepOutputs` for a training step; that is a host defect, not a contract — never treat an `imageUrls` entry as a checkpoint.) To publish a trained epoch, navigate the viewer to Civitai's own model wizard: ```ts const { navigate } = useCivitaiNavigate(); navigate( `models/train/from-orchestrator?workflowId=${snapshot.workflowId}&epoch=${epoch.epochNumber}`, { scope: 'site' }, ); ``` The viewer completes (or abandons) the publish there, on Civitai's surface; {@link BlockWorkflowSnapshot.publishedModel} then reports the result. 🔴 REQUIRES A HOST VERSION THAT EMITS IT. Absent on older hosts — treat absence as "unknown", not as "no checkpoint". The run's spend and timeout follow the pass-through arm's rules — see {@link WorkflowBodyPassThroughStep.maxBuzz}. |
publishedModel? | BlockPublishedModel | The Civitai model this run has been published as — present once the viewer has STARTED or FINISHED publishing it through the model wizard reached from {@link BlockWorkflowSnapshot.trainedEpochs}. `published` is `false` while the model is still a draft and `true` once it is published. 🔴 REQUIRES A HOST VERSION THAT EMITS IT. Absent on older hosts, and absent on any run nobody has started publishing. |
trainingQuote? | BlockTrainingQuote | The quote a {@link WorkflowBodyTraining} ESTIMATE produced — the orchestrator's own price for exactly that run, stored server-side. Pass `trainingQuote.quoteId` back as the body's `quoteId` on `useRunTraining().runTraining()`; the consent dialog then shows the viewer this price, read back from the server. `cost.total` on the same snapshot equals `trainingQuote.total`. `expiresAt` is an ISO-8601 timestamp: after it the run is refused and you estimate again. `imageCount` is the server-derived size of the prepared dataset, never a number the block sent. Present ONLY on the reply to a `kind: 'training'` estimate. Absent on hosts that predate civitai/civitai#5434. |
stepQuote? | BlockStepQuote | The viewer-confirmable quote a pass-through `step` ESTIMATE ({@link WorkflowBodyPassThroughStep}) produced, when the job costs more than the app may spend without asking. Its presence means: this job cannot run through `submit()`, and CAN run through `useRunConfirmedStep()` once the viewer confirms `stepQuote.total` in Civitai's own dialog. THE FLOW, mirroring a training run's: 1. `useBuzzWorkflow().estimate(body)` — the reply carries `stepQuote`. 2. `useRunConfirmedStep().runConfirmedStep({ ...body, quoteId: stepQuote.quoteId })` — Civitai shows the viewer the price (read back from the server, never from the body) and submits on their confirm. 3. `useBuzzWorkflow().watch(snapshot.workflowId)`. WHEN THE HOST SENDS IT — all of these, decided server-side: - the orchestrator quoted a **FIXED** price for exactly this step: positive, and not a variable or metered one. The viewer confirms what they are charged, so a price that may move is never confirmable; - that price is ABOVE the token's per-call budget and at most {@link BLOCK_CONFIRMED_STEP_MAX_BUZZ_PER_JOB} (5,000 Buzz); - the block runs in a page app for a signed-in viewer on an ordinary token. Development, dev-tunnel and review sessions never get one, and neither does the model slot. Everywhere else the field is ABSENT and the estimate is exactly what it was. Absent is therefore not an error — it is also what every host that predates civitai/civitai#5786 sends. 🔴 ONLY A SINGLE ESTIMATE CARRIES IT. A batch estimate (`ESTIMATE_WORKFLOW_BATCH`, `useBatchEstimate()`) stores nothing, so its cells never have a `stepQuote`, whatever they cost. Price a grid in a batch, then estimate the one body you want to run on its own. 🔴 TWO BODIES MAKE THAT ESTIMATE FAIL INSTEAD OF RETURNING A PRICE — only where a quote would otherwise have been granted (so not below the budget, and not in a batch). The reply is the failure snapshot `{ workflowId: 'failed', status: 'failed', error }`, which `estimate()` rejects with; the same text arrives as `CONFIRMED_STEP_RESULT`'s `error` if such a body is run. Neither has an {@link BlockWorkflowSnapshot.errorCode}; they are server messages, so match on the text and keep a generic branch: - `a training step type cannot run as a confirmed step — use kind:'training'` — `$type` is `training` or `imageResourceTraining`, in any casing. Training has its own kind ({@link WorkflowBodyTraining}). - `a confirmed step needs plain JSON input (objects, arrays, strings, finite numbers, booleans, null) — <path> is <what>` — `input` holds a `Date`, `Map`, `Set`, class instance, `BigInt`, `NaN`/`Infinity`, an `undefined` array element or an empty slot in a sparse array (`… — body.input.list[1] is an empty array slot`). The quote is bound to the exact body, so the body must be exactly representable. The part after the dash names the offending path (`body.input.when is a Date`) and varies: match on the text BEFORE the dash. `total` is the price the viewer confirms and is charged. 🔴 It can differ from `cost.total` on the same snapshot, which stays what an UNCONFIRMED submit would reserve (`max(maxBuzz, quote)`); show `stepQuote.total` on a confirm button. `expiresAt` is an ISO-8601 timestamp 15 minutes out; after it the run is refused and you estimate again. A quote is single-use. |
model3dOutputs? | BlockModel3dOutput[] | What each 3D model in {@link BlockWorkflowSnapshot.imageUrls} IS — one label per model a pass-through step produced (e.g. `polyGen`). Every model url is still listed in `imageUrls`, unchanged and in the same order as before the field existed; this only says which entry is which. Match on `url`: ```ts const glb = snapshot.model3dOutputs?.find( (m) => m.format === 'glb' && m.variant === 'model', ); if (glb) viewer.load(glb.url); ``` OMITTED when the workflow produced no 3D model, and on hosts that predate civitai/civitai#5786 — in which case `imageUrls` is all there is. |
steps? | BlockWorkflowStepSnapshot[] | The steps of a `kind: 'workflow'` run ({@link WorkflowBodyWorkflow}), in the orchestrator's order, each with its own status and results. The steps a `repeat` step generates follow it, named `<repeat name>.<n>`, so the list can be longer than the body's. The top-level fields keep their meaning: `status` is the workflow's, `imageUrls` is the union of every step that did NOT set `suppressOutput`, and `cost` is the whole workflow's. 🔴 A `failed` WORKFLOW IS NOT AN EMPTY ONE. Steps that succeeded before the failing step stay charged and keep their results, so a snapshot with `status: 'failed'` can carry `cost.total > 0` and usable `imageUrls`. Read `steps` to see which step failed (`status: 'failed'`, with its `error`) and which finished. Read it from the poll and cancel replies of a `kind: 'workflow'` run; do not rely on it being on the submit reply. ABSENT for every other kind, on an estimate (which creates no workflow) and on a host that predates it. |
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. |
publishedModel? | BlockPublishedModel | The Civitai model a training run in this workflow was published as — present once the viewer has started or finished publishing it through the model wizard. Same shape and meaning as {@link BlockWorkflowSnapshot.publishedModel}. 🔴 REQUIRES A HOST VERSION THAT EMITS IT. Absent on older hosts, and on every workflow nobody has published. |
trainedEpochs? | BlockTrainedEpoch[] | The epochs of a pass-through `training` / `imageResourceTraining` step in this workflow that produced a checkpoint. Same shape and rule as {@link BlockWorkflowSnapshot.trainedEpochs}: only runs whose training moderation status is approved are listed, the checkpoint itself is not exposed, and the field is OMITTED when there are no epochs to list. Lets a block offer "publish epoch n" from its queue without polling each run. 🔴 REQUIRES A HOST VERSION THAT EMITS IT. Absent on older hosts — treat absence as "unknown", not as "no checkpoint". |
model3dOutputs? | BlockModel3dOutput[] | Labels for the 3D models among this workflow's {@link AppWorkflow.images} — same shape and rule as {@link BlockWorkflowSnapshot.model3dOutputs}: every labelled `url` is also an entry of `images`, and the field is OMITTED when there are none (and on hosts that predate it). |
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. |