React hooks
@civitai/blocks-react is the React-first way to build a Civitai App. Each hook wraps a slice of the message bridge so you never touch postMessage directly — you call a hook, get typed state back, and the host brokers the privileged work.
The signatures below are generated from the published package's type definitions; the examples come from its README.
Trust model
Every hook that reads private data or submits work is host-mediated: the host resolves the viewer from the block token and performs the privileged call on Civitai's side of the iframe boundary. Your app never holds a credential or calls a privileged API directly.
useBuzzWorkflow's generated example is one kind of several
The useBuzzWorkflow entry below is generated from the package README, whose example sends a kind: 'textToImage' body. That is one member of the WorkflowBody union, not the whole surface — the same hook also submits ComfyUI workflows (kind: 'customComfy') and registered orchestrator steps (kind: 'step'). See Workflow bodies: the kind union below before concluding a capability is missing.
useBlockContext
useBlockContext(): Pick<BlockSnapshot, 'ready' | 'renderMode' | 'context' | 'token' | 'settings' | 'viewer' | 'theme' | 'blockId' | 'blockInstanceId' | 'appId'>The primary hook. Returns everything the host delivered in `BLOCK_INIT` plus a `ready` gate — fields are sentinel-empty before init, so gate your UI on `ready`.
const { ready, context, viewer, theme, settings, blockId, blockInstanceId, appId, token, renderMode } =
useBlockContext();useBlockTheme
useBlockTheme(): ThemeThe host's CURRENT site theme, and nothing else. Same value as `useBlockContext().theme` — reach for this when theme is all you need.
function ThemedRoot() {
const theme = useBlockTheme(); // 'light' | 'dark'
return <div data-theme={theme}>…</div>;
}useBlockResize
useBlockResize(ref: RefObject<HTMLElement | null>): voidAttach to your root element. Observes its height and posts `RESIZE_IFRAME` so the host sizes the iframe to fit. No-op on the inline transport (host DOM reflows naturally).
const rootRef = useRef<HTMLDivElement>(null);
useBlockResize(rootRef);useBlockBreakpoint
useBlockBreakpoint(ref?: RefObject<HTMLElement | null>): BlockBreakpointReports the block's **own** width tier, so you can branch on "am I narrow?" without hand-rolling a `ResizeObserver` or hard-coding pixel numbers.
const bp = useBlockBreakpoint();
<div style={{ display: 'flex', flexDirection: bp.below('sm') ? 'column' : 'row' }}>
{bp.atLeast('md') && <aside>…</aside>}
</div>useBlockToken
useBlockToken(): BlockToken & {
refresh: () => Promise<void>;
}Current block-scoped JWT, auto-refreshing ~2 min before expiry. Returns the token fields plus a `refresh()` for the 401-retry path.
const { raw, scopes, expiresAt, buzzBudget, refresh } = useBlockToken();
// after a 401: await refresh(); then retry the request once with the new `raw`.useHostOrigin
useHostOrigin(): string | undefinedThe validated host origin to direct-fetch the App Blocks HTTP API against — `undefined` until init. Use it as the base URL when you need to bypass the host bridge, always paired with the bearer token from `useBlockToken()`.
const host = useHostOrigin(); // e.g. "https://civitai.com" (undefined until BLOCK_INIT)
const { raw } = useBlockToken();
// Once `host` is set, fetch the API on that validated origin with the block token:
if (host) {
const res = await fetch(`${host}/api/v1/blocks/me`, {
headers: { authorization: `Bearer ${raw}` },
});
}useBlockSettings
useBlockSettings(): BlockSettingsShorthand for `useBlockContext().settings`. Read-only from the iframe — settings are *written* on the platform `/apps/installed` page, not via a bridge message.
const { publisherSettings, userSettings } = useBlockSettings();useBuzzWorkflow
useBuzzWorkflow(): UseBuzzWorkflowReturnThe generation flow: `estimate` → `submit` → `poll`, host-mediated. Returns `{ estimate, submit, poll, status, result, error }`.
import type { WorkflowBody } from '@civitai/app-sdk/blocks';
const { estimate, submit, poll, status, result } = useBuzzWorkflow();
declare const modelId: number, modelVersionId: number, userPrompt: string;
const body: WorkflowBody = {
kind: 'textToImage',
modelId,
modelVersionId,
params: { prompt: userPrompt },
};
// The viewer-facing copy is a string YOUR APP owns, chosen by `err.code`.
// Nothing on the error may be rendered: `err.message` is developer-facing and
// its wording is not a contract; `err.snapshot.error` is server-authored and
// unsanitised.
const estimateFailureMessage = (err: WorkflowEstimateError) =>
err.code === 'no-cost'
? 'We could not get a price for this configuration. Try adjusting it.'
: 'Pricing is unavailable right now. Please try again shortly.';
// 🔴 estimate() REJECTS when the reply carries no usable price. ALWAYS catch it.
let priced = false;
try {
await estimate(body); // status 'estimating' → 'confirming' (cost in result.cost.total)
priced = true;
} catch (err) {
if (!(err instanceof WorkflowEstimateError)) throw err;
// status is now 'error'. Log both for the developer; render neither.
logForDebugging(err.message, err.snapshot.error);
showError(estimateFailureMessage(err));
}
if (priced) {
// 🔴 submit() REJECTS when the reply carries no usable workflow outcome. A
// priced refusal is different — it RESOLVES.
try {
const snap = await submit(body); // status 'submitting' → 'polling'
if (snap.status === 'failed') {
// 🔴 A RESOLVED `failed` IS A PRICED SERVER OUTCOME — and only SOME of
// them are about the viewer's wallet. Affordability (per-call budget, the
// per-user daily Buzz cap) IS fixable by buying Buzz; the per-app velocity
// limit, the per-app aggregate daily cap, a fail-closed "temporarily
// unavailable" deny and a missing price quote are NOT. Selling Buzz for
// one of those takes money and fixes nothing, so branch before you offer.
showError(submitOutcomeMessage(snap)); // YOUR app owns this copy
} else {
await poll(snap.workflowId); // you loop this on a backoff until terminal
}
} catch (err) {
if (!(err instanceof WorkflowSubmitError)) throw err;
// Log both for the developer; render neither.
logForDebugging(err.message, err.snapshot.error);
// 🔴 TWO SEPARATE QUESTIONS — DO NOT CONJOIN THEM. `code` decides what you may
// say about MONEY; the id decides only whether there is something to POLL.
// Folding the id test into the `code` test sends a 'workflow-failed' reply
// whose id is 'whatif' into the reassuring arm — the exact blind-retry
// invitation this whole guard exists to remove.
if (err.code === 'workflow-failed') {
// 🔴 Spend MAY ALREADY BE COMMITTED. Do not tell the viewer it was free,
// and do not retry blindly — a retry mints a fresh idempotency key, i.e. a
// SECOND reservation.
showError('The generation may have started but did not complete. Check your history.');
// Only NOW ask about pollability: 'whatif' is a non-workflow sentinel.
if (err.snapshot.workflowId !== 'whatif') await poll(err.snapshot.workflowId);
} else {
// 🔴 'exception' means the host had no workflow to report — USUALLY nothing
// was queued, but a lost response or an in-progress idempotency conflict
// reaches this arm too. Retry with the SAME idempotencyKey, not a fresh one.
showError('Could not start the generation. Please try again.');
}
}
}useBuzzPurchase
useBuzzPurchase(): {
openPurchaseModal: (suggestedAmount?: number) => Promise<{
purchased: boolean;
newBalance?: number;
}>;
}Open the Buzz purchase modal — the insufficient-budget recovery path.
const { openPurchaseModal } = useBuzzPurchase();
const { purchased, newBalance } = await openPurchaseModal(suggestedAmount);
if (purchased) { /* retry the generation */ }useBuzzBalance
useBuzzBalance(): UseBuzzBalanceThe signed-in viewer's per-pool Buzz balance (`{ blue, green, yellow }` — the domain-clamped pools a block may read; never the platform-internal `red`/`purple`). Host-mediated over `GET_BUZZ_BALANCE` → `BUZZ_BALANCE_RESULT`; same trust model as `useBuzzWorkflow`/`useBuzzPurchase` (the host resolves the viewer from the block token — the block never touches the balance API). Fetches on mount; `refetch` for on-demand refreshes.
const { balance, loading, error, refetch } = useBuzzBalance();
// `balance` is null until the first successful fetch. refetch() after a
// generation debits it. An anon viewer / missing scope / host failure → `error`.
if (!loading && balance) console.log(`Yellow: ${balance.yellow}`);useViewer
useViewer(): UseViewerThe signed-in viewer as an on-demand authoritative self-read (`{ id, username, status, buzzBudget }`) — distinct from `useBlockContext().viewer`, the coarse `BLOCK_INIT`-time snapshot. `status` is `'active' | 'muted'`; `username` (`string | null`) and `buzzBudget` (`number | null`) are present-but-nullable, so handle the null case. Host-mediated over `GET_VIEWER` → `VIEWER_RESULT` (the host resolves the viewer from the block token via `blocks.getMyViewer`); an anonymous / banned viewer comes back as `error`. Fetches on mount; `refetch` for on-demand refreshes.
const { viewer, loading, error, refetch } = useViewer();
// `viewer` is null until the first successful fetch. An anon / banned viewer,
// missing scope, or host failure → `error`. `username`/`buzzBudget` may be null.
if (!loading && viewer) console.log(`${viewer.username ?? 'anon'} · budget ${viewer.buzzBudget ?? 0}`);useBuzzTransactions
useBuzzTransactions(params?: BlockBuzzTransactionsParams): UseBuzzTransactionsThe signed-in viewer's Buzz-transaction ledger (a paged, host-projected read of the Buzz dashboard). Returns `{ transactions, cursor, loading, error, refetch }`; `transactions` rows are rehydrated so `date` is a `Date`. Pass the returned `cursor` back as `params.cursor` to page forward. Requires the `buzz:read:self` scope; host-mediated over `GET_BUZZ_TRANSACTIONS`.
const { transactions, cursor, loading, error } = useBuzzTransactions({ type: 'Tip', limit: 20 });
if (!loading && transactions) transactions.forEach((t) => console.log(t.type, t.amount, t.date));useBuzzAccounts
useBuzzAccounts(): UseBuzzAccountsThe viewer's all-pool Buzz balances — the three spendable pools **plus** the creator payout pools (`{ accountType, balance }[]`), a superset of `useBuzzBalance`. Returns `{ accounts, loading, error, refetch }`. Requires `buzz:read:self`; host-mediated over `GET_BUZZ_ACCOUNTS`.
const { accounts, loading, error } = useBuzzAccounts();
if (!loading && accounts) accounts.forEach((a) => console.log(a.accountType, a.balance));useDailyCompensation
useDailyCompensation(params: BlockDailyCompensationParams): UseDailyCompensationPer-modelVersion generation-compensation for the month containing `params.date` (Buzz totals + cash totals in pennies). Returns `{ resources, hasPublishedResources, loading, error, refetch }`. Requires `buzz:read:self`; host-mediated over `GET_DAILY_COMPENSATION`.
const { resources, hasPublishedResources } = useDailyCompensation({ date: '2026-07-01' });useWildcardPack
useWildcardPack(modelVersionId: number): UseWildcardPackImport a wildcard pack's parsed prompt lists by model version — the host resolves + fetches + unzips + parses it **in the user's own page session** (every download gate enforced), so the untrusted iframe never sees the bytes. Returns `{ pack, loading, error, refetch }`. On failure `error` is a `WildcardPackError` with a discriminated `code` (`not-found` / `forbidden` / `too-large` / `parse-failed` / `busy` — `busy` is retryable), not free text.
const { pack, loading, error, refetch } = useWildcardPack(modelVersionId);
// `error.code === 'busy'` is retryable — call refetch(); the other codes are terminal.
if (error instanceof WildcardPackError && error.code === 'busy') void refetch();
if (!loading && pack) console.log(Object.keys(pack.lists));useCollectionFollow
useCollectionFollow(): UseCollectionFollowFollow / unfollow a collection **for the viewer**, host-mediated over
`SET_COLLECTION_FOLLOW`. Returns `{ setFollow, pending, error }`.
**No block scope, and no token on the wire.** The host calls the session-authed
`collection.follow` / `collection.unfollow` procedures, which self-bind to the
viewer server-side — `collectionId` is the only thing a block influences.
🔴 **Every call opens a host-chrome consent confirm naming the collection**, and
that click is the *only* consent this path has ever had: the HTTP predecessor's
`collections:write:self` scope is consent-exempt server-side and prompted nobody.
Moving to this bridge **tightens** the flow; what it gives up is the manifest
`scopes` declaration a moderator reads before install. The host resolves the
collection's name itself (there is no `name` field on the wire, deliberately) and
bounds that to **20 distinct ids per block instance** — past the cap it refuses
with `collection-unavailable`, the same code a collection the viewer cannot see
gets.
`setFollow` **rejects** with a `CollectionFollowError` on every non-success. Two
of those are not failures to render:
| | meaning | what to do |
|---|---|---|
| `err.declined` | the viewer dismissed the confirm — **no write occurred** | revert, say nothing |
| `err.signInRequired` | no session | route into `useRequestSignIn()` |
| `err.timedOut` | no reply arrived within the 10-min consent bound | 🔴 **check this BEFORE `.message`** — it also has no `.code`, and its message is an SDK-internal string. It does **not** mean no write occurred; re-read your state |
| `err.code` set otherwise | a host refusal (`invalid-request` / `review-mode` / `not-ready` / `collection-unavailable`) | show or ignore per case |
| `err.code === undefined` **and** `!err.timedOut` | a **server** message the host forwarded verbatim | show `err.message` |const { setFollow, pending } = useCollectionFollow();
const { requestSignIn } = useRequestSignIn();
async function toggle() {
try {
const result = await setFollow({ collectionId, follow: !followed });
setFollowed(result.followed); // adopt the host's echo, not the guess
} catch (err) {
if (err instanceof CollectionFollowError) {
if (err.signInRequired) return requestSignIn();
if (err.declined) return; // the viewer said no — say nothing
if (err.timedOut) return showToast('Still working — check back in a moment.');
showToast(err.message); // a real server message, safe to render
}
}
}useCreatePostFromApp
useCreatePostFromApp(): UseCreatePostFromAppPublish a **real, published Post on the viewer's profile** from this app's own
outputs, host-mediated over `CREATE_POST_FROM_APP`. Returns
`{ createPost, pending, error }`.
The strictly-more-consequential sibling of `usePublishGenerationOutputs()`: that
one makes a bare `Image` row with no post, no feed presence, no reward and no
notification; this one makes **public, feed-visible, reward-earning content under
the viewer's byline**.
🔴 **Requires the `posts:write:self` scope**, which is **sensitive** and
**consent-gated**. Declare it in your manifest *with* a `scopeJustifications`
entry — the server rejects the manifest at submit without one — and expect the
viewer to be prompted to grant it before the first call succeeds.
🔴 **The grant is not the consent.** Every call opens a host-chrome confirm, and
what it shows is the **server's** resolution of your request, never your strings:
the tag names that will *actually* be applied, host-fetched model and version
names for a gallery attach, and real thumbnails. A block cannot show one post and
publish another.
🔴 **No arm of `sources` takes a URL.** Name a workflow from this app's own
subqueue plus indexes into its outputs, or `Image` ids from a previous
`usePublishGenerationOutputs()` publish. The server re-verifies both — ownership,
this app's provenance marker, and that the image is not already in a post.
⚠️ **Posting a published image removes it from this app's own grid.** The
app-scoped read behind `useGatedImages()` is conjoined with `postId IS NULL`, so
an image that joins a post stops resolving there. An app cannot both keep an
image in its shared grid and let the viewer post it — design around it.
Text is advisory: the server bounds `title`/`detail`, screens them, refuses a
`detail` containing a link, and resolves `tags` against **existing** tags only (a
name matching no tag is dropped, never minted, and is shown to the viewer on the
confirm).
`createPost` **rejects** with a `CreatePostError` on every non-success:
| | meaning | what to do |
|---|---|---|
| `err.declined` | the viewer dismissed the confirm — **no post was created** | revert, say nothing |
| `err.signInRequired` | no session | route into `useRequestSignIn()` |
| `err.timedOut` | no reply arrived within the 10-min consent bound | 🔴 **check this BEFORE `.message`** — it also has no `.code`, and its message is an SDK-internal string. It does **not** mean nothing happened; tell the viewer to check their profile and never retry automatically |
| `err.code` set otherwise | a host refusal (`review-mode` / `block is not ready` / `no images to post` / `no block token`) | show or ignore per case |
| `err.code === undefined` **and** `!err.timedOut` | a **server** message the host forwarded verbatim (rate limit, blocked title, refused gallery attach) | show `err.message` |const { createPost, pending } = useCreatePostFromApp();
const { requestSignIn } = useRequestSignIn();
async function share() {
try {
const post = await createPost({
sources: [{ kind: 'workflow', workflowId: w.workflowId, imageIndexes: [0, 2] }],
title: 'Made with Sticker Studio',
});
showToast(`Posted! ${post.url}`);
} catch (err) {
if (err instanceof CreatePostError) {
if (err.signInRequired) return requestSignIn();
if (err.declined) return; // the viewer said no — say nothing
if (err.timedOut) return showToast('Still working — check your profile.');
showToast(err.message); // a real server message, safe to render
}
}
}useAppWorkflows
useAppWorkflows(params?: AppWorkflowsParams): UseAppWorkflowsThe calling app's **own** generator subqueue — the tag-scoped list of generations **this app** produced for the viewer (newest-first), plus a fail-closed `cancel`. The host self-binds the account off the block token and **forces** the per-app tag filter, so a block only ever sees the queue it produced — never the viewer's personal queue or another app's. Returns `{ workflows, cursor, loading, error, refetch, cancel }`; each `AppWorkflow` is `{ workflowId, status, images[], cost, createdAt }`. Pass the returned `cursor` back as `params.cursor` to page forward. Requires `ai:write:budgeted` (same trust boundary as submit); host-mediated over `QUERY_APP_WORKFLOWS` / `CANCEL_APP_WORKFLOW`. `cancel(workflowId)` sends `CANCEL_APP_WORKFLOW`, resolves once the host confirms the terminal state (which is optimistically spliced into `workflows` in place — no refetch round-trip), and rejects with the host's error on failure.
const { workflows, cursor, loading, error, refetch, cancel } = useAppWorkflows({ limit: 20 });
if (!loading && !error) {
workflows.forEach((w) => console.log(w.workflowId, w.status, w.images.length, w.cost));
}
async function onCancel(id: string) {
try {
await cancel(id); // optimistically flips the row to `canceled`
} catch (err) {
console.error('cancel failed', err);
}
}useAppStorage
useAppStorage(): UseAppStoragePer-(block instance, viewer) KV datastore, host-mediated. 64 KB per value, 50 MB + ~1M rows per app.
const storage = useAppStorage();
await storage.set('key', { any: 'json' }); // throws "PAYLOAD_TOO_LARGE" over a limit
const v = await storage.get<MyShape>('key'); // null if unset / anon
await storage.delete('key'); // idempotent
const { keys } = await storage.list({ prefix: 'note-' });
const quota = await storage.getQuota(); // { usedBytes, rowCount, limitBytes, limitRows }useSharedStorage
useSharedStorage(): UseSharedStorageApp-scoped, append-only, community-votable SHARED datastore (every viewer sees the same list). Sibling of `useAppStorage`; anonymous viewers get the read path and a hard reject on mutations.
const shared = useSharedStorage();
const { key } = await shared.append({ title: 'Add dark mode', body: 'please' });
const { items } = await shared.list({ limit: 20 }); // newest-first
const count = await shared.vote(key); // idempotent up-vote
await shared.unvote(key);
await shared.withdraw(key); // remove my own entryuseCheckpointPicker
useCheckpointPicker(): {
open: (opts: {
/**
* Ecosystem key (e.g. 'Flux1', 'SDXL'). Get it from
* `useBlockContext().context.checkpoint?.baseModel` — but for the
* picker filter the host will collapse to the ecosystem family, so
* any baseModel in the family works as a hint.
*/
baseModelGroup: string;
/** Currently-selected versionId so the picker can pre-highlight it. */
currentVersionId?: number;
}) => Promise<{
selected?: BlockCheckpointInfo;
}>;
persist: (versionId: number | null) => Promise<void>;
}Drive the platform Checkpoint picker + persist a viewer override.
const { open, persist } = useCheckpointPicker();
const { selected } = await open({ baseModelGroup: 'SDXL', currentVersionId });
if (selected) await persist(selected.versionId); // null clears the overrideuseResourcePicker
useResourcePicker(): {
open: (opts: {
/** Which resource type to pick. v1: `'Checkpoint' | 'LORA'` only — the
* host rejects any other type (the modal never opens). */
resourceType: BlockResourcePickerType;
/**
* Optional base-model family hint — an ecosystem key (e.g. 'Flux1', 'SDXL')
* OR a baseModel name (e.g. 'Flux.1 D'); the host collapses it to the
* ecosystem family. Use the chosen checkpoint's `baseModel` to constrain a
* LoRA pick to the same family. Omit for an unconstrained pick of the type.
*/
baseModelGroup?: string;
}) => Promise<BlockResourceInfo | null>;
}Drive the platform resource picker for page blocks — `'Checkpoint' | 'LORA'`. The viewer searches in host chrome; the block only ever sees the one resource it picked. DISCOVERY ONLY — the returned `versionId` is re-validated + re-priced server-side at estimate/submit.
const { open } = useResourcePicker();
const picked = await open({ resourceType: 'LORA', baseModelGroup: 'SDXL' });
if (picked) {
const versionId = picked.versionId; // feed into body.additionalResources
const weight = picked.strength; // recommended default weight (may be undefined)
}useImageUpload
useImageUpload(options: {
purpose: 'generationSource';
}): {
open: () => Promise<BlockGenerationSourceImageInfo | null>;
}Host-mediated image upload — the host opens its native upload modal and the iframe never handles the bytes. Resolves with a moderated image (or `null` on dismiss); pass `{ purpose: 'generationSource' }` for an unscanned img2img source or `{ asyncScan: true }` for the early-resolve + `scanStatus()` flow.
const { open } = useImageUpload();
const img = await open(); // BlockUploadedImageInfo | null
if (img) {
await submit({
kind: 'textToImage',
modelId,
modelVersionId,
sourceImage: { url: img.url, width: 1024, height: 1024 },
params: { prompt },
});
}useGenerationResources
useGenerationResources(): {
fetch: (versionIds: number[]) => Promise<BlockResourceInfo[]>;
}Rehydrate a saved set of generation resources by version id — WITHOUT re-opening the picker. Returns the same widened projection `useResourcePicker` yields (recommended weights, trigger words, clipSkip). DISCOVERY ONLY.
const { fetch } = useGenerationResources();
const resources = await fetch([691639, 666002]); // by saved versionIds
const first = resources[0]; // .versionId / .strength / .trainedWords / .clipSkipuseCivitaiNavigate
useCivitaiNavigate(): {
navigate: (path: string, target?: 'current' | 'new_tab') => void;
}Request a navigation within civitai.com (host-mediated; fire-and-forget).
const { navigate } = useCivitaiNavigate();
navigate('/models/12345', 'new_tab'); // 'new_tab' needs allow-popups* in the manifest sandboxuseBlockAnalytics
useBlockAnalytics(): {
track: (eventName: string, properties?: Record<string, unknown>) => void;
}Fire-and-forget event tracking into the host's analytics pipeline.
const { track } = useBlockAnalytics();
track('generate_clicked', { modelId });useRequestSignIn
useRequestSignIn(): {
requestSignIn: (payload?: {
returnUrl?: string;
}) => void;
}Ask the host to open its sign-in flow for an ANONYMOUS viewer (fire-and-forget). On sign-in the host re-inits the block with the now-authenticated viewer.
const { requestSignIn } = useRequestSignIn();
// e.g. onClick of a "Sign in to generate" button:
requestSignIn();useRequestConsent
useRequestConsent(): {
requestConsent: (payload?: {
scopes?: string[];
}) => void;
}Lazy consent: ask the host to open its consent UI when a LOGGED-IN viewer takes an action whose consent-gated scope the block token is missing (e.g. Generate needs `ai:write:budgeted` but the viewer hasn't granted it). Fire-and-forget — on grant the host pushes a new token; observe `useBlockToken().scopes` and retry.
import { useRequestConsent } from '@civitai/blocks-react';
const { requestConsent } = useRequestConsent();
requestConsent({ scopes: ['ai:write:budgeted', 'buzz:read:self'] });useConsentUnavailable
useConsentUnavailable(): UseConsentUnavailableSome environments withhold a scope at mint (a dev-tunnel preview token, a surface that carries no money scope), so no consent round-trip can ever add it. The host then pushes an uncorrelated `CONSENT_UNAVAILABLE` — *not* a reply, because `REQUEST_CONSENT` carries no `requestId`. Consume it and stop telling the user to retry something that can't succeed:
import { useConsentUnavailable, useRequestConsent } from '@civitai/blocks-react';
function ConsentAwareGenerate() {
const { requestConsent } = useRequestConsent();
const { refusal, reset } = useConsentUnavailable();
// 🔴 Branch on `refusal !== null`, NEVER on `refusal.scopes.length`. The host
// refuses on its own unfiltered set but names only scopes in the public
// vocabulary, so `scopes: []` is a legitimate refusal — gating on the length
// silently drops the very message you subscribed for. Use the names for copy.
if (refusal) {
return (
<div>
<p>Generating isn't available on this page.</p>
<button onClick={reset}>Try again</button>
</div>
);
}
// 🔴 `scopes` is REQUIRED for a refusal to ever arrive — see above.
return <button onClick={() => requestConsent({ scopes: ['ai:write:budgeted'] })}>Generate</button>;
}useDomainMaturity
useDomainMaturity(): DomainMaturityRead the maturity ceiling in force for the current viewer, so a block can hide/blur mature affordances. **Fail-closed SFW** until `BLOCK_INIT` lands or against a host that projects no ceiling.
const { isSfw, isLevelAllowed } = useDomainMaturity();
const showRSlider = isLevelAllowed(BrowsingLevel.R); // false on a SFW domainuseTip
useTip(): UseTipSend a Buzz TIP from the viewer through the block-token-gated `POST /api/v1/blocks/tip` REST endpoint (scope `social:tip:self`). Direct-fetch (bypasses the postMessage bridge) against the VALIDATED host origin (`useHostOrigin()`) with the block bearer token (`useBlockToken().raw`) — the same security-reviewed pattern as {@link useGenerationResources}. The SENDER is always the token subject (server self-binds it); the block never supplies a `fromUserId`. IDEMPOTENCY: pass a stable `options.idempotencyKey` to make a retry-after- timeout safe (the server replays the first terminal result). Omitting it mints a fresh key per call, so each call is a distinct logical tip.
const { tip, loading, error } = useTip();
const key = React.useId(); // stable across this component's retries
await tip({ toUserId: 123, amount: 50, entityType: 'Image', entityId: 99 }, { idempotencyKey: key });useTipAllowance
useTipAllowance(): UseTipAllowanceRead the viewer's REAL remaining daily tip allowance `{ cap, spent, remaining }` through the block-token-gated `GET /api/v1/blocks/tip-allowance` REST endpoint (scope `social:tip:self` — the SAME scope the app already holds to tip, so no manifest change). Direct-fetch against the validated host origin with the block bearer token, the same pattern as {@link useGenerationResources}. Lets a block show a genuinely-tracked remaining allowance and disable the tip button at the true ceiling — instead of a dead client-side full-cap guess (`localStorage` is inert in the opaque-origin sandbox). Fetches once on mount and exposes `refetch` (call it after a successful `useTip().tip(...)`).
const { allowance, refetch } = useTipAllowance();
// allowance?.remaining — Buzz the viewer may still tip todayusePublishGenerationOutputs
usePublishGenerationOutputs(): UsePublishGenerationOutputsPublish selected outputs of one of the calling app's OWN generations into bare, real-scanned public `Image` rows via the host-mediated `PUBLISH_GENERATION_OUTPUTS` → `PUBLISH_RESULT` bridge. Token-bound + fail-closed: the host self-binds the account off the block token, re-derives (viewer, app, workflowId) ownership before reading the workflow, and re-uploads + FULL-scans each selected output server-side (no url ever crosses from the iframe). The result is a set of bare (post-less) scanned `Image` row ids — no Post, no gallery attach, no rewards/notifications. Host-chrome shows a consent confirm before anything is published, and because that confirm waits on a human the request carries {@link HUMAN_INTERACTION_TIMEOUT_MS}, not the default protocol timeout.
const { publish } = usePublishGenerationOutputs();
const imageIds = await publish({ workflowId: w.workflowId, imageIndexes: [0, 2] });
// …store imageIds via useSharedStorage() so the grid can read them back gated.useGatedImages
useGatedImages(): UseGatedImagesRead per-viewer gated display data for a list of image ids via the host-mediated `GET_IMAGES_BY_IDS` → `IMAGES_RESULT` bridge — the read side of a cross-user image grid (e.g. ids stored via `useSharedStorage()`). The host applies the requesting viewer's browsing-level clamp server-side and returns each image as `visible` (url, plus a rating UNLESS it is the viewer's own not-yet-rated image) or `hidden` (NO url — above ceiling / flagged / scan-refused / someone else's unrated image). This is the load-bearing cross-user moderation boundary: an unclamped edge URL never crosses to a viewer who can't see the image, and the block must render a placeholder for any `hidden` entry. 🔴 `nsfwLevel` AND `contentRating` ARE OPTIONAL, AND A MISSING ONE IS NOT "G". They are absent exactly when `ratingPending` is present. Treating absent as a safe default is the bug this state exists to stop: an image published seconds earlier came back `hidden` under the old two-state contract and a grid rendered it as *"Hidden — rated mature"*, a maturity claim about an image nothing had rated, which a page reload then contradicted.
const { getImages } = useGatedImages();
const images = await getImages([101, 102, 103]);
for (const image of images) {
if (image.status === 'hidden') renderPlaceholder(image.imageId);
else if (image.ratingPending) renderStillProcessing(image.url); // NO rating to show
else renderRated(image.url, image.contentRating);
}useSaveImage
useSaveImage(): UseSaveImageDownload an image via the host-mediated `SAVE_IMAGE` → `SAVE_IMAGE_RESULT` bridge. See {@link SaveImageInput} for the url-vs-id security posture.
const { saveImage } = useSaveImage();
// block's own generation output (origin-allowlisted host-side):
await saveImage({ url: output.url, filename: 'my-render.png' });
// a cross-user grid cell (routed through the gated per-viewer read):
await saveImage({ imageId: cell.imageId });useDirectLoad
useDirectLoad(options?: UseDirectLoadOptions): booleanDetect a DIRECT (unembedded) top-level load of a block and, after a short grace period, report it so the SDK can show an "Open on Civitai" fallback instead of hanging on the perpetual loading state. Returns `true` ONLY when BOTH hold: 1. The block is TOP-LEVEL (`window.self === window.top` — not in the host iframe), AND 2. No `BLOCK_INIT` has landed (`ready` is still `false`) within `timeoutMs`. This is precise by construction: - An EMBEDDED block (framed) is never top-level → always `false`, even before `ready`. The embedded happy path is untouched. - The dev harness / `createMockHost` runs the block top-level BUT posts `BLOCK_INIT` immediately (a `setTimeout(0)` macrotask), so `ready` flips long before `timeoutMs` and the timer is cleared → always `false`. The dev flow is untouched. - A real direct load (nobody sends `BLOCK_INIT`) stays top-level + not-ready past `timeoutMs` → `true`. Once `ready` flips it stays authoritative: this can never return `true` while `ready` is `true`, so a late init can't leave a stuck fallback.
Workflow bodies: the kind union
estimate() and submit() both take a full WorkflowBody — a discriminated union keyed by kind. The hook forwards the body to the host verbatim and never reads member-specific fields, so every member flows through the same estimate → submit → watch lifecycle shown above.
As of the pinned @civitai/app-sdk@0.45.0 the union has three kind values, and kind: 'step' is itself two arms — four members in all:
kind | what it runs | what your block sends |
|---|---|---|
textToImage | a Civitai checkpoint (plus optional LoRAs / img2img) | modelId + modelVersionId + params |
customComfy | a ComfyUI workflow — a server-registered recipe, or your own graph | a registered recipe id, or mode: 'inline' plus the graph itself |
step (step present) | a server-registered orchestrator step (convert-image, chat-completion) | a registered step id + bounded params |
step (step omitted) | an orchestrator step type named directly, input forwarded unmodified | a $type + input + a maxBuzz ceiling |
Narrow on body.kind before touching member-specific fields — and note that kind === 'step' alone leaves both step arms in play, so narrow further on whether step is present. The full field tables are in the generation bridge reference.
kind: 'customComfy' — ComfyUI from a block
customComfy has two arms, selected by mode. This is the member most often missed, because the generated example above never shows it.
Recipe arm — mode omitted (or 'recipe'). Your block names a server-registered, code-reviewed workflow and passes bounded params; the server owns the graph:
import type { WorkflowBodyCustomComfyRecipe } from '@civitai/app-sdk/blocks';
const body: WorkflowBodyCustomComfyRecipe = {
kind: 'customComfy',
recipe: 'starter-comfy-txt2img', // a SERVER-registered id — unknown ids are rejected fail-closed
params: {
prompt: 'a serene alpine lake at golden hour',
// seed?: number | null — omit to let the orchestrator pick
},
};Inline arm — mode: 'inline' (required). Your block ships the ComfyUI graph itself, plus a declared resources manifest and a maxBuzz ceiling:
import type { WorkflowBodyCustomComfyInline } from '@civitai/app-sdk/blocks';
const body: WorkflowBodyCustomComfyInline = {
kind: 'customComfy',
mode: 'inline', // REQUIRED, and exactly this value — see below
workflow: {
// the ComfyUI `/prompt` graph, keyed by node id — the shape
// ComfyUI's "Save (API Format)" export produces
'3': { class_type: 'KSampler', inputs: { seed: 42, steps: 20 } },
},
resources: ['urn:air:sdxl:checkpoint:civitai:101055@128078'],
maxBuzz: 50, // integer 1…250, and ALSO the step timeout in seconds
};Three things trip up a first attempt, all covered in the guide:
mode: 'inline'is required. Including aworkflowkey does not select the arm — a body withoutmoderoutes to the recipe arm and is then rejected for a missingrecipe.resourcesis a declared manifest, not an inference. Every AIR the graph names must also appear inresourcesor the submit is rejected.maxBuzzis the only spend knob, and doubles as the step timeout in seconds.
The published SDK types BOTH arms
WorkflowBodyCustomComfy is itself a union on mode, and both arms are importable from @civitai/app-sdk/blocks: WorkflowBodyCustomComfyRecipe and WorkflowBodyCustomComfyInline (plus InlineComfyNode for the graph nodes). Import them rather than declaring the inline shape locally — a hand-declared copy will drift from the SDK.
Annotate the ARM, not the union, as both examples above do. When the mode discriminant is omitted — which is the shape this page recommends — TypeScript's excess-property check runs against the whole union and accepts any key belonging to any constituent, so a body annotated WorkflowBodyCustomComfy silently tolerates a workflow key on a recipe body and you find out at submit, server-side. (Spelling mode out explicitly narrows the union to one constituent and does restore the error — but then you are carrying a field the recommended shape leaves off.) Annotating WorkflowBodyCustomComfyRecipe (or …Inline) makes it a compile error either way. Use the union only where a value genuinely holds either arm.
Still narrow on the value of body.mode === 'inline', never on whether the key is present: mode is optional on the recipe arm, so a body that merely carries a workflow key routes to the recipe arm and is rejected for a missing recipe.
The recipe arm is mod-gated; the inline arm additionally requires an app-developer account. For the graph rules, the entitlement and moderation gates, the budget model, and a runnable local example, read Comfy on Civitai (customComfy) — this section is a pointer, not a replacement.
Install
pnpm add @civitai/blocks-react @civitai/app-sdkSee the Quickstart for a full scaffold, and the message bridge reference for the protocol these hooks sit on.