Multi-step workflows (kind: 'workflow')
A kind: 'workflow' body sends several orchestrator steps as one generation, and a later step can read an earlier step's result: draft a story outline and then write the story from it, generate an image and then remove its background, write one caption per item of a list. The host checks the whole graph, prices it once, reserves Buzz once, and returns one workflow you watch like any other.
On React it goes through the hooks you already use: useBuzzWorkflow()'s estimate, submit, watch, poll and cancel, a useBatchEstimate() cell, and useWorkflowBatch(). There is no new hook and no new message. On @civitai/sdk, which the default template uses, it goes through app.workflows.
Closed beta, and it needs a current SDK and host
Like the rest of generation, this is mod-gated during the closed beta, page apps only, needs the ai:write:budgeted scope and a signed-in viewer. This page is written against @civitai/app-sdk@0.71.0, whose types (WorkflowBodyWorkflow and friends) include the workflow_quote_* estimate codes, and @civitai/blocks-react@0.73.0, whose mock host can simulate an estimate with no quote. The app.workflows calls are written against @civitai/sdk@0.12.0, the first release that has them. A host that predates the kind refuses the body, so estimate() and submit() reject.
Getting those versions into a scaffolded app
civitai app create writes its own @civitai/* version ranges, and they can trail the packages. A caret range on a 0.x version never moves to the next minor, so npm install and npm update alone will not cross one. See what you have:
npm ls @civitai/sdk @civitai/app-sdk @civitai/blocks-reactOn page-elements, if @civitai/sdk is below 0.12.0 there is no app.workflows; raise it:
npm install @civitai/sdk@^0.12.0On page-money, if @civitai/app-sdk is below 0.68.0, or @civitai/blocks-react below 0.72.2, raise them together:
npm install @civitai/app-sdk@^0.68.0 @civitai/blocks-react@^0.72.2Commit package.json and the lockfile, then run the project's typecheck and tests before going on.
The scaffold's AGENTS.md says not to hand-edit the @civitai/* versions. That rule is there to keep the packages matched; this is the case where one has to move, and the commands above move it. Once npm ls shows these versions or later, skip this box.
Which template, and which package
The examples on this page are React: they call useBuzzWorkflow() from @civitai/blocks-react, which is what the page-money template ships (civitai app create <name> --template page-money).
The default template, page-elements, is not React. It uses @civitai/sdk, and its calling code is in the next section, Calling it from @civitai/sdk. That template's npm run dev:harness does not answer these calls: its token is not a real one, so every app.workflows call rejects there. Running the app inside the real host is covered under Previewing inside the real host.
Everything else on this page (the body, the limits, the costs, the refusal codes, the result) is the same on both; only the calling code differs.
Calling it from @civitai/sdk
From 0.12.0, @civitai/sdk has a client for a block's generation calls: app.workflows, with estimate, submit, get, watch, wait, cancel and list. It sends the same body to the same block routes as the React hook. The body and reply types are exported from @civitai/sdk itself, so on the default template import WorkflowBody, WorkflowBodyWorkflowStep, WorkflowStepRef, WithWorkflowStepRefs and BlockWorkflowSnapshot from '@civitai/sdk' where the examples below import them from '@civitai/app-sdk/blocks'.
import { initialize, type WorkflowBody } from '@civitai/sdk';
const app = await initialize();
// `idempotencyKey` is minted by the caller when the viewer presses the button,
// and kept there: a retry of the same press sends the same key.
async function run(body: WorkflowBody, idempotencyKey: string) {
// 1. Price it. Nothing is charged or reserved.
const estimate = await app.workflows.estimate(body);
if (estimate.status === 'failed') {
// A refusal RESOLVES: read `errorCode` from the snapshot itself.
return showMessage(`Cannot price this: ${estimate.errorCode ?? 'refused'}`);
}
if (!(await confirmSpend(estimate.cost?.total))) return;
// 2. Run it.
const submitted = await app.workflows.submit(body, { idempotencyKey });
if (submitted.status === 'failed') {
// The placeholder id 'failed': the host refused this request. A real id:
// a run was created and came back failed, and Buzz may have been spent.
return showMessage(
submitted.workflowId === 'failed'
? `Could not start: ${submitted.errorCode ?? 'refused'}`
: 'This run failed. Check your history before trying again.'
);
}
// 3. Follow it to a final status, whichever it is.
const done = await app.workflows.wait(submitted.workflowId);
if (done.status !== 'succeeded') {
// 'failed', 'canceled' or 'expired'. A failed run can still carry the
// results, and the cost, of the steps that finished (see Partial failure).
showMessage(`This run ended ${done.status}.`);
}
render(done.steps ?? [], done.imageUrls ?? [], done.textOutputs ?? []);
}Four things differ from the React examples, and the first changes how you read the rest of this page:
- A refusal resolves; it does not reject. Where this page says
estimate()rejects withWorkflowEstimateErrororsubmit()withWorkflowSubmitError, that is the React hook.app.workflowsresolves with the host'sfailedsnapshot, so checksnapshot.statusand readsnapshot.errorCode. What rejects is anApiErrorcarrying the HTTP status (a400is a body or key the host cannot parse), aCivitaiErrorfor a2xxreply with no snapshot, or whateverfetchthrew. submitrequires anidempotencyKeyand refuses to send without one. Mint it once when the viewer presses the button (crypto.randomUUID()fits), keep it until that press has a known outcome, and send the same key on every retry.- A
submitthat rejects has an unknown outcome, not a failed one: the run may exist. Retry with the same key, or look for the run withapp.workflows.list(); never mint a new key for the same press. The package README has the rules, the two body kinds the key does not identify, and a workedcatch, under When a submit rejects. - The names differ. The hook's
poll(id)isapp.workflows.get(id).app.workflows.watch(id)is an async iterator that yields the workflow now and each time it changes, andwait(id)resolves with its last value, the final snapshot. There is no batch call: price each body withestimate, and watch or cancel each workflow id on its own.
app.workflows needs the block token, which is what a block holds unless its manifest declares auth: "oauth", and the ai:write:budgeted scope. See Money calls for both, and for the consent call that comes first.
Not the same thing as calling the orchestrator yourself
An App Block never holds an orchestrator token. This page is about the bridge body a block sends through the host, which adds a step-type denylist, a reference check, spend caps and an author fee on top of the orchestrator's own workflow. If you are building a backend that calls the orchestrator directly with your own token, read the orchestration guide's Workflows page instead. It is also the reference for what each step type takes and returns, and for how references, format and arguments resolve.
When to use it
| You want to… | Use |
|---|---|
| Feed one step's output into another step (text into a prompt, an image into an edit) | kind: 'workflow' |
| Run the same step over a short list you already have (up to 16 items) as one generation | kind: 'workflow' with a repeat. Not yet for image generation: see the known limitation |
| Run independent generations the viewer tracks one by one, such as a grid of cells | one submit() per cell, watched with useWorkflowBatch() |
| Price many independent bodies before the viewer confirms | useBatchEstimate() (a kind: 'workflow' body can be one of its cells) |
| Run exactly one orchestrator step type | the single-step pass-through arm (kind: 'step' with a $type) |
A workflow is one generation to the viewer: one estimate, one reservation, one workflowId. Cells of a grid are separate generations: each can be retried, refused or cancelled on its own. If a failure in one part should not touch the others, keep them as separate submits.
The body
import type { WorkflowBody } from '@civitai/app-sdk/blocks';
const body: WorkflowBody = {
kind: 'workflow',
arguments: { prompt: 'a lighthouse at dusk' },
steps: [
{
name: 'draw',
$type: 'imageGen',
input: {
engine: 'google',
model: 'nano-banana-2',
prompt: { $ref: '$arguments', path: 'prompt' },
},
// An intermediate result: run and charged, but its image is not returned.
suppressOutput: true,
},
{
name: 'cutout',
$type: 'imageBackgroundRemoval',
input: { image: { $ref: 'draw', path: 'output.images[0].url' } },
},
],
};steps: 1 to 8 (BLOCK_WORKFLOW_MAX_STEPS), run as one orchestrator workflow. Each step is{ name?, $type, input, suppressOutput? }.$typeis the orchestrator's own step type, 1 to 64 characters. The host forwardsinputunmodified, so the orchestrator's schema for that$typedecides whatinputmay hold. Any$typeis accepted except the platform-internal ones (seeworkflow_step_denied).nameis optional. It must match^[a-z][a-z0-9_-]{0,31}$(a lowercase letter, then up to 31 lowercase letters, digits,_or-), be unique in the body, and not beblock-step, which is reserved. A step you do not name is named by its position:$0,$1, and so on.argumentsis an optional object the steps can read. Put a value several steps share there, and the list arepeatiterates.suppressOutput: truemarks an intermediate step: its images are left out of the result. It still runs and is still charged.- There is no
maxBuzz. The spend ceiling is derived for you; see What it costs. - The schema is strict: an unknown key on the body or on a step is rejected, not dropped.
References: reading an earlier result
Anywhere a value would go in a step's input, at any depth, you can put a reference instead:
import type { WorkflowStepRef } from '@civitai/app-sdk/blocks';
const image: WorkflowStepRef = { $ref: 'draw', path: 'output.images[0].url' };The orchestrator replaces it with the value at path once the source is ready, and the step waits for its source to finish. The fields:
$refnames the source. It is one of:'$arguments', the body's ownarguments;- the
nameof an earlier step; '$<index>', an earlier step you did not name, by its zero-based position ('$0'is the first step). A named step is referenced by its name only.
pathselects the value inside that source:'prompt'inarguments,'output.images[0].url'or'output.choices[0].message.content'in a step. The host does not check that the path exists; the orchestrator resolves it, and a path that resolves to nothing usesdefault.default(optional) is used whenpathresolves to nothing.format(optional) puts the value inside a longer string, at{0}. The orchestration guide covers the rules forformat.
An object counts as a reference only when it has both $ref and path and no other key except default and format. Anything else is ordinary input.
A reference stands in for a plain value (a string, a number, a URL). A field that accepts several shapes, such as a chat message's content (a string or an array of parts), rejects a reference in its place: put it one level down, in a plain field of the shape you mean (see the chat example). The host does not catch this; the orchestrator does, so the estimate fails with workflow_quote_rejected, and the submit is not refused by the host (see What it costs).
🔴 References point backwards only. A step cannot read itself, a later step, or a step that does not exist. That keeps every workflow free of cycles, and the host checks it before anything is priced: a reference that breaks the rule is refused with workflow_ref_invalid.
To keep a step's input typed while wiring references into it, use WithWorkflowStepRefs<T> from @civitai/app-sdk/blocks, which accepts a WorkflowStepRef wherever T has a value:
import type { WithWorkflowStepRefs } from '@civitai/app-sdk/blocks';
type CutoutInput = { image: string; format?: 'png' | 'webp' };
const input: WithWorkflowStepRefs<CutoutInput> = {
image: { $ref: 'draw', path: 'output.images[0].url' },
format: 'png',
};Example: one chat step feeding another
Step 1 writes an outline; step 2 writes a story from it. The second step's prompt is the first step's reply, read with a reference.
🔴 Put a reference in a text part, not in content itself. A chat message's content is either a string or an array of parts, and the orchestrator does not accept a reference in its place: content: { $ref, path } is rejected as an invalid request. Use the parts form, content: [{ type: 'text', text: { $ref, path } }], as below. A reference in a plain string field (such as text inside a part) works.
import { useState } from 'react';
import { useBuzzWorkflow, WorkflowEstimateError, WorkflowSubmitError } from '@civitai/blocks-react';
import type { WorkflowBody } from '@civitai/app-sdk/blocks';
const MODEL = 'openai/gpt-4o-mini';
function outlineThenStory(topic: string): WorkflowBody {
return {
kind: 'workflow',
arguments: { topic },
steps: [
{
name: 'outline',
$type: 'chatCompletion',
input: {
model: MODEL,
messages: [
{
role: 'user',
// A reference goes in a text PART, never directly in `content`.
content: [
{ type: 'text', text: 'Write a three-point outline for a very short story about:' },
{ type: 'text', text: { $ref: '$arguments', path: 'topic' } },
],
},
],
},
},
{
name: 'story',
$type: 'chatCompletion',
input: {
model: MODEL,
messages: [
{
role: 'user',
content: [
{ type: 'text', text: 'Write a 150-word story that follows this outline:' },
{ type: 'text', text: { $ref: 'outline', path: 'output.choices[0].message.content' } },
],
},
],
},
},
],
};
}
export function StoryWriter({ topic }: { topic: string }) {
const { estimate, submit, watch } = useBuzzWorkflow();
const [price, setPrice] = useState<number | null>(null);
const [texts, setTexts] = useState<string[]>([]);
const [message, setMessage] = useState('');
async function quote() {
try {
const snap = await estimate(outlineThenStory(topic));
// An estimate, not a maximum: see "What it costs".
setPrice(snap.cost?.total ?? null);
} catch (err) {
// No price quote (a `workflow_quote_*` code) or a refused body: no number to show.
if (err instanceof WorkflowEstimateError) {
setPrice(null);
setMessage(
err.snapshot.errorCode === 'workflow_quote_unavailable'
? 'Could not get a price just now. Try again.'
: 'This story cannot be priced as written.'
);
return;
}
throw err;
}
}
async function run() {
try {
const submitted = await submit(outlineThenStory(topic));
if (submitted.status === 'failed') {
setMessage('This story could not be started.');
return;
}
const done = await watch(submitted.workflowId);
// Moderated chat text, in step order (a step can contribute more than one).
setTexts(done.textOutputs ?? []);
if (done.status !== 'succeeded') setMessage('The story did not finish.');
} catch (err) {
if (err instanceof WorkflowSubmitError && err.snapshot.errorCode) {
setMessage(`Refused: ${err.snapshot.errorCode}`);
return;
}
throw err;
}
}
return (
<div>
<button onClick={() => void quote()}>Price it</button>
{price !== null && <p>About {price} Buzz</p>}
<button onClick={() => void run()}>Write the story</button>
{texts.map((t, i) => (
<p key={i}>{t}</p>
))}
{message && <p>{message}</p>}
</div>
);
}Where the text comes back. chatCompletion is a step type the host moderates, so its reply arrives on the snapshot's textOutputs (text that passed the output scan) or, if the scan held it back, as textOutputWithheld.reason. Its entry in steps reports its status but carries no output. Read text from textOutputs, on the poll(), watch() or cancel() result, never the submit() reply.
repeat: one step per item
repeat runs a template step once for each item of an array in the body's arguments. Inside the template, the as name is a source a reference can read.
import type { WorkflowBody } from '@civitai/app-sdk/blocks';
const body: WorkflowBody = {
kind: 'workflow',
arguments: {
animals: [{ name: 'a red fox' }, { name: 'a snowy owl' }, { name: 'a lynx' }],
},
steps: [
{
name: 'captions',
$type: 'repeat',
input: {
for: { $ref: '$arguments', path: 'animals', as: 'animal' },
template: {
$type: 'chatCompletion',
input: {
model: 'openai/gpt-4o-mini',
messages: [
{
role: 'user',
content: [
{ type: 'text', text: 'Write a one-line photo caption for:' },
{ type: 'text', text: { $ref: 'animal', path: 'name' } },
],
},
],
},
},
},
},
],
};The rules, all checked by the host before anything is priced:
- The input is exactly
{ for: { $ref, path, as }, template: { $type, name?, input? } }, with every key spelled exactly. A template carries only what a top-level step may. A different shape is refused withworkflow_ref_invalid. for.$refmust be'$arguments'andfor.pathmust name an array inarguments. Arepeatover another step's output is refused, because its length is unknown until that step has run, so the workflow could not be priced up front. Put the list inargumentsinstead.- At most 16 iterations (
BLOCK_WORKFLOW_REPEAT_MAX_ITERATIONS), with nested repeats multiplied: a 4-itemrepeatinside a 4-itemrepeatis 16. Repeats nest at most 4 deep. - The
asname may not also be a step name or$arguments. - What a template may read. The host checks a template's references like any other step's, with the
asname added as one more source. So a template may reference theasname,$arguments, or a step earlier than therepeat. It cannot read therepeatstep itself or a later step, and theasname means nothing outside its own template. That is what the host's check accepts; as with anyrepeat, price the body before you build on it. - The template's
$typeis held to the same denylist as a top-level step. - A
repeatneeds a price quote. If the host cannot get one, the workflow is refused rather than run at the ceiling; see When arepeatcannot be priced.
Use object items and read a field with path, as above ({ $ref: 'animal', path: 'name' }). When a repeat is priced, the quote covers every iteration, because the list is fixed in the body. Whether a repeat can be priced at all depends on the template's step type: a chatCompletion template is priced on civitai.com today, and an imageGen template is not. See the known limitation below.
Each item becomes its own generated step in the result, named <repeat name>.<n> with <n> counting from 0 in list order: captions.0, captions.1 and captions.2 here, listed after the repeat step itself.
repeat belongs to this kind only. The single-step pass-through arm (kind: 'step' with a $type) refuses a repeat outright, because it has no arguments for one to iterate.
Known limitation: a repeat of imageGen cannot be priced yet
Refused on civitai.com today
A repeat whose template is an imageGen step is currently refused on civitai.com, by estimate() and by submit(), with workflow_repeat_unbounded and an error that currently reads … a workflow with a repeat needs a price quote and none could be obtained; try again. It is refused however the template is written, including with no reference in it at all, and despite that "try again", retrying does not change the answer: the platform cannot price that combination yet. Other step types may be affected in the same way, so an estimate is the test: it is free, and a repeat that can be priced returns a number.
What does work today:
- a
repeatwith achatCompletiontemplate, as in the example above; - a single
imageGenstep, and a chain that feeds animageGenstep from an earlier one (a chat step writing its prompt, for example).
For several images in one generation, write one imageGen step per item as explicit steps, up to the 8-step limit:
import type { WorkflowBody, WorkflowBodyWorkflowStep } from '@civitai/app-sdk/blocks';
// One explicit step per subject. Keep the list at 8 or fewer.
function portraits(subjects: string[]): WorkflowBody {
return {
kind: 'workflow',
steps: subjects.map(
(prompt, index): WorkflowBodyWorkflowStep => ({
name: `portrait-${index}`,
$type: 'imageGen',
input: { engine: 'google', model: 'nano-banana-2', prompt },
})
),
};
}Each explicit step counts toward the 8-step limit and the app velocity limit, and raises the ceiling by one per-generation budget. For more than 8 images, send separate submits and watch them with useWorkflowBatch().
The mock host does not have this limitation: it prices and runs an imageGen repeat. Check a repeat body against the real host with dev:live before you build on it.
merge: combining results
merge reads values out of earlier steps and combines them into one result a later step can reference. Its sources are named in input.fields, so the host checks them like references: every field needs a string $ref naming $arguments or an earlier step (never a repeat's as name) and a reduce.
import type { WorkflowBodyWorkflowStep } from '@civitai/app-sdk/blocks';
// Added after a `repeat` step named `cutouts` whose children each return an
// image: collect every child's first image.
const gallery: WorkflowBodyWorkflowStep = {
name: 'gallery',
$type: 'merge',
input: {
onMissing: 'partial',
fields: {
urls: {
$ref: 'cutouts',
path: 'output.steps[*].output.images[0].url',
reduce: 'push',
includeIndex: false,
},
},
},
};A later step reads the combined value as { $ref: 'gallery', path: 'output.fields.urls' }. The reducers (push, first, max, group and the rest) and onMissing are the orchestrator's; the merge step's schema in the orchestration reference lists them. Each field counts toward the 64-reference limit.
Limits
| Limit | Value | Refused with |
|---|---|---|
| Steps per body | 1 to 8 | workflow_too_many_steps (more than 8). An empty steps fails the body schema, so the call rejects without a code |
Step name | ^[a-z][a-z0-9_-]{0,31}$ (or exactly the step's own $<index>), unique, not block-step | workflow_ref_invalid; an empty name or one over 64 characters fails the body schema (rejects, no code) |
| References per body | 64 (each merge field and each repeat for counts) | workflow_ref_invalid |
Nesting depth of a step input | 32 levels | workflow_ref_invalid |
repeat iterations | 16, nested repeats multiplied | workflow_repeat_unbounded |
repeat nesting | 4 deep | workflow_ref_invalid |
$type length | 1 to 64 characters | the body schema (rejects, no code) |
Size of every step input plus arguments | 262,144 characters of serialized JSON, all together (256 KB for ASCII) | the body schema (rejects, no code) |
Every other generation limit still applies: the app velocity and daily spend limits, the viewer's daily cap and consent budget, and the estimate and poll rate limits. Two of them count a workflow differently:
- App velocity counts steps, not submits. One
kind: 'workflow'submit uses one unit per step, and arepeatstep one per iteration (nested repeats multiplied). A 3-itemrepeatplus amergestep uses 4 of the app's per-minute allowance. - The per-call budget is multiplied by the number of steps. See below.
What it costs
The ceiling. There is no maxBuzz. The host's ceiling for one workflow is your per-generation budget (page.buzzBudgetPerGen, see Budget model) times the number of steps in steps. A repeat is one step for this purpose, however many times it runs, so a long repeat can quote above the ceiling and be refused with insufficient buzz budget. With a per-generation budget of 50, a body whose only step is a repeat has a ceiling of 50 whether its list holds 2 items or 16, while three explicit steps have a ceiling of 150. Whatever the budget and step count, one workflow call's ceiling is never above 8,000 Buzz, so a budget × steps product above that is capped there. The workflow's whole price, author fee included, must fit under the ceiling or the submit is refused before anything is charged.
The reservation. The host asks the orchestrator for one quote for the whole workflow and reserves that quote plus the author fee, once, against every cap. The estimate shows the same number.
🔴 The estimate of a chained workflow is not a maximum. When a step's input references an earlier step, the orchestrator prices that step before the values it references exist, and prices it again once they do. If the second price is higher, the viewer is charged the difference. So a workflow can cost more than its estimate. Show the estimate as an estimate ("about 40 Buzz"), never as "you will pay at most". The host corrects its spend caps afterwards, but the extra charge is not bounded by the estimate.
No quote. The orchestrator may not price a workflow: it can reject the body as invalid (for example a reference placed where the step's schema does not accept one), give no usable answer just then, or answer without a price. The estimate and the submit then behave differently.
- The estimate fails. It comes back as a
failedsnapshot with nocost,{ workflowId: 'failed', status: 'failed', error, errorCode }, whereerrorCodenames the cause:workflow_quote_rejected,workflow_quote_unavailableorworkflow_quote_unpriced(see Refusals for which one a retry can clear).useBuzzWorkflow().estimaterejects it withWorkflowEstimateError, anduseBatchEstimate()marks that body's cellok: false. There is no number to show the viewer. - The submit still runs. The host does not refuse the same body for having no quote: it is reserved at the ceiling, with each step's timeout set from your per-step budget. So a failed estimate does not predict a refused submit.
Two kinds of workflow are refused on both the estimate and the submit instead, before anything is reserved, and they keep their own codes rather than a workflow_quote_* one:
- one containing a training step, which is refused without a quote exactly as on the single-step arm, with a
training-quote-*errorCode; - one containing a
repeatat any depth, refused withworkflow_repeat_unboundedand anerrorthat names the cause. The cause can be the body itself, in which case retrying never succeeds; see When arepeatcannot be priced.
A host that predates the workflow_quote_* codes resolved such an estimate instead, with the ceiling as its cost.total; civitai.com fails it.
The author fee. If your app charges one (see How an app earns), a workflow pays it once, not once per step, computed on the whole workflow's quoted base. The estimate itemises it as cost.authorFee, already inside cost.total. The fee charged is never more than the fee reserved at submit, even when the workflow bills more than its quote. A workflow that does not succeed has its whole fee reversed, even when earlier steps delivered output, once the host sees it end; a reversal is not applied after that day's fees have settled. A workflow with no quote has no fee.
Reading the result
The poll, watch and cancel replies of a kind: 'workflow' run carry steps, one entry per step in the orchestrator's order:
type BlockWorkflowStepSnapshot = {
name: string; // your name, or `$<index>`; `<repeat name>.<n>` for repeat children
$type: string;
status: 'pending' | 'processing' | 'succeeded' | 'failed' | 'expired' | 'canceled';
imageUrls?: string[]; // this step's images; never for a `suppressOutput` step
output?: unknown; // the step's raw output, minus its media
error?: string; // why it did not succeed, when the orchestrator gave plain text
};imageUrlsat the top level is the union of every step that did not setsuppressOutput.statusandcostat the top level are the whole workflow's.outputis the orchestrator's output for that$type, with images and other media removed (they are onimageUrls). It is not moderated, and its shape is the orchestrator's. It is absent for the step types the host extracts itself:imageGen,comfyandcustomComfyreport onlyimageUrls, and the types Civitai registers (chatCompletion,convertImage) report through their own fields, chat text ontextOutputs(see the chat example). Arepeatstep's own entry has none either: its children are listed as steps of their own. Amerge, or any other$type, reports itsoutputhere.erroris diagnostic text. Log it; show the viewer your own words.- Image URLs are signed and expire. Each URL carries a signature and an expiry in its query string (
sigandexp), and the same image can come back with a different query string on every poll. Render from the latest snapshot, and do not use the whole URL as a cache key or a listkey: a finished image would then be fetched again on each poll. Identify an image by its stepnameand its position in that step'simageUrls. stepsis absent on an estimate (no workflow exists yet), on every otherkind, and from a host that predates it. Don't rely on it in thesubmit()reply either; read it frompoll(),watch()orcancel()(onapp.workflows:get(),watch(),wait()orcancel()).
Partial failure
🔴 A failed workflow is not an empty one. If step 3 fails, steps 1 and 2 already ran: they stay charged, and their images stay in the result. So a snapshot with status: 'failed' can carry cost.total > 0 and usable imageUrls. Read steps to find which step failed and show the viewer what did finish. Don't tell them nothing was charged. The author fee is the exception: it is reversed in full for a workflow that does not succeed (see What it costs).
import type { BlockWorkflowSnapshot } from '@civitai/app-sdk/blocks';
export function StepList({ snap }: { snap: BlockWorkflowSnapshot }) {
return (
<ul>
{(snap.steps ?? []).map((step) => (
<li key={step.name}>
{step.name}: {step.status}
{/* Keyed by position: the URL's signature can change between polls. */}
{step.imageUrls?.map((url, index) => <img key={index} src={url} alt="" />)}
</li>
))}
</ul>
);
}Refusals
A body the host will not run is refused before anything is reserved or charged, with a machine-readable errorCode. (Most codes are decided before the host even asks for a price; the no-quote cases are decided after it tried.) The three workflow_quote_* codes are different: they are estimate-only, for a body the host would run but could not price, and the submit of that body is not refused (see What it costs). estimate() rejects with WorkflowEstimateError, submit() with WorkflowSubmitError, and a batch estimate returns it as that body's cell. Read it from err.snapshot.errorCode, and keep a default branch: the host may add codes, and error's wording is not a contract. On app.workflows nothing rejects for a refusal: the call resolves with the failed snapshot, and the code is its errorCode.
errorCode | Means | What to do |
|---|---|---|
workflow_too_many_steps | more than 8 steps | Split the job into two workflows, or move repeated work into a repeat where its step type can be priced in one |
workflow_step_denied | a step (or a repeat template) names a platform-internal $type: the model and image scanners, the moderation classifiers such as promptModeration, the hashing and model-ingestion types, a few other platform tools, web scrape and web search, and the retired textToImage step type. The match ignores case | Remove or replace that step. These types are reserved for Civitai's own use |
workflow_ref_invalid | a reference or merge field names the step itself, a later step, or nothing; a step name breaks the rules or repeats; a repeat or merge is malformed; a repeat's as name is also a step name; more than 64 references; or an input nested deeper than 32 levels. error names the step by its position, except for the as-name clash | Fix the body. Resending it unchanged gives the same answer |
workflow_repeat_unbounded (the loop) | a repeat iterates something other than an array in arguments, or more than 16 times with nested repeats multiplied. Decided from the body alone, before the host asks for a price | Move the list into arguments, or shorten it. Resending it unchanged gives the same answer |
workflow_repeat_unbounded (no price) | the workflow has a repeat and the host could not get a price for it. The same code whatever the reason was; the error reads workflow step N: a workflow with a repeat needs a price quote and <cause> | See When a repeat cannot be priced. Retrying helps only if the cause was temporary |
training-quote-rejected / -unavailable / -unpriced | a training step in the workflow could not be priced | As for a single training step |
workflow_quote_rejected (estimate only) | the orchestrator refused the body, so there is no price | Fix the body; retrying the same body will not help. Check where each reference sits first |
workflow_quote_unavailable (estimate only) | no usable answer just then (an outage, a timeout, a rate limit) | Estimate again after a short wait. The only workflow_quote_* code a retry can clear |
workflow_quote_unpriced (estimate only) | the orchestrator answered without a price | Retrying the same body is not expected to help |
None of the workflow_quote_* codes is a submit refusal: submit() of the same body still runs, reserved at the ceiling. A repeat or a training step without a price is the exception, and it is refused on both with the codes above it in the table.
Other refusals look the same as on every other kind. A price over the ceiling, a daily cap, the consent budget or the app velocity limit makes submit() resolve with status: 'failed', the 'failed' placeholder workflowId and an error, and nothing is charged (see refused submits). A body the schema rejects (an unknown key, an empty steps, inputs over the size limit) rejects without an errorCode.
When a repeat cannot be priced
workflow_repeat_unbounded covers two different situations, and the code alone does not say which.
What your app can tell apart. The loop rules are properties of the body you built, so check them before you send: for.$ref is '$arguments', for.path names an array in arguments, and the iteration count (nested repeats multiplied) is 16 or fewer. A body that passes all three and is still refused with this code was refused for the missing price. Today the error text also differs (the no-price refusal reads workflow step N: a workflow with a repeat needs a price quote and <cause>), but its wording is not a contract.
What your app cannot reliably tell apart. A missing price has three possible causes, and the errorCode is the same for all of them:
- the price could not be fetched just then, which is temporary;
- the template's
inputis not valid for its$type, such as a reference placed where that step type does not accept one; - the template's step type cannot be priced inside a
repeatyet, as withimageGen.
The <cause> in error is a hint, not a verdict. It is the same three causes as the workflow_quote_* codes: none could be obtained; try again when no usable answer came back, the orchestrator rejected the request… when the body was refused, and the orchestrator answered without one. Only the first one says "try again", but cause 3 can produce it too: an imageGenrepeat currently reads "try again" and never clears.
Only the first cause is fixed by retrying. In a shipped app, show the viewer that the job cannot be run right now and let them try again; do not retry in a loop, since estimates are rate-limited and causes 2 and 3 never clear on their own.
Finding the cause while you build. Estimates are free, so narrow it down with them:
- Estimate the same body again after a short wait. A number means the cause was temporary.
- Estimate the template as an ordinary step. Send a
kind: 'workflow'body whose only step is the template's$typeandinput, with one item's values written in where the template read theasname. If that estimate fails withworkflow_quote_rejected, the input is the problem, and itserrormay name the reason. Fix the input and repeat this step. If it fails withworkflow_quote_unavailable, wait and estimate it again. - If the step is priced on its own but the
repeatis still refused, that step type cannot be priced inside arepeatyet. Write the items as explicit steps instead, as shown under the known limitation.
Testing with the mock host
The mock host (createMockHost / Harness from @civitai/blocks-react/testing, and npm run dev:harness in an app scaffolded from page-money) estimates, submits and polls this kind:
- It prices each step with
generation.costPerGenand addsgeneration.authorFeeonce. A functioncostPerGenreceives the step as its second argument (MockWorkflowStepInfo:index,name,$type,input), and arepeatstep is priced once per iteration. - It refuses with the same four
workflow_*codes as the host, in the host's order: the step count, then the names and references (includingrepeatandmerge), then the denylist. - Polls report
steps, finishing them in order, withsuppressOutputhonoured. Each succeeded step that is not suppressed gets a placeholder image. generation.failWorkflowStep: '<name>'('$<index>'for an unnamed step) fails that step: the steps before it succeed and stay charged, so you can render a partial failure.generation.workflowUnpricedsimulates the host getting no quote, for a cause you pick:'rejected','unavailable'or'unpriced'(truemeans'unavailable'). A workflow with a training step or arepeatis refused on bothestimate()andsubmit(). Any other fails itsestimate()withworkflow_quote_<cause>and nocost, while itssubmit()is reserved atbuzzBudget× steps with no author fee. The mock has no orchestrator reason to pass on, so'rejected'always gives the message without a reason.
import { createMockHost } from '@civitai/blocks-react/testing';
const host = createMockHost({
consentGranted: true,
generation: { costPerGen: 10, authorFee: 2, failWorkflowStep: 'story' },
});What the mock does not do
A body the mock prices and runs can still be refused, or look different, on the real host. The mock:
- checks where a reference points, not whether its
pathexists; - never re-prices a step;
- does not expand a
repeatinto<name>.<n>children: therepeatis one entry insteps; - prices and runs every
repeat, including one with animageGentemplate, which the real host currently refuses. Arepeatthat works in the mock has not been shown to be priceable. To see how your app handles the refusal, setgeneration.workflowUnpriced: true; - gives every succeeded step that is not suppressed a placeholder image, whatever its
$type. AchatCompletionorrepeatstep therefore hasimageUrlsin the mock and none on the real host, so a gallery built from the top-levelimageUrlsshows more pictures in the mock than it will in production; - prices every step the same when
costPerGenis a number, so a chat step costs as much as an image and the mock total can be well off a real quote. Pass a function and branch on the step's$typefor a closer number; - produces no chat text (
textOutputs) and no stepoutput; - returns placeholder URLs that stay the same from poll to poll, where real image URLs are signed and can change.
Exercise those against the real host with dev:live.
Since @civitai/blocks-react 0.70.0 the mock also refuses estimate, batch estimate and submit for a token without ai:write:budgeted, as the real host does. A test that is not about consent needs consentGranted: true (or ?consent=granted on the dev harness URL); see Local dev loop.
Next
- Generating images (text-to-image): the lifecycle, refused submits, and the limits every kind shares.
- Generation bridge reference: the generated field tables for
WorkflowBodyWorkflow,WorkflowStepRefandBlockWorkflowSnapshot. - Orchestration: Workflows: how the orchestrator runs steps, resolves references and reports status, and the step reference for each
$type's input and output.