What civitai app validate proves
civitai app validate is a best-effort LOCAL mirror of the platform's approve-time validator: run before you submit, it catches most of what a moderator's build would.
The server is the source of truth
Passing validate is not a guarantee of approval — some rules exist only server-side.
What it actually checks
Three layers:
- The manifest's shape, against a vendored JSON Schema — the same schema published as the manifest reference.
- The ported semantic rules the server runs: the sandbox trust-tier allowlist,
page⇒iframe, the required iframe sub-fields, therenderModetier gate,targets[].slotIdregistry membership, and thestoragedeclaration's rules the schema cannot express. - Structural project checks — the lockfile rule, the
BLOCK_READYadvisory and the storage source advisories below.
A few checks are necessarily approximate locally: the slot registry is vendored, and per-app origin-binding and scope checks the CLI cannot see are not reproduced at all.
The lockfile rule
The one build-time rule validate mirrors, because its failure is otherwise an opaque server-side "build failed": your committed lockfile must match the package manager the platform derives from buildCommand. The platform build installs strictly from the lockfile, with no registry re-resolve fallback:
buildCommand | required lockfile |
|---|---|
pnpm run build | pnpm-lock.yaml |
yarn run build | yarn.lock |
npm run …, vite build, npx vite build, or omitted | package-lock.json |
A mismatch or a missing lockfile is a hard validate error; an extra unused lockfile is a warning. Apps with no package.json are static — the platform never installs for them, and they are never flagged.
The lockfile also has to be one
A package-lock.json must parse as JSON and declare a numeric "lockfileVersion" of 1 or more (stricter than npm ci, which tolerates a 0, string, null or missing key — shapes npm never writes); a pnpm-lock.yaml or yarn.lock must be non-empty.
An empty lockfile fails the platform build like a missing one, so touch package-lock.json is not a fix — run the package manager and commit what it writes. A lockfile that cannot be read, or is implausibly large, is never flagged.
The BLOCK_READY advisory
validate emits one advisory about the host handshake: if your manifest declares a page surface and nothing your app loads posts BLOCK_READY, it says so.
Such an app renders fine locally and is replaced by a failure card in the real host. It is a warning, never an error — it infers runtime behaviour from static text and can be wrong — so it exits 0 unless you pass --strict. civitai app submit prints the same warnings and does not block on them.
Copying civitai-host.js in is only half the fix
The emitter has to be loaded too — a <script src="./civitai-host.js"></script> in index.html, or an import './civitai-host.js'; at the top of the entry module index.html loads. An unreferenced copy does not satisfy the check.
It checks REACHABILITY where it can, and says when it can't
Starting at index.html it follows every <script src>, inline module and import it can resolve, and asks whether any of those files posts the message — so it can report "you have an emitter, but nothing loads it".
When it cannot resolve the graph — no index.html at your project root, a bundler alias (import '@/…'), a missing file, an import chain deeper than it follows — it falls back to scanning the whole tree for the text, and the warning says "it did NOT check that the file is loaded". In that mode an unreferenced emitter silences the warning and leaves the app broken.
What else to know:
- Only
@civitai/blocks-reactand@civitai/sdkcount as an ack — their iframe transports ack internally, so apage-moneyapp gets no "you have no emitter" warning.@civitai/app-sdk,@civitai/themeand@civitai/componentsdo not postBLOCK_READYand do not silence it. - An acking dependency is not an ack that fires. On
@civitai/blocks-react0.72.0 and older, the transport is built only once something calls it, and hooks such asuseBuzzWorkflowconnect only inside the functions they return. An app that uses only those hooks never sendsBLOCK_READY. The host waits 10 s, shows "This app didn't load in time", retries twice and gives up. When your lockfile resolves@civitai/blocks-reactto 0.72.0 or older and nothingindex.htmlloads calls a hook that connects at render or mount, validate warns (an error under--strict). Fix: calluseBlockContext()(orgetTransport()) in your root component, or upgrade to 0.72.2 or later (civitai/civitai-app-starters#632), where every hook exported from the package root connects at mount. Do not also addcivitai-host.js: two handshakes race. This warning stays quiet when the lockfile is missing or unreadable, when the import graph cannot be fully resolved, or when@civitai/sdkis also declared. - It reads source only — never
node_modules, build directories (dist,build,out, …) or.mdfiles, and comments are stripped. Asrcthat is a symlink into a shared package is followed. - It stays quiet when it cannot see the whole project — an unreadable file, a file over 2 MiB, a very large tree, a directory holding only a manifest, or an unresolvable entry graph whose tree contains the literal somewhere.
- It cannot prove the ack ever fires — at best, that a file
index.htmlloads mentions the message. - False alarm (your ack comes from a bundled dependency, or a file type it doesn't open)? Under
--strictin CI, drop--strictor add the ack, and report it withcivitai feedback send --about cli(Report a platform problem).
Storage checks
From civitai CLI 0.1.131, validate checks your document store declaration beyond its JSON Schema, and reads your source for calls to the store. The schema already covers the declaration's shape (names, enums, bounds, the shared-only keys, ownedModelVersions); these are the rules it cannot express. Like everything here, they are a local mirror — the server's manifest validation is authoritative.
Manifest rules — errors
Each is a refusal the server returns, with the server's message, so it is a hard error:
| Catches | field | Fix |
|---|---|---|
| A shared collection that uses one name for both a counter and a mark | storage.collections.<name> | Rename one of them. |
Collections declared without apps:storage in scopes | scopes | Add apps:storage, or remove storage. |
A shared collection that viewers write (write: "own") without apps:storage:publish | scopes | Add the scope, with a scopeJustifications entry. |
apps:storage with no collection or import declared | storage | Declare the collections under storage.collections, or remove the scope. |
apps:storage:publish with no shared write: "own" collection | storage | Remove the scope, or declare the collection it is for. |
Imports declared without apps:storage:import, or (with no collections of the app's own) without apps:storage (0.1.135 or later) | scopes | Add the scope, with a scopeJustifications entry for apps:storage:import. |
apps:storage:import with no import declared (0.1.135 or later) | storage | Declare the imports under storage.imports, or remove the scope. |
An import from this app itself, or a second import from the same app (0.1.135 or later) | storage.imports[<i>].from / storage.imports[<i>] | Declare the app's own collections instead; merge the two imports into one. |
The scope rules run only once the declaration itself has no errors: an invalid declaration declares nothing, as on the server. Where a message lists collection names, they are sorted, not in declaration order.
Collection schemas
These checks ship in civitai CLI 0.1.135. 0.1.134 and older report schema as an unknown property, so validate fails and civitai app submit refuses the manifest: upgrade the CLI. The server enforces the schema and validates it at submit either way.
From 0.1.135, a collection's schema is checked in two layers.
- The JSON Schema checks the keyword subset (any other keyword is an unknown property at its location), each keyword's shape, a
patternof at most 256 characters, apatternwith nomaxLengthof at most 1000 on the same schema, and a schema on awrite: "none"collection. - These errors carry the server's message, for the bounds the JSON Schema cannot express:
| Catches | field |
|---|---|
A schema over 16384 bytes of JSON, counted the way the server counts JSON.stringify | storage.collections.<name>.schema |
| Schemas nested more than 16 deep | the innermost schema's location |
| More than 16 patterns in one collection schema | storage.collections.<name>.schema |
| More than 32 patterns across all of the manifest's collection schemas, counting every place one appears, in the collections that are otherwise valid | storage.collections |
minLength, minItems or minimum above its maximum | the lower bound's location |
| A count keyword above 2^53 − 1 | that keyword's location |
A schema on a write: "none" collection, with the server's message beside the JSON Schema's bare false schema | storage.collections.<name>.schema |
Patterns are a warning, never an error, and passing is not parity. The server compiles a pattern as a JavaScript regular expression with the u flag. The CLI is written in Go, and Go's engine (RE2) is a different language, so neither one's verdict predicts the other's:
- A pattern RE2 cannot compile, such as a lookahead
(?=…), a backreference or\u{…}, is a warning that the CLI cannot check it offline and the server decides at submit. Valid JavaScript gets this warning too. - A pattern RE2 compiles draws nothing, but that does not mean the server accepts it:
(?i)x,\z,\pLand[[:alpha:]]compile in RE2 and are refused by the server. - The server also refuses, at submit, a pattern that can backtrack catastrophically. The CLI has no check for that.
createFakeStorage in @civitai/sdk reads patterns the way the server does, as JavaScript u-flag regular expressions, but does not run the backtracking check (see Testing).
Features not available yet — warnings
The schema describes seven features the server refuses today. validate reports each as a warning, not an error: the platform enables each one with a server-side switch, and a CLI you already installed must not refuse a manifest the server has started to accept. Until it does, treat these as blocking:
| Declared | Fix the message gives |
|---|---|
kind: "log" | declare kind "docs" (or omit kind) for now |
write: "anyone" on a shared collection | use "own" or "none" for now |
write: "owner" on a shared collection | use "own" or "none" for now |
ttlSeconds | remove ttlSeconds for now |
dailyWritesPerAuthor | remove dailyWritesPerAuthor for now (the platform default applies) |
registry (RESERVED) | remove it to keep the collection on Civitai only |
hook (RESERVED) | remove hook (the collection schema still applies) |
Each message is the server's refusal plus why the CLI only warns about it.
A
hookgets the warning only once its shape is valid. A malformed hook gets the schema's error instead, as on the server.registryandhookneed CLI v0.1.137 or later. An older CLI does not know them and rejects either one as an unknown property: an error, not this warning.An out-of-range value (
ttlSeconds: 59) gets the schema's range error instead, as on the server.These warnings do not hold back the scope rules:
write: "anyone"withoutapps:storage:publishgets the warning and the scope error, so the manifest is also right once the feature is switched on.
Source advisories — warnings
Three warnings, read from your source the way the BLOCK_READY advisory reads it (source only, comments stripped, node_modules and build output skipped). They exit 0 unless you pass --strict.
| Catches | field | Fix |
|---|---|---|
storage.collection('<name>') with a string-literal name storage.collections does not declare — every call on it is refused collection_not_declared | storage.collections.<name> (storage.collections when the name is not a valid collection name) | Declare the collection, or correct the name. |
The document store called while the manifest has neither apps:storage nor a storage declaration — every call is refused insufficient_scope | scopes | Add apps:storage and declare each collection. |
A call to useAppStorage() with neither apps:storage:read nor apps:storage:write in scopes, or to useSharedStorage() with neither apps:storage:shared:read nor apps:storage:shared:write — every call 403s in production | scopes | Add the scopes it uses, each with a scopeJustifications entry, or move off the legacy stores. |
When storage is declared but apps:storage is missing, the second advisory stays quiet: the manifest error above already says so. One scope for a legacy store silences that store's advisory, and the two stores are checked separately.
What counts as a call:
- A document-store call is
.collection((or?.collection() on a storage client: a name ending instorage/Storage(app.storage,docStorage), or an awaited call with no arguments,(await docs()).collection(…). A chain split across lines and a type argument (collection<Row>() still match. Other.collection(calls, such asPATHS.collection(id), are not the store. - Only a single string literal is checked as a name. A computed name (
collection(name), a template with${}) is not flagged. - Missed: a client held in a variable whose name does not end in
storage(const s = app.storage; s.collection('x')), or returned by a call that takes arguments or is not awaited. False alarm: a call written inside a string literal. - Test code is not read:
*.test.*and*.spec.*files, files whose name starts withfake-,test-ormock-, and anything under__tests__/,__mocks__/ortesting/. A production file named that way is skipped too. - It stays quiet when it cannot read the whole tree — an unreadable file, a file over 2 MiB, or a very large tree.
The --json result shape
{
"dir": "./my-block",
"errors": [ { "field": "iframe.sandbox", "message": "…" } ],
"ok": false,
"warnings": [ { "field": "page.buzzBudgetPerGen", "message": "…" } ]
}fieldis on every finding, nevernullor empty, semantic findings included (sandbox, justification and money-path rules).- It uses dotted paths —
blockId,iframe.sandbox,scopes[1],targets[0].slotId,scopeJustifications.<scope>. If your scripts match/-prefixed fields (/blockId), update them.
Two findings have no single manifest field:
field | meaning |
|---|---|
(root) | the manifest document — it is missing, unparseable, or the schema reports a violation at the top level (e.g. missing properties 'blockId', 'version', …). |
(project) | repository state outside the manifest — the committed lockfile, or the source tree the BLOCK_READY advisory reads. No manifest edit alone resolves these. |
ok is false on hard errors, and on warnings under --strict; the exit code matches. The JSON goes to stdout and the failure to stderr, so civitai app validate --json | jq works on a project that fails validation.
A refused path emits no object at all
Branch on the exit code before parsing
This object is written only when validation actually produced a result.
A path that does not exist, or is not a directory, writes nothing to stdout and exits 2.
$ civitai app validate ./definitely-not-here --json
Error: ./definitely-not-here: no such directory — pass the path to an App project root, or scaffold one with `civitai app create <name>`
$ echo $?
2A project directory the CLI cannot stat (unreadable, or a path component below it is not a directory), or an internal schema failure, produces no result either: no object, exit 1.
An unreadable block.manifest.json is a verdict, not one of those cases
The object is printed in full, with a single (root) finding carrying the permission denied message.
| exit | stdout |
|---|---|
0 | the object, "ok": true |
1 | the object with "ok": false for a validation verdict — including an unreadable manifest — but nothing when validation produced no result at all (an unreadable project directory, or an ENOTDIR partway down the path; also an internal schema failure, which a released binary should never hit) |
2 | nothing — the path does not exist, or is not a directory |
Reading it from a script
jq -e is the wrong tool for reading ok
It exits 1 on a JSON false and on a missing key alike, so it cannot tell a failing result from no result.
Test for an empty string instead, and read ok as a value:
out=$(civitai app validate ./my-block --json); rc=$?
case $rc in
2) echo "bad path — check the argument"; exit 2 ;;
0|1)
if [ -z "$out" ]; then
echo "no result to parse (rc=$rc)"; exit "$rc"
fi
echo "ok=$(jq -r .ok <<<"$out")" # true | false — the verdict
jq -r '.errors[] | "ERROR \(.field): \(.message)"' <<<"$out"
jq -r '.warnings[] | "WARNING \(.field): \(.message)"' <<<"$out"
;;
esacWhere to go next
- What goes in the bundle — what
app submitpackages, and what it leaves out. - Review, approval and deploy — the lifecycle after a successful submit.
- Exit codes —
civitai --helpprints the summary table for every code; the full per-code ledger is in the CLI README.