Generating images (text-to-image)
Text-to-image is the primary generation path for an App Block: your block sends a small, bounded WorkflowBody, and the host builds the generation graph, prices it, spends the viewer's Buzz, and returns the images. Your block never holds an orchestrator token.
This is the narrative companion to the generated generation bridge reference. For the ComfyUI path — a server-owned graph invoked by name, or your own graph shipped inline — see Comfy on Civitai.
Closed beta — mod-gated
Like the rest of the Apps platform, generation is mod-gated during the closed beta. You can scaffold the page-money template and run the whole flow against its local mock host today; real Buzz generation needs closed-beta builder access.
On the default template (@civitai/sdk)
The examples on this page are React (useBuzzWorkflow() from @civitai/blocks-react, the page-money template). The default page-elements template sends the same WorkflowBody with app.workflows.estimate(body), app.workflows.submit(body, { idempotencyKey }) and app.workflows.wait(workflowId) from @civitai/sdk 0.12.0 or later. Three differences to carry through this page: a refusal resolves with a failed snapshot there instead of rejecting, submit requires the idempotencyKey, and a submit that rejects has an unknown outcome (the run may exist). Money calls has the calling code.
The happy path
The smallest possible generation is a checkpoint + a prompt. You submit it through useBuzzWorkflow(), which takes a full WorkflowBody — the discriminated union keyed by kind:
import { useState } from 'react';
import {
useBuzzWorkflow,
useBuzzBalance,
useBuzzPurchase,
useBlockContext,
useDomainMaturity,
WorkflowSubmitError,
} from '@civitai/blocks-react';
import { isSfwCeiling } from '@civitai/app-sdk/blocks';
import type { WorkflowBodyTextToImage, ModelSlotContext } from '@civitai/app-sdk/blocks';
type Outcome = 'refused' | 'short' | 'not-started' | 'maybe-started' | 'error' | null;
export function Generate() {
const { estimate, submit, watch, status } = useBuzzWorkflow();
const { openPurchaseModal } = useBuzzPurchase();
// Needs `buzz:read:self` in your manifest AND granted by the viewer, and a
// signed-in viewer; otherwise `balance` stays null and no top-up is offered.
const { balance } = useBuzzBalance();
// The DOMAIN's ceiling, not `isSfw` (which the viewer's own setting narrows):
// the server picks the pool from the ceiling.
const { maxBrowsingLevel } = useDomainMaturity();
const { context } = useBlockContext();
const ctx = context as ModelSlotContext; // model slot: has modelId + modelVersionId
const [outcome, setOutcome] = useState<Outcome>(null);
// The hook starts at 'idle'; only estimate() moves it to 'confirming'. Disable
// the button while a request is in flight, not "until confirming".
const busy = status === 'estimating' || status === 'submitting' || status === 'polling';
const run = async () => {
setOutcome(null);
const body: WorkflowBodyTextToImage = {
kind: 'textToImage',
modelId: ctx.modelId,
modelVersionId: ctx.modelVersionId,
params: { prompt: 'a serene alpine lake at golden hour' },
};
let quote: number | undefined;
try {
quote = (await estimate(body)).cost?.total; // the price to show, and to test the balance against
const snap = await submit(body);
// A REFUSED submit (over your per-generation budget, a spend cap, a rate
// limit) still RESOLVES, with `status: 'failed'` and a placeholder
// workflowId of 'failed'. A 'failed' reply is terminal, so there is
// nothing to watch. Branch on `status`, never on whether `workflowId` is
// set. See "Refused submits" below.
if (snap.status === 'failed') {
console.warn('generation did not run:', snap.workflowId, snap.error); // log, never render
setOutcome('refused');
return;
}
await watch(snap.workflowId); // owns the loop; resolves on the terminal snapshot
} catch (err) {
console.warn(err); // developer-facing; never render err.message or snapshot.error
if (err instanceof WorkflowSubmitError && err.code === 'exception') {
// Usually nothing was queued. A balance too low for this run lands HERE,
// but so do review preview, a lost response, an idempotency conflict and
// a network error, so the rejection alone never means "out of Buzz".
// Offer a top-up only when the balance PROVES it. A block spends blue plus
// ONE paid pool: green under an SFW ceiling, yellow under a mature one
// (an unknown ceiling counts as SFW, as it does on the server).
const spendable = balance
? balance.blue + (isSfwCeiling(maxBrowsingLevel) ? balance.green : balance.yellow)
: null;
setOutcome(spendable !== null && quote !== undefined && spendable < quote ? 'short' : 'not-started');
} else if (err instanceof WorkflowSubmitError) {
setOutcome('maybe-started'); // 'workflow-failed': spend may be committed
} else {
setOutcome('error'); // estimate() rejected, or watch() gave up
}
}
};
return (
<>
<button onClick={run} disabled={busy}>Generate</button>
{outcome === 'refused' && <p>This generation did not run.</p>}
{outcome === 'short' && (
<p>
Not enough Buzz for this generation.{' '}
<button onClick={() => void openPurchaseModal()}>Top up</button>
</p>
)}
{outcome === 'not-started' && <p>Could not start the generation. Please try again.</p>}
{outcome === 'maybe-started' && <p>The generation may have started but did not complete.</p>}
{outcome === 'error' && <p>Something went wrong. Please try again.</p>}
</>
);
}Both modelId and modelVersionId are required even though they look redundant: the host validates that modelId matches the token's bound model and that the version belongs to it. On a model slot you already have both from useBlockContext().context; on a page app you obtain them from a resource picker.
Generation parameters
params is a BlockTextToImageParams object. Everything except prompt is optional — the host fills sensible defaults (sampler Euler, 25 steps, family-appropriate dimensions), so the simplest block sends only a prompt. Each bound below is server-enforced (over-limit values are rejected before any Buzz is spent); the authoritative list is the reference table.
| field | range | default |
|---|---|---|
prompt | required | — |
negativePrompt | optional | — |
cfgScale | 1–30 | model-dependent |
steps | 1–50 | 25 |
sampler | name | Euler |
seed | int / null | orchestrator picks |
width / height | 64–2048 | 1024 (SDXL/Flux), 512 (SD1/SD2) |
clipSkip | 0–12 | model-dependent (Flux ignores) |
quantity | 1–4 | 1 |
Adding LoRAs (additionalResources)
Layer LoRAs on top of the checkpoint with additionalResources — up to 5 entries, each { modelVersionId, strength? } with strength in [-1, 2] (default 1):
Because additionalResources is page-only (see the warning below), this is a page app: the checkpoint comes from a resource picker, not from a slot context.
import { useBuzzWorkflow, useResourcePicker } from '@civitai/blocks-react';
import type { WorkflowBodyTextToImage } from '@civitai/app-sdk/blocks';
// PAGE APP: modelId/modelVersionId come from a resource picker, not a slot
// context. A page context carries no model fields — see the warning below.
export function GenerateWithLora({ modelId, modelVersionId, baseModel }: {
modelId: number;
modelVersionId: number;
baseModel: string;
}) {
const { submit } = useBuzzWorkflow();
const { open } = useResourcePicker();
const run = async () => {
// Constrain the LoRA pick to the checkpoint's base-model family.
const lora = await open({ resourceType: 'LORA', baseModelGroup: baseModel });
if (!lora) return;
const body: WorkflowBodyTextToImage = {
kind: 'textToImage',
modelId,
modelVersionId,
additionalResources: [{ modelVersionId: lora.versionId, strength: 0.8 }],
params: { prompt: 'a serene alpine lake at golden hour, watercolor' },
};
await submit(body);
};
return <button onClick={run}>Generate with LoRA</button>;
}The server enforces the whole contract before spending Buzz: entries must be LoRAs (a non-LoRA version is rejected), each must be base-model-family compatible with the checkpoint, and each is entitlement-checked (early-access / Private-subscription). Use the checkpoint's baseModel as the picker's baseModelGroup so you never offer an incompatible LoRA.
additionalResources is a page-only field
Like sourceImage (below), additionalResources is rejected fail-closed on a model-bound token — it is honored only for page apps. A model-slot block that sends additionalResources gets a FORBIDDEN it can't diagnose from the response. See page-vs-model constraints.
Building a LoRA stack (several slots)
The resource picker returns one resource per open(). An app that layers several LoRAs (a stack, or a matrix of LoRA × weight) opens it once per slot and keeps the picks in its own state.
Omit baseModelGroup for an unconstrained pick (the checkpoint picker below passes none, so the viewer can switch family). A LoRA slot must stay in the chosen checkpoint's family, so pass baseModelGroup derived from that checkpoint's baseModel when you open the picker — never a hardcoded string.
import { useCheckpointPicker, useResourcePicker } from '@civitai/blocks-react';
import type { BlockCheckpointInfo, BlockResourceInfo } from '@civitai/app-sdk/blocks';
const MAX_LORAS = 5; // server cap on additionalResources
interface LoraSlot {
resource: BlockResourceInfo;
strength: number; // [-1, 2]
requestedFamily: string; // the family this slot was picked FOR
}
export function useLoraStack(
checkpoint: BlockCheckpointInfo,
loras: LoraSlot[],
setCheckpoint: (next: BlockCheckpointInfo) => void,
setLoras: (next: LoraSlot[]) => void,
) {
const { open: openCheckpoint } = useCheckpointPicker();
const { open: openResource } = useResourcePicker();
// One open() per slot: the picker returns a single resource.
const addLora = async () => {
if (loras.length >= MAX_LORAS) return;
const family = checkpoint.baseModel; // DERIVED, never a literal
const picked = await openResource({ resourceType: 'LORA', baseModelGroup: family });
if (!picked || loras.some((l) => l.resource.versionId === picked.versionId)) return;
setLoras([...loras, { resource: picked, strength: 1, requestedFamily: family }]);
};
// The checkpoint pick passes NO baseModelGroup, so the viewer can change family.
const changeCheckpoint = async () => {
const { selected } = await openCheckpoint({ currentVersionId: checkpoint.versionId });
if (!selected) return;
setCheckpoint(selected);
// A new family strands the old LoRAs. Drop them (and tell the viewer).
setLoras(loras.filter((l) => l.requestedFamily === selected.baseModel));
};
return { addLora, changeCheckpoint };
}- Re-filter when the checkpoint changes. A LoRA left over from the previous family makes the server reject the next estimate, with nothing on screen to explain it. Compare against the family you asked for (
requestedFamily), not the pick's ownbaseModel: the host maps a family to an ecosystem, so a pick requested asSDXL 1.0can come back labelledSDXL Turbo. - Respect the limits. At most 5 entries, each
strengthin [-1, 2]. Clamp before you build the body, and disable "add" at the cap. Page apps only, like everyadditionalResourcesrequest. - Map the stack to the body as
additionalResources: loras.map((l) => ({ modelVersionId: l.resource.versionId, strength: l.strength })), and omit the key when the stack is empty.
The full worked version is the generate-studio example: src/components/ModelSection.tsx has the per-slot picker and the checkpoint change, and keepCompatibleLoras in src/studio/setup.ts is the re-filter.
Image-to-image (sourceImage / sourceImages) — page apps only
Add a source image to turn the request into img2img, seeded from your image. There are two fields — sourceImage (one { url, width, height }) and sourceImages (an array, for multi-image conditioning); see which to send. The constraints are all server-enforced, for both fields:
- Page apps only. A source image is rejected fail-closed on a model-bound token — a
model.*slot block gets aFORBIDDENwith no hint why. - The checkpoint's ecosystem picks the graph. SD-family checkpoints get plain
img2img("Image Variations"); edit-capable ecosystems getimg2img:edit; anything else is rejected. The ecosystem list and the hard limits (64–2048 per side, a per-ecosystem image cap, never both fields) are in what the source-image fields can and cannot do. - Civitai-hosted URL only — an arbitrary remote URL is rejected. Get one from the host's image-upload bridge with
purpose: 'generationSource', which returns an unscanned private{ url, width, height }. In the array form one bad element rejects the whole body.
import { useBuzzWorkflow, useImageUpload } from '@civitai/blocks-react';
import type { WorkflowBodyTextToImage } from '@civitai/app-sdk/blocks';
// PAGE APP: modelVersionId comes from a resource picker, not a slot context.
export function Img2Img({ modelId, modelVersionId }: { modelId: number; modelVersionId: number }) {
const { submit } = useBuzzWorkflow();
const { open } = useImageUpload({ purpose: 'generationSource' });
const run = async () => {
const source = await open(); // { url, width, height } — Civitai-hosted, unscanned
if (!source) return;
const body: WorkflowBodyTextToImage = {
kind: 'textToImage',
modelId,
modelVersionId,
// Singular, on purpose: `sourceImage` is `@deprecated` but works on EVERY
// host, and for one image it is byte-identical to a 1-element
// `sourceImages`. See "One image, or several?" below.
sourceImage: { url: source.url, width: source.width, height: source.height },
params: { prompt: 'the same lake, now at dawn' },
};
await submit(body);
};
return <button onClick={run}>Remix an image</button>;
}The generationSource upload is scanned by the orchestrator at generation time, not before.
One image, or several?
The SDK marks sourceImage @deprecated in favour of sourceImages — a signpost, not a removal notice:
| you want | send | why |
|---|---|---|
| exactly one image | sourceImage | understood by every host. The server normalizes it into a 1-element array, so a 1-element sourceImages would produce a byte-identical generation, so either field works. The SDK states the alias keeps working indefinitely. |
| two or more images | sourceImages | the only field that can express it |
Sending both fields is rejected as ambiguous.
import { useBuzzWorkflow, useImageUpload } from '@civitai/blocks-react';
import type { WorkflowBodyTextToImage } from '@civitai/app-sdk/blocks';
// PAGE APP: multi-image edit. Needs a checkpoint whose ecosystem allows more
// than one image (Qwen: 3).
export function MultiImageEdit({ modelId, modelVersionId }: { modelId: number; modelVersionId: number }) {
const { submit } = useBuzzWorkflow();
const { open } = useImageUpload({ purpose: 'generationSource' });
const run = async () => {
const a = await open();
const b = await open();
if (!a || !b) return;
const body: WorkflowBodyTextToImage = {
kind: 'textToImage',
modelId,
modelVersionId,
// Order is preserved into the graph's `images` input.
sourceImages: [a, b],
params: { prompt: 'put the subject from the second image into the first' },
};
await submit(body);
};
return <button onClick={run}>Combine two images</button>;
}The lifecycle — estimate, submit, watch, cancel
useBuzzWorkflow() runs estimate → confirm → submit → watch. You drive each step; once a workflow is running, watch() owns the polling loop. Full return in the reference; the members you drive:
estimate(body)— a host-side whatIf price.statusgoes'estimating' → 'confirming'; the cost lands onresult.cost.total.'confirming'is idle — keep your Generate button enabled.submit(body, options?)— the host runs a whatIf preflight, gatescost ≤ token.buzzBudget, spends, and returns a snapshot with aworkflowId.statusgoes'submitting' → 'polling'. A refused submit also resolves — withstatus: 'failed'and a placeholderworkflowId, and the hook'sstatusgoes to'done', not'polling'— so aworkflowIdalone does not mean a run started; see refused submits. Theoptionsbag carriesidempotencyKey— read retrying a submit before writing any retry path, or a viewer can be charged twice.watch(workflowId, options?)— the one you want. It owns the polling loop (one request in flight per workflow), resolves with the terminal snapshot, and callsonUpdatewith every intermediate one.poll(workflowId)— a single host round-trip, for driving your own cadence; call it on a backoff until the snapshot is terminal (succeeded | failed | canceled | expired).cancel(workflowId)— a real server-side orchestrator cancel, so the workflow stops spending Buzz. Only workflows the viewer owns can be canceled. Resolves with the (now-canceled) snapshot.
import { useEffect } from 'react';
import { useBuzzWorkflow } from '@civitai/blocks-react';
export function useAutoWatch() {
const { watch, result, status } = useBuzzWorkflow();
useEffect(() => {
if (status !== 'polling' || !result?.workflowId) return;
const ac = new AbortController();
void watch(result.workflowId, {
signal: ac.signal,
onUpdate: (snap) => console.log(snap.status, snap.imageUrls?.length ?? 0),
});
// Stops WATCHING on unmount. It does not cancel the workflow — Buzz is
// already spent and the orchestrator keeps running; use cancel() for that.
return () => ac.abort();
}, [status, result?.workflowId, watch]);
}Refused submits, and running out of Buzz
A refused submit does not reject (with the exceptions below). submit()resolves with a snapshot whose status is 'failed', whose workflowId is the placeholder 'failed' (no workflow was started, so never watch() or poll() it), whose cost.total is the amount the server refused to spend (the price on text-to-image; on Comfy and step workflows it can be the declared ceiling, maxBuzz), and whose error says why. (A real workflow that failed straight away can resolve as 'failed' too, with its real id — equally terminal, equally nothing to watch.) So branch on snap.status === 'failed', as the happy path does — never on whether workflowId is set. error is server-authored and unsanitised: log it, and show the viewer copy your app owns.
A resolved 'failed' is not always "nothing charged". Only the 'failed' placeholder id means the server refused before spending. A real id means a workflow ran and failed, and the server keeps that reservation. Training is the other exception: an unconfirmed training submission comes back with the 'failed' id and submissionUnconfirmed: true, and its reservation is not refunded either. (Training never goes through submit(); it runs through useRunTraining().)
A resolved refusal reports a limit, never the viewer's balance: a price over your per-generation budget (see Budget model), a daily spend cap, the per-app rate limit or daily cap, a temporary "unavailable" deny, or a missing price quote. Buying Buzz raises none of them, so do not answer one with useBuzzPurchase().openPurchaseModal().
Running out of Buzz is a rejection, not a refusal. submit() rejects with a WorkflowSubmitError whose code is 'exception' — but 'exception' also covers review preview, a lost response, an idempotency conflict and a network error, distinguishable only by err.snapshot.error's server-authored text. So the rejection alone cannot tell you the viewer is short.
Decide from the balance instead. The happy path reads it with useBuzzBalance() and offers openPurchaseModal() only when the Buzz the block can spend is below the quoted cost.total. A block spends blue plus one paid pool, chosen by the domain's maturity ceiling — green on an SFW ceiling, yellow on a mature one, never both — so do not add all three pools. Read the ceiling as maxBrowsingLevel from useDomainMaturity() and test it with isSfwCeiling from @civitai/app-sdk/blocks (an unknown ceiling counts as SFW, as on the server). Two look-alikes must not choose the pool:
domainis informational only. On civitai.red a block can be tolddomain: 'blue'while its ceiling is fully mature, and the server then spendsyellow.isSfwis the wrong test here. It also reflects the viewer's own NSFW setting, which is right for deciding what to show but not what the server spends.
Reading the balance needs the buzz:read:self scope, which is not consent-exempt (useBuzzBalance() does not request it) and is refused for a signed-out viewer. Without it balance stays null, and the happy path shows a generic "Could not start the generation" with no top-up offer.
A pass-through training step the orchestrator would not quote also rejects (no cost), from estimate() and from submit() before anything is reserved. Branch on err.snapshot.errorCode ('training-quote-rejected', 'training-quote-unavailable' or 'training-quote-unpriced'), not on the error text, and keep a default branch for a code a later host adds; see "Why a training step could not be priced" under useBuzzWorkflow.
A registered step (kind: 'step' with a step id such as chat-completion) that the orchestrator would not quote rejects from estimate() the same way, with no cost and a 'step_quote_rejected', 'step_quote_unavailable' or 'step_quote_unpriced' errorCode; it does not fall back to the step's declared price. Its submit needs a quote too, and fails without one, with no step_quote_* code. See Estimating a registered step. A multi-step workflow does the same with workflow_quote_* codes. The useBuzzWorkflow reference lists every resolved refusal and each WorkflowSubmitError code with what it says about money.
Retrying a submit safely — idempotencyKey
This section is the React hook. For app.workflows.submit in @civitai/sdk, the retry rules are under Money calls.
If a submit succeeded server-side but its response was lost (a timeout, a dropped connection), a naive retry starts a second paid workflow. submit()'s second argument prevents that:
import { useBuzzWorkflow } from '@civitai/blocks-react';
import type { WorkflowBody } from '@civitai/app-sdk/blocks';
export function useSubmitWithRetry() {
const { submit } = useBuzzWorkflow();
// `cellId` identifies ONE logical generation. Every retry of it reuses the
// same key, so the host + orchestrator collapse them to ONE Buzz charge.
// The separator is `-`, not `:` — a colon fails the key's charset (below).
return async (body: WorkflowBody, cellId: string) => {
let lastErr: unknown;
for (let attempt = 0; attempt < 3; attempt++) {
try {
return await submit(body, { idempotencyKey: `gen-${cellId}` });
} catch (err) {
lastErr = err;
}
}
throw lastErr;
};
}- Omit it and the hook generates a fresh key per
submit()call — the right default, since each call is a new logical submit. - Pass a stable id — a grid-cell id, a request id you already hold — only for the retry of a submit you already made.
- Don't key it to something coarse (a component instance, the block id): two genuinely different generations that share a key become eligible to be collapsed as if one were a retry of the other.
The key's charset is validated — ^[A-Za-z0-9_-]{1,64}$
Letters, digits, _ and - only, 1–64 characters — so no :, ., / or spaces; join parts with - or _. Safe: `gen-${cellId}`, crypto.randomUUID(). Not safe: `gen:${cellId}`, React.useId() (it returns a colon-wrapped value such as :R0:). A key outside the class is refused BAD_REQUEST / 400 before anything is priced, so nothing is charged — and nothing ever generates. The same charset applies to every idempotency key the platform takes; the error shapes and the reasons for : and 64 are in idempotencyKey — the validated charset.
The reference entry is SubmitWorkflowOptions.
What you get back — the result shape
Both submit and poll resolve with a BlockWorkflowSnapshot — a flattened subset of the orchestrator's workflow that the host maps down before it crosses the boundary. The fields you render (full list in the reference):
status—'pending' | 'processing' | 'succeeded' | 'failed' | 'expired' | 'canceled'.imageUrls— the finished image URLs, flattened from the workflow'ssteps[].output.images[].url. This is where your results are.cost.total— the host-attested Buzz total for the run.spentAccountType— the Buzz pool that was the primary funder (informational; surface it, don't gate on it).autoClaim— set when the host opportunistically claimed a daily-boost reward during submit; surface a small "+25 daily boost" notice.
import { useBuzzWorkflow } from '@civitai/blocks-react';
export function Results() {
const { result } = useBuzzWorkflow();
if (result?.status !== 'succeeded') return null;
return (
<div>
{result.imageUrls?.map((url) => <img key={url} src={url} alt="" />)}
<small>Cost: {result.cost?.total} Buzz</small>
</div>
);
}To let the viewer publish a finished result to their Civitai profile, pass result.workflowId to createPost() as a workflow source. See Posting from an app.
Reading your app's queue (useAppWorkflows)
To show a running list of the generations your app submitted (across reloads), read the per-app subqueue with useAppWorkflows(). It returns a wire-stable AppWorkflow[] projection — workflowId, status, images (only available blobs with a URL), cost, createdAt — with every internal field (steps, params, prompts, resources) deliberately dropped. The full shape is in the reference.
Budget model
Text-to-image is prepaid (unlike Comfy on Civitai, which is post-paid). The host whatIf-prices the graph exactly, so:
- Your
estimate()mirrors thesubmit()price — surface it before you spend. submit()gatescost ≤ token.buzzBudgetper call, then debits the viewer.
A page app sets its per-generation budget with page.buzzBudgetPerGen in the manifest. That budget is a safety ceiling, not an estimate — it exists so a buggy or compromised app can't drain the viewer's Buzz, and you are charged the real price regardless. Size it at several times your worst-case run, not at what you expect a run to cost: a submit priced above the budget is rejected before it runs (nothing charged, nothing delivered), and it stays that way for every user until you ship a new manifest version. See Sizing the budget in the manifest reference. The ai:write:budgeted scope is required either way.
Running many generations (limits and fan-out)
There is no batch submit. A grid or matrix app (one generation per cell) makes one submit() per cell, and every limit below applies to each of those calls on its own. (To chain steps into one generation, where a later step reads an earlier one's result, use a multi-step workflow instead; it is one submit, counted per step against the limits below.) Two hooks cover the rest of a grid:
useBatchEstimate()prices up to 16 bodies in one request against the estimate allowance. Its run total is a quote, not a lock: each cell is priced again when it is submitted.useWorkflowBatch()watches up to 30 submitted workflows and cancels them together. It is still onewatch()and onecancel()per workflow underneath, so the poll and estimate limits below apply to each of those calls. See Watching and cancelling a grid.
The limits
Values at civitai 3c6d553986, 2026-10-14. They can change, and the per-app values depend on the app's spend tier. Every app starts on standard.
| Limit | Value | Keyed on | Tier | What your app sees when it trips |
|---|---|---|---|---|
| Per-call budget | price + author fee ≤ page.buzzBudgetPerGen (for a kind: 'workflow' body, that budget × its number of steps, and never more than 8,000 for one call). Default 1,000 when the manifest omits it; anything above 5,000 is clamped to 5,000 | one submit() | no | submit() resolves: status: 'failed', workflowId: 'failed', cost.total, error: 'insufficient buzz budget: …'. Nothing charged |
| Images per submit | quantity 1–4 | one submit() | no | the body fails validation, so submit() rejects (WorkflowSubmitError, code: 'exception'). Nothing charged |
| Estimate rate | 150 requests / 10 s | the block instance. For a page app that is one instance shared by every viewer of the app; for a model-slot block it is one installed instance. The same allowance also covers cancel(), useAppWorkflows() and the host's own reads for your block (viewer, Buzz balance, image and model lookups) | no | estimate() rejects (WorkflowEstimateError, message Rate limit exceeded, please retry shortly.). Nothing charged. A refused cancel() resolves with status: 'processing': the cancel was not sent, so call it again |
| Poll rate | 1,200 requests / 60 s | the block instance and the viewer | no | the poll resolves with status: 'processing' and no new data. watch() keeps looping and picks up the real status on a later poll. Nothing lost, nothing charged |
| Submit request rate | none | n/a | n/a | n/a. Submits are bounded by the spend limits below instead |
| App generation velocity | 120 accepted submits / 60 s on standard (trusted: 600, platform: 3,000). One submit counts once, whatever its quantity; a kind: 'workflow' submit counts once per step, and a repeat once per iteration | the app: every viewer and every install together | yes | submit() resolves: status: 'failed', workflowId: 'failed', cost.total, error: 'app generation rate limit reached: …'. Nothing charged |
| App daily spend | 5,000,000 Buzz / UTC day on standard and trusted (platform: 25,000,000) | the app: every viewer together | yes | submit() resolves failed, error: 'app daily spend cap reached: …'. Nothing charged |
| Viewer daily spend | 50,000 Buzz / UTC day | the viewer, across all their apps | no | submit() resolves failed, error: 'daily Buzz cap reached: … daily cap is 50000'. Nothing charged |
| Viewer's consent budget | whatever the viewer set for your app when they granted spend (optional, at most 50,000 / UTC day) | the viewer and your app | no | submit() resolves failed, with an error naming that budget. Nothing charged |
| Concurrent workflows | no host-side cap | n/a | n/a | n/a |
Civitai can also set a per-app override of the two app-wide values. The 60 s and 10 s windows are fixed windows, not sliding ones: the app velocity window starts on each clock minute.
The resolved refusals above are all case 1 in the resolved 'failed' list: the placeholder workflowId, nothing charged, every reservation released. As refused submits explains, error is server-authored text, not a contract. Log it and show your own copy; don't branch on its wording.
You will not hit the app-wide limits locally
The mock host does not model the rate limits or the app-wide limits, and a dev:tunnel session skips the app velocity and app daily limits. A velocity refusal first shows up once your app is live, so write the handling before you need it.
Fan-out, cell by cell
- One
submit()per cell, each with its own stableidempotencyKey, for example`cell-${runId}-${x}-${y}`. Reuse that key for every retry of that cell and never for another cell; see Retrying asubmit()without double-charging and the section above. - One
watch()per workflow. Eachwatch()keeps exactly one poll in flight and waitsintervalMs(default 1,500 ms) between polls, so one watched workflow makes at most 60 / 1.5 = 40 polls a minute. - Track each cell from its own promise, the value
submit()andwatch()resolve with (pluswatch()'sonUpdate). The hook'sresultandstatusare one value per hook, so with 16 cells in flight they only show whichever call updated them last. - Record an outcome per cell. A grid run ends with a mix: some cells succeeded, some were refused (the
'failed'placeholder, nothing charged), some ran and failed (a realworkflowId, possibly charged). Show each one. Don't abort the whole grid because one cell was refused. - Cancel what is still running, and check each result. A cancel that comes back
'processing'was refused (the estimate allowance was used up) and the workflow is still running, so call it again later.useWorkflowBatch()'scancelAll()does this for the whole grid; see below. - Estimate when the viewer is about to confirm, not on every edit. On a page app the 150-per-10-s allowance is shared by every viewer of your app. A 16-cell grid priced cell by cell spends 16 of it per estimate pass, so about nine viewers re-estimating a full grid in the same 10 s would use it all, and then your other reads (balance, viewer,
cancel()) start being refused too.useBatchEstimate()spends one request per pass, but its cells are metered against an allowance of their own, so it is not a way around the limit. Debounce, and treat a rejected estimate as "try again in a few seconds".
Watching and cancelling a grid
useWorkflowBatch(workflowIds) follows the workflows your cells submitted and returns { cells, summary, cancelAll, rewatch }. There is no server-side batch id: the array of ids is the batch. Append each id as its submit() resolves, skip the 'failed' placeholder (nothing was created), and persist the same array to re-attach after a reload.
import { useState } from 'react';
import type { WorkflowBody } from '@civitai/app-sdk/blocks';
import { useBuzzWorkflow, useWorkflowBatch } from '@civitai/blocks-react';
export function GridRun({ runId, bodies }: { runId: string; bodies: WorkflowBody[] }) {
const { submit } = useBuzzWorkflow();
const [ids, setIds] = useState<string[]>([]);
const { summary, cancelAll, rewatch } = useWorkflowBatch(ids);
const [cancelNote, setCancelNote] = useState('');
async function run() {
for (const [i, body] of bodies.entries()) {
try {
const snap = await submit(body, { idempotencyKey: `cell-${runId}-${i}` });
if (snap.workflowId !== 'failed') setIds((prev) => [...prev, snap.workflowId]);
} catch {
// Rejected: record this cell's outcome and carry on with the grid.
}
}
}
async function stopAll() {
const r = await cancelAll(); // never rejects
setCancelNote(r.complete ? '' : 'Some cells are still running. Try again in a few seconds.');
}
return (
<div>
<button onClick={() => void run()}>Run grid</button>
<p>
{summary.succeeded} done, {summary.failed} failed, {summary.running} running.
Spent {summary.realisedCost} Buzz.
</p>
{!summary.settled && <button onClick={() => void stopAll()}>Cancel all</button>}
{summary.stopped > 0 && <button onClick={rewatch}>Refresh stalled cells</button>}
{cancelNote && <p>{cancelNote}</p>}
</div>
);
}- At most 30 ids (
WORKFLOW_BATCH_MAX_CELLS). A longer list, a duplicate, an empty id or a'failed'/'whatif'placeholder throws. summary.realisedCostexcludes the author fee. It sumscost.totalon the finished snapshots that carry one: the generation cost the host reported, not a ledger read. The author fee is charged separately.- A host failure placeholder is not a failed workflow. A poll the host could not answer stops that cell (
phase: 'stopped', counted insummary.stopped, not insummary.failed). It is not polled again until you callrewatch(). cancelAll()keeps at most four cancels in flight and stops after the first refused one. The refused cell is inrefused, the cells it did not send are innotAttempted, andcompleteisfalse. Nothing is retried for you; callcancelAll()again after a few seconds, and it targets only the cells that are still not finished.
The app velocity limit is shared by every viewer
120 accepted submits a minute is the allowance for your whole app, not for one viewer. A 16-cell grid uses 16 of them, so 120 / 16 = 7.5 full grids a minute across all of your viewers together. Low concurrency per viewer does not protect you: three viewers each starting a 16-cell grid in the same minute is 48 submits, and the fourth viewer's grid can be refused partway through, because of traffic from people they never see.
So a grid app has to treat a velocity refusal in the middle of a grid as a normal outcome:
- The refused cell resolved with the
'failed'placeholder, so nothing was charged and nothing is running. Mark it "waiting", not "failed". - Wait until the next minute begins before you retry. A refused submit still counts toward the current minute, so retrying sooner keeps the app at the limit for everyone.
- Retry with the same
idempotencyKey. The refused attempt released it, so the retry runs normally, and if the earlier response was lost instead, the key stops it from being charged twice. - If it is refused again, stop and show the viewer which cells did not run, with a button to retry them.
Pacing submits does not save velocity. It counts submits, not workflows in flight. A 16-cell grid uses 16 of the minute's 120 whether you start the cells all at once or three at a time, unless the pacing spreads them across more than one minute.
Recommended concurrency
None of the per-viewer limits calls for a cap below the 16 cells in a 4×4 grid. Watching 16 workflows at once is at most 16 × 40 = 640 polls a minute, about half of the 1,200 allowed for one viewer, so one viewer can watch up to 1,200 / 40 = 30 workflows before polls start returning placeholders (and even then nothing breaks, watch() just updates more slowly). A full grid's estimates are 16 of 150 per 10 s. Spend is the other bound: a grid can reserve up to 16 × your per-call budget against one viewer's 50,000 a day. At a budget of 3,125 or less a full grid fits; above that, the per-viewer daily cap can refuse cells partway through a single grid.
So with a per-call budget of 3,125 or less, submitting a 16-cell grid in one go is within every per-viewer limit. A lower per-viewer concurrency does not change your exposure to the shared app velocity limit, which the steps above handle.
Posting and image-upload limits
Posting from an app has limits of its own, separate from the generation limits above. Values at civitai fc092d7b03, 2026-10-10. They can change, and none of them depends on the app's spend tier.
| Limit | Value | Keyed on | What your app sees when it trips |
|---|---|---|---|
| Upload size | 40 MiB per file | one upload() | upload() rejects, file exceeds the maximum upload size. Nothing was sent |
| Upload burst | 3 uploads and 80 MiB / 60 s (a rolling window, kept by the host page) | one open page of your app, so one viewer's tab | upload() rejects, busy. Nothing was stored. Wait and try again |
| Upload rate | 60 uploads / hour (rolling) | the viewer | upload() rejects with a rate-limit message |
| Image allowance | 60 images / 300 s. One upload spends 1, one post spends 1 per image in it, and usePublishGenerationOutputs() spends 1 per image | the block instance. For a page app that is one instance shared by every viewer of the app | the call rejects, Rate limit exceeded, please retry shortly. |
| Posts | 3 posts / hour | the viewer, within one block instance. On a page app every viewer has their own 3 | createPost() rejects (CreatePostError, no code), Rate limit exceeded, please retry shortly. No post was created |
| Posts, app-wide | 300 posts / hour | the app | the same rejection |
| Images per post | 1–20, from at most 10 sources | one createPost() | createPost() rejects, a post may contain at most 20 images. Nothing is trimmed to fit |
The 300 s and one-hour windows on the image allowance and the two post limits are fixed windows, not sliding ones.
Three things follow from how these are counted:
- 3 posts an hour is each viewer's allowance; the app-wide ceiling is 300. One viewer's posts do not use up another's, but every viewer of your app draws on the same 300 an hour and the same image allowance. A viewer's fourth post in the same hour is refused until the window ends. Posting has to be something a viewer does on purpose with a finished result. Show a refusal as "try again later"; do not retry on a timer.
- A post counts once the viewer has confirmed it, even if the server then refuses it. Refusals after the confirmation spend one of the 3: the set of images changed since the dialog was shown, the image allowance is used up, or a generation output could not be fetched. Refusals before that point do not. Civitai checks the request before it opens the dialog, so a blocked title or an image that is not available is refused there, with no dialog and no post spent. A viewer dismissing the dialog does not count, because the post request is only sent after they confirm. An account that may not post is refused before the post is counted. Each
createPost()call does spend one request of the 150-per-10-s estimate allowance for that check. - The image allowance is shared between uploading, publishing and posting. A 20-image post spends a third of it.
Page-vs-model constraints
The same textToImage body is accepted on both a page app token and a model-slot token, but two body fields are page-only and rejected fail-closed on a model-bound token:
| field | model slot (model.*) | page app |
|---|---|---|
modelId / modelVersionId / params | ✅ | ✅ |
additionalResources (LoRAs) | ❌ FORBIDDEN | ✅ |
sourceImage (img2img, single) | ❌ FORBIDDEN | ✅ |
sourceImages (img2img, multi) | ❌ FORBIDDEN | ✅ |
If you are building a model.* slot block, keep to checkpoint-only txt2img. If you need LoRA stacking or img2img, build a page app.
Try it locally
The generation scaffold wires all of this up against the mock host, so the estimate/submit/poll round-trip runs with no backend:
civitai app create my-app --template page-money # the generation template
cd my-app && npm install
npm run dev:harness # mock host — no backend neededReal Buzz generation needs closed-beta access; see the Quickstart.
Next
- Generation bridge reference — the generated field-level contract.
- What the bridge can and cannot do — the boundary vs the orchestrator, and what to do when a model isn't reachable.
- Comfy on Civitai (customComfy) — the recipe-gated ComfyUI path.
- Multi-step workflows — chain orchestrator steps in one generation (
kind: 'workflow'). - Posting from an app — publish a result to the viewer's profile.
- Hooks reference · Messages reference.
- Scopes —
ai:write:budgeted. · Manifest —page.buzzBudgetPerGen.