Skip to content

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:

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

fieldrangedefault
promptrequired
negativePromptoptional
cfgScale1–30model-dependent
steps1–5025
samplernameEuler
seedint / nullorchestrator picks
width / height64–20481024 (SDXL/Flux), 512 (SD1/SD2)
clipSkip0–12model-dependent (Flux ignores)
quantity1–41

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):

tsx
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. sourceImage is 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 a model.* slot block you will get a FORBIDDEN with 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 get img2img:edit. A checkpoint whose ecosystem supports neither is rejected fail-closed. The full ecosystem list — and the limits sourceImage cannot be argued out of (one image only, Civitai-hosted URLs only) — is in what the bridge can and cannot do.
  • Civitai-hosted URL only. url must 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 with purpose: 'generationSource', which returns an unscanned private { url, width, height }.
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,
      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. status goes 'estimating' → 'confirming'; the cost lands on result.cost.total. 'confirming' is idle — keep your Generate button enabled.
  • submit(body) — the host runs a whatIf preflight, gates cost ≤ token.buzzBudget, spends, and returns a snapshot with a workflowId. status goes '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.
tsx
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'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>
  );
}

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:

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

fieldmodel 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:

bash
civitai app create my-app     # 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.

Next

Civitai Developer Documentation