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.
The fields nested inside storage are listed too, as dotted rows such as storage.collections.<name>.ownedModelVersions, where <name> stands for any collection name. On those rows Required means required within the parent object. Every collection must declare visibility. write shows as optional because the schema requires it only on a shared collection.
| Field | Type | Required | Notes |
|---|---|---|---|
$schema | string | optional | Optional JSON-Schema reference; ignored by the platform validator. |
blockId | string | required | 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 |
version | string | required | Semantic version (x.y.z, optional -prerelease). pattern: ^\d+\.\d+\.\d+(-[0-9A-Za-z.-]+)?$, minLength 1 |
name | string | required | 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 |
tagline | string | optional | 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 |
repository | string | optional | 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 by the server's manifest validation. 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 a manifest declaring "oauth" alongside "apps:storage" or any "apps:storage:*" scope is rejected by manifest validation. |
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. |
scopes | string[] | required | Capabilities the block requests. Must be a strict subset of what review grants. Each scope is lowercase colon-separated. |
scopeJustifications | object | optional | 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`, `apps:storage:publish`, `posts:write:self`, `apps:store:items:write`, `social:react: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. |
minApiVersion | string | optional | Minimum App SDK API version the block targets (informational). pattern: ^\d+(\.\d+)*$ |
buildCommand | string | optional | 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 |
outputDir | string | optional | 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 |
publicSettingsKeys | string[] | optional | Allowlist of settings keys exposed to anonymous viewers. Default (omitted) = none exposed. maxItems 32 |
assetBundleUrl | string (uri) | optional | Optional v2 surface — HTTPS URL to a hosted asset bundle. Must be a public https URL. pattern: ^https:// |
iframe | object | optional | iframe envelope. NOTE: iframe.src is SERVER-OWNED — do NOT set it; the platform stamps the canonical bundle URL during build/approve. |
bootSkeleton | boolean | optional | 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 |
page | object | optional | Full-page surface descriptor (W10). Page apps mount at /apps/run/<slug>. |
goods | object[] | 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 |
analytics | object | optional | Optional CUSTOM EVENTS declaration. `events` names every event your app may send with `useBlockAnalytics().track(name, properties)` and the properties each event may carry. Manifest-governed and REVIEW-GATED: the declaration a moderator approves is the only one the platform will accept events against, so a reviewer can see what your app tracks. Properties are typed `enum` (a declared list of string values), `number` or `boolean` ONLY — free-text strings are deliberately not supported, so a string that is not one of an enum's declared values will never be recorded. That is what keeps prompts, emails and usernames out of string properties (a `number` property is not range-limited, so do not use one to carry an identifier). Owners will see aggregates only, never individual events. The platform's manifest validator is authoritative. |
storage | object | optional | Optional APP STORAGE declaration — every collection of JSON documents your app keeps, and who may read and write each one. Manifest-governed and REVIEW-GATED: a collection that is not declared here does not exist at runtime, and the moderator sees each collection's visibility, write policy, counters, marks and indexes. Declaring any collection also requires the `apps:storage` scope; declaring a shared collection that viewers write (`write` of "own" or "anyone") also requires the sensitive, consent-gated `apps:storage:publish` scope with a `scopeJustifications` entry; and requesting either scope with nothing here for it to reach is rejected. This schema describes the WHOLE declaration, including features the platform does not accept yet: today the server's manifest validation refuses `kind` "log", `ttlSeconds`, `dailyWritesPerAuthor`, `write` "anyone" and `write` "owner" with a message saying they are not available yet, so those validate offline and are rejected at submit. Kept in lockstep with src/shared/constants/block-storage.constants.ts (a drift-guard test enforces equality); the imperative validator in that module is authoritative. |
storage.collections | object | required | Map of collection name → declaration. At least 1 and at most 16 collections. A collection name is lowercase snake_case: a letter, then up to 31 of a-z, 0-9 or _. Renaming a collection, or changing its visibility, is a NEW collection: the old one's documents are not carried over. minProperties 1, maxProperties 16, key pattern: ^[a-z][a-z0-9_]{0,31}$ |
storage.collections.<name>.kind | "docs" | "log" | optional | "docs" (the default): documents with app-chosen or server-assigned keys that can be updated in place. "log": append-only entries with server-assigned keys, never updated, trimmed to `maxEntries` per key space. RESERVED: civitai.com refuses "log" until log collections are enabled, so declare "docs" (or omit `kind`). |
storage.collections.<name>.visibility | "private" | "shared" | required | Who can read the collection. "private": a separate key space per viewer — only that viewer reads or writes their documents. "shared": one key space for the whole app — every viewer of the app reads it, including signed-out viewers. Cannot be changed later: declare a new collection instead. |
storage.collections.<name>.write | "own" | "anyone" | "owner" | "none" | optional | Shared collections only, and REQUIRED on them. Who may create, update and delete documents. "own": eligible signed-in viewers create documents, and only a document's author updates or deletes it. "anyone": eligible signed-in viewers may update or delete any document — RESERVED: civitai.com refuses "anyone" until it is enabled. "none": nobody creates, updates or deletes documents — viewers may only increment the declared `counters` and set the declared `marks`, and incrementing a counter on a document that does not exist creates an empty one authored by no viewer; a "none" collection must declare at least one counter or mark. "owner": only the app owner writes documents; viewers can still mark and count. Only "own" and "anyone" require the `apps:storage:publish` scope. RESERVED: civitai.com refuses "owner" until owner-written documents are enabled. Available today: "own" and "none". |
storage.collections.<name>.counters | string[] | optional | Shared collections only. Names of the counters each document carries — atomic tallies the app increments (for example plays). At most 8 distinct names, each lowercase snake_case: a letter, then up to 31 of a-z, 0-9 or _. A counter may not share a name with a mark on the same collection, or with the built-in `report` mark. maxItems 8, unique items, item pattern: ^[a-z][a-z0-9_]{0,31}$, item not: "report" |
storage.collections.<name>.marks | string[] | optional | Shared collections only. Names of the per-user marks a viewer can set on a document (for example vote) — at most one of each per viewer per document. A reader sees each mark's count and whether THEY set it, never who else did. At most 8 distinct names, each lowercase snake_case: a letter, then up to 31 of a-z, 0-9 or _. `report` is built in on every shared collection and must NOT be declared. A mark may not share a name with a counter on the same collection. maxItems 8, unique items, item pattern: ^[a-z][a-z0-9_]{0,31}$, item not: "report" |
storage.collections.<name>.indexes | object | optional | Fields of a document's `data` the app can filter and sort by, as a map of field PATH → type. A path is 1 to 3 segments joined by `.` (for example status, datasetId or meta.round), each a letter followed by up to 31 letters, digits or _; a field whose own name contains `.` cannot be indexed. At most 4 indexes. Types: "string" (values up to 128 bytes), "number", "boolean", "time". `key`, `createdAt` and `updatedAt` are built-in sort keys and cannot be index names. Changing an index's type is not supported: remove it and add one under a new name. maxProperties 4, key pattern: ^[A-Za-z][A-Za-z0-9_]{0,31}(\.[A-Za-z][A-Za-z0-9_]{0,31}){0,2}$, values: "string" | "number" | "boolean" | "time" |
storage.collections.<name>.showAuthor | boolean | optional | Shared collections only. When true, readers see each viewer-authored document's author USERNAME. Default false: readers learn only whether they wrote a document themselves. The numeric user id is never exposed either way. |
storage.collections.<name>.ttlSeconds | integer | optional | RESERVED — civitai.com refuses this field until expiring documents are enabled, so do not declare it yet. Intended meaning: the default lifetime of a document in this collection, in seconds. The bounds (60 to 31536000, i.e. one minute to 365 days) are provisional and may change before the field is enabled. min 60, max 31536000 |
storage.collections.<name>.maxEntries | integer | optional | RESERVED — log collections are refused by civitai.com until enabled, so this field cannot be used yet. Intended meaning, for `kind` "log" only: the most entries kept per key space, the oldest trimmed beyond it, from 1 to 10000 (omitted means 10000). The default is provisional and may change before log collections are enabled. min 1, max 10000 |
storage.collections.<name>.dailyWritesPerAuthor | integer | optional | RESERVED — civitai.com refuses this field until per-collection write budgets are enabled, so do not declare it yet. Intended meaning: for a shared collection that viewers write (`write` "own" or "anyone"; refused with "owner" or "none"), the per-viewer daily budget for creating and updating documents in this collection in place of the platform default, from 1 to 1000, shown to the moderator at review. The bounds are provisional and may change before the field is enabled. min 1, max 1000 |
storage.collections.<name>.ownedModelVersions | string[] | optional | Shared collections with `write` "own" only. Fields of a document's `data` that, when present, must hold a Civitai model version id owned by the viewer writing the document — for example ["/workflow/modelVersionId"] for a collection where viewers publish their own workflows. Each entry is a pointer of 1 to 4 segments, each "/" then a letter or _ followed by up to 63 letters, digits or _; 1 to 4 distinct pointers. A pointer names one field of nested objects: it never indexes into an array, and a step through an array or any other non-object finds nothing. On every create and update the server resolves each pointer in the new `data`: a field that is absent is allowed; a field that is present must be a positive integer naming a model version whose model the writer owns, and is otherwise refused with 403 `resource_not_owned` — a version that is someone else's, missing or deleted, and a value that is not a positive integer, are all refused the same way. The check runs before moderation and before any write budget is spent. Ownership is checked when a document is written, not afterwards: if the model later changes hands or is deleted, documents already stored keep the id. minItems 1, maxItems 4, unique items, item pattern: ^(/[A-Za-z_][A-Za-z0-9_]{0,63}){1,4}$ |
targets | object[] | 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 |
One key the platform reads that the table does not list: description
An on-site app's store listing takes its description from a top-level description string in block.manifest.json (see Store listing). The canonical schema does not declare that key yet, so it is missing from the generated table above and an editor will not suggest it. The schema does not forbid extra top-level keys, so a manifest that sets it still validates.
Correction to the storage row: not "rejected at submit"
The storage row's description comes from the canonical schema, which says the reserved storage features are "rejected at submit". They are refused when a moderator approves the version (and on a direct git push), not when civitai app submit uploads it, and so are the rules tying storage to scopes, except that the scopeJustifications entry apps:storage:publish needs is checked at upload. A version that breaks the others waits in review until approval fails. See Declare your collections.
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-formeda:b:cstring. See Scopes.
Optional fields worth calling out
category(enforced) — an optional marketplace category for the app's/appsstore 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 publichttps://URL on an origin registered in your app's OAuth-clientallowedOrigins(SSRF + origin binding); private, non-HTTPS, or off-origin values are rejected.typeandminApiVersion(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 atopacity: 1from 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 — sobootSkeleton: trueover an empty#rootis 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 inscopes. 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/appsstore 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-levelgoodsrow, because it expands nested fields understorageonly — here is the per-entry shape it does not expand (additionalProperties: false, so an unknown key is rejected):Key Type Required Bound idstringrequired ^[a-z0-9][a-z0-9_-]*$, 1–64 chars. Unique within the manifest.titlestringrequired 1–80 chars. Shown to the viewer at purchase. priceBuzzintegerrequired 2–50000 whole Buzz for a "good". An"app_unlock"is capped at 5000, not 50000 — see below.descriptionstringoptional ≤ 500 chars. kind"good" | "app_unlock"optional Defaults 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.payloadobjectoptional Opaque, copied verbatim onto the entitlement; ≤ 2048 bytes serialized. Four things the bounds don't say:
idis the entitlement key. Changing it in a later version orphans every entitlement already granted under the old id. Treat it as permanent.priceBuzzstarts 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 frompage.buzzBudgetPerGenand never consults it.kind: "app_unlock"is not enforced yet, and is still not free to declare. No access gate reads it today, so anapp_unlockentitlement 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 — and then your app enforces the gate itself, by reading the entitlement before it renders the paid experience: charging for access to the app itself is the full pattern. (Not a platform rule, but worth saying: until a platform gate lands, a listing that implies paid access is describing something your code, not the platform, has to do.) 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:priceBuzzmay 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_unlockgood per manifest (BLOCK_APP_UNLOCK_MAX_PER_MANIFEST), so "is this viewer admitted?" has exactly one answer. justificationbecomes 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 othermaxLength.)
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
goodscatalog always requires thegoods:purchase:selfscope — including a catalog whose only entry is anapp_unlock. The scope is declared once and does not move when a later version adds an unlock, which is exactly whyjustificationexists: 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
goodsdoes not by itself let you sell — the app must also declaregoods:purchase:self(andgoods:read:selfto read entitlements back, plus ascopeJustificationsentry 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 withuseEntitlementsand sell withuseGoodPurchase. 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 route under/api/v1/blocks/*, plus/api/v1/models/{id}. A few routes underblocks/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/v1routes accept as a viewer bearer.
"oauth"is a trade, not an upgrade: it gives up the block REST routes that re-verify the bearer as a block token — app storage, shared storage, the workflow routes anduser-checkpoint/set— so an app that uses app storage or generates should stay onblock-token. See Porting → choose your credential.storage(enforced) — the collections of the app's document store:storage.collectionsmaps each collection name to itsvisibility, itswritepolicy, and itscounters,marks,indexes,showAuthorandownedModelVersions, each with its ownstorage.collections.<name>.*row in the table above. A collection that is not declared here does not exist at runtime. The scopes it needs are in Scopes and consent, and how it is declared and checked is in Declare your collections.
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:budgetedmints tokens with the platform's default per-call budget, which a viewer is never asked to confirm and which can change without your app being re-reviewed. Declare the budget your generations actually need.civitai app validatewarns when it is omitted. - The server clamps the budget to the platform per-call cap, so you cannot set an unsafe value by being generous. The current default and cap are in the limits table.
- 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
maxBuzzceiling 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 fromhttps://<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
scopesmust be a subset of the app's OAuth-client allowed bits (see Scopes). - Sandbox-token allowlisting by trust tier.
outputDirtraversal — 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.