Skip to content

Manifest ​

Every app ships a block.manifest.json that declares its identity, the scopes it needs, and how it renders. The platform publishes the canonical JSON Schema (Draft 2020-12) for this file at https://civitai.com/schemas/app-block/v1.json. Set that URL as your manifest's $schema for editor validation. The table below is generated from the same canonical schema — the one the @civitai/app-sdk and the civitai CLI vendor and validate against — so it never drifts from what the server accepts.

FieldTypeRequiredNotes
$schemastringoptional
Optional JSON-Schema reference; ignored by the platform validator.
blockIdstringrequired
The block slug. Becomes the canonical submission slug. Lowercase, starts with a letter, hyphen-separated, 3-40 chars.
pattern: ^[a-z][a-z0-9-]*[a-z0-9]$, minLength 3, maxLength 40
versionstringrequired
Semantic version (x.y.z, optional -prerelease).
pattern: ^\d+\.\d+\.\d+(-[0-9A-Za-z.-]+)?$, minLength 1
namestringrequired
Human-readable display name. The server only requires a non-empty string (no length cap), so the CLI does not impose one either.
minLength 1
taglinestringoptional
Optional one-line pitch shown under the app's name on its `/apps` store card + detail page. Manifest-governed: it flows to the store listing on moderator-approve and is re-synced from the manifest on every subsequent approved version. Omit it and the store simply shows no tagline. Must contain a non-whitespace character and is capped at 140 characters — the same bound off-site listings use (OFFSITE_TAGLINE_MAX), kept in lockstep with MANIFEST_TAGLINE_MAX_LENGTH in src/server/services/block-manifest-validator.service.ts (a drift-guard test enforces equality). NOTE: the server measures the TRIMMED length, while this maxLength counts the raw string — so a value padded past 140 is rejected here but accepted server-side. That asymmetry is deliberate and one-directional: this schema is never more permissive than the server, so local validation can't green-light something submit would reject.
pattern: \S, minLength 1, maxLength 140
repositorystringoptional
Optional PUBLIC SOURCE-REPOSITORY link for an open-source app, rendered as a `Source` row on the app's `/apps` store DETAIL page (never on a store grid card). Manifest-governed: it flows to the store listing on moderator-approve and is re-synced from the manifest on every subsequent approved version; remove the key and the link is cleared. This is NOT your app's internal Civitai repository — it is a link you publish. Must be an `https://` URL, carrying no embedded credentials and no port, whose host is EXACTLY one of github.com, gitlab.com or codeberg.org (an exact-host allowlist — `www.github.com`, `gist.github.com` and `raw.githubusercontent.com` are all rejected), and whose path is exactly `/<owner>/<repo>`, the repository ROOT (a deep link such as `/owner/repo/tree/main` is rejected). A trailing `/` or `.git`, a query string and a fragment are accepted and STRIPPED — the stored value is normalised to `https://<host>/<owner>/<repo>`. Capped at 200 characters, kept in lockstep with MAX_REPOSITORY_URL_LENGTH in src/server/schema/blocks/external-app.schema.ts (a drift-guard test enforces equality, and enforces that this description names exactly the hosts REPOSITORY_HOST_ALLOWLIST contains). NOTE: as with `tagline`, the server measures the TRIMMED string while this maxLength counts the raw one, so the LENGTH bound here is never looser than the server's. The `pattern`, however, is a coarse SHAPE check and NOT the whole rule, so unlike `tagline` this field CAN pass local validation and still be rejected at submit. The server additionally requires each of the two path segments to begin with an ASCII letter or digit and to contain only letters, digits, `.`, `-` and `_`, and it strips a trailing `.git` BEFORE applying that test. Values such as a repository segment that is only `.git`, one starting with a dash, one containing a percent-escape, and one containing a non-ASCII character all match this pattern and are refused by the server. The pattern is deliberately left coarse rather than tightened: the CLI and the starter templates byte-mirror this file, and a stricter regex would be a compatibility change for every vendored copy. Treat passing local validation as necessary, not sufficient — `validateRepositoryUrl` in src/server/schema/blocks/external-app.schema.ts is authoritative.
pattern: ^https://(github\.com|gitlab\.com|codeberg\.org)/[^/]+/[^/]+/?$, minLength 1, maxLength 200
type"block"optional
Free-form descriptor used by some examples (e.g. "block"). Not validated by the platform.
contentRating"g" | "pg" | "pg13" | "r" | "x"required
Content rating of the app surface.
category"generation" | "games" | "utility" | "discovery" | "moderation" | "analytics" | "other"optional
Optional marketplace category for the app's `/apps` store listing. When present it flows to the listing automatically on moderator-approve (only when a moderator has not already curated a category). Omit to let a moderator categorise the app. Must be one of the known marketplace categories — this enum is kept in lockstep with MARKETPLACE_CATEGORIES in src/server/services/blocks/marketplace-categories.constants.ts (a drift-guard test enforces equality).
renderMode"iframe" | "inline" | "hybrid"optional
How the block renders. Defaults to "iframe". "inline"/"hybrid" require a verified/internal trust tier (server-assigned), so authors should leave this as iframe.
auth"block-token" | "oauth"optional
Which credential the host hands the block: "block-token" (default when omitted) is the block-scoped JWT; "oauth" asks for an opaque OAuth access token for the block's own OauthClient, which ordinary /api/v1 routes accept as a viewer bearer. Minting it is gated on a server flag that is not generally enabled, and also requires a signed-in viewer and a declared "user:read:self" scope; when any of those is unmet the host hands back a block JWT instead of failing, so branch on the token kind the host reports rather than on this field. An OAuth token is refused by the postMessage bridge procedures, which verify a block JWT only, and declaring "oauth" alongside any "apps:storage:*" scope is refused at submit time.
trustTier"unverified" | "verified" | "internal"optional
SERVER-OWNED. Do NOT set this in your manifest — the platform assigns the trust tier during review. Present here only to reject dev-set values.
scopesstring[]required
Capabilities the block requests. Must be a strict subset of what review grants. Each scope is lowercase colon-separated.
scopeJustificationsobjectoptional
Per-scope justification: a map of scope-id → free-text rationale explaining WHY the app needs that permission, shown to the moderator during review. REQUIRED for SENSITIVE scopes — any declared scope that can spend or read the viewer's Buzz, read the viewer's private data, or write data other users see (e.g. `ai:write:budgeted`, `social:tip:self`, `goods:purchase:self`, `buzz:read:self`, `collections:read:private`, `apps:storage:shared:write`, `posts:write:self`) MUST carry a non-empty justification here, or the manifest is rejected at submit time. OPTIONAL for non-sensitive scopes — omit those and the manifest stays valid. Every key MUST be a scope also present in `scopes` (justifications for scopes you don't request are rejected). Each value is a non-empty string of at most 500 characters. The requirement is enforced imperatively by the manifest validator (not expressed as JSON-Schema conditionals here). NOTE: the justification captures the developer's STATED rationale only; the platform does not verify the truth of the claims.
minApiVersionstringoptional
Minimum App SDK API version the block targets (informational).
pattern: ^\d+(\.\d+)*$
buildCommandstringoptional
Config-as-code: command the platform runs to build the static bundle. Must be one of an allowlisted set of build invocations (defense-in-depth against shell injection): "npm run <script>", "pnpm run <script>", "yarn run <script>" (where <script> is a package.json script name), "vite build", or "npx vite build". Omit for no-build (static) apps. When set, outputDir must also be set. The pattern and max length are kept in lockstep with BUILD_COMMAND_RE / BUILD_COMMAND_MAX_LENGTH in src/server/services/block-manifest-validator.service.ts (a drift-guard test enforces equality).
pattern: ^(?:(?:npm|pnpm|yarn) run [a-zA-Z0-9:_-]+|(?:npx )?vite build)$, minLength 1, maxLength 128
outputDirstringoptional
Config-as-code: directory (relative to the project root) the buildCommand emits static files into (e.g. "dist"). Must be a safe relative path — no leading "/", no ".." path traversal, no backslash separators, and no Windows drive prefix (e.g. C:). Required when buildCommand is set. Kept in lockstep with the outputDir checks in src/server/services/block-manifest-validator.service.ts (a drift-guard test enforces equality). (The server additionally rejects a NUL byte; that impossible-in-a-manifest case is intentionally omitted here for RE2 regex portability.)
minLength 1, maxLength 256
publicSettingsKeysstring[]optional
Allowlist of settings keys exposed to anonymous viewers. Default (omitted) = none exposed.
maxItems 32
assetBundleUrlstring (uri)optional
Optional v2 surface — HTTPS URL to a hosted asset bundle. Must be a public https URL.
pattern: ^https://
iframeobjectoptional
iframe envelope. NOTE: iframe.src is SERVER-OWNED — do NOT set it; the platform stamps the canonical bundle URL during build/approve.
bootSkeletonbooleanoptional
The app's shipped index.html paints its own loading state inside #root. The full-page run host then stands down its own branded overlay and shows the iframe from mount, so the app's boot state is visible at first paint and its own render replaces it in place with no cross-fade and no reveal transform. Omit (or false) unless the app really ships one: with the overlay stood down, an empty #root is a blank iframe for the whole load. To paint in the HOST's theme rather than guessing from prefers-color-scheme, the app must also be enabled for the BLOCK_INIT URL fragment and read it before first paint; without that the theme is a guess that can disagree with the host and be corrected on BLOCK_INIT.
default: false
pageobjectoptional
Full-page surface descriptor (W10). Page apps mount at /apps/run/<slug>.
goodsobject[]optional
Optional DIGITAL GOODS catalog — entitlements the platform sells to a viewer on your app's behalf, for Buzz. Manifest-governed and REVIEW-GATED: the catalog a moderator approves is the catalog that can be sold, and changing a price means shipping a new version and being re-reviewed. The platform owns the ledger (who bought what, when, at what price, and its refund state); the good's MEANING is your app's business — read the viewer's entitlements from GET /api/v1/blocks/entitlements and keep the semantics in your own app storage. Declaring goods does not by itself let you sell: the app must also declare the `goods:purchase:self` scope (and `goods:read:self` to read entitlements back), and the viewer must consent. Sales split platform 30% / app owner 70%, paid immediately. Kept in lockstep with the bounds in src/shared/constants/block-goods.constants.ts (a drift-guard test enforces equality); the imperative validator in that same module is authoritative.
maxItems 32
targetsobject[]optional
Model-page slot targets. Each target's slotId must be a known registered model slot (not the page slot). Optional for page-only apps.
maxItems 16

Required fields ​

blockId, version, name, contentRating, and scopes are always required. iframe.src is server-owned — do not set it (see below); the platform stamps the canonical bundle URL during build/approve.

Note the tightened constraints the schema now surfaces (all server-enforced):

  • blockId — a DNS-label slug: lowercase, starts with a letter, 3–40 chars, ^[a-z][a-z0-9-]*[a-z0-9]$. It becomes <blockId>.civit.ai.
  • version — semantic version (x.y.z, optional -prerelease), not just any non-empty string.
  • scopes — each entry must be one of the known scopes (the enum in the table above), not merely a well-formed a:b:c string. See Scopes.

Optional fields worth calling out ​

  • category (enforced) — an optional marketplace category for the app's /apps store listing. If present it must be one of the enum values in the table; an unknown value is rejected at submit time. Omit it to let a moderator categorise the app.

  • assetBundleUrl (enforced) — an optional v2 surface. Must be a public https:// URL on an origin registered in your app's OAuth-client allowedOrigins (SSRF + origin binding); private, non-HTTPS, or off-origin values are rejected.

  • type and minApiVersion (informational) — accepted but not enforced by the validator. Safe to include as documentation; don't treat them as load-bearing.

  • bootSkeleton (boolean, optional) — opts your app out of the full-page run host's loading cover: no opaque veil, the iframe at opacity: 1 from mount, and no reveal settle. It is a declaration that your document already paints something, not a performance switch, and the host takes you at your word — so bootSkeleton: true over an empty #root is a blank iframe for the entire load, strictly worse than not opting in, and nothing validates that today. Never ship the key without the markup that justifies it. The markup shape, the dark-by-default theme rule and the per-framework removal step are in Running embedded → the boot skeleton.

  • scopeJustifications (enforced) — a map of scope id → free-text rationale shown to the moderator. It is not optional across the board: any declared scope the platform treats as sensitive — one that can spend or read the viewer's Buzz, read their private data, or write data other users see — must carry a non-empty justification here, or the manifest is rejected at submit time. Every key must also be a scope you actually declared in scopes. See the row in the table above for the current list and the length bound.

  • tagline (enforced) — the one-line pitch under your app's name on its /apps store card. It is manifest-governed, not a listing field you edit separately: it flows to the listing on moderator-approve and is re-synced from the manifest on every subsequent approved version, so an edit made anywhere else is overwritten by your next release.

  • goods (enforced) — an optional digital-goods catalog: entitlements the platform sells to a viewer for Buzz on your app's behalf. At most 32 entries. The table above shows only the top-level row, because it renders top-level fields only — here is the per-entry shape it cannot expand (additionalProperties: false, so an unknown key is rejected):

    KeyTypeRequiredBound
    idstringrequired^[a-z0-9][a-z0-9_-]*$, 1–64 chars. Unique within the manifest.
    titlestringrequired1–80 chars. Shown to the viewer at purchase.
    priceBuzzintegerrequired2–50000 whole Buzz for a "good". 🔴 An "app_unlock" is capped at 5000, not 50000 — see below.
    descriptionstringoptional≤ 500 chars.
    kind"good" | "app_unlock"optionalDefaults to "good". Not merely a label: "app_unlock" carries three extra rules, below.
    justificationstringrequired for "app_unlock", optional otherwise1–500 chars (BLOCK_GOOD_JUSTIFICATION_MAX_LENGTH), measured after trimming. Review metadata only — shown to the moderator, never to the viewer, and never copied onto the entitlement.
    payloadobjectoptionalOpaque, copied verbatim onto the entitlement; ≤ 2048 bytes serialized.

    Four things the bounds don't say:

    • id is the entitlement key. Changing it in a later version orphans every entitlement already granted under the old id. Treat it as permanent.

    • priceBuzz starts at 2, not 1, because the app owner's 70% share is floored — a 1 Buzz item would earn its owner nothing, permanently. The cap bounds a single purchase; a viewer also has a daily ceiling across every app, so a purchase can be refused with a clean 4xx even at a legal price. This rail is separate from page.buzzBudgetPerGen and never consults it.

    • kind: "app_unlock" is not enforced yet, and is still not free to declare. No access gate reads it today, so an app_unlock entitlement is recorded like any other and the platform does not yet refuse entry on it — selling one does not paywall your app. Set it only when you intend to charge for access. (Not a platform rule, but worth saying: until the gate lands, a listing that implies paid access is describing something the platform is not doing on your behalf.) Declaring it turns on three extra rules the platform validator enforces and the schema above deliberately does not restate, so each one validates offline and is rejected at submit:

      • priceBuzz may be at most 5000 Buzz (BLOCK_APP_UNLOCK_MAX_PRICE_BUZZ), not the 50000 the row above allows — an unlock is bought before the viewer has used the app, so it is capped at a single Buzz tip and the smallest top-up.
      • At most one app_unlock good per manifest (BLOCK_APP_UNLOCK_MAX_PER_MANIFEST), so "is this viewer admitted?" has exactly one answer.
      • justification becomes mandatory: adding an unlock turns a free app into a paid one, and a moderator has to be told why. (Its 1–500 length bound is in the row above and is not one of these three — the schema declares that one, so it fails offline like any other maxLength.)

      The constant names are given because a submit rejection quotes these bounds, and the names are what let you match an error against the platform source. They live in civitai:src/shared/constants/block-goods.constants.ts.

      Of the two caps, only one REPLACES something: the 5000 price ceiling stands in for the 50000 in the row above, because the per-good ceiling is chosen by kind. The arity cap replaces nothing — the 32-entry catalog limit is kind-blind and still applies, so a 33-entry catalog is refused whether or not one entry is an unlock.

      Declaring a goods catalog always requires the goods:purchase:self scope — including a catalog whose only entry is an app_unlock. The scope is declared once and does not move when a later version adds an unlock, which is exactly why justification exists: an app already selling ordinary items can start charging for admission with its permission set unchanged, and this is what makes that visible at review.

    The catalog is review-gated: the catalog a moderator approves is the catalog that can be sold, so changing a price means shipping a new version and being re-reviewed. Declaring goods does not by itself let you sell — the app must also declare goods:purchase:self (and goods:read:self to read entitlements back, plus a scopeJustifications entry for the sensitive purchase scope), and the viewer must consent. The platform owns the ledger; the good's meaning is your app's business — read entitlements back with useEntitlements and sell with useGoodPurchase. The split, the rounding direction, why the floor is 2, and every refusal a purchase can answer with are in How an app earns.

  • auth (enforced) — "block-token" (the default when omitted) or "oauth". This is the only field that changes which credential the host hands your block, so it belongs to a decision rather than a preference:

    • "block-token" is the block-scoped JWT. It reaches every block-token routes under /api/v1/blocks/*, plus /api/v1/models/{id}. A few routes under blocks/ are publisher tooling that takes a personal API key instead, and reject a block token. It does not reach /api/v1/me — blocks read the viewer from /api/v1/blocks/me.
    • "oauth" is an opaque OAuth access token for the block's own client, which ordinary /api/v1 routes accept as a viewer bearer.

    "oauth" is live in production (verified 2026-09-25), and it is a trade rather than an upgrade.

    🔴 "oauth" gives up 22 of the block REST routes. Not for want of identity — an OAuth token resolves to the same (app, viewer) claims. The reason is that those routes re-verify the raw bearer as a block JWS in their service layer, and an OAuth access token is not one. That covers app storage (5 routes), shared storage (11), all five workflow routes (estimate/submit/poll/cancel and query) and user-checkpoint/set — 22 in all — including the routes that carry the Buzz budget, the per-viewer and per-app caps, the maturity clamp and the attribution tag. The other 12 block routes work on either credential.

    So declare "oauth" when your app calls /api/v1, the orchestrator or the MCP directly and needs none of those 22. 🔴 An app that uses app storage cannot use "oauth" today — and validation does not yet refuse the pair, so the failure surfaces at runtime rather than at civitai app validate. An app that generates should stay on block-token and the host-proxied workflow routes. Nothing in Civitai's own fleet declares "oauth" yet, so expect to be the first to exercise it. See Porting → choose your credential.

Sizing page.buzzBudgetPerGen ​

page.buzzBudgetPerGen is a safety ceiling, not a cost estimate. It caps what a single generation your app requests is allowed to cost, so that a bug in your app — or a compromised bundle — cannot drain the viewer's Buzz. It is not a forecast of your bill.

Set it well above your worst-case run. A good rule of thumb is several times your worst case — 1000 when a run costs ~100, not 100. Headroom is free: the server re-prices every submit and charges the real price, so a generous ceiling never costs you or the viewer more.

The multiplier is generous rather than tight because the trade is asymmetric: too low breaks the app for every user until a new manifest ships and is re-approved, while too high costs nobody anything. So pick the number the way you would pick a blast radius — how large a single generation am I willing to let a compromised build request? — rather than by taking a price and adding a margin. A budget derived from one workflow's current price re-breaks the moment you add a step or call a pricier model or recipe.

Setting it to an estimate is the common mistake, and it breaks the app. The server compares the real price against your budget before anything runs, so a generation that comes in over budget is rejected outright with insufficient buzz budget — no workflow is created and no Buzz is spent, but the user gets nothing back, and it stays broken for every user until you ship a new manifest version and it is re-approved. Anything that pushes real cost up — more steps, a bigger resolution, a pricier model, a costlier recipe — turns a budget sized to today's estimate into a hard outage.

Practical notes:

  • Omitting it on a page that declares ai:write:budgeted mints tokens with a 10 Buzz fallback budget, which is below almost any real generation — so every submit fails. civitai app validate warns about this.
  • The server clamps the budget to the platform per-gen cap (1000 today), so you cannot set an unsafe value by being generous.
  • It bounds one generation only. Cumulative spend is separately capped per viewer per day and per app, so a high per-gen ceiling does not widen total exposure.
  • For Comfy recipes it is also a hard floor: a submit is rejected when the recipe's own maxBuzz ceiling exceeds the token budget.

Server-owned fields ​

Some fields appear in the schema for completeness but are owned by the platform — a value you submit is normalized or overridden server-side:

  • iframe.src — normalized and host-allowlisted at registration. You don't point this at your own host; the platform serves your app from https://<slug>.civit.ai/.
  • trustTier — always assigned by the server; a submitted value is ignored.

What the schema can't express (the validator wins) ​

The JSON Schema describes the manifest's shape and enums. The authoritative BlockManifestValidator at submit time additionally enforces semantic rules that JSON Schema can't:

  • SSRF host allowlisting on iframe.src.
  • Scope ⊆ OAuth-client — your declared scopes must be a subset of the app's OAuth-client allowed bits (see Scopes).
  • Sandbox-token allowlisting by trust tier.
  • outputDir traversal — the schema blocks a leading /; the validator also rejects .., backslashes, and other traversal/escape sequences.

In two small spots the published schema is marginally stricter than the runtime validator: it locks type to ["block"] and requires outputDir whenever buildCommand is set, whereas the validator ignores type and defaults outputDir. The server validator is the true gate — and it enforces more than the schema elsewhere (the semantic rules above) — so don't over-constrain based on the table alone.

The validator is the enforcement boundary

A manifest can pass local JSON-Schema validation and still be rejected at submit time. If the schema and the validator ever conflict, the validator wins.

Civitai Developer Documentation