---
url: /apps/guide/text-to-image.md
description: >-
  The primary App Blocks generation path — submit a text-to-image WorkflowBody,
  add LoRAs, do img2img (page apps only), handle refusals and limits, and read
  the result.
---

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

# 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](https://developer.civitai.com/apps/reference/generation.md). For the ComfyUI path —
a server-owned graph invoked by name, **or your own graph shipped inline** — see
[Comfy on Civitai](https://developer.civitai.com/apps/guide/comfy-cloud.md).

::: warning Closed beta — mod-gated
Like the rest of the [Apps platform](https://developer.civitai.com/apps/guide.md), 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.
:::

::: info 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](https://developer.civitai.com/apps/guide/sdk.md#money-calls) has the calling code.
:::

## The happy path

The smallest possible generation is a checkpoint + a prompt. You submit it
through [`useBuzzWorkflow()`](https://developer.civitai.com/apps/reference/generation/lifecycle.md#bridge-useBuzzWorkflow),
which takes a full `WorkflowBody` — the discriminated union keyed by `kind`:

```tsx
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](https://developer.civitai.com/apps/reference/hooks/storage-and-media.md#hook-useResourcePicker).

## 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](https://developer.civitai.com/apps/reference/generation/bodies.md#bridge-BlockTextToImageParams).

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

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

::: warning `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](#page-vs-model-constraints).
:::

### Building a LoRA stack (several slots) {#lora-stack}

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.

```tsx
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 own `baseModel`: the host maps a family to an ecosystem, so a
  pick requested as `SDXL 1.0` can come back labelled `SDXL Turbo`.
* **Respect the limits.** At most **5** entries, each `strength` in
  **\[-1, 2]**. Clamp before you build the body, and disable "add" at the cap.
  Page apps only, like every `additionalResources` request.
* 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](https://github.com/civitai/civitai-app-starters/tree/main/starters/examples/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](#one-image-or-several). 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 a `FORBIDDEN` with no hint
  why.
* **The checkpoint's ecosystem picks the graph.** SD-family checkpoints get
  plain `img2img` ("Image Variations"); edit-capable ecosystems get
  `img2img: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](https://developer.civitai.com/apps/reference/generation.md#what-sourceimage-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.

```tsx
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? {#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.

```tsx
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](https://developer.civitai.com/apps/reference/generation/lifecycle.md#bridge-useBuzzWorkflow); the members
you drive:

* **`estimate(body)`** — a host-side whatIf price. `status` goes
  `'estimating' → 'confirming'`; the cost lands on `result.cost.total`.
  `'confirming'` is **idle** — keep your Generate button enabled.
* **`submit(body, options?)`** — the host runs a whatIf preflight, gates
  `cost ≤ token.buzzBudget`, spends, and returns a snapshot with a
  `workflowId`. `status` goes `'submitting' → 'polling'`. A **refused** submit
  also resolves — with `status: 'failed'` and a placeholder `workflowId`, and
  the hook's `status` goes to `'done'`, not `'polling'` — so a `workflowId`
  alone does not mean a run started; see
  [refused submits](#refused-submits). The `options` bag carries
  **`idempotencyKey`** — **read [retrying a submit](#retrying-a-submit-safely)
  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 calls `onUpdate` with 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.

```tsx
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 {#refused-submits}

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](#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](#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()`](https://developer.civitai.com/apps/reference/hooks/buzz-and-purchases.md#hook-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()`](https://developer.civitai.com/apps/reference/hooks/viewer-and-navigation.md#hook-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:

* **`domain` is informational only.** On civitai.red a block can be told
  `domain: 'blue'` while its ceiling is fully mature, and the server then
  spends `yellow`.
* **`isSfw` is 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`](https://developer.civitai.com/apps/reference/hooks/generation.md#hook-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](https://developer.civitai.com/apps/reference/generation.md#registered-step-estimate).
A [multi-step workflow](https://developer.civitai.com/apps/guide/workflows.md#refusals) does the same with
`workflow_quote_*` codes. The
[`useBuzzWorkflow` reference](https://developer.civitai.com/apps/reference/generation/lifecycle.md#bridge-useBuzzWorkflow)
lists every resolved refusal and each `WorkflowSubmitError` `code` with what it
says about money.

### Retrying a submit safely — `idempotencyKey` {#retrying-a-submit-safely}

This section is the React hook. For `app.workflows.submit` in `@civitai/sdk`,
the retry rules are under [Money calls](https://developer.civitai.com/apps/guide/sdk.md#submit-rejects).

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:

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

::: danger 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](https://developer.civitai.com/apps/reference/generation.md#idempotency-key-charset).
:::

The reference entry is
[`SubmitWorkflowOptions`](https://developer.civitai.com/apps/reference/generation/lifecycle.md#bridge-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](https://developer.civitai.com/apps/reference/generation/lifecycle.md#bridge-BlockWorkflowSnapshot)):

* **`status`** — `'pending' | 'processing' | 'succeeded' | 'failed' |
  'expired' | 'canceled'`.
* **`imageUrls`** — the finished image URLs, **flattened from the workflow's
  `steps[].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.

```tsx
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](https://developer.civitai.com/apps/guide/posting.md).

## 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](https://developer.civitai.com/apps/reference/generation/lifecycle.md#bridge-AppWorkflow).

## Budget model

Text-to-image is **prepaid** (unlike Comfy on Civitai, which is post-paid). The host
whatIf-prices the graph exactly, so:

1. **Your `estimate()` mirrors the `submit()` price** — surface it before you
   spend.
2. **`submit()` gates `cost ≤ token.buzzBudget`** per 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](https://developer.civitai.com/apps/reference/manifest.md) in the manifest reference. The
`ai:write:budgeted` [scope](https://developer.civitai.com/apps/reference/scopes.md) is required either way.

## Running many generations (limits and fan-out) {#limits}

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](https://developer.civitai.com/apps/guide/workflows.md) instead; it is one submit, counted per step
against the limits below.) Two hooks cover the rest of a
grid:

* [`useBatchEstimate()`](https://developer.civitai.com/apps/reference/hooks/batches-and-training.md#hook-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()`](https://developer.civitai.com/apps/reference/hooks/batches-and-training.md#hook-useWorkflowBatch) watches up to
  30 submitted workflows and cancels them together. It is still one `watch()`
  and one `cancel()` per workflow underneath, so the poll and estimate limits
  below apply to each of those calls. See [Watching and cancelling a grid](#watch-cancel-grid).

### The limits {#limits-table}

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'`](https://developer.civitai.com/apps/guide/workflows.md#what-it-costs) 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'`](https://developer.civitai.com/apps/guide/workflows.md#limits) 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](https://developer.civitai.com/apps/reference/generation/lifecycle.md#bridge-useBuzzWorkflow): the
placeholder `workflowId`, nothing charged, every reservation released. As
[refused submits](#refused-submits) explains, `error` is server-authored text,
not a contract. Log it and show your own copy; don't branch on its wording.

::: tip 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 {#fan-out}

* **One `submit()` per cell, each with its own stable `idempotencyKey`**, for
  example `` `cell-${runId}-${x}-${y}` ``. Reuse that key for every retry of
  that cell and never for another cell; see
  [Retrying a `submit()` without double-charging](https://developer.civitai.com/apps/reference/generation.md#retrying-a-submit-without-double-charging)
  and [the section above](#retrying-a-submit-safely).
* **One `watch()` per workflow.** Each `watch()` keeps exactly one poll in
  flight and waits `intervalMs` (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()` and `watch()`
  resolve with (plus `watch()`'s `onUpdate`). The hook's `result` and `status`
  are 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 real `workflowId`, 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()`'s
  `cancelAll()` 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 {#watch-cancel-grid}

[`useWorkflowBatch(workflowIds)`](https://developer.civitai.com/apps/reference/hooks/batches-and-training.md#hook-useWorkflowBatch)
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.

```tsx
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.realisedCost` excludes the author fee.** It sums `cost.total` on
  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 in `summary.stopped`,
  not in `summary.failed`). It is not polled again until you call `rewatch()`.
* **`cancelAll()` keeps at most four cancels in flight and stops after the first
  refused one.** The refused cell is in `refused`, the cells it did not send are
  in `notAttempted`, and `complete` is `false`. Nothing is retried for you; call
  `cancelAll()` 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 {#velocity-is-shared}

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:

1. The refused cell resolved with the `'failed'` placeholder, so nothing was
   charged and nothing is running. Mark it "waiting", not "failed".
2. 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.
3. 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.
4. 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 {#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-limits}

[Posting from an app](https://developer.civitai.com/apps/guide/posting.md) 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](#limits-table)
  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:

```bash
civitai app create my-app --template page-money   # the generation template
cd my-app && npm install
npm run dev:harness           # mock host — no backend needed
```

Real Buzz generation needs closed-beta access; see the
[Quickstart](https://developer.civitai.com/apps/guide/quickstart.md).

## Next

* [Generation bridge reference](https://developer.civitai.com/apps/reference/generation.md) — the generated field-level contract.
* [What the bridge can and cannot do](https://developer.civitai.com/apps/reference/generation.md#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)](https://developer.civitai.com/apps/guide/comfy-cloud.md) — the recipe-gated ComfyUI path.
* [Multi-step workflows](https://developer.civitai.com/apps/guide/workflows.md) — chain orchestrator steps in one generation (`kind: 'workflow'`).
* [Posting from an app](https://developer.civitai.com/apps/guide/posting.md) — publish a result to the viewer's profile.
* [Hooks reference](https://developer.civitai.com/apps/reference/hooks.md) · [Messages reference](https://developer.civitai.com/apps/reference/messages.md).
* [Scopes](https://developer.civitai.com/apps/reference/scopes.md) — `ai:write:budgeted`. · [Manifest](https://developer.civitai.com/apps/reference/manifest.md) — `page.buzzBudgetPerGen`.
