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 streams back the images. Your block never holds an orchestrator token — the host brokers every call from Civitai's side of the iframe boundary.
This guide is the narrative companion to the generated generation bridge reference: it walks the body shape, LoRA stacking, img2img, the estimate → submit → poll → cancel lifecycle, and what you get back — and states the page-vs-model rules that are enforced server-side but easy to trip over. For the ComfyUI-recipe path (a server-owned graph you invoke by name) see Comfy on Civitai instead.
Closed beta — mod-gated
Like the rest of the Apps platform, generation is mod-gated during the closed beta. You can scaffold and run the whole flow against the local mock host today; real Buzz generation needs closed-beta builder access.
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 { useBuzzWorkflow, useBlockContext } from '@civitai/blocks-react';
import type { WorkflowBodyTextToImage, ModelSlotContext } from '@civitai/app-sdk/blocks';
export function Generate() {
const { estimate, submit, poll, status, result } = useBuzzWorkflow();
const { context } = useBlockContext();
const ctx = context as ModelSlotContext; // model slot: has modelId + modelVersionId
const run = async () => {
const body: WorkflowBodyTextToImage = {
kind: 'textToImage',
modelId: ctx.modelId,
modelVersionId: ctx.modelVersionId,
params: { prompt: 'a serene alpine lake at golden hour' },
};
await estimate(body); // review cost via result.cost.total
const snap = await submit(body);
await poll(snap.workflowId); // drive to a terminal snapshot (see the lifecycle)
};
return (
<button onClick={run} disabled={status !== 'confirming'}>
Generate
</button>
);
}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):
import { useBuzzWorkflow, useResourcePicker, useBlockContext } from '@civitai/blocks-react';
import type { WorkflowBodyTextToImage, ModelSlotContext } from '@civitai/app-sdk/blocks';
export function GenerateWithLora() {
const { submit } = useBuzzWorkflow();
const { open } = useResourcePicker();
const ctx = useBlockContext().context as ModelSlotContext;
const run = async () => {
// Constrain the LoRA pick to the checkpoint's base-model family.
const lora = await open({ resourceType: 'LORA', baseModelGroup: 'SDXL' });
if (!lora) return;
const body: WorkflowBodyTextToImage = {
kind: 'textToImage',
modelId: ctx.modelId,
modelVersionId: ctx.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.
Image-to-image (sourceImage) — page apps only
Add a sourceImage to turn the request into img2img: the block bridge emits an img2img graph instead of txt2img, seeded from your image. This is the one part of the contract with the sharpest constraints, and they are all server-enforced:
- 🔴 Page apps only.
sourceImageis rejected fail-closed on a model-bound token — a model-slot block cannot do img2img. This is documented nowhere else; if you copy a page-app img2img example into amodel.*slot block you will get aFORBIDDENwith no hint why. img2img lives on page apps. - The checkpoint's ecosystem picks the graph. SD-family checkpoints get plain
img2img("Image Variations"); edit-capable ecosystems getimg2img:edit. A checkpoint whose ecosystem supports neither is rejected fail-closed. The full ecosystem list — and the limitssourceImagecannot be argued out of (one image only, Civitai-hosted URLs only) — is in what the bridge can and cannot do. - Civitai-hosted URL only.
urlmust resolve to a Civitai-controlled host — an arbitrary remote URL is rejected (SSRF guard). The way to get a qualifying URL is the host's image-upload bridge withpurpose: 'generationSource', which returns an unscanned private{ url, width, height }.
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,
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 an unscanned private input by contract — the orchestrator scans it at generation time, so the moderation stamp is the gen-time scan, not a pre-crossing one. That is the correct posture for an edit source (it is not a public display image).
The lifecycle — estimate, submit, poll, cancel
useBuzzWorkflow() orchestrates a deliberate estimate → confirm → submit → poll dance; it does not auto-poll. The full return is 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)— the host runs a whatIf preflight, gatescost ≤ token.buzzBudget, spends, and returns a snapshot with aworkflowId.statusgoes'submitting' → 'polling'.poll(workflowId)— you call this on a backoff until the snapshot is terminal (succeeded | failed | canceled | expired).cancel(workflowId)— a real server-side orchestrator cancel (not just client-side untracking), so a running workflow stops spending Buzz. The host re-derives ownership from the viewer's token, so you can only cancel workflows the viewer owns. Resolves with the (now-canceled) snapshot.
import { useEffect } from 'react';
import { useBuzzWorkflow } from '@civitai/blocks-react';
export function useAutoPoll() {
const { poll, result, status } = useBuzzWorkflow();
useEffect(() => {
if (status !== 'polling' || !result?.workflowId) return;
const id = setTimeout(() => void poll(result.workflowId), 2000);
return () => clearTimeout(id);
}, [status, result?.workflowId, poll]);
}Out of Buzz?
submit() rejects when the estimate exceeds the token budget. Call useBuzzPurchase().openPurchaseModal() to let the viewer top up, then retry.
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>
);
}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.
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) | ❌ 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 # 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.
- Hooks reference · Messages reference.
- Scopes —
ai:write:budgeted. · Manifest —page.buzzBudgetPerGen.