README
ΒΆ
civitai CLI
Browse and download Civitai models, images, and articles β and author, validate, and submit App Blocks. Two paths in one static binary: a read/download client for the public API (reads are anonymous; downloads need a token), and the toolchain for shipping Apps (every
civitai appcommand needs one).
β οΈ Apps is in a limited, invite-only beta (pre-GA). You can install this CLI,
login, scaffold, validate, and run an app locally right now β butcivitai app submitanddev:liverequire an invite: submission anddev:liveare limited to invited beta testers while the feature is in a limited (pre-GA) beta, until Apps opens to the public.Anyone can request an invite β open a request below and we'll review it:
The command-line interface for Civitai β a single static
binary that does two things: it's a thin read/download client for Civitai's
public API (browse and fetch models, images, and articles β no account needed
to read those), and it's the toolchain to author, validate, and ship Apps.
Everything under civitai app needs a credential, including the App-store
browse commands app list / app view β see
Browse the App store.
An App is a small, sandboxed web app that runs inside Civitai surfaces (it's served in an iframe; the platform owns the build and the runtime). The CLI replaces the error-prone "hand-format a ZIP" flow: it scaffolds a correct project, validates the manifest against the platform contract, and packages/submits it for review.
New here? The Build your first App guide is the full end-to-end walkthrough.
Contents
Get started
- Install
- Quickstart: browse & download
- Quickstart: build an App Block
- Command reference β every command, one table
Author an App
- SDK packages
- The blockId
- Templates
- The host handshake (
BLOCK_READY) - Local dev loop (harness: mock vs live)
- Preview in the real host (
app dev-tunnel) - Examples
- Validate fidelity
- Submit & auth
- Submission status
- Pull your app's repository (
app pull) - Browse the App store
- App metrics
Use the API
- Browse the public API
- Download model files
- Generate β spends real Buzz
--max-costis an estimate check, not a spending cap- Silent model substitution
- Confirmation
- Image-to-image:
--imageand--ecosystem - The content flags, and why there aren't twelve
- Raw graphs:
--print-inputand--input - Waiting, downloading, and re-attaching
- Listing and cancelling workflows
- Exit codes specific to
generate
- Scripting with
--json
Reference
- Upgrading
- Global flags β colour,
--version, the update nag - Configuration
- Exit codes
- Troubleshooting β look the error message up here
- Development
- Releasing
- License
Install
Pick whichever fits β npm is the most convenient if you already have Node
(App authors usually do); Homebrew is quickest on macOS/Linux; the prebuilt
binary needs no toolchain; go install builds from source.
npm (Node)
A thin wrapper that downloads the matching prebuilt binary for your OS/arch on
install and verifies its sha256 against the release checksums.txt:
npm install -g @civitai/cli
# or run it without installing:
npx @civitai/cli --help
Homebrew (macOS / Linux)
brew install civitai/tap/civitai
Nix flake
This repo is a Nix flake,
so you can run or install civitai without a Go toolchain (works on
x86_64/aarch64 Linux and macOS):
# Run without installing:
nix run github:civitai/cli -- models search "sdxl"
# Install into your Nix profile:
nix profile install github:civitai/cli
Pin it as an input in your own flake:
{
inputs.civitai-cli.url = "github:civitai/cli";
outputs = { self, nixpkgs, civitai-cli }: {
# e.g. add to a devShell / home-manager / systemPackages:
# civitai-cli.packages.${system}.default
};
}
Prebuilt binary
Download a prebuilt binary for your OS/arch from the
GitHub Releases page (linux, macOS,
windows Γ amd64/arm64), verify it against checksums.txt, then put it on your
PATH:
tar xzf civitai_*_linux_amd64.tar.gz
sudo mv civitai /usr/local/bin/
civitai version
Go install (from source, Go 1.25+)
go install github.com/civitai/cli/cmd/civitai@latest
# installs the `civitai` binary into $(go env GOPATH)/bin
Quickstart: browse & download
Reads of the public catalog β models, model versions, images, tags,
creators, users, articles, collections β are anonymous: no login needed
for the commands in this section. Every one of them takes --json to emit the
raw API response for scripting.
What is not anonymous.
civitai downloadneeds a token, and so does everycivitai app β¦command β including the App-store browse commandscivitai app listandcivitai app view, which exit3withno token configuredwhen you have not logged in. The store endpoint keys the visible catalog off your identity, so there is no anonymous view of it. See Browse the App store.
# Search models β filter by base model, type, and sort:
civitai models search --base-model Illustrious --type Checkpoint --sort "Most Downloaded"
# --base-model works on any type, including embeddings (TextualInversion):
civitai models search --type TextualInversion --base-model "SDXL 1.0"
# Inspect a specific model or a specific model version:
civitai models get 618692
civitai model-versions get 691639
# Download a version's file(s) β SHA256-verified, streamed atomically.
# `--layout` routes each file into the right app subfolder (also `a1111`);
# `--dry-run` prints the plan without transferring. Downloads require `civitai login`.
civitai download 691639 --layout comfyui --root ~/ComfyUI
civitai download 691639 --dry-run
# Find and read articles (guides) right in the terminal:
civitai articles search --query "comfyui workflow"
civitai articles get 32680 --content
See Browse the public API and Download model files below for the full command and flag reference (images, tags, creators, collections, pagination, folder routing, base-model compatibility checks, and more).
Quickstart: build an App Block
# 1. Authenticate once (browser device login; or `civitai login --token <t>`).
civitai login
# 2. Scaffold a ready-to-build App (batteries-included page-money default).
civitai app create my-app
cd my-app
# 3. Install deps and run it locally against the mock host (no real Buzz/compute).
# `npm run dev` alone renders blank β the harness supplies the host.
npm install
npm run dev:harness
# 4. Edit your app; build it, then check the manifest before submitting.
# (the `static` template has no build step β skip `npm run build`.)
npm run build
civitai app validate
# 5. Package + submit for review (uploads with your stored token by default).
# Interactively this asks you to confirm. In CI β or any non-TTY shell β
# a token-carrying submit REFUSES without --yes rather than firing a real
# moderator-review request nobody approved. Scripts must pass it:
# civitai app submit --yes
civitai app submit
# 6. Check where your submission is in review / deploy.
civitai app status
# 7. Attach the store-listing media. An icon AND a cover are REQUIRED before the
# listing can publish β do it now, while the app is in review; it carries
# forward on approval. `listing status` shows what's still missing.
# The scaffold creates `assets/` and a README of the requirements, but NO
# images β save your own icon.png and cover.png in there first:
# icon png/jpeg/webp, <= 2 MiB, square-ish β start from 512 x 512
# cover png/jpeg/webp, <= 4 MiB, landscape β start from 1600 x 900
# Full bounds (and who checks what) in "Listing media requirements" below.
civitai app listing set-icon ./assets/icon.png
civitai app listing set-cover ./assets/cover.png
civitai app listing status
Step 7 needs artwork you supply. Every template scaffolds an
assets/directory with a README of the requirements, and deliberately no placeholder images β a placeholder passes every check and uploads cleanly, which is how a stub icon reaches a public listing. Sizes, formats and aspect ratios are in Listing media requirements.
Want to drive the real backend (real Buzz/compute) before submitting? Mint a dev token with
civitai app dev-tokenand runnpm run dev:liveβ see Local dev loop.
Submit β live.
civitai app submitenters your app into moderator review β it is not published immediately. The lifecycle is submit β review β approve β build + deploy βhttps://<blockId>.civit.ai/: that URL 404s until a moderator approves your submission and the platform builds + deploys it (a few minutes after approval). Until then, track status on/apps/my-submissions(a fresh submission sits atpending). See Submit & auth for the full flow. (And note Apps is in an invite-only beta β see the warning above.)
Enable shell completion (optional):
source <(civitai completion bash) # bash; see `civitai completion --help` for zsh/fish/powershell
SDK packages
This CLI scaffolds, validates, and submits β but the code your app actually
imports lives in two published npm packages (the page-money template wires
them for you; static and page-vite are deliberately dependency-free):
| Package | What it is |
|---|---|
@civitai/blocks-react |
The React hooks + iframe transport app authors call β useBlockContext, useBuzzWorkflow, useBlockResize, the /ui component pack, and the /testing dev hosts. Start here for the hook reference. |
@civitai/app-sdk |
The framework-agnostic contract under the hooks β manifest types, scope strings, the postMessage protocol, and the defineBlock validator (@civitai/app-sdk/blocks). |
# Already installed by the scaffold; this is the explicit install line:
pnpm add @civitai/blocks-react @civitai/app-sdk react
The full hook-by-hook reference (with snippets) lives in each package's npm README. For the end-to-end walkthrough, see Build your first App.
Command reference
| Command | What it does |
|---|---|
civitai login [--scopes <set>] [--token [<t>]] [--no-browser] |
Browser OAuth device login by default (stores auto-refreshing tokens). The default scope set grants identity + Apps submit + dev-tunnel and not Buzz-spend; --scopes generate additively grants generation + Buzz spend (needed by civitai generate and money-path dev:live). --token <t> stores a personal API key instead (not combinable with --scopes). --token with no value prints where to create a personal key (civitai.com/user/account) and how to re-run β handy when you know you want a personal key but haven't minted one yet. Config at ~/.config/civitai/config.yaml, 0600. Also reads CIVITAI_TOKEN. |
civitai whoami [--scopes] [--json] |
Verify the stored token; print the authenticated user and a Capabilities section β credential type (OAuth login vs personal API key), Read Buzz balance, and Spend Buzz β decoded from the token's scope, so a money-path dead end (a default OAuth login can't spend) is visible before dev:live β and when it can't, the output names the fix for that credential (login --scopes generate for an OAuth login, a full-scope key otherwise). --scopes also lists every granted scope; --json emits the user + credentialType/canReadBalance/canSpend/scopes (scriptable). |
civitai buzz [--json] |
Show your spendable Buzz balance (blue / green / yellow, plus a total). Needs the BuzzRead scope β a full-scope personal API key or civitai login --scopes generate; a default OAuth login token can't read it, and gets a clear message naming both fixes. --json emits {blue,green,yellow,total} (scriptable β handy for before/after diffing a dev:live spend). |
civitai app list [--kind <k>] [--category <c>] [--sort <s>] [--limit <n>] [--cursor <c>] [--json] |
Discover published Apps in the store (GET /api/v1/apps) β filter-based discovery, not free-text search. Needs a credential (civitai login or CIVITAI_TOKEN): the endpoint keys the visible catalog off your identity, so this is not one of the anonymous reads. Cursor-paged. See Browse the App store. |
civitai app view <slug> [--json] |
Show one published App's store detail (GET /api/v1/apps/{slug}) β description, category, rating, gallery, live/external target. Needs a credential, same as app list. Reads the public store catalog, which is a different resource from your own deploy β a not-found here says nothing about <slug>.civit.ai. See Browse the App store. |
civitai app create [name] [dir] [--template static|page-vite|page-money] [--dir <path>] [--name <display>] [--slug <slug>] [--yes] |
The friendly happy path. Scaffold a ready-to-build App, defaulting to the batteries-included page-money SDK template (default dir ./<slug>). --slug sets the blockId explicitly instead of deriving it from the name β needed when derivation refuses the name (see The blockId). -y/--yes is the non-interactive form: it never prompts, taking flags and defaults instead, and fails if a name is missing. |
civitai app init [name] [dir] [--yes] [...] |
Same scaffolder as create with a no-build static default (back-compat alias); same --yes. |
civitai app dev-token <slug> [--env] [--spend] [--budget <n>] |
Mint a short-lived (~4h) dev block token for npm run dev:live. --spend must be asked for explicitly to request real-Buzz spend β without it the CLI filters ai:write:budgeted out of the mint request. --env prints a paste-ready VITE_LIVE_BLOCK_TOKEN=<token>. See Local dev loop. |
civitai app dev-tunnel [blockId] [--block <id>] [--port <n>] [--local-host <host>] [--tunnel-endpoint <h:p>] [--idle-timeout <d>] [--ready-timeout <d>] [--no-wait] |
(Pre-GA / invite-gated) Preview your local dev server inside the real Civitai host at civitai.com/apps/dev/<blockId> β a prod-fidelity inner-dev-loop. Pre-flights whether the host can actually embed your dev server and warns (never fatally) when it cannot. See Preview in the real host. |
civitai app validate [dir] [--strict] [--json] |
Best-effort local pre-check of block.manifest.json; emits non-fatal warnings (--strict fails on them). --json emits the structured result (ok, plus errors/warnings each with field/message β field is always present and never null) for scriptable parsing β still exits non-zero on failure. π΄ BREAKING: a [dir] that does not exist, or is not a directory, is now a usage error β exit 2 with no JSON object on stdout, where it used to print {"ok": false, β¦} and exit 1. See Validate fidelity and The --json result shape. |
civitai app submit [dir] [--yes] [--package-only] [--out f.zip] [--skip-validate] |
Validate + package the source tree + upload it with your stored token (or, with no token, write the bundle + print next steps). A submit that would really upload asks for confirmation, and in a non-interactive shell it refuses without --yes β civitai app submit --yes is the CI form. (The refusal is reached only when there is a token to upload with: --package-only, and the no-token fallback that just writes the .zip, never submit and so never ask.) |
civitai app pull [dir] --app <slug|appBlockId> |
Clone (or sync) the canonical git repository behind one of your approved Apps β the read side of git authoring. β The clone URL embeds your access token, and a fresh clone persists it into .git/config. See Pull your app's repository. |
civitai app listing status|set-icon <file>|set-cover <file>|add-screenshot <file>|rm-screenshot <id>|reorder <id...> |
Attach the store-listing media your App needs before it can be published β an icon and a cover are mandatory (screenshots are optional, up to 8). listing status prints what is attached vs. what the publish floor still requires. The CLI checks format + byte size locally; dimensions and aspect ratio are checked by the platform at attach. See After you submit and Listing media requirements. |
civitai app status [blockId] [--id <pubreq>] [--json] |
Check the review/deploy status of your own submissions. No arg lists them all; a blockId (app slug) or --id shows one in detail (rejection reason if rejected, live URL once deployed). See Submission status. |
civitai app metrics <slug> [--from <d>] [--to <d>] [--json] |
Owner-only analytics for one of your Apps β installs, runs + Buzz spent, Buzz purchased, and API engagement. Always prints the window the server served (it defaults to 30 days and clamps to 366), so a zero is never ambiguous. Needs a personal API key (an OAuth login is refused). See App metrics. |
civitai app withdraw [pubreq-id] [--id <pubreq>] |
Withdraw your own pending submission (the pubreq_β¦ id from civitai app status). Frees the slug so a fresh civitai app submit can replace it. Idempotent; only a pending request can be withdrawn. See Submission status. |
civitai generate "<prompt>" [--negative-prompt <p>] [--quantity <n>] [--aspect-ratio <r>] [--checkpoint <version-id>] [--lora <version-id>[:strength]] [--image <path-or-url>] [--ecosystem <key>] [--input <file>] [--print-input] [--dry-run] [--json] [--max-cost <buzz>] [--fail-on-substitution] [--yes] [--no-wait] [--timeout <dur>] [--out-dir <dir>] [--no-download] [--force] [--external-id <key>] |
Generate images from a text prompt β this SPENDS REAL BUZZ. Prices the job with the server's estimator, shows the cost + your balance, asks before spending, submits, then waits and downloads the results. --dry-run prices it and exits without submitting; --max-cost is an estimate check, not a spending cap. Needs the AI Services scopes β civitai login --scopes generate or a full-scope personal API key; a default OAuth login is refused. See Generate for the wait/download flags, image-to-image, raw graphs, and silent model substitution. |
civitai workflows list [--limit <n>] [--cursor <c>] [--tag <t>] [--json] |
List the generation workflows you have submitted, newest first β status, when, cost, and deliverable/total outputs. Cursor-paged: the next cursor is printed on stdout when more results exist. Reading spends nothing. See Generate. |
civitai workflows get <workflow-id> [--json] |
Look up one generation workflow β status, steps and outputs. This is how you re-attach after --no-wait, a --timeout expiry or a Ctrl-C. Outputs that are blocked, unavailable or hidden are listed with the reason rather than omitted. Output URLs are presigned and expire; re-run for fresh links. Reading spends nothing. See Generate. |
civitai workflows cancel <workflow-id> [--yes] [--json] |
Stop a running generation. π΄ This does not undo the charge β a mid-run cancel bills the cost already accrued. Cancel because you no longer want the output, never to save money. Asks for confirmation (default no); --yes skips the prompt and a non-TTY without it refuses. See Generate. |
civitai upgrade [--force] |
Self-update this binary in place β resolve the latest GitHub release, verify its SHA-256 against checksums.txt, and replace the running executable. A Homebrew install delegates to brew upgrade instead; --force reinstalls anyway (and self-replaces a Homebrew install). See Upgrading. |
civitai version |
Print version / commit / build date. |
civitai completion [shell] |
Generate a shell-completion script. |
Run civitai help, civitai app --help, or civitai <command> --help for the
full details and examples.
The blockId
The blockId is your app's permanent public identity: the hostname it will be
served at once approved (https://<blockId>.civit.ai/) and the argument every
later command takes (app status, app metrics, app listing, app dev-token,
app dev-tunnel). It cannot be renamed afterwards. app create / app init
echo the one they chose, so it is on screen before you commit anything.
By default it is derived from the name: "My Cool Block" β my-cool-block.
Pass --slug <slug> to choose it yourself β it bypasses derivation entirely,
so name, blockId and directory are three fully independent axes.
Breaking change. Derivation used to lowercase the name and replace every run of non-
[a-z0-9]with a hyphen, which silently dropped characters:civitai app create "CafΓ© App"minted the blockIdcaf-app, and"ΓberApp"mintedberappβ a different permanent public id than the author typed, with no warning and exit 0. Derivation now refuses and names the offending characters, exiting 2 and asking for--slug. If you have a script passing a non-ASCII name, it must now pass--slug <slug>. The old output was wrong, so the break is the point β but it is a break.
What derivation refuses is letters, digits and marks above ASCII that the slug alphabet cannot carry. Three things still derive rather than refuse, and they are deliberate:
| input | blockId | why |
|---|---|---|
"Rocket π App" |
rocket-app |
Symbols, emoji and non-ASCII punctuation are separators β that is what makes "Widget β Pro" β widget-pro right. An emoji has no lossless ASCII form either, so refusing would only trade a silent drop for a dead end. Tracked as #272. |
"Δ°stanbul App" |
istanbul-app |
Exactly two runes above ASCII lowercase into ASCII β Δ° (U+0130) and K (U+212A). Lowercasing is what decides whether a character survives, so these transliterate for free. |
"My Cool___Block" |
my-cool-block |
ASCII is exempt by construction β every derivation that worked before still produces the byte-identical blockId. |
A name that is not valid UTF-8 is refused outright (it used to lose the bad
bytes from the blockId and write them into block.manifest.json).
Templates
staticβ a no-build page app (index.html+ a tinyapp.js,block.manifest.jsonwithpage:{}, no build step).page-viteβ a Vite + React page app with config-as-code build fields (buildCommand: "npm run build"+outputDir: "dist").page-moneyβ a Vite + React + TypeScript full-page (W10) money-path app wired to the published App SDK (@civitai/blocks-react+@civitai/app-sdk): prompt β estimate β lazy consent β submit β poll β real Buzz spend, viauseBuzzWorkflow/useRequestConsent/useBlockResize(never rawpostMessage). Ships adev:harnessmock host,.env.*allowed parent-origin config, and a unit-test stub. Runnpm run dev:harness(plainnpm run devrenders blank without a host).
Every template also scaffolds an assets/ directory holding a README of the
store-listing media requirements β and no images, so the set-icon / set-cover
step fails loudly until you supply real artwork. See
Listing media requirements.
The host handshake (BLOCK_READY)
Every template declares a page surface, and the host will not reveal a page
app until the app posts BLOCK_READY β that handler is the only transition
into the host's ready state. An app that never sends it is replaced by a visible
failure card once the host's bounded retries run out, even though the app itself
renders perfectly. Nothing you can run locally reproduces that.
page-money gets the handshake for free: @civitai/blocks-react's iframe
transport acks internally, which is why the SDK templates never touch raw
postMessage. The two SDK-free templates (static, page-vite) therefore
ship a small vendored emitter, civitai-host.js, loaded from the entry
point. Leave it in place β and if you are retrofitting it into an older app,
note that the file has to be referenced as well as copied: static loads it
with <script src="./civitai-host.js"></script> in index.html, page-vite
with import './civitai-host.js'; as the first line of src/main.jsx.
civitai app validate checks that reference where it can resolve your entry
point, and tells you when it can't.
β οΈ If you adopt
@civitai/blocks-react, deletecivitai-host.jsin the same change. This is the one situation where removing it is correct, and running both is worse than running neither: whichever handshake answers the host's firstBLOCK_INITcancels the host's retry loop and its readiness timeout. If the vendored emitter wins that race, the SDK transport can be left never having seen an init β itswaitForInitrejects after 10s and the host sits "ready", showing an app that never started, with no retry and no error card.
Two rules it encodes, which apply to every message you add afterwards:
- The envelope is
{ type, payload }. The host dispatchesevent.data.payloadto its subscribers, so fields put at the top level ({ type: 'X', height: 0 }) arrive aspayload: undefined. - Answer, don't announce. The ack goes out in response to the host's
BLOCK_INIT, addressed at the origin that init arrived from rather than broadcast to'*'. It is also why nothing is posted when you preview locally: there is no host to sendBLOCK_INIT, so the emitter stays silent by design.
π The emitter checks the sender window, not the sender's identity. It answers
window.parentβ whoever framed you β which is sound for this one message because the ack carries no data. It is not sufficient for anything you add next. The moment you handle an inbound message carrying a token, a viewer, storage or a result, checkevent.originagainst an allowlist of origins you trust, or any page that frames your app can feed it whatever it likes. The emitter deliberately does not vendor that allowlist β the real list (production, preview subdomains, dev tunnels) is platform state that moves without notice, and@civitai/blocks-reactalready maintains it fromVITE_BLOCK_ALLOWED_PARENT_ORIGINS. Adopt the SDK before you handle data.
RESIZE_IFRAME is not part of a page app's protocol: the host renders a
page block full-viewport, so it does not size to content and ignores the
message. (useBlockResize is surface-agnostic and page-money still calls it β
on a page surface it is simply a no-op, which is why the SDK templates can share
component code across surfaces.) The pre-#206 templates demoed a raw
postMessage of RESIZE_IFRAME, so a project scaffolded before that fix still
carries dead code you can delete. (This CLI's own CI fails if a shipped template
ever reintroduces it; there is no author-facing command that scans your project
for it β civitai app validate checks the manifest and the handshake, not this.)
Local dev loop (harness: mock vs live)
A scaffolded App is a sandboxed iframe, and locally there is no host to send
BLOCK_INIT β so npm run dev shows you your own UI and nothing of the
protocol. The page-money template ships a dev harness (the SDK's
@civitai/blocks-react/testing
hosts) to close that gap, with two modes:
| Command | Mode | What it does |
|---|---|---|
npm run dev:harness |
mock (default) | Mounts the SDK mock host β synthetic replies, no real Buzz, no compute, no network. Safe to spam; drive money/error/insufficient-Buzz UX via on-screen scenarios or ? URL params. Start here. |
npm run dev:live |
live | Mounts the SDK live host (createLiveHost) β forwards the App protocol to the real Civitai backend with a pasted dev token (Bearer). Spends REAL Buzz / real compute. |
β οΈ
dev:liveworks on a pending (un-approved) app. The dev-token mint (POST /api/v1/blocks/dev-token) accepts a pending slug β right after a successfulcivitai app submit(statuspending) it returns200withappId: pending-pubreq_β¦anddev:livemounts the live host against the pending app. For real generation you must mint with a credential carrying AI Services β a full-scope personal API key, or an OAuth login that opted in viacivitai login --scopes generate. A default OAuth login (civitai login, no--scopes) mints read-only (user:read:self) and cannot spend. Usecivitai buzz/civitai whoamito confirm your credential can spend before a live run, and pass--spendtodev-tokento request the spend scope explicitly β without it the CLI filtersai:write:budgetedout of the request, so a spend-capable credential still mints a token that will not generate.
Live mode needs a short-lived dev block token. Mint it with civitai app dev-token (the CLI handles the invite-gated POST /api/v1/blocks/dev-token
call with your stored credential β no hand-rolled curl) and paste it into
.env.development.local as VITE_LIVE_BLOCK_TOKEN=:
# From your scaffolded project dir (reads scopes from block.manifest.json):
civitai app dev-token my-block --env >> .env.development.local
npm run dev:live
dev-token reads the scopes to request from your local
block.manifest.json, so it works on a slug you have never submitted.
.env.development* is never committed (submit excludes it) and the token is
short-lived (~4h) β re-run dev-token when it expires. For real generation mint
with a spend-capable credential (full-scope personal API key or
civitai login --scopes generate) and add --spend: the CLI never asks for
budgeted spend implicitly β even when your manifest declares it (the
scaffolded money app does) β so without the flag the mint filters
ai:write:budgeted out and dev:live refuses to generate with block lacks ai:write:budgeted scope. --budget <n> sets the token's per-generation Buzz
budget (1β250; omit it and the server picks one β 50 for an unsubmitted app). A
default OAuth login mints a read-only token either way (the command warns you at
mint time). With no token, dev:live fails safe
(renders a notice, never spends). Live v1 covers the money path
(estimate/submit/poll/cancel); pickers, checkpoint-set, App-Storage KV,
and in-band Buzz purchase are mock-only.
Under the hood (the scaffold wires this β you don't configure it): dev:live
routes the live host's backend calls through the vite dev proxy
(server.proxy['/api']), not straight to civitai.com: createLiveHost fetches
/api/... SAME-ORIGIN against the dev server (localhost:5186), and vite proxies
that server-side to civitai with the Origin header rewritten to an allowlisted
host. This is load-bearing β a direct cross-origin fetch from localhost is both
blocked by CORS preflight and rejected by civitai's tRPC origin gate. The
same-origin proxy + Origin rewrite fixes both. VITE_LIVE_HOST_ORIGIN overrides
the proxy target (default https://civitai.com).
Which credential can spend? Spending Buzz β a real dev:live generation in
your app, or a civitai generate run from the terminal β needs the
AI Services scope. Two credentials carry it; the default OAuth login
deliberately does not:
| Credential | Can spend Buzz? (dev:live, civitai generate) |
How to get it |
|---|---|---|
| Personal API key (full scope) | β Yes β estimate β submit β generation β real Buzz | create it in the web UI at civitai.com/user/account, then civitai login --token <key> (a personal key carries AI Services) |
civitai login --scopes generate (OAuth, opt-in) |
β Yes β additive on top of the default, so it also keeps submit + dev-tunnel | civitai login --scopes generate (the civitai-cli client's allowedScopes on civitai.com includes the generate bits β see Submit & auth) |
civitai login (OAuth, default) |
β No β viewer + catalog + app storage only | the default scope set omits AI Services, so the server strips the spend scope β fine for read/identity dev:live, not for generation |
This is the single most common blocker for civitai generate: a default OAuth
login looks perfectly valid, and the refusal is a scope problem, not a login
problem β re-running plain civitai login will not fix it, but
civitai login --scopes generate (or a full-scope personal key) will.
civitai whoami shows the capability as Spend Buzz (AI Services) and names
the fix for whichever credential you have.
You can't mint a personal key over OAuth or the CLI (apiKey.add returns 403
without a full-scope session) β create it in the web UI. The dev token always grants
user:read:self, so your viewer resolves on either path. For the scope mechanics
behind this, see Submit & auth.
Env vars (VITE_BLOCK_ALLOWED_PARENT_ORIGINS, VITE_HARNESS_MODE,
VITE_LIVE_BLOCK_TOKEN, β¦) and the scenario knobs are documented in depth in the
scaffolded project's own README.md and .env.example β see
internal/scaffold/templates/page-money/README.md.tmpl.
Preview in the real host (app dev-tunnel)
(Pre-GA / invite-gated.) Access is gated behind an Apps-author invite and a server kill-switch flag, so if you are not enrolled the mint reports "not available" β ask to be added to the cohort.
The harness is a mock of the host. civitai app dev-tunnel is the other end of
that trade: it previews your local dev server inside the real Civitai
host at civitai.com/apps/dev/<blockId>, so you see prod chrome, prod sandbox
and the prod handshake against the code in your editor.
npm run dev:tunnel # start your dev server first
civitai app dev-tunnel my-block # then open the tunnel
It mints an ephemeral in-memory ssh keypair, opens a reverse tunnel from your
dev port to the Civitai tunnel endpoint (sish.civitai.com:2224, live), prints
the URL to open, and tears everything down on Ctrl-C or an idle timeout.
Publishing the tunnel host through external-dns + Cloudflare usually takes 1β3
min (occasionally longer); the command waits for it and prints the elapsed time.
Embeddability preflight. Before minting, the command checks whether the host
can actually embed your dev server. The host iframes it sandboxed, at an opaque
null origin, so a dev server that is missing Access-Control-Allow-Origin: *,
missing the .civit.ai entry in allowedHosts, or sending a framing header that
excludes civitai.com loads as a blank iframe with no error anywhere β the
worst kind of failure to debug. Those are printed as warnings, never fatal,
and deliberately twice: once the moment the checks run, so you still get them
if you Ctrl-C the DNS wait, and again just above the URL, with the
vite.config.ts fix. Apps scaffolded by civitai app create (the page-money
template) already satisfy all of it.
Flags. The defaults match what the scaffold's npm run dev:tunnel binds, so
most authors pass none of these:
| Flag | Default | What it is for |
|---|---|---|
--block <id> |
the blockId in block.manifest.json in the CWD |
The app to tunnel, if you are not standing in its project (it is also the positional argument). |
--port <n> |
5186 |
The local dev-server port. Matches the scaffold's dev:tunnel script. |
--local-host <host> |
localhost |
The host your dev server is bound to. Change this when the dev server is not on the CLI's own loopback β inside a container or pod (--local-host 10.42.0.100), a VM, or bound to one specific interface. A dev server that is reachable in your browser but not from the CLI's localhost is exactly what this flag is for. |
--no-wait |
off | Print the public URL immediately instead of waiting for it to start serving. It may 404/NXDOMAIN for a few minutes while DNS and routing propagate. |
--ready-timeout <d> |
0 (wait indefinitely) |
Cap the readiness wait. On expiry the command warns and prints the URL anyway rather than failing. |
--idle-timeout <d> |
30m |
Tear the tunnel down after this much inactivity. |
--tunnel-endpoint <host:port> |
sish.civitai.com:2224 |
The sish SSH endpoint to dial. Also settable with CIVITAI_DEV_TUNNEL_ENDPOINT; the flag wins. |
Publishing DNS takes 1β3 minutes, sometimes longer. That wait is normal and the command reports elapsed time while it happens β it is not a hang. If you Ctrl-C out of it you also lose the embeddability warnings, which is why they are printed once before the wait as well as again after it.
Examples
Two real example manifests live under examples/ (copied from the
civitai-block-* dogfood apps). Read them for manifest shape β between them
they cover the required fields, $schema wiring, the page/iframe blocks, and
scope declarations with justifications:
The values are those apps' own choices, not recommendations. In particular don't
copy buzz-generator's page.buzzBudgetPerGen β it is a safety ceiling against a
malicious or compromised app, not an estimate of one run, so size your own from the
field's description in the canonical schema
(notepad doesn't take the budgeted scope, so it has no budget at all).
Both validate clean (examples_test.go asserts this so the claim stays true) β
schema conformance only, which says nothing about whether a value is well-sized.
Browse the public API
Beyond authoring Apps, the CLI is a thin client for Civitai's public read REST
API (GET /api/v1/**). These subcommands work anonymously β no login
needed, because the data is public β but when you're logged in your stored token
is sent automatically (pass --anon to force a no-auth request). Every command
also takes --json to print the raw API JSON response for scripting.
| Command | What it does | Notable flags |
|---|---|---|
civitai models search |
Search models (GET /api/v1/models) |
--query, --tag, --username, --type, --base-model (repeatable), --sort, --period, --nsfw; paging --limit (β€100), --page, --cursor |
civitai models get <id> |
Get one model by id | --json, --anon |
civitai model-versions get <id> |
Get a model version by id (alias mv) |
--json, --anon |
civitai model-versions by-hash <hash> |
Look up a model version by file hash (AutoV2, SHA256, β¦) | --json, --anon |
civitai download <version-id> |
Download a model version's file(s) | --model, --file, --all, --out, --out-dir, --layout, --root, --for-base, --no-verify, --force, --anon |
civitai images get <id> |
Get one image by id (GET /api/v1/images?imageId=<id>) |
--json, --anon |
civitai images search |
Search images (GET /api/v1/images) |
--model-id, --model-version-id, --post-id, --username, --base-model (repeatable), --type (image/video/audio), --sort, --period, --nsfw, --meta (include generation metadata); paging --limit (β€200), --page, --cursor |
civitai tags search |
Search model tags | --query; paging --limit (β€200), --page |
civitai creators search |
Search creators | --query; paging --limit (β€200), --page |
civitai users get <username-or-id> |
Look up a user via public search (a number = exact id; a name = exact-username match, else it lists close matches) | --json, --anon |
civitai articles search |
Search articles (GET /api/v1/articles) |
--query, --tags, --username, --sort, --nsfw; paging --limit (β€100), --cursor |
civitai articles get <id> |
Get one article by id (--content renders the article body as readable text/markdown) |
--content, --json, --anon |
civitai collections search |
Search public collections (GET /api/v1/collections) |
--query, --sort, --nsfw; paging --limit (β€100), --cursor |
civitai collections get <id> |
Get one collection by id | --json, --anon |
Pagination. List commands print a compact footer with the next-page hint.
models/images support both shallow --page and deep --cursor paging (the
API caps page*limit at 1000 and 429s beyond it β prefer --cursor for deep
paging); articles/collections are cursor-only (keyset feed β no
--page); tags/creators are --page-only. Each endpoint caps --limit
(models/articles/collections 100; images/tags/creators 200).
civitai models search --query "pony" --limit 5
civitai models get 4384
civitai model-versions by-hash 5D8D26E2A6
civitai articles get 32680
civitai articles get 32680 --content # render the article body (the guide) as readable text/markdown
civitai images search --model-id 4384 --sort "Most Reactions" --json # raw JSON for scripting
Filtering by base model. --base-model is repeatable and maps to the REST
baseModels filter (an OR across the values). It's the key discovery filter for
things --type can't separate β e.g. video checkpoints all share
--type Checkpoint and are distinguished only by base model. It works on both
models search and images search:
civitai models search --type Checkpoint --base-model "Wan Video 2.2 T2V-A14B"
civitai models search --base-model Pony --base-model Illustrious --limit 20
# images too β find recent-popular images generated with a given base model:
civitai images search --base-model "Krea 2" --sort "Most Reactions" --period Week
civitai images search --type video --sort "Most Reactions" # videos only
Generation metadata (--meta). By default the image list is a compact table
without generation data (matching the API, which omits meta unless asked). Add
--meta to include each image's prompt, sampler, cfg, steps, seed, and model β
rendered as an indented detail block per image (the table can't hold a prompt).
Images whose uploader chose to hide their generation data show
meta: (hidden by uploader). With --json, --meta adds the raw meta object
to each item.
civitai images search --nsfw --sort "Most Reactions" --period Month --meta
civitai images search --model-version-id 128713 --meta --json | jq '.items[].meta'
The human table includes a BASE MODEL column (the base model each image was
generated with, when the API reports one; - when it doesn't), so you can see
the ecosystem at a glance without dropping to --json.
--sort is ignored with --model-id. The REST API returns images for a
given modelId in its own default order regardless of sort, so
images search --model-id <id> --sort β¦ prints a one-line note on stderr and the
results are NOT re-sorted. (--model-version-id is unaffected β it honours
--sort.)
Non-weights file marker. In the human (non---json) output of
models get and model-versions get, a version whose primary file is not
model weights (type != "Model") is tagged with its actual file type β e.g.
[Archive] (a "Workflows" model's downloadable deliverable), [Training Data],
or [Other] β so you can see at a glance that the version's file isn't weights.
It's purely informational: any file type still downloads. --json output is an
unchanged raw passthrough.
Download model files
civitai download fetches the file(s) of a model version. Identify the
version deterministically by its numeric version id, or resolve a model's
default (first) published version with --model:
civitai download 691639 # the version's primary file β ./<server-name>
civitai download --model 4384 # resolve model 4384's default version, then download its primary file
civitai download --model 4384 --dry-run # print the plan (files, sizes, hashes, targets) β download nothing
civitai download 691639 --out ./flux_dev.safetensors
civitai download 290640 --file vae --out-dir ./models # pick a file by name; write into a dir
civitai download 691639 --file 1234567 # pick one of two same-named files by its file id
civitai download 290640 --all --out-dir ./models # every file in the version
civitai download 290640 --all --layout comfyui --root ~/ComfyUI # route each file to its type folder
civitai download 691639 --layout a1111 --for-base "SDXL 1.0" # A1111 layout + base-model compat warning
Downloads require authentication. Every model-file download needs a token β even a small public embedding 401s anonymously. Run
civitai loginfirst. The read/search commands work anonymously; downloads do not.--anonis meaningful for the read commands, not fordownload.
Behavior:
-
Identifier β exactly one of the positional id or
--model <model-id>is required. The positional is normally a model-version id, but becausemodels search/models getprint model ids, handing one over just works: the CLI notices it is a model id and downloads that model's default version, printing anote: <id> is a model id β downloading its default version <v>line. When a number is both a valid model id and a valid version id (common for low/mid numbers), the CLI stops rather than guess, naming both interpretations β re-run with--model <id>(that model's default version),--version <id>(that version as-is), or--yesto take the version interpretation and have it echoed back.--versionnames a version id explicitly and skips the stop entirely. -
--modelresolves the default version β the model's default (first published) version; its primary file is downloaded regardless of file type. Any model type works, including atype: Workflowsmodel whose deliverable is a downloadableArchive. -
--dry-runβ resolve the version + selected file(s) and print the plan (each file's name, size, SHA256, resolved target path, and whether authentication will be required) then exit0, transferring nothing and creating no file (not even a.part). Works with--file,--all,--model,--out,--out-dir, and--layout/--root(the plan shows the routed target paths). -
File selection β defaults to the version's primary file.
--fileselects one file by numeric file id (the version'sfiles[].id) or by name (exact, else a unique case-insensitive substring; ambiguous/none errors and lists the candidate files with their ids).--alldownloads every file. -
Same-named files (no silent overwrite) β a version can ship two files that share a name (e.g. Flux Dev's fp16 and fp8, both
flux_dev.safetensors). Selecting that shared name with--fileis ambiguous and errors, listing both files with their ids β pass the numeric id to pick exactly one (--file 1234567; the id is shown by--dry-runand in the error).--allrefuses to run when two selected files would resolve to the same on-disk path (which would silently clobber one) β it fails before transferring anything, lists the colliding files with their ids/sizes, and tells you to pick one with--file <id>(or write them to separate paths). No download ever silently overwrites another. -
Output β
--out <path>sets an exact target path (single file only).--out-dir <dir>writes server-named files into a directory (works with--all). Parent directories are created as needed. Default is the server-provided filename in the current directory. -
Type-aware folder routing (
--layout) β--layout <a1111|comfyui>writes each file into the correct subfolder for that app, keyed by the file/model type, under--root <dir>(default.). This fixes the footgun where--all --out-dir Xdumps a bundled VAE into the checkpoint folder and pollutes the model dropdown: with--layout, the checkpoint lands in the checkpoints folder and the VAE in the VAE folder.--layoutis mutually exclusive with--out/--out-dir;--rootonly applies with--layout. An unmapped type (Poses, Wildcards, Archive, β¦) is written to--rootwith a stderr note rather than silently misplaced. The routed folder maps:Civitai type A1111 / Forge ComfyUI Checkpoint models/Stable-diffusionmodels/checkpointsVAE (standalone or bundled) models/VAEmodels/vaeLORA / LoCon / DoRA models/Loramodels/lorasTextualInversion (embedding) embeddingsmodels/embeddingsHypernetwork models/hypernetworksmodels/hypernetworksControlnet models/ControlNetmodels/controlnetUpscaler models/ESRGANmodels/upscale_models(Sources: the AUTOMATIC1111 wiki + the sd-webui-controlnet
models/ControlNetdefault; the ComfyUI models docs.) -
Mis-file warning (without
--layout) β when--allwould place files of differing types into one directory (the mis-file footgun), the CLI prints a one-line stderr warning naming the off-type file(s) and suggesting--layout. It's a warning, not an error; a single-type download stays quiet. -
Base model + compatibility (
--for-base) β the version's base model is always shown in the plan/output.--for-base "<baseModel>"warns on stderr when the version's base model is in a confidently different family than your target (e.g. anSD 1.5embedding like EasyNegative downloaded for anSDXL 1.0model β won't work; the wrong VAE β black images). The check is conservative β it groups the common bases into architecture families (SD1.x, SD2.x, the SDXL family [SDXL/Pony/Illustrious/NoobAI, treated loosely], SD3, Flux, video, β¦) and only warns on an architecture-level mismatch, never on near-neighbours (Pony vs Illustrious) or unclassifiable bases. -
Streaming + atomicity β the body streams to
<target>.partand is renamed into place only on success, so an interrupted run never leaves a truncated final file. Large files (10+ GB) are never buffered in memory. TTY-aware progress is printed to stderr. The Civitai download URL 302-redirects to signed storage; the CLI follows it. -
Auth β your stored login token (
civitai login) orCIVITAI_TOKENis used automatically; Civitai requires a token to download any model file, even public ones, so an anonymous download gets an actionable 401 (401β runcivitai login;403β the file is gated for your account).--anonforces no token. -
Transient-failure retry (reads) β the read endpoints (search / model / version / images / tags / creators / users / articles / collections) retry a transient
502/503/504or network error a few times with exponential backoff (with jitter), noting each retry on stderr. A429is retried only when it carries aRetry-Afterheader (a genuine throttle, honored up to a cap); a429withoutRetry-Afteris Civitai's deterministic deep-paging limit and is surfaced immediately with the hint to use--cursorinstead of--page. The download stream is not retried mid-transfer. -
Integrity (default on) β the streamed bytes are verified against the file's
SHA256; a mismatch deletes the.partand fails.--no-verifyskips it; a file with no published SHA256 downloads with a warning (not a hard failure). Note that SHA256 verifies integrity (the bytes match what the API advertised), not authenticity β it proves the download wasn't corrupted or truncated in transit, but a compromised source that advertises a matching hash for malicious bytes cannot be detected by the hash alone. Only download models from creators you trust. -
Pickle/archive safety note β when a downloaded file has a pickle/executable extension (
.ckpt,.pt,.pth,.bin,.pickle,.pkl) or an archive extension (.zip,.tar,.tar.gz,.tgz,.rar,.7z), the CLI prints a one-line stderr note: these formats can execute arbitrary code when loaded by ComfyUI/A1111/torch.load, and they land in folders those apps auto-scan.safetensorsand image files are inert and get no note. The note is informational β it never blocks the download. -
ControlNet preprocessor note β when the parent model is a ControlNet, the CLI prints a one-line stderr note: a ControlNet model needs a matching preprocessor/annotator (e.g. the ComfyUI
comfyui_controlnet_auxcustom node β OpenPose/Canny/Depth) to derive the control image from your input, and that preprocessor is a separate install, not hosted on Civitai. The note is informational β it never blocks the download. -
Idempotency β an already-present target (that verifies, or with
--no-verify) is skipped with a note;--forcere-downloads. -
Any file type downloads β the selected/primary file is downloaded whatever its
type(Modelweights, atype: Workflowsmodel'sArchive, training data, or other artifacts). The humanmodels get/model-versions getoutput tags a non-weights primary file with its type (e.g.[Archive]) purely for information; it never blocks a download.
Scripting with --json
Every read subcommand takes --json, which prints the raw /api/v1/... REST
response β a stable passthrough, not a CLI-invented shape. So the field schema
is exactly the public Site API's; keep the
REST field reference open
(e.g. models,
model-versions)
rather than reverse-engineering fields with jq keys.
Two properties make the output safe to pipe:
--jsonstdout is pure JSON β nothing else is written to stdout, so... --json | jq -e .always parses.- Errors go to stderr with a non-zero exit β a failed call writes the error
to stderr, exits non-zero, and prints nothing to stdout, so
jqnever sees error prose. For examplecivitai model-versions get 999999999 --jsonexits4withError: not found (404): Model not foundon stderr and an empty stdout.
Both properties hold for civitai generate and civitai workflows β¦ too, but
their payloads are not Site API REST shapes β generation has no REST route,
so those commands pass through the raw orchestrator reply. Read
Generation --json before scripting against them.
Cursor pagination loop
For deep paging use --cursor (not --page β the API caps page*limit at
1000 and 429s beyond it). Read .metadata.nextCursor from each response and feed
it back via --cursor; stop when it's absent/null:
export CIVITAI_NO_UPDATE_CHECK=1
cursor=""
while :; do
page=$(civitai models search --type LORA --base-model Illustrious \
--sort "Most Downloaded" --limit 5 ${cursor:+--cursor "$cursor"} --json) || break
echo "$page" | jq -r '.items[].id' # do your work here
cursor=$(echo "$page" | jq -r '.metadata.nextCursor // empty')
[ -z "$cursor" ] && break # no more pages
done
Clean output for pipelines
The CLI runs a background check for a newer release and prints a nag to
stderr. In scripts, silence it with CIVITAI_NO_UPDATE_CHECK=1 (env) or
--no-update-check (flag). Either way stdout stays pure JSON β the nag never
touches stdout β but suppressing it keeps stderr clean for logs.
Generation --json
civitai generate --dry-run --json, civitai workflows list --json and
civitai workflows get <id> --json emit the raw orchestrator payload. Two
caveats have bitten people, and neither shows up as an error:
-
Output URLs are presigned and EXPIRE. The links in a workflow payload are short-lived signatures, not durable addresses. A pipeline that stores them and fetches later gets a 401/403 from the storage host that no credential can fix β re-run
civitai workflows get <id>for fresh links instead of caching the old ones. (Fetch them with noAuthorizationheader; they are already authorized, and the CLI deliberately attaches nothing to them.) -
--jsonstill exits0when the server reports the resources are unavailable.--dry-run --jsonprints the estimate and exits0even when the payload says"ready": false. A human--dry-runprints a warning and this CLI refuses to submit in that state, but a script reading only the exit code sees success. Branch on the field, exactly asapp metricsrequires branching onnotOwnedβ and note the shape below, which fails closed:q=$(civitai generate "a cat" --checkpoint 128713 --dry-run --json) || exit $? case "$(printf '%s' "$q" | jq -r 'if has("ready") then .ready else "absent" end')" in false) echo "resources unavailable" >&2; exit 1 ;; # decisive: do not submit true) ;; # NOT a green light β see below *) echo "no readable .ready field" >&2; exit 1 ;; # absent, null, or jq failed esac printf '%s' "$q" | jq -r .cost.totalThe
*arm is the point. An earlier version of this snippet tested[ β¦ = "false" ] && exit 1, which exits 0 when the key is absent orjqfails β it read "we could not ask" as "we asked and it was fine", the fabricated-zero mistakeapp metricsdocuments forviews.unavailable.π΄
readyis one-directional, and the human label says so. It reports only that the resources this job needs are currently available β the server computes it as "every job's queue position reportssupport: available", and a job carrying no queue position at all is skipped, leaving the flagtrue. It is not a moderation verdict and not a prediction that the job produces an image;--dry-runtherefore prints it asResources ready, not "Generatable". A run reportingready: truecan still be charged and return nothing β measured: 8 submits across 3 checkpoints that all quotedready: trueproduced 0 outputs. So gate on the FALSE direction, as above, and never treattrueas a success predicate. The only thing that settles whether a job produced output is the finished workflow (civitai workflows get <id>), andcivitai generateexits non-zero when it waited and got no deliverable output.What
ready: falsegets you is a LOCAL refusal, and that is all this repo can evidence.civitai generatereads the flag and refuses to submit; no server-side enforcement of it has been found βsupport !== 'available'appears once in the whatIf reply builder and nowhere on the submit path, and the checkpoints that failed in the measurement above surfaced as HTTP 400s rather than asready: false. Treat it as this CLI's own pre-flight, not as a promise about what the server would have done.Cost keys (
cost.factors,cost.fixed) are server-owned and passed through verbatim, so treat them as an open map rather than a fixed set.
Gotchas
- SHA256 is UPPER-case in the API/
--json(e.g.42BA94DF20CC0F4E6DF46E3C294587A2F8CF133BF0134185884EE1C9C5E108C4), whilesha256sumemits lowercase. Case-fold before comparing if you roll your own verify (civitai download's built-in check is already case-insensitive):[ "$(echo "$api_sha" | tr A-Z a-z)" = "$(sha256sum file | cut -d' ' -f1)" ]. models searchalready embeds.modelVersions[]β each item carries its full versions, includingfiles[].hashes.SHA256andtrainedWords. If you're iterating search results you usually don't need a follow-upmodel-versions getper version.- Creator + model-level download counts live only in the search response.
model-versions get <id>returns a version, whose.modelis just{name, type, nsfw, poi}β nocreator, no modelstats.downloadCount. If you started from a version and need those, fetch them frommodels search/models getand join on the model id (.modelIdon the version).
Worked example β top LoRAs for a base model, then plan a download
Search β pick versions with jq β hand each version id to download with app
folder routing. --dry-run prints the plan (files, sizes, hashes, target paths)
without transferring, so this snippet is safe to copy-paste:
export CIVITAI_NO_UPDATE_CHECK=1
civitai models search --type LORA --base-model Illustrious \
--sort "Most Downloaded" --limit 3 --json |
jq -r '.items[].modelVersions[0].id' |
while read -r vid; do
civitai download "$vid" --layout comfyui --root ~/ComfyUI --dry-run
done
Drop --dry-run (and civitai login first) to actually fetch the files β
--layout comfyui routes each into its ComfyUI type folder.
Validate fidelity
civitai app validate is a best-effort LOCAL mirror of the platform's
approve-time validator (BlockManifestValidator). The server is the source of
truth at review time β passing validate locally is a strong pre-check, not a
guarantee of approval.
It checks block.manifest.json against a vendored JSON Schema
(schema/app-block.manifest.schema.json,
syntactic shape) plus the ported semantic rules the server runs (sandbox
trust-tier allowlist, page β iframe, required iframe sub-fields, the
renderMode tier gate, targets[].slotId registry membership) and structural
project checks. A few checks are necessarily approximate locally (the slot
registry is vendored; per-app origin-binding/scope checks the CLI can't see are
not reproduced).
It also mirrors one build-time rule, because the failure it prevents 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 β no registry re-resolve
fallback β so "buildCommand": "pnpm run build" needs pnpm-lock.yaml,
"yarn run build" needs yarn.lock, and npm run β¦ / vite build /
npx vite build / an omitted buildCommand all need 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, not merely exist. A package-lock.json
must parse as JSON and declare a numeric "lockfileVersion" of 1 or more; a
pnpm-lock.yaml or yarn.lock must be non-empty. That version rule is
deliberately stricter than npm ci measures β npm states it as a
precondition but will happily install from an otherwise-intact lockfile whose
version key is 0, a string, null or absent β and validate keeps it because
npm never writes those shapes, so a file carrying one was made by hand.
An empty lockfile fails the platform build exactly like a missing one, so
touch package-lock.json is not a fix β run the package manager and commit what
it writes. If the lockfile cannot be read, or is implausibly large, validate
says nothing rather than guessing: it never blocks a submit on a file it could
not inspect.
Finally it emits one advisory about the
host handshake: if your manifest declares a
page surface and nothing your app loads posts BLOCK_READY, validate
says so. That is the shape of an app scaffolded before the templates were fixed
(#206) β it renders perfectly everywhere you can look locally and is replaced by
a failure card in the real host. It is a warning, never an error: unlike the
lockfile rule (where the platform build provably dies), this one infers
runtime behaviour from static text and can be wrong, so it must not fail a
correct project.
β οΈ Copying
civitai-host.jsin is only half the fix. A browser never fetches a file nothing references, so the emitter has to be loaded too β a<script src="./civitai-host.js"></script>inindex.html, or animport './civitai-host.js';at the top of the entry moduleindex.htmlloads. Earlier releases of this check looked only for the textBLOCK_READYanywhere in your tree, so an unreferenced copy silenced it and a still-broken app validated clean. It now resolves what yourindex.htmlactually loads.
Four things follow:
- It checks REACHABILITY where it can, and says when it can't. Starting at
index.htmlit follows every<script src>, inline module andimportit can resolve, and asks whether any of those files posts the message. When that resolution is complete you get a precise finding β including "you have an emitter, but nothing loads it". When it isn't β noindex.htmlat your project root, a bundler alias (import '@/β¦'), a reference to a file that isn't there, an import chain deeper than it follows β it falls back to scanning your whole tree for the text, and the warning says so in as many words: "it did NOT check that the file is loaded". Read that sentence as it is written; in that mode, adding the emitter without referencing it will silence the warning and leave the app broken. - A dependency that acks ends the check β today that is
@civitai/blocks-react, and nothing else. Its iframe transport acks internally and the literal never appears in yoursrc/, so apage-moneyapp is never flagged. This is an exact list, not the@civitai/scope:@civitai/app-sdkis the server-side SDK and no runtime code in it postsBLOCK_READY, and@civitai/theme/@civitai/componentsare CSS. Depending on those does not give you the handshake, so it does not silence the check either. - It reads source only β never
node_modules, never the conventional build directories (dist,build,out, β¦), and never a.mdfile: a README describing the handshake is not an implementation of it. Comments are stripped too, so a comment namingBLOCK_READYdoes not satisfy it. 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, or a directory holding only a manifest all mean "we could not look" β reported as nothing, never as a finding. Likewise a project whose entry graph can't be resolved and which contains the literal somewhere: quiet, deliberately, because warning at a correct project is the more expensive mistake.
If it fires on a project you know is correct β your ack arrives from a bundled
dependency, or from a file type this scan doesn't open β it is a false alarm, and
it never blocks (exit 0) unless you pass --strict. What it proves stays narrow
even at its strongest: that a file your index.html really loads mentions the
message. It cannot prove the ack ever fires; only the real host can.
civitai app submit prints the same warnings before it uploads, and likewise
does not block on them.
β οΈ If you already run
civitai app validate --strictin CI, this advisory is new and can turn a previously-green project red β which is what--strictasks for. If it is a false alarm for your project, drop--strictor add the ack, and please open an issue: a warning at a correct project is a bug in the check, not something you should have to work around.
The durable fix is a server-side civitai app validate endpoint that calls
the real BlockManifestValidator (the faithful contract), with this schema
published as the syntactic half. See AGENTS.md for the full
caveat and how the vendored schema + Go checks are kept in sync.
The --json result shape
{
"ok": false,
"dir": "./my-block",
"errors": [ { "field": "iframe.sandbox", "message": "β¦" } ],
"warnings": [ { "field": "page.buzzBudgetPerGen", "message": "β¦" } ]
}
Two guarantees, both of which the previous release broke:
fieldis present on every finding, and is nevernullor empty. It used to be recovered by parsing the message text, which worked only for the JSON Schema errors β so every semantic finding (the sandbox rules, the justification rules, the money-path warnings: the ones a local pre-check exists for) arrived as"field": null, and grouping by field in CI silently dropped them. Findings now carry their field from where they are produced.- One notation: dotted paths.
blockId,iframe.sandbox,scopes[1],targets[0].slotId,scopeJustifications.<scope>β the same way the human-readable messages, this README and the schema all name fields. Earlier releases mixed JSON Pointer (/blockId,/scopes/1) into--jsonwhile the text output used dotted; if you were matching on/-prefixed fields, update your scripts.
Two findings have no single manifest field, and say so explicitly rather than omitting the key:
field |
meaning |
|---|---|
(root) |
the manifest document β it is missing, unparseable, or the schema reports a violation at the top level (e.g. missing property 'contentRating'). |
(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 already accounts for --strict: it is false when there are hard errors,
and also when --strict is passed and there are warnings. The process exit code
matches, and the JSON goes to stdout while the failure is reported on
stderr β so civitai app validate --json | jq works on a project that fails
validation.
π΄ BREAKING β a refused path now emits no object at all. This object is
written only when validation actually produced a result. A path that does not
exist, or that is not a directory, is a mistake about the invocation: it writes
nothing to stdout and exits 2. It used to print
{"ok": false, "dir": "/nope", "errors": [ β¦ ]} and exit 1 β a fabricated
validation result, complete with a finding about a manifest nobody could have
written. A failure that produces no validation result at all likewise emits
no object and exits 1 β a project directory the CLI cannot stat (it is
unreadable, or a component of the path below it is not a directory), and in
principle an internal schema failure, which is a directory the CLI can read
that still yields nothing to print.
An unreadable
block.manifest.jsonis not one of those cases β it is a validation verdict, and the object is printed in full with a single(root)finding carrying thepermission deniedmessage. The distinction is how far the CLI got before it stopped: it could not read your manifest, which is something to report about the project; it could not read the directory, which is nothing at all.
So branch on the exit code before parsing:
| 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 |
π΄ jq -e is the wrong tool for reading ok. It exits 1 on a JSON
false, which is indistinguishable from its exit code for a missing key β so
the obvious one-liner reports a failing but perfectly well-formed result as
"no result", which is the one distinction this whole section exists to draw.
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"
;;
esac
Submit & auth
civitai login (no flags) runs the OAuth device-authorization grant: it
prints a URL + a short code, you approve in your browser, and the CLI stores a
short-lived access token (1h) plus a refresh token (30d) that it rotates
automatically before requests and once on a 401. By default it requests
UserRead | AppBlocksSubmit | AppBlocksDevTunnel (== 100663297) β identity,
Apps submit (which gates both app submit and the dev-token mint), and the
on-site dev tunnel. That default deliberately omits AIServicesWrite, so a plain
login cannot spend Buzz: it drives the read/identity dev:live paths
(viewer, catalog, app storage) but, for a generation app, has its
ai:write:budgeted scope stripped at mint time and cannot estimate, submit, or
spend real Buzz.
Opt into generation explicitly with a named scope set:
civitai login --scopes generate # additive: keeps submit + dev-tunnel, ADDS generation
which requests 100777985 (the default plus AIServicesRead | AIServicesWrite | BuzzRead) β one credential that can both submit apps and run
civitai generate. --scopes takes a named set, never a raw
bitmask, and an unknown name is rejected with the valid list. It applies only to
the browser device login and is refused alongside --token.
π΄ The device-flow scope check is all-or-nothing: requesting any bit the
civitai-cliOAuth client'sallowedScopesdoes not permit rejects the whole login withinvalid_scope. Oncivitai.comthat client'sallowedScopesis100777985, so--scopes generateworks. Against a self-hosted or older auth server (a non-defaultCIVITAI_BASE_URL) that predates the widening it is rejected, and the CLI maps the rejection to a message telling you plaincivitai loginstill works there.
A full-scope personal API key remains the other way to get spend authority
(see the credential table under
Local dev loop above), and is still the
only credential carrying the rest of the Full scope mask.
civitai login --token <key> stores a personal API key instead (no refresh).
CIVITAI_TOKEN overrides the stored credential (treated as a personal key).
civitai app submit:
- always validates + packages the canonical source ZIP, then
- uploads it with your stored token to the token-authenticated route
POST /api/v1/blocks/submit-version(Authorization: Bearer). OAuth tokens refresh transparently. SetCIVITAI_SUBMIT_PATHto override the route. - With no token configured (and not
--package-only), it instead writes the.zipand prints the next steps (civitai login, or web upload at/apps/submit).
--package-only always just writes the .zip and stops.
After you submit: review β approve β deploy
A successful submit does not publish your app β it queues it for
moderator review. The lifecycle is:
- submit β your submission lands at
/apps/my-submissionswith statuspending. - review β a moderator reviews the manifest + files. They either approve or reject (with a reason you can read inline, then fix and resubmit).
- deploy β on approval, the platform builds and deploys your app
(injects its build recipe β builds the image β deploys β programs the
<blockId>.civit.aiDNS record). A few minutes after approval it serves live athttps://<blockId>.civit.ai/.
Before approval, https://<blockId>.civit.ai/ 404s β submitting does not
make the subdomain serve (but dev:live works against a pending app β see
Local dev loop). For the full end-to-end
walkthrough (build β submit β review β deploy), see the
Build your first App
guide.
Budget time for artwork. Review and deploy are not the only gate: a store
listing cannot go live without an icon AND a cover. civitai app submit
mints your listing as a draft and prints this reminder inline β which is the
moment to act on it, because the media is settable while the app is in review
and carries forward when a moderator approves it. Attach it without the browser:
$ civitai app listing status # what's attached vs. required
App: my-app
Listing status: draft
Icon: MISSING (required)
Cover: MISSING (required)
Screenshots: 0
β Not publishable yet β missing icon and cover.
Add one: civitai app listing set-icon <file>
Add one: civitai app listing set-cover <file>
$ civitai app listing set-icon ./assets/icon.png # png/jpeg/webp, β€2 MiB
$ civitai app listing set-cover ./assets/cover.png # png/jpeg/webp, β€4 MiB
$ civitai app listing add-screenshot ./assets/screenshot-1.png --caption "Grid view" # optional, up to 8
assets/ is scaffolded by every template, with a README of the requirements and
no placeholder images β the files above are ones you supply.
Details worth knowing before you start:
- Source images are png/jpeg/webp and are size-checked locally before any upload β icon β€2 MiB, cover β€4 MiB, screenshot β€2 MiB. That is the whole of what the CLI enforces.
- Dimension and aspect rules are the platform's, and it states them. The CLI
does not enforce them locally β a copied number goes stale and starts refusing
valid images β but it does document the current bounds, as guidance rather
than as a gate: see
Listing media requirements. It uploads,
attaches, and then waits for the content scan β in that order, because the
platform validates dimensions, aspect and format at the attach step. So a
wrongly-shaped image comes back in a couple of seconds with the platform's own
message naming the bound and your value
(e.g.
icon must be square-ish (aspect 2.00 outside 0.9β1.1)), instead of after the scan has finished. - A blocked image never goes live. The scan verdict is still waited on, so these commands never report success on a pending or blocked scan; a failure tells you what state the listing was left in.
- The app is resolved from
block.manifest.jsonin the current directory; pass--slug <blockId>(with--dirif you prefer) to run it from anywhere. - On a listing that is already LIVE, attaching media does not edit the live
listing β it opens a revision that goes back to moderator review. Describe
it with
--changelog "<what changed>";-y/--yesskips the confirmation.civitai app listing statuson a live listing reports that in-progress revision's media, and tells you when a revision is already under review. - Screenshots are managed by id (the
alsc_β¦idslisting statusprints):rm-screenshot <id>removes one, andreorder <id...>takes all the current ids in the new order β a partial set is rejected.
Need to change the bundle while a request is still pending? Withdraw it
first to free the slug, then resubmit:
$ civitai app status # find the pubreq_ id
$ civitai app withdraw pubreq_01HZX # frees the slug
$ civitai app submit # resubmit the new bundle
civitai app withdraw <pubreq-id> (or --id <pubreq>) withdraws your own
pending publish request. It is idempotent (an already-withdrawn request still
returns success) and only a pending request can be withdrawn β an already
approved/rejected one cannot.
Listing media requirements
Two different things check your listing images, and it is worth knowing which is which before you open an image editor.
What the CLI checks, locally, before anything is uploaded: the file's format and its byte size. That is all.
| kind | how many | format | byte cap (the file you pass) |
|---|---|---|---|
| icon | 1, required | png / jpeg / webp | β€ 2 MiB |
| cover | 1, required | png / jpeg / webp | β€ 4 MiB |
| screenshot | up to 8, optional | png / jpeg / webp | β€ 2 MiB |
What the platform checks, server-side, when the image is attached: the
dimensions and the aspect ratio. The CLI does not reproduce these, so this table
is guidance β the server is the authority and its rejection names the bound and
your value (icon must be square-ish (aspect 2.00 outside 0.9β1.1)).
| kind | aspect (width Γ· height) | minimum size |
|---|---|---|
| icon | 0.9 β 1.1 β square-ish, not exactly square | 128 px on the shorter side |
| cover | 1.3 β 2.4 β landscape, ~4:3 to ~21:9 | 640 px wide |
| screenshot | 0.4 β 2.6 β either orientation | 320 px on the shorter side |
Easy starting points: a 512 Γ 512 icon and a 1600 Γ 900 cover.
Four behaviours that are not obvious from the numbers:
- Icons are re-encoded server-side. Whatever you upload is downscaled to at most 1024 px on its longer side and re-encoded to PNG β aspect preserved, and never enlarged. So an oversized icon is harmless, but an undersized one is not: the 128 px floor still bites, because nothing is ever scaled up. One consequence to know about: the platform also caps the re-encoded icon at 1 MiB, and that is a different measurement from the 2 MiB the CLI applies to the file you pass. A detailed photographic icon can clear the local check and still be refused for the size of the PNG the server made from it β the message quotes bytes, not pixels. Flat, simple artwork re-encodes far smaller.
- An icon's upper bound is a PIXEL count, not a file size. The decoder that re-encodes it refuses a source above roughly 16 megapixels β about 4096 Γ 4096 β and it refuses it regardless of how small the file is. A flat 5000 Γ 5000 PNG compresses to a few hundred KB, so it clears every byte cap in the first table and is still rejected. Downscale before you upload: 1024 Γ 1024 is plenty, because that is what the server re-encodes to anyway.
- Covers and screenshots are not rescaled. What you upload is what the store renders, so ship them at the size you want shown.
- A wrong image is rejected, not quietly accepted, and it comes back fast. The CLI attaches before it waits on the content scan, so the platform's verdict on shape arrives in a couple of seconds rather than after a scan that can take two minutes β and always before a moderator sees it. The message names the bound it applied and the value it measured β read it rather than guessing which limit you crossed.
Why the CLI does not enforce the second table. These are platform constants that can move. Stale guidance costs you one rejection that carries the current bound; a stale local gate refuses valid images and cannot be argued with. The asymmetry is the reason the split exists β please don't "helpfully" promote these numbers into a local check (see
AGENTS.mditem 25).
Submission status
civitai app status checks where your own submissions are in that lifecycle
without leaving the terminal. It calls the token-authenticated, self-scoped route
GET /api/v1/blocks/submissions with your stored credential β you only ever see
your own submissions (the same token that submitted can read its status; OAuth
tokens need the Apps submit scope).
With no argument it lists every submission, newest first:
$ civitai app status
BLOCK_ID VERSION STATUS DEPLOY SUBMITTED URL
gen-matrix 0.6.0 approved live 2026-06-22 https://gen-matrix.civit.ai/
my-block 0.2.0 pending - 2026-06-21 -
old-app 0.1.0 approved building 2026-06-19 -
Pass a blockId (app slug) or --id <pubreq_id> to see one in detail β including
the rejection reason if it was rejected (so you can fix + resubmit) and the
live URL once it is approved and deployed:
$ civitai app status gen-matrix
Block ID: gen-matrix
Version: 0.6.0
Publish request: pubreq_01HZX
Status: rejected
Deploy state: -
Submitted: 2026-06-22 09:05 CDT
Reviewed: 2026-06-22 11:40 CDT
Rejection reason:
the budgeted scope needs the per-app Sybil cap signed off first
Not live yet β gen-matrix.civit.ai only serves after the app is approved and deployed (deployState 'live').
The unfiltered listing is capped server-side at 100 rows, and the API returns no cursor and no total β so there is no way to page and no way to know how many were dropped. When a full-length page comes back the CLI says so on stderr rather than presenting it as your complete history:
note: showing the newest 100 submissions β the API caps this listing and offers no way to page, so older submissions may exist but are not listed. Look up a specific app with `civitai app status <blockId>`.
That is an inference (a page that is exactly full is indistinguishable from one
that was cut off), so it says may. A per-app lookup β civitai app status <blockId> β is not affected: the server narrows to the slug before applying
the cap.
--json emits the raw response for scripting. An empty list prints a friendly
"run civitai app submit" hint; with no token it points you at civitai login.
Notes like the cap caveat go to stderr, so --json stdout stays pure and the
exit code stays 0.
Deployed is not the same as listed in the store
civitai app status and civitai app view read different resources, and an
app can legitimately be in one and not the other:
civitai app status <slug>reads your submission pipeline (GET /api/v1/blocks/submissions) β review status, deploy state, live URL.civitai app view <slug>reads the public store catalog (GET /api/v1/apps/{slug}) β the published store listing.
So app status can show approved / live with a working <slug>.civit.ai URL
while app view <slug> returns not found (exit 4). That 404 is truthful and
says nothing about your deploy: the store lists an app only once its store
listing is published (a listing needs an icon and a cover β see
civitai app listing status), and the catalog itself is still gated by a launch
flag while the store is pre-GA. When the 404 lands on a slug you own, the CLI
detects that and says so, naming both next commands, instead of leaving you with
a bare "App not found".
Pull your app's repository (app pull)
civitai app pull is the read side of git authoring: it clones (or, if
[dir] is already a checkout, syncs) the canonical repository backing one of
your approved Apps, so you can edit locally and then civitai app submit or
push.
civitai app pull --app my-block # clone into ./my-block
civitai app pull ./my-block --app my-block # clone/sync into ./my-block
civitai app pull . --app my-block # sync the current directory
--app is required (the slug or appBlockId; find it with civitai app status). Authentication uses your stored credential (civitai login or a
personal API key) β the command calls an owner-only endpoint that lazily
provisions a scoped, read-only Forgejo identity for you and returns a clone URL
with a pull token embedded.
The repo only exists once your first version has been submitted as a ZIP and approved; before then the command tells you so rather than failing obscurely.
β οΈ SECURITY β TOKEN-IN-URL LEAKAGE. The clone URL embeds your access token as HTTP-Basic credentials (
https://<user>:<token>@β¦).
- On a fresh CLONE, git writes the remote URL into
.git/config, so the token lands on disk in the checkout. Treat the directory as sensitive: do not commit.git/configor share the directory. To drop the token, point the remote at the credential-less HTTPS URL βgit -C <dir> remote set-url origin <httpUrl>β which is exactly the command the CLI prints for you after a clone.- On a SYNC (pull into an existing checkout) the URL is passed explicitly and is not persisted to
.git/config. The token still appears transiently in the git child process's arguments, so it is briefly visible to other local processes viaps//proc/<pid>/cmdline.The CLI prints the applicable half of this warning to stderr after every run.
A sync is git fetch + git merge --ff-only, so it never creates a merge commit
and refuses rather than clobbering diverged history or a conflicting dirty tree.
Browse the App store
civitai app list and civitai app view <slug> read the public App store
catalog (GET /api/v1/apps and GET /api/v1/apps/{slug}).
π΄ These are not anonymous reads. Unlike the model/image/article commands in Browse the public API, both require a credential and exit
3withno token configured β run 'civitai login' (or set CIVITAI_TOKEN) to browse the App storewithout one. The store endpoint keys the visible catalog off your identity, so an anonymous call would see nothing; the CLI refuses up front rather than presenting an empty catalog as the whole store.
civitai app list
civitai app list --kind onsite --sort popular --limit 10
civitai app list --category generation --json
civitai app list --cursor '<next-cursor-from-a-previous-page>'
civitai app view my-cool-app
civitai app view my-cool-app --json
- Filter-based discovery, not search. There is no free-text query β the store
service does not expose one, which is why there is no
civitai app search. Filter with--kind(all,onsite,offsite),--category(generation,games,utility,discovery,moderation,analytics,other) and--sort(top-rated,popular,newest,name). - Keyset cursor pagination, not page numbers: the next cursor is printed
after the results, and you pass it back with
--cursor.--limitis 1β50. - The store is gated by a launch flag. Until it opens publicly you only see apps if your account is a moderator or an app-dev-tester, so a perfectly valid login may get an empty list. That is the pre-GA state, not a broken login.
- Rate-limited per caller β a tight scripted loop may see
429s, which the CLI backs off and retries automatically. app viewreads the store catalog, which is not your deploy: see Deployed is not the same as listed in the store.
App metrics
civitai app metrics <slug> shows the owner-only analytics for one of your
App Blocks. The slug is resolved to its appBlockId through your own
submissions, so analytics exist only once a version has been approved β an
app still in review reports that instead of an empty dashboard.
$ civitai app metrics gen-matrix --from 2026-05-01 --to 2026-08-03
App: gen-matrix
Window: 2026-05-01 00:00 UTC β 2026-08-03 00:00 UTC
Granularity: week
Installs
Total 12
Active 9
Runs
Count 20
Buzz spent 65
Buzz purchased
Purchases 3
Buzz 15000
Gross $14.97
App loads
Impressions 124
Unique viewers 12
Signed-out loads 40
Engagement
API calls 26
Active users 2
Error rate 3.8%
Top scopes:
ai:write:budgeted 20
Top endpoints:
/api/v1/blocks/me 4
Three things are worth knowing, because each one otherwise produces a believable-but-wrong reading:
- The window is always printed, and it comes from the server. The API
defaults to the last 30 days and clamps any request to 366 days, so a
real app with 20 runs in mid-June reads
0under the default window. The window shown is the one the server actually served β if it clamped your--from, the printed range says so. Widen it with--from/--to, which accept a plainYYYY-MM-DD(midnight UTC) or a full RFC3339 timestamp; a malformed value or an inverted window is a usage error (exit2) caught before any request. - "Not entitled" is not "zero". When the caller doesn't own the app (or
lacks Apps-author access) the API answers HTTP 200 with every counter
zeroed, flagged only by a
notOwnedfield. The CLI refuses to render a dashboard in that case and tells you to checkcivitai whoami/civitai app status <slug>instead β a silently-empty dashboard that looks like real data is the failure mode this command is built to avoid. - It needs a personal API key. The query is full-scope, so an OAuth
civitai logintoken gets a 403. Both refusals β the 403 and the no-token-at-all case β name the route that actually works (civitai login --token <key>, created atcivitai.com/user/account) rather than the generic "runcivitai login", which here is the one route that cannot succeed.
Two data caveats.
Engagement counts only authenticated, scope-gated API
calls. An app that ships no scoped API surface shows real installs and revenue
with a flat engagement section β that is expected, not a bug. Installs is a
different case again: it shows n/a for an app that cannot be installed at
all (a page app has no install slot, so an install record cannot exist), which
is deliberately distinct from a real 0 on an installable app nobody has
installed yet. App loads is the exception: it is measured on every load, so it counts signed-out visitors and
static blocks that engagement structurally cannot see. Unique viewers counts
signed-in people once each and approximates signed-out ones by network address,
so read it as reach rather than an identity count, and Signed-out loads is a
count of LOADS (one anonymous visitor reloading ten times is 10 there and 1
unique viewer), so it can legitimately exceed Unique viewers. Note also that
these are mount ATTEMPTS: a load that FAILED still counts, because a failed
mount's only beacon is the same event and it carries no status. Error rate is the
share of those calls that failed, and the human view renders it as a percentage
(the server sends it as a 0β1 ratio, which --json passes through unchanged).
Only a genuine zero prints 0.0%: a real but tiny rate β a high-traffic app with
a handful of failures β reads <0.1% rather than rounding away to look
error-free.
--json emits the raw analytics payload (the server's own object, including
notOwned and the per-bucket series arrays the human view omits) for
scripting. Note that --json does not refuse a not-entitled read the way the
human view does: a notOwned: true payload is passed through with every
counter zeroed and the command still exits 0, so
civitai app metrics <slug> --json | jq .runs.count returns 0 for an app you
can't see. A script must branch on the notOwned field rather than trusting the
counts.
Installs carries installs.notApplicable for the case above. It is NOT an
outage flag β it means the question does not apply to this app type, so a script
should render it as "not applicable" rather than retrying or warning about
infrastructure. --json passes it through and still exits 0, so branch on it
rather than trusting the counts.
App loads has a SECOND, section-local unavailability flag β views.unavailable,
independent of notOwned. It is the one section the server reads from a
different store, which can be unreadable β or merely too slow, the read is
time-bounded server-side β while every other counter in the same response is
genuinely measured. When that happens the human view prints unavailable and
says so explicitly rather than printing a 0 you would read as "nobody opened
my app". --json passes the flag through and still exits 0, so a script must
branch on views.unavailable too β jq .views.count alone cannot tell an
outage from a real zero. A server old enough to predate this section omits the
views key entirely; the human view reports that as unavailable as well
(naming the different cause), and a script should treat a missing .views the
same way.
Generate
civitai generate "<prompt>" runs a text-to-image generation on Civitai's
generator.
π΄ This spends real Buzz and cannot be undone. A submitted generation is charged the moment the orchestrator accepts it, and nothing local calls that back β not
--timeout, not Ctrl-C, notcivitai workflows cancel. Price it with--dry-runfirst β that calls the cost estimator and spends nothing.
π΄ What the LEDGER does with a charge is not something this CLI reports, in either direction. If a run fails, expires, or you cancel it, whether any Buzz comes back is decided server-side; this CLI cannot see your Buzz ledger β
civitai buzzreports a balance, not a history, so settle it against your Buzz transaction history (/user/transactions). The CLI states the outcome and stops there. It does not tell you the charge stands, and it does not tell you it was refunded β earlier versions asserted the first, which the platform's own client contradicts forfailed/expired/canceled. The rule itself lives in the orchestrator service and is not readable from the civitai monorepo, so neither claim is made. Tracked at civitai/cli#307.
# Price it. Spends nothing.
civitai generate "a cat wearing sunglasses" --dry-run
# The same estimate as raw JSON, for scripts
civitai generate "a cat wearing sunglasses" --dry-run --json
# Generate, refusing if the estimate exceeds 50 Buzz
civitai generate "a cat wearing sunglasses" --quantity 4 --max-cost 50
# A specific checkpoint plus a LoRA at 0.8 strength
civitai generate "a cat" --checkpoint 128713 --lora 250712:0.8
# Wait for the result and write the images into ./out
civitai generate "a cat" --yes --out-dir ./out
# Fire and forget; collect the results later
civitai generate "a cat" --yes --no-wait
civitai workflows list
civitai workflows get <workflow-id>
# Non-interactive (CI) β --yes is required, or the run is refused
civitai generate "a cat" --yes --max-cost 20
# Image-to-image from a local file β --ecosystem is REQUIRED with --image
civitai generate "make it winter" --ecosystem Flux1Kontext --image ./cat.png --dry-run
# β¦or from a public https URL, with two reference images
civitai generate "combine these" --ecosystem Seedream \
--image https://example.com/a.jpg --image ./b.png --yes
# Graduate from flags to a raw graph: print, edit, send back
civitai generate "a cat" --quantity 2 --print-input > graph.json
civitai generate --input graph.json --dry-run
Credential. Generation needs the AI Services scopes. Either a full-scope
personal API key (create one, then
civitai login --token <key>), or a browser login that opted in:
civitai login --scopes generate. A default OAuth browser login
(civitai login, no --scopes) does not carry them and is refused.
civitai whoami shows the capability as Spend Buzz (AI Services).
The one exception is --print-input: it assembles the graph and exits before the
estimator, the submit and the balance read, so it needs no credential β useful
for building a graph to edit before you have logged in. Two caveats, and they are
not the same caveat:
--print-inputwith--imagedoes need a credential, because it uploads each local file first and that upload is authenticated.--print-inputwith--checkpointor--loraneeds none β the model-version lookup is a public read β but it is not offline: that lookup is a real request, and with no network it fails (exit5) rather than printing a graph. Measured against a dead endpoint: bare--print-inputexits0;--print-input --checkpoint <id>exits5after the read's retries.
So only a bare --print-input needs neither a credential nor a network.
π΄ --max-cost is an estimate check, not a spending cap
The cost this command shows is an estimate, not a quote: the server's
estimator returns no quote id, no signed price and no expiry, so there is
nothing to hand back at submit time β and no server-side spending ceiling is
reachable from an API key at all. The realized charge can exceed the estimate,
and --max-cost cannot claw the difference back β it never reaches the server.
What the ledger then does with that charge is not something this CLI reports β
see What the LEDGER does with a charge under Generate.
--max-cost compares that estimate against your number and refuses locally
before submitting. It catches a --quantity typo. That is all it can do. Do not
run an unattended loop believing it caps spend. (The per-API-key buzzLimit on
your account does not bind this path either β the generator meters a separate
server-minted subject, not your key.)
π΄ Silent model substitution
If you pass a --checkpoint version id that is not valid for the model family
being generated, the server does not reject it. It substitutes that family's
default checkpoint, runs the job, and bills you for what actually ran. Until
recently the reply was indistinguishable from success β a nonexistent version id
came back 200 OK at the default price.
The server now reports each swap, and civitai generate surfaces it:
$ civitai generate "a cat" --checkpoint 999999999 --dry-run
β The server will NOT use the checkpoint you asked for. It has substituted a
different model, and this estimate prices the SUBSTITUTE. Nothing has
been submitted or charged yet.
requested version 999999999 -> will run version 2436219 (reason: unrecognized)
the server does not offer that version in this model family at all β¦
The checkpoint line in the summary you approve is annotated too, so the model that will not run is never the last one you read before saying yes:
Checkpoint: DreamShaper β 8 (Checkpoint, id 128713) [SUPERSEDED β the server will run version 2436219 instead; see the warning above]
Your own id stays on the line: the CLI marks it, it never quietly substitutes the applied one.
It is reported on the estimate (--dry-run, and before the confirmation
prompt on a real run β while you can still back out), again after the submit
(where it is the receipt for what was billed), and on a later read with
civitai workflows get <id>, which is the only place a --no-wait run can still
discover it. The report always goes to stderr, so --json stdout stays
machine-clean β and --json carries the raw modelSubstitutions array itself.
There are three reason values, and they want different fixes:
reason |
What it means | What to do |
|---|---|---|
wrong-workflow |
The version is real for this family but scoped to a different workflow (e.g. an edit-only version sent to text-to-image). | Pick a version offered for the workflow you are running, or change --ecosystem to match. |
unrecognized |
The version is in no list for this family β a community checkpoint, or one retired since your script was written. | Check it with civitai model-versions get <id> and pin one that is still offered. |
gated |
The version is offered here, but a gate rule hides it from your account. | An entitlement issue, not a command mistake: may need a membership, an early-access window, or an accepted licence. |
By default this is a warning and the run continues β the substitution is a
deliberate graceful degradation, so a script pinned to a version that was later
retired keeps working rather than breaking on a CLI upgrade. Pass
--fail-on-substitution to refuse instead; it is checked against the
estimate, so nothing is submitted and nothing is charged when it refuses.
π΄ Silence is not an assurance. The field is omitted when nothing was substituted, so "no warning" means either "no substitution" or "a server older than this feature" β the CLI cannot tell those apart and deliberately never claims the negative.
π΄ --fail-on-substitution is therefore NOT a spend guard. It can only
refuse what the server reports. Against a deployment that does not report
substitutions the flag is silently inert β exit 0, submitted, charged β
and there is no signal distinguishing that from "nothing was substituted". Two
further limits worth knowing before you build a pipeline on it:
- It is evaluated on the estimate. A substitution appearing only on the
submit reply is reported (and is in
--json) but exits0, because the money is already gone and failing there would strand a result you paid for and still need to collect. - It refuses on the first reported substitution; the message names that one and counts the rest.
Confirmation
An interactive run prints the estimate, your balance and the resolved model
names, then asks. A non-interactive shell (pipe/CI) without --yes is
refused rather than charged silently. Everything the confirmation prints goes
to stderr, so --json keeps stdout machine-clean.
Image-to-image: --image and --ecosystem
--image <path-or-url> (repeatable) attaches a reference image and turns the
job into an edit. A local .png/.jpg is uploaded to Civitai first and the
stored blob is referenced; an https URL is passed through as-is, but must
be publicly reachable β the generator downloads it server-side too, and an
unfetchable URL is a 400 after you have already been priced. Either way the
CLI reads the image's width and height from its header only (never decoding
the pixels) and sends them, because the server requires both and rejects an entry
without them. http://, file:// and data: are refused, local files are
capped at 64 MiB (checked by stat, before a byte is read), and only png and
jpeg are supported β webp would need a new third-party decoder dependency.
π΄
--imagerequires--ecosystem, and the reason is money. The server promotes a text-to-image job to image-to-image only when the request names an ecosystem. Without one it ignores the images, generates from the prompt alone, and charges you the full amount β HTTP 200, no error, no warning. Measured: the same graph with and withoutimages[]priced byte-identically.
Two more things the CLI genuinely cannot check for you, so it says them instead of pretending:
- Only some ecosystems accept reference images at all.
Qwen,Flux1Kontext,NanoBanana,Seedream,OpenAI,Grok,Reve,MAI,Booguand a few more do; the Stable Diffusion family and the default ecosystem do not β and for those the images are dropped silently and billed. The cost estimate cannot tell you which case you are in: several edit-capable ecosystems price identically with and without images (measured onFlux1Kontext,NanoBananaandSeedream), so a price comparison is not a detector. Name an ecosystem you know supports editing. - Too many reference images are silently truncated. Per-ecosystem limits run
from 1 to 7, live only inside the server's per-engine graphs, and the extras
are dropped before any limit check can fire β so the server never reports it
and the truncated job is billed. Measured on
Qwen(limit 3): 4, 5, 6 and 12 images all priced identically to 3. The CLI refuses more than 7 (no ecosystem accepts more, so that refusal can never block a valid request) and warns for anything above 1. It deliberately does not vendor the per-ecosystem table β seeAGENTS.mditems 13 and 19(c).
--ecosystem is sent to the server verbatim and is not checked locally;
an unknown value comes back as the server's own unknown ecosystem error.
--dry-run does upload local --image files, because an estimate built on a
graph with no images[] prices a plain text-to-image job. Uploading spends no
Buzz, and --dry-run still never submits.
The content flags, and why there aren't twelve
--negative-prompt, --quantity, --aspect-ratio, --checkpoint <version-id>, --lora <version-id>[:strength] (repeatable), plus --image /
--ecosystem above.
The generator is permissive, not a validator β it returns HTTP 200 for things it silently changes:
- An out-of-range
--quantityis clamped with no error (asking for 40 charges you for the server's limit). The CLI warns when you cross it. --steps 0/--cfg-scale 0are accepted and price a degenerate, cheaper, wrong job β which is exactly why those flags are not exposed yet.- A checkpoint id that does not exist is accepted, the ecosystem default is silently substituted, and you are billed for it.
So --checkpoint and every --lora is resolved against the public
model-version API before anything is submitted: a bad id becomes a hard
local not found (exit 4) instead of a wrong charge, and the confirmation
echoes the resolved model name so you approve a name rather than an integer.
--model is deliberately absent: civitai download --model takes a model id,
while this takes a version id.
Raw graphs: --print-input and --input
The five flags cover the common job. Everything else the generator understands lives in the generation graph β the JSON document the flags assemble. You can write that document yourself:
# 1. Assemble it from flags, print it, and exit. No submit, no cost estimate,
# no balance read β with no --checkpoint/--lora, no request at all.
civitai generate "a cat" --quantity 2 --aspect-ratio 1:1 --print-input > graph.json
# 2. Edit graph.json however you like.
# 3. Send it as-is. Price it first; --dry-run still spends nothing.
civitai generate --input graph.json --dry-run
civitai generate --input graph.json --yes
# β¦or pipe it, with `-`
jq '.prompt = "a dog"' graph.json | civitai generate --input - --dry-run
--print-input reaches no money seam: not the submit, not the cost
estimator, not the balance read. With --checkpoint/--lora it does still make
the public model-version read those flags always make β that lookup supplies
model.type, which graph resources[] require, so skipping it would print a
document --input could not submit.
--print-input's output is a valid --input document by construction β that
round-trip is the point of the pair, and it is what replaces a --set some.path=value expression language the CLI deliberately does not have (a wrong
type in such an expression is accepted by the server silently, and billed; an
edited file is inspectable before it is sent).
Four things to know, all of them consequences of it being a passthrough:
- txt2img only. A graph declaring any other workflow is refused. The
server's content audit reads the top-level
promptnode, and it rebuilds what it inspects from declared graph nodes β so a graph carrying its prompt somewhere else (a comfy node, a nested step input) is exactly the shape that could reach the generator unaudited. That question is open upstream, and this CLI will not be the path that answers it the wrong way. - Envelope keys are refused, not ignored.
civitaiTip,creatorTip,buzzType,tags,externalId,sourceMetadata,sourceMetadataMap,remixOfIdand a top-levelinputbelong to the request envelope around the graph, not to the graph. A file settingcivitaiTipwould charge a tip that--dry-runstructurally cannot show you β the estimator prices a strictly smaller request and is never sent tips at all β so the file is rejected with an error rather than quietly cleaned up. - Keys the CLI does not model are passed through, with a warning. The warning says the CLI cannot verify the key; it is not a claim that the key is invalid, because the CLI does not carry a copy of the server's node registry. It matters because the server's failure mode for a key it does not declare is to drop it silently at HTTP 200 β a typo costs Buzz and produces a job that ran without your parameter, with no error anywhere.
- No model-id safety net.
--checkpoint/--loraare resolved against the public API before submitting; a raw graph is not interpreted, so a nonexistent id in it is accepted, the ecosystem default is substituted, and you are billed.
--input cannot be combined with a prompt argument or with
--negative-prompt / --quantity / --aspect-ratio / --checkpoint /
--lora β there is no predictable answer to "does --lora append to or replace
the file's resources?", so the combination is a usage error. Every execution
flag (--dry-run, --yes, --max-cost, --json, --no-wait, --timeout,
--out-dir, --no-download, --force, --external-id) still applies.
Waiting, downloading, and re-attaching
By default generate waits for the job to finish and writes every
deliverable output into --out-dir (default .) as
<workflow-id>-<n>.<ext>. --force overwrites existing files; without it a
collision is refused before any bytes move.
--no-waitsubmits, prints the workflow id and exits0.--no-downloadwaits and prints the output URLs instead of writing files.civitai workflows get <workflow-id>shows a workflow at any time. It is the re-attach path for every case where the CLI stopped early, and it spends nothing.
π΄
--timeoutstops waiting. It does not stop paying. When the deadline passes (or you press Ctrl-C) the generation keeps running server-side, and cancelling it does not stop the cost already accrued β a mid-run cancel bills that. Both cases exit non-zero, print the workflow id, the idempotency key and the exactcivitai workflows get β¦command, and never report success.
Output URLs are presigned and expire. Download promptly; re-read the workflow for fresh links. The blob fetch deliberately carries no credential β the URL is already authorized, and attaching your full-scope API key to it would hand 25 unrelated permissions to a request that needs none.
A finished workflow can contain fewer usable results than you paid for. An
output can be blocked by moderation, never land, or be one you hid on the
website. Those are filtered out of the download β and reported, with the
reason, plus an explicit note when the count differs from --quantity.
Silently writing three files for a four-image job is the failure this exists to
prevent. If every output is filtered out the command exits non-zero.
Crash safety. The orchestrator's idempotency key is written to
~/.config/civitai/pending/<key>.json before the request is sent, because
the money moves server-side even if the process dies mid-POST. If a submit's
reply never arrives, re-run with --external-id <key>: the orchestrator dedupes
on it and returns the pre-existing workflow instead of charging again (it
answers a duplicate with HTTP 200, not a 409, so re-attachment is inferred
locally).
Polling cadence. The status poll starts at 5s, backs off exponentially to a
cap, and backs off harder on a 429. That floor is not tunable downward: the
workflow read proxies straight through to the orchestrator with no cache and no
server-side rate limit, so the CLI's own restraint is the only thing between it
and a 429 storm.
Listing and cancelling workflows
civitai workflows list # newest first
civitai workflows list --limit 5
civitai workflows list --limit 50 --cursor <next-cursor>
civitai workflows list --json # raw server payload, incl. nextCursor
civitai workflows cancel <workflow-id> # asks for confirmation
civitai workflows cancel <workflow-id> -y # skip the prompt (scripts/CI)
list is cursor-paged, not page-numbered: when more results exist it prints
Next cursor: <c> on stdout, which you pass back as --cursor. --tag
filters on orchestrator workflow tags (repeatable).
The OUTPUTS column reads deliverable/total. The two differ when an output was
blocked by moderation, never landed, or you hid it on the website β so
0/4 means four images were produced and paid for and none of them are usable,
which is a very different fact from 0/0. civitai workflows get <id> shows
the per-output reason.
π΄
canceldoes not undo the charge. A mid-run cancel bills the cost already accrued, orchestrator-side: by the time a workflow is running the money has moved, and stopping it does not call that back. Cancel a job because you no longer want its output β never as a way to save Buzz, and never to undo a submit. Whether the ledger returns anything afterwards is the server's call and this CLI does not report it either way (civitai/cli#307). (This is also why--timeoutand Ctrl-C deliberately do not cancel: stopping the wait costs nothing, while stopping the job would cost the same as letting it finish and throw the result away.)
cancel asks for confirmation, matching civitai generate and
civitai app submit. It is the one irreversible action here, and it destroys a
job you have already paid for, so it is gated the same way every other
destructive path in this feature is:
--yes/-yproceeds without prompting;- an interactive terminal prints what is lost and prompts β the default is no, so a bare Enter aborts;
- a non-interactive shell without
--yesrefuses rather than cancelling silently. Scripts must pass--yesexplicitly.
Nothing is cancelled when the confirmation is refused β the gate runs before the request goes out.
Exit codes specific to generate
generate follows the global exit-code table, with one
deliberate refinement. The API answers several very different failures with the
same HTTP status, and the generic mapping would send a script down the wrong
path β in particular a caller who is out of Buzz, muted, or hitting a
server-side outage must never be told to re-run civitai login. Those cases
therefore exit 1 (generic), not 3 (auth) or 2 (usage):
π΄ An exit code does not tell you whether you were charged. Every failure
above the divider happens before anything is submitted, so nothing was spent.
Every failure below it happens after the submit, and the Buzz is gone β
including a --timeout, a Ctrl-C, and a workflow that ends failed. Do not
write a retry loop that branches on the exit code alone; re-attach with
civitai workflows get <workflow-id> instead of re-submitting.
| Failure | Exit |
|---|---|
| β nothing submitted, nothing spent β | |
| Missing AI Services scope / no token / not authenticated | 3 |
| Not enough Buzz (caught locally against your balance, or reported by the server) | 1 |
| Account muted, or onboarding incomplete | 1 |
| Generation disabled server-side | 1 |
| Prompt refused by content moderation β π΄ never retry, repeated blocked prompts get the account muted | 1 |
The server priced the job but reports ready: false (a selected resource is not currently available) |
2 |
Estimate above --max-cost, an unknown ecosystem, or a resource that resolved fine but is "not enabled for generation" (the ids exist; the combination is not runnable β distinct from exit 4, which means "no such id") |
2 |
--fail-on-substitution and the estimate reported a substituted checkpoint β nothing submitted (see Silent model substitution). π΄ A substitution that appears only on the submit reply is reported but exits 0: by then the charge has happened, and failing would strand a result you paid for. And against a server that does not report substitutions at all the flag is inert β exit 0, submitted, charged |
1 |
--input that is malformed, declares a non-txt2img workflow, carries an envelope key (civitaiTip, β¦), or is combined with a content flag |
2 |
No such --checkpoint / --lora version id |
4 |
civitai workflows get / workflows cancel on an unknown workflow id (a read; spends nothing) |
4 |
| β π΄ submitted: the Buzz is already spent β | |
--timeout expired, or Ctrl-C while waiting β the job keeps running server-side and was not cancelled |
1 |
The workflow finished failed / expired / canceled |
1 |
| The workflow succeeded but every output was filtered out (blocked / unavailable / hidden) | 1 |
Upgrading
civitai upgrade replaces the running binary with the latest GitHub release:
civitai upgrade # no-op (and says so) when already current
civitai upgrade --force # reinstall anyway
The release is resolved from the public GitHub releases API β no token is
ever sent β and the downloaded archive is verified against its SHA-256 entry in
the release's checksums.txt before anything is replaced. A mismatch, or a
release carrying no checksums.txt at all, aborts and leaves the current
binary untouched: it will not upgrade without integrity verification.
If this binary came from Homebrew, upgrade does not self-replace it β it tells
you to run brew upgrade civitai/tap/civitai, so the package manager keeps
owning the file. --force overrides that and self-replaces anyway.
The other install paths update the way they normally do β npm install -g @civitai/cli@latest, nix profile upgrade, or re-running go install β¦@latest.
Separately from this command, the CLI runs a background check for a newer
release and prints a one-line notice; --no-update-check (or
CIVITAI_NO_UPDATE_CHECK=1) turns that off, which is what you want in CI.
Global flags
These are accepted by every command:
| Flag | What it does |
|---|---|
-v, --version |
Print the version and exit. (civitai version prints version + commit + build date.) |
-h, --help |
Help for any command. civitai --help also prints the exit-code contract. |
--no-color |
Disable all colour and styling. Also via NO_COLOR or CIVITAI_NO_COLOR. |
--color |
Force colour even when stdout is not a TTY. Also via CLICOLOR_FORCE or CIVITAI_COLOR. |
--no-update-check |
Skip the background check for a newer release. Also via CIVITAI_NO_UPDATE_CHECK. |
The colour contract, for pipelines. Colour is off by default whenever stdout is not a TTY, so a redirected or piped run already emits plain text with no escape sequences β you do not have to ask for anything. When you do want to override that, the precedence is fixed, highest first:
--no-color/NO_COLOR/CIVITAI_NO_COLORβ off--color/CLICOLOR_FORCE/CIVITAI_COLORβ on- otherwise: on if stdout is a TTY, off if it is not
Off always beats on, so a NO_COLOR in the environment cannot be re-enabled by
a --color further down a pipeline. NO_COLOR follows the
no-color.org convention β present and non-empty is
what counts, not the value.
π΄ --json output is never styled, at any of those settings. It is written
without passing through the presentation layer at all, so --json is always
safe to pipe into jq regardless of how colour is configured or whether a TTY
is attached.
Configuration
| Setting | Config key | Env var | Default |
|---|---|---|---|
| Personal API key | token |
CIVITAI_TOKEN |
β |
| OAuth tokens (device login) | auth_kind, access_token, refresh_token, token_expiry, scope |
β | β |
| API base URL | base_url |
CIVITAI_BASE_URL |
https://civitai.com |
| Submit endpoint | β | CIVITAI_SUBMIT_PATH |
/api/v1/blocks/submit-version |
| Skip the update check | β | CIVITAI_NO_UPDATE_CHECK |
unset (the check runs) |
| dev-tunnel SSH endpoint | β | CIVITAI_DEV_TUNNEL_ENDPOINT |
sish.civitai.com:2224 |
| Disable colour | β | NO_COLOR, CIVITAI_NO_COLOR |
unset |
| Force colour | β | CLICOLOR_FORCE, CIVITAI_COLOR |
unset |
Where a setting also has a flag β --token, --tunnel-endpoint, and the colour
and update-check flags β the flag wins over the environment. See
Global flags for the full colour precedence.
Config lives at ~/.config/civitai/config.yaml (honours XDG_CONFIG_HOME),
written owner-readable only.
Exit codes
civitai returns a differentiated exit code so scripts can branch on the kind
of failure without parsing stderr. The human-readable error message is unchanged
by this β only echo $? differs.
The table is the index. Each code's full ledger β the paths it covers, the
residuals it deliberately does not, and the rules a script has to branch on β is
in that code's ### Exit code N subsection below. civitai --help prints the
table's summaries and points here for the rest.
| Code | Meaning |
|---|---|
0 |
Success. |
1 |
Generic / unclassified error. A filesystem failure lands here, and so does a validation verdict β an invalid manifest, or a real directory holding no manifest. Detail |
2 |
Usage error β a bad flag, a missing required flag or argument, a bad flag value, or a path that does not exist / is not a directory. Detail |
3 |
Not authorized β login required, token invalid/expired, or the credential lacks the needed scope (HTTP 401/403). Detail |
4 |
Not found β the requested resource does not exist. Detail |
5 |
Network/transport failure or service unavailable β the code to retry on. Detail |
6 |
Rate limited β throttled by the API (HTTP 429). |
# Branch on failure kind
if ! civitai models get "$id" >/dev/null 2>&1; then
case $? in
3) echo "log in first: civitai login" ;;
4) echo "no such model: $id" ;;
5|6) echo "transient β retry later" ;;
*) echo "failed" ;;
esac
fi
Exit code 1
- A filesystem failure lands here β a file that exists but cannot be read, an unwritable config directory, an I/O error. It is neither a mistake about the invocation (
2) nor a transport failure (5), and there is no filesystem-specific code. - A validation verdict lands here, and deliberately not on
2:civitai app validateexits1when the manifest is invalid, and likewise when the directory you named is a real directory with noblock.manifest.jsonat its root β you pointed at a real place, so the invocation was right and the project is wrong. (A path that does not exist, or that is not a directory, is the invocation being wrong, and exits2.) - When validation produces a result,
civitai app validate --jsonprints it in full and itsokfield is the structured form of the same answer; a failure that produces no result at all β a project directory the CLI cannot stat, say, because it is unreadable or because a path component below it is not a directory β still exits1with nothing on stdout, so branch on the exit code before parsing. The full exitβstdout table is in The--jsonresult shape. - A resource that exists but is not ready lands here too, and deliberately not on
4:civitai app metrics <slug>for an app whose submitted version is still in review exits1, because the slug is right and the app does exist β only its analytics do not exist yet, and the error namescivitai app status <slug>as the next command.4stays reserved for a slug with no submissions at all, so the two remain separately actionable: fix the slug, versus wait for approval.
Exit code 2
- Usage error β a bad flag, a missing required flag or argument (e.g.
civitai app withdrawwith no publish-request id), a bad flag value (--limitout of range, a non-integer id,--template nope), or a request the API rejected as malformed (HTTP 400, e.g. a bad--period/--sortenum). - This does not depend on where the refusal happens: a mistake the CLI catches locally and one the server rejects both exit
2. - A local image the CLI refuses before uploading anything (
civitai app listing set-icon <file>,civitai generate --image) exits2when the file is missing, empty, a directory, over the size cap, or not a PNG/JPEG/WebP β but a file that exists and cannot be read (permissions, an I/O error) is a filesystem failure rather than a mistake about the invocation, and exits1, not2. - That split is not images-only and it is not flags-only β it holds for a flag's value and a positional argument alike, over the paths listed here:
civitai generate --input <file>likewise exits2for a path that is not there or is a directory, and1when the file is there and the read fails. - The project commands take a positional path and refuse it the same way:
civitai app validate <dir>andcivitai app submit <dir>exit2when the path does not exist or is not a directory, because both are mistakes about the invocation. A directory that does exist but holds noblock.manifest.jsonis a validation verdict instead, and exits1. app listing set-coverandapp listing add-screenshottake the same positional<file>and refuse it the same way. (The CLI has no--fileimage flag at all: the only--fileiscivitai download --file, which picks a file inside a model version.)- Paths outside that list are not covered, and mostly exit
1.civitai app listing β¦ --dir <missing>exits1(it reports "noblock.manifest.jsonfound in β¦", the same way it does for a directory that is really there but holds no manifest), and so doescivitai app submit β¦ --out <path under a directory that does not exist>. Both are stated rather than promised: this is a ledger of the paths the split is published for, not a claim about every path in the CLI. - A usage error emits no JSON object, in every mode.
civitai app validate /nope --jsontherefore writes nothing to stdout and exits2; it used to print{"ok": false, β¦}and exit1, which reported a nonexistent path as a validation result. Scripts that parsed that object must branch on the exit code first.
Exit code 3
- Authentication/authorization β login required, token invalid/expired, or the credential lacks the needed scope (HTTP 401/403, or no token configured).
civitai generaterefines this: several of its failures are not credential problems but would otherwise land here or on2, so they exit1instead and a script never loops oncivitai login. A muted account or incomplete onboarding arrives as a bare403that is byte-identical to a missing scope; out of Buzz and generation disabled arrive as400(the upstream 403 is re-thrown server-side as a tRPCBAD_REQUEST), which would otherwise read as "bad flags". See Generate.
Exit code 4
- Usually an HTTP 404, but not always: some lookups answer
200with an empty result set instead (civitai app status <slug>for an unregistered slug,civitai users getfor an unknown username), and those exit4too. - The same question therefore exits the same way however the API happens to phrase the miss.
Exit code 5
- Network/transport failure or service unavailable β dial/timeout, or HTTP 502/503/504 after retries.
- This is the code to retry on, so a filesystem failure never lands here however retryable its errno looks: a permissions or I/O problem does not fix itself, and a loop that sleeps and re-runs would never terminate. Those exit
1.
Troubleshooting
Look up the message you got. Every row's left column is a fragment of a string this CLI really prints, so searching this page for a few words of your error should land you on the right row. (A test asserts that each of these strings still exists in the source, so the index cannot quietly go stale the way a hand-written list does.)
Credentials and access
| You saw | What it means | Where to read more |
|---|---|---|
no token configured |
Nothing is logged in. Run civitai login, or set CIVITAI_TOKEN. This includes civitai app list / app view: the App store is not an anonymous read. |
Submit & auth, Browse the App store |
not logged in (401) |
The credential is present but invalid or expired. OAuth tokens refresh themselves; when the refresh token has also expired, log in again. For a personal key, mint a new one at civitai.com/user/account. |
Submit & auth |
forbidden (403) |
Usually the invite-only Apps beta rather than a broken token β the same account reads the public API fine. | Submit & auth |
not permitted for your account (403) |
Managing a store listing needs Apps-author access, which is a narrower grant than being able to submit. | Listing media requirements |
not permitted to read this app's analytics (403) |
app metrics needs a full-scope personal API key. An OAuth login is refused here even when it can submit. |
App metrics |
block lacks ai:write:budgeted scope |
Printed by your app at runtime under dev:live. The dev token was minted without --spend, so the CLI filtered the budgeted-spend scope out β it never requests that scope implicitly, even when your manifest declares it. |
Local dev loop |
insufficient Buzz / generation disabled |
Not credential problems, which is why they exit 1 rather than 3 β a script must not loop on civitai login for either. |
Exit codes specific to generate |
rate limited (429) |
Throttled; exit 6. For deep paging use --cursor rather than --page. |
Exit codes |
Scaffolding a project
| You saw | What it means | Where to read more |
|---|---|---|
cannot derive a slug from / cannot appear in a blockId |
The name holds characters the blockId alphabet cannot carry, and dropping them would mint a different permanent public id than you typed. Choose one yourself with --slug <slug>. |
The blockId |
is not valid UTF-8 |
The same refusal one step earlier: the name's bytes cannot be read at all. Pass --slug, and a --name that is valid UTF-8. |
The blockId |
refusing to overwrite. Scaffold somewhere else |
app create / app init will not clobber a non-empty directory, and there is deliberately no --force β overwriting a directory you already have is not recoverable. Use --dir <new path>, or remove the directory first. |
Templates |
Validating and submitting
| You saw | What it means | Where to read more |
|---|---|---|
is this an App project? |
There is no block.manifest.json at the path you named. The path itself was fine β which is why this exits 1 and not 2. |
Validate fidelity |
no such directory β pass the path to an App project root |
The path does not exist. This is a usage error: exit 2, and --json prints nothing at all. |
Exit codes |
is not a directory β pass the App project ROOT |
You pointed at a file β often the manifest itself. Pass the directory holding it. Exit 2. |
Exit codes |
it did NOT check that the file is loaded |
The BLOCK_READY advisory on its weak tier: it could not resolve what your index.html loads, so it only checked whether some file mentions the message. The lines that follow name what it could not follow. |
The host handshake |
nothing index.html loads reaches it |
The strong tier: the emitter is in your project but nothing the browser loads reaches it β an orphan file. Copying civitai-host.js in is only half the fix; it has to be referenced too. |
The host handshake |
no lockfile is committed / is not a lockfile |
The platform build installs strictly from the committed lockfile, so a missing one β or a zero-byte one created with touch β fails the build server-side. A lockfile is generated by the package manager, never hand-written. |
Validate fidelity |
refusing to submit without --yes |
A submit that would really upload asked for confirmation and found no TTY. Pass --yes in CI, or --package-only to just write the .zip. |
Command reference |
Generating
| You saw | What it means | Where to read more |
|---|---|---|
refusing to spend Buzz without --yes |
The same gate on the money path. --dry-run prices the job without spending anything. |
Confirmation |
--image requires --ecosystem |
Without an ecosystem the server never promotes the job to image-to-image: your images are silently dropped and you are billed for a plain text-to-image run. Hence a refusal rather than a warning. | Image-to-image |
interrupted while waiting |
The generation is still running and has already been charged. Ctrl-C stopped the wait, not the job. Re-attach with civitai workflows get <id>. |
Waiting, downloading, and re-attaching |
model substituted |
The server ran a different checkpoint than you asked for and billed for what ran. Warned by default; --fail-on-substitution turns it into a refusal on the estimate, before any spend. |
Silent model substitution |
Everything else
| You saw | What it means | Where to read more |
|---|---|---|
has no approved App Block yet |
The slug is right and the app exists β its analytics do not, because the version is still in review. Exit 1, not 4. |
App metrics |
no such app for your account |
The slug matches none of your submissions. List them with civitai app status. |
Submission status |
is ambiguous β it matches |
A model version has several files sharing that name. Select one by its numeric file id with --file <id>. |
Download model files |
SHA256 mismatch for |
A download's hash did not match, and the partial file was deleted. Retry β this is integrity checking working, not a bug. | Download model files |
checksum mismatch for |
The same, during civitai upgrade. The binary was not replaced. |
Upgrading |
git is required for `civitai app pull` |
app pull shells out to git, which is not on your PATH. |
Pull your app's repository |
Still stuck? Every command takes --help, civitai --help prints the exit-code
contract, and failures are differentiated by exit code β so a
script can branch on the kind of failure without matching any of these
strings.
Development
make ci # go mod tidy + vet + test + build
make lint # golangci-lint β a SEPARATE job; `make ci` does not run it
make test
make build # -> bin/civitai
make fmt
go test ./... -cover
π΄ make ci is not a mirror of CI. It runs tidy + vet + test + build and
does not run lint, which is its own CI job β so run make lint too before
calling a change done. It errors out when golangci-lint is not on PATH
rather than degrading to something weaker, which is what makes a clean run mean
anything.
- Language: Go 1.25, Cobra (commands) + Viper (config).
- Layout / conventions / how to add a command / release process: see
AGENTS.md. - Contributing: see
CONTRIBUTING.md.
CI is eight jobs, not four steps. .github/workflows/ci.yml runs
build-test (vet + gofmt -s -l . + test + build), lint, schema-drift,
pins-vs-published, ready-ack-runtime, template-page-vite,
template-page-money and scaffold-currency on every push to main and every
PR. Several exist to catch drift between this repo and the platform β the
vendored schema, the scaffold's npm pins, the blockβhost handshake β which a
plain go test cannot see.
Running and gating are different questions, and fewer of those jobs gate a
merge than run. The measured set of required status checks, and the instruction
to re-measure rather than trust a written copy, live in AGENTS.md
item 11 β deliberately in one place, because a second copy is how the original
claim went stale. Notably lint reports without blocking, which is another
reason to run it locally.
Releasing
Releases are built by goreleaser from a GitHub
Actions workflow on a v* tag push:
git tag v0.1.0
git push origin v0.1.0
This cross-compiles for linux/darwin/windows Γ amd64/arm64, stamps
version/commit/date, and creates a draft GitHub Release with archives +
checksums.txt. It also renders the Homebrew cask and attaches it to the
release, but does not push it to the tap β see below. See
AGENTS.md for the full process and the secrets it needs
(HOMEBREW_TAP_GITHUB_TOKEN).
π΄ There are three publication channels, and clicking "Publish release" fires
the other two. .github/workflows/release-npm.yml publishes the npm/
wrapper as @civitai/cli β
the very first install option at the top of this README β and
.github/workflows/release-homebrew.yml pushes the cask to
civitai/homebrew-tap. Both trigger on release: [published]. So publishing
the draft is not the last step of the GitHub release; it is also, in the same
click, an npm publish and a Homebrew release. That matters because npm
unpublish is restricted: a bad version is corrected by publishing another one,
not by taking it back.
π΄ Nothing downstream may act on a tag alone, and the Homebrew channel used to. Until #308, the same goreleaser run that created the draft release also pushed the cask bump, so the cask named a version whose archives 404 for everyone until a human clicked "Publish release". Measured on 2026-08-09, with v0.1.91 tagged at 01:09Z and still a draft hours later:
cask Casks/civitai.rb version "0.1.91"
GET .../download/v0.1.91/civitai_0.1.91_linux_amd64.tar.gz 404
GET .../download/v0.1.90/civitai_0.1.90_linux_amd64.tar.gz 200
npm @civitai/cli 0.1.90 (correct)
brew install civitai/tap/civitai failed for every user for ~2 hours. npm was
correct throughout because it already waited for release: [published]; the
Homebrew channel now waits for the same event, and tools/caskcheck asserts the
invariant β the cask must never name a version that is not publicly
downloadable β on every publish and once a day, over real unauthenticated HTTP.
π΄ A failing scheduled run notifies almost nobody, so the daily check files
its own report. GitHub sends scheduled-workflow notifications only to the one
account that last edited the cron line, through that person's own per-user
Actions setting β there is no org-, team- or repo-level failure notification.
Measured in this repo: every scheduled run of bump-flake-vendorhash.yml failed
on 2026-07-20, 2026-07-27 and 2026-08-03, and no issue was ever filed about it.
So release-homebrew.yml opens a GitHub issue titled [cask-check] β¦ when
the check is not green, rewrites that one issue's body on each subsequent
failure (an edit notifies nobody, so a week of failures is one issue and zero
comments), comments only when the kind of failure changes, and closes the
issue when the check goes green β which is what makes the next failure notify
again. It distinguishes "the check failed" from "the check could not run": an
unreachable tap, an unreadable cask, an unclassifiable error and a verify job
that died before producing a verdict each get their own wording, and none of
them is allowed to read as a clean bill of health.
gh workflow run release-homebrew.yml -f drill=broken|lagging|unmeasurable is a
fire drill: it points the check at a fixture, opens a real issue exactly as a
real failure would, and cannot touch the tap. Run it after changing any of this β
a notification path nobody has watched work is not a notification path.
A cask that merely lags a published release is green for the first 24h,
because that is the normal state right after a publish and a permanently-red
check is worse than none. After that it is a finding of its own, distinct from
the 404 outage above and explicitly not claiming users are broken: the only
thing allowed to move the cask is that release: published job, so a cask still
lagging a day later means the event never reached it β a dropped webhook, an
expired HOMEBREW_TAP_GITHUB_TOKEN, a failed push nobody read. The threshold is
measured, not guessed: across the 30 most recent releases the gap from
published_at to the tap commit is ~1 minute, and the tagβpublish gap (a
strictly larger window) has a median of ~2m30s and a worst case of 1h55m β the
2026-08-09 incident itself.
Authentication for that job is OIDC trusted publishing β there is no
NPM_TOKEN secret. The trust is bound to the repository and to that workflow
file's path, so moving or renaming release-npm.yml breaks publishing, and no
secret rotation will fix it.
License
Documentation
ΒΆ
Index ΒΆ
Constants ΒΆ
This section is empty.
Variables ΒΆ
var ExamplesFS embed.FS
ExamplesFS holds the real example manifests (copied from the shipping civitai-block-* apps). They are embedded so the validate test can assert the "examples validate clean" claim against the same files the README points to.
var SchemaJSON []byte
SchemaJSON is the vendored App manifest JSON Schema, embedded so the CLI validates against the same contract it ships. The file is the canonical copy intended to also be published server-side (see README).
Functions ΒΆ
This section is empty.
Types ΒΆ
This section is empty.
Directories
ΒΆ
| Path | Synopsis |
|---|---|
|
cmd
|
|
|
civitai
command
Command civitai is the unified Civitai CLI.
|
Command civitai is the unified Civitai CLI. |
|
internal
|
|
|
antipattern
Package antipattern is the scaffold-currency "born-broken app" gate.
|
Package antipattern is the scaffold-currency "born-broken app" gate. |
|
appapi
Package appapi is the CLI-internal client for the Civitai App Blocks developer surface: bundle submission, submission status / withdraw, the per-app dev-token and dev-tunnel machinery, the owner-only Forgejo clone info, and the OAuth device-authorization login flow.
|
Package appapi is the CLI-internal client for the Civitai App Blocks developer surface: bundle submission, submission status / withdraw, the per-app dev-token and dev-tunnel machinery, the owner-only Forgejo clone info, and the OAuth device-authorization login flow. |
|
auth
Package auth bridges the persisted config and the civitai.TokenSource contract: it yields a Bearer token for the api client, refreshing device-flow OAuth tokens (and persisting the rotated refresh token) when they expire or after a 401.
|
Package auth bridges the persisted config and the civitai.TokenSource contract: it yields a Bearer token for the api client, refreshing device-flow OAuth tokens (and persisting the rotated refresh token) when they expire or after a 401. |
|
blockproto
Package blockproto is the CLI's single authority for the block -> host postMessage contract that scaffolded, SDK-free apps must implement themselves.
|
Package blockproto is the CLI's single authority for the block -> host postMessage contract that scaffolded, SDK-free apps must implement themselves. |
|
cmd
Package cmd wires the cobra command tree for the civitai CLI.
|
Package cmd wires the cobra command tree for the civitai CLI. |
|
config
Package config handles the CLI's persisted configuration (API base URL and token) via Viper.
|
Package config handles the CLI's persisted configuration (API base URL and token) via Viper. |
|
devtunnel
Package devtunnel holds the transport-level pieces of `civitai app dev-tunnel`: the EPHEMERAL SSH keypair the CLI mints per session, the reverse-tunnel dialer (`ssh -R` to the sish endpoint) behind an interface, and a small clock/timer seam β so the command's lifecycle (mint β tunnel β teardown on signal / idle) is unit-testable without a live server or a real network.
|
Package devtunnel holds the transport-level pieces of `civitai app dev-tunnel`: the EPHEMERAL SSH keypair the CLI mints per session, the reverse-tunnel dialer (`ssh -R` to the sish endpoint) behind an interface, and a small clock/timer seam β so the command's lifecycle (mint β tunnel β teardown on signal / idle) is unit-testable without a live server or a real network. |
|
dnsprobe
Package dnsprobe resolves a hostname via DNS-over-HTTPS (DoH) to Cloudflare, bypassing the OS/local resolver, and builds an *http.Client that dials the DoH-resolved IP for that host.
|
Package dnsprobe resolves a hostname via DNS-over-HTTPS (DoH) to Cloudflare, bypassing the OS/local resolver, and builds an *http.Client that dials the DoH-resolved IP for that host. |
|
dogfoodguard
command
Command dogfoodguard classifies a `civitai` invocation for the dogfood sandbox (scripts/dogfood-sandbox.sh) by asking the REAL command tree what the argv means, and prints a machine-readable verdict.
|
Command dogfoodguard classifies a `civitai` invocation for the dogfood sandbox (scripts/dogfood-sandbox.sh) by asking the REAL command tree what the argv means, and prints a machine-readable verdict. |
|
genapi
Package genapi is the CLI-internal client for the orchestrator generation graph (the tRPC `orchestrator.*` procedures behind `civitai generate`).
|
Package genapi is the CLI-internal client for the orchestrator generation graph (the tRPC `orchestrator.*` procedures behind `civitai generate`). |
|
manifest
Package manifest holds the App manifest filename constant and a lightweight reader for the fields the CLI needs (slug/version/name).
|
Package manifest holds the App manifest filename constant and a lightweight reader for the fields the CLI needs (slug/version/name). |
|
pkgzip
Package pkgzip packages an App project directory into the canonical ZIP the platform build recipe expects: the SOURCE tree (manifest + src + build config), with build artifacts and VCS/dependency dirs excluded.
|
Package pkgzip packages an App project directory into the canonical ZIP the platform build recipe expects: the SOURCE tree (manifest + src + build config), with build artifacts and VCS/dependency dirs excluded. |
|
scaffold
Shared @civitai/* scaffold-pin logic.
|
Shared @civitai/* scaffold-pin logic. |
|
scaffold/cmd/bump-pins
command
Command bump-pins keeps the scaffold's @civitai/* pins in lockstep with npm.
|
Command bump-pins keeps the scaffold's @civitai/* pins in lockstep with npm. |
|
ui
Package ui is the single, cohesive presentation layer for the civitai CLI.
|
Package ui is the single, cohesive presentation layer for the civitai CLI. |
|
validate
Package validate checks a block.manifest.json against the vendored JSON Schema plus structural project checks (manifest at root, build coherence).
|
Package validate checks a block.manifest.json against the vendored JSON Schema plus structural project checks (manifest at root, build coherence). |
|
pkg
|
|
|
civitai
Package civitai is the importable HTTP client SDK for the Civitai API.
|
Package civitai is the importable HTTP client SDK for the Civitai API. |
|
tools
|
|
|
caskcheck
command
Command caskcheck asserts the ONE invariant the Homebrew channel has to hold:
|
Command caskcheck asserts the ONE invariant the Homebrew channel has to hold: |