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 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. |
The four 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.
Features not available yet — warnings
The schema describes five 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) |
Each message is the server's refusal plus why the CLI only warns about it.
- 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.