Quickstart
Go from nothing to a block running in a local host simulator. About ten minutes. This covers building and running locally — publishing is a separate, closed-beta flow (see the end of this page).
Closed beta
You can scaffold, build, and run a block locally with the public packages below right now. Publishing an app to civitai.com is limited to approved builders during the closed beta — see Introduction. Everything on this page works without access except the two live modes in §2, which need it.
Prerequisites
- Node ≥ 20.
- The
civitaiCLI installed (npm install -g @civitai/cli, or Homebrew / a prebuilt binary — see the CLI reference). - A Civitai account. Not needed for §2's mock harness; needed from §2's live modes onward, and for submitting.
1. Scaffold
The civitai CLI's app create command scaffolds a correct, ready-to-build App (a Vite + React + TypeScript project wired to the App SDK), slugifying the name you pass into your blockId:
civitai app create my-appThe default template is page-money, a working generation app, and the rest of this page assumes it — the other templates are deliberately SDK-free, so they have no .env.example, no dev:harness script and none of the SDK imports below. Use --template page-vite for a React JavaScript start (Vite plus react/react-dom, no SDK) or --template static for no build at all, and --dir ./path to control the output directory. static validates and runs with no install; page-vite and page-money need an npm install first — until you have run it, civitai app validate fails on the missing package-lock.json.
Then install dependencies:
cd my-app
cp .env.example .env
npm installYou now have a project shaped roughly like this:
my-app/
├── block.manifest.json # the one required file — slug, version, scopes
├── index.html
├── vite.config.ts # base: '/' (the block is served at the subdomain root)
└── src/
├── App.tsx # your UI
├── main.tsx
└── Harness.tsx # local host simulator (dev only)2. Run it locally
The starter ships a harness — a local simulator that plays the role of the host: it posts a fake BLOCK_INIT, captures your outbound messages into a debug log, and echoes token refreshes, so you can iterate without civitai.com embedding your block.
# from your scaffolded project:
npm run dev:harness # Vite + the harness on http://localhost:5186dev:harness runs Vite with the mock host mounted, and needs no account, no token and no Buzz. To iterate against the real Civitai backend instead, mint a dev token and run npm run dev:live:
civitai app dev-token my-app --spend --budget 250 --env >> .env.development.local
npm run dev:liveBoth that and civitai app dev-tunnel need closed-beta author access — the same access civitai app submit needs. Neither waits for your app to be reviewed, or even submitted: the dev-token mint accepts a brand-new slug with no app row at all and reads the scopes from your local block.manifest.json.
🔴 For dev:live, generating for real needs two things, and author access is only the first. The token must be minted from a credential carrying the AI Services scopes — civitai login --scopes generate, or a full-scope personal API key. A default civitai login can submit an app and cannot spend, so --spend on its own still mints read-only and dev:live refuses with block lacks ai:write:budgeted scope.
Both flags above are load-bearing. --env is what writes VITE_LIVE_BLOCK_TOKEN into .env.development.local; without it dev:live has no token and fail-safes to a setup notice rather than generating. And on an unsubmitted app the server grants a flat 50 Buzz per generation — your manifest's page.buzzBudgetPerGen is not read, because there is no submitted manifest to read — which the default scaffold's own sample exceeds, so --budget is not optional there.
That last point is why the CLI route above is the one to follow. The setup notice dev:live shows also offers a one-click Set up automatically button, which mints and writes the token for you — but it cannot pass a budget, so it lands on the flat 50 and the scaffold's own sample then fails with insufficient buzz budget. Use it for a sample that fits 50; otherwise mint from the command line.
⚠️ dev-tunnel is narrower than dev:live for real generation on an app you have never submitted. That path has a third, spend-specific gate, and it is open to fewer people than author access is; when it is closed the app still renders but cannot spend. If you want real generation before submitting, use dev:live. See Local dev loop.
Match the harness origin
The harness pins a parent origin (http://localhost:5186 in the shipped page-money template), and so does VITE_BLOCK_ALLOWED_PARENT_ORIGINS. They must match, or the transport's origin allowlist drops BLOCK_INIT and the block hangs on "Loading…". If your block never leaves the loading state, check that the two agree. The key is set in .env.development, and is only a comment in .env.example — so a .env you made from that file will not carry it. Vite merges .env → .env.local → .env.development → .env.development.local with the later file winning, so a stale copy in .env.development.local outranks the shipped one. Auto-setup will not fix that for you: it writes only VITE_LIVE_BLOCK_TOKEN and CIVITAI_HOST_KEY, and leaves any origins line alone.
3. Read the block
civitai app create defaults to the page-money template, so the src/App.tsx you already have is a working estimate → consent → submit → poll app with tests that import it — don't overwrite it. What every block does first is read what the host delivered with useBlockContext() and gate its UI on ready, because the context fields are sentinel-empty until BLOCK_INIT lands. That shape, minimally:
import { useBlockContext } from '@civitai/blocks-react';
import type { BlockContext } from '@civitai/app-sdk/blocks';
// A PAGE app's context. The host's PageBlockHost sends
// { slotId: 'app.page', entityType: 'none', slug, subPath, viewerUserId,
// viewerUsername, theme }. The SDK also exports `PageSlotContext` and the
// runtime guard `isPageSlotContext()` — prefer the guard in real code, which
// checks the shape rather than asserting it. Narrowed inline here so the fields
// are visible in one place.
type PageContext = BlockContext & {
slotId: 'app.page';
slug: string;
subPath: string;
};
export function App() {
const { ready, context, viewer, theme } = useBlockContext();
if (!ready) return <div data-theme={theme}>Loading…</div>;
const page = context as PageContext;
return (
// Set data-theme on YOUR OWN root — the host can't reach into the iframe to
// set it, so any [data-theme="dark"] CSS is otherwise dormant.
<div data-theme={theme}>
<p>Hello {viewer?.username ?? 'anon'} — running {page.slug}.</p>
</div>
);
}Don't reach for ModelSlotContext here
ModelSlotContext is the model-slot narrowing: its slotId is typed 'model.sidebar_top' | 'model.below_images' | 'model.actions_extra', and it carries modelId / modelVersionId / modelName. A page app is a different surface — the host sends slotId: 'app.page' and none of those model fields — so casting a page context to ModelSlotContext compiles happily and then reads undefined at runtime. It is the same page-vs-model-slot confusion as useBlockResize below. Reach for ModelSlotContext only on the model slots, where it is genuinely correct.
useBlockResize does nothing on a page app
civitai app create scaffolds a page app (block.manifest.json declares a page key), and a page app is rendered by the host's PageBlockHost, which mounts the iframe full-viewport (flex: 1, width: 100%) and subscribes to no RESIZE_IFRAME handler at all. useBlockResize still runs its ResizeObserver and still posts the message — the host simply ignores it, so your app is sized by the surface, not by its content. It is fire-and-forget, so nothing hangs; it is just inert.
Reach for it on the model-slot surface, where IframeHost does handle RESIZE_IFRAME and clamps the height to your manifest's iframe.minHeight / iframe.maxHeight. (iframe.resizable in the manifest schema still describes itself in size-to-content terms; on a page app that wording does not apply.)
Sizing your block to whatever box it lands in — on either surface — is Responsive blocks.
A few things this snippet establishes as habits:
- Gate on
ready. Nothing incontext/vieweris trustworthy before it. viewercan benull— that's an anonymous viewer, not an error.- Theme yourself. Put
data-theme={theme}on your root; the host cannot set it from outside the iframe.
To generate media and bill Buzz, reach for useBuzzWorkflow() (estimate → submit → poll) — see the @civitai/blocks-react README for the full pattern, including the rule that your estimate must build the same params as your submit.
4. Validate the manifest
block.manifest.json is the contract the platform validates. Check it against the same rules the platform uses, any time, with the CLI:
civitai app validateThat is the path the scaffold gives you out of the box — see the CLI reference.
If you would rather fail the build than run a command, the SDK ships a Vite plugin you can add yourself. The scaffold does not wire it for you, and it needs ajv — an optional peer of @civitai/app-sdk that the scaffold does not install either:
npm install -D ajv// vite.config.ts
import { blockManifestPlugin } from '@civitai/app-sdk/vite';Then add it to the plugins array the scaffold already wrote. Append — do not replace the array, or you drop the plugins your app needs to build at all:
- plugins: [react(), civitaiSetupPlugin()],
+ plugins: [react(), civitaiSetupPlugin(), blockManifestPlugin()],It fails the build with a plain Error whose message leads with the offending field — block.manifest.json is invalid [scopes]: …. The plugin catches BlockManifestError and re-throws deliberately, because Vite prints the message and not the error's own properties, so a .field you branched on would be invisible.
Validating outside Vite
defineBlock used to live on @civitai/app-sdk/blocks. It moved to @civitai/app-sdk/manifest, a Node-only subpath — it compiles the vendored canonical schema with Ajv, which needs node:fs and so cannot sit on the browser-facing surface. Reach for it in a Node script; in a Vite app use the plugin above.
The manifest declares your blockId (which becomes your <slug>.civit.ai subdomain), version, name, contentRating, and the scopes your app requests. You omit iframe.src's hostname concerns — keep it at the subdomain root and leave Vite's base: '/'; the platform owns the subdomain and enforces it server-side.
5. Build
npm run build # → dist/ (a static SPA; skip it for the `static` template)That's a shippable bundle. Everything up to here works today with the public packages — except §2's two live modes, which need closed-beta author access.
Submitting (closed beta)
When you're ready to go live, the lifecycle is validate → submit → review. The civitai CLI packages your source tree and submits it — the platform rebuilds and deploys it, so there is no client-side deploy step:
civitai app validate # local pre-check of block.manifest.json
civitai app submit # package the source + submit for review
civitai app status # track review / deploy statecivitai app submit enters your app into moderator review — it is not published immediately. On approval the platform provisions the OAuth client, git repo, build, deploy, and <slug>.civit.ai DNS for you, and serves it at https://<slug>.civit.ai/. Submitting also creates your store listing as a draft, so you can fill in its icon and cover while you wait for review (see Store-listing media below).
The platform builds from your committed lockfile
civitai app submit packages your source tree and the platform reinstalls dependencies strictly from your committed lockfile (package-lock.json for npm/Vite, pnpm-lock.yaml for pnpm, yarn.lock for yarn — derived from your buildCommand). A missing or out-of-date lockfile is a guaranteed build failure, so commit it (and re-run your install after changing dependencies). civitai app validate flags this before you submit.
That flow is gated to approved builders during the closed beta. To request access, reach out to the Civitai team (see Introduction). See the CLI reference for every command and flag.
Store-listing media
Your store listing is the card shoppers see in the /apps store. It is created as a draft the moment you run civitai app submit — not at approval — so you can set its media while your app is still in review. Whatever you attach carries forward when a moderator approves the app, so the listing can go live the same day it's approved instead of waiting on a second round-trip.
A listing has a hard publish floor: it needs an icon and a cover before it can go live. Screenshots (up to 8) are optional. You attach all of them with the civitai app listing command group, run from your app directory (it resolves the app from block.manifest.json, or pass --slug):
civitai app listing status # what's attached + what's missing vs the publish floor
civitai app listing set-icon ./assets/icon.png # square-ish icon (required)
civitai app listing set-cover ./assets/cover.png # landscape hero image (required)
civitai app listing add-screenshot ./shot.png --caption "Grid view" # optional, up to 8
civitai app listing rm-screenshot alsc_01H... # remove one by its id (from `status`)
civitai app listing reorder alsc_02 alsc_01 alsc_03 # pass ALL screenshot ids in the new orderEach command ingests a local image, waits for the content scan, and attaches it — the same pipeline the web submit form uses. Run civitai app listing status any time to see what's attached and what's still blocking publish.
Editing a LIVE listing opens a revision
Once your listing is approved and live, attaching or changing media opens a revision that goes back to moderator review — your live listing is untouched until the revision is approved. Pass --changelog "..." to describe the change (the set-* / add-screenshot commands accept it), and -y to skip the revision confirmation prompt.
See the CLI reference for every app listing subcommand and flag.
Next
- Local dev loop — the two harness modes, which credential can spend, and generating for real before you submit.
- Concepts — the block / install / slot / trust-frame / bridge model.
@civitai/blocks-react— every hook with a snippet.@civitai/app-sdk— the framework-agnostic manifest, scope, and message contract.