Source for developer.civitai.com — built with VitePress.
Covers the Orchestration API (submit AI workflows for video, image, audio, and text generation through a single contract) and the Civitai Site API (browse models, images, creators, and tags on civitai.com). Structured to host additional product docs (SDKs, Signals, etc.) over time.
npm install
npm run devThe dev server starts with HMR. The OpenAPI spec is resolved automatically from the sibling civitai-orchestration repo if present, otherwise from the committed snapshot — see OpenAPI spec sync. Neither path touches the network.
npm run build # outputs .vitepress/dist/
npm run preview # serve the built site locallydocker build . -t civitai-developer-docsProduces an nginx:alpine image serving the static site.
├── index.md # Developer Hub landing page
├── orchestration/
│ ├── guide/ # Consumer onboarding (auth, workflows, results, errors)
│ ├── recipes/ # Runnable examples with interactive Try It widgets
│ ├── reference/ # Auto-generated from OpenAPI spec (dynamic routes)
│ └── internals/ # Architecture diagrams
├── site/
│ ├── guide/ # Civitai site API: auth, pagination, errors, AIR
│ └── reference/ # Hand-written per-resource endpoint docs
├── .vitepress/
│ ├── config.mts # Nav, sidebar, plugins
│ └── theme/
│ ├── components/ # AuthBar, RecipeRun, ResultViewer
│ └── composables/ # useAuthToken, useWorkflow
├── agent-setup/index.md # Landing page for the one-line agent setup
├── public/agent-setup/prompt.md # The raw prompt, served VERBATIM (see below)
├── scripts/copy-spec.mjs # OpenAPI spec sync script
├── openapi-snapshots/ # Committed spec snapshot — the build's default source
├── public/openapi/ # Spec destination (gitignored)
├── public/.well-known/ai-catalog.json # Machine-discovery catalog (see below)
├── Dockerfile # Multi-stage build for production
└── nginx.conf # cleanUrls routing + caching, .md content types, Link: advertisement
Facts about how this site is served that are easy to get wrong and hard to notice, plus one that turned out not to be ours at all.
.md is text/markdown, not text/plain. nginx's mime.types has no entry
for .md, so nginx.conf sets it explicitly. It used to say text/plain "like
GitHub raw". That is the wrong type and it is not cosmetic: some agent fetchers
bypass lossy summarisation only for text/markdown responses under ~100k
characters, so every per-page .md this site publishes for machines was being
summarised away by exactly the consumers vitepress-plugin-llms exists to serve.
Every page URL negotiates. A request whose Accept lists text/markdown
at a q-value at least that of text/html gets the page's .md twin from the
page URL itself (/apps/guide/, /apps/guide, /apps/guide.html all ->
/apps/guide.md); browsers, */* and no Accept get the HTML; a page with no
twin always gets the HTML. Both sides send Vary: Accept, and the HTML side
adds Link: <….md>; rel="alternate"; type="text/markdown", </llms.txt>; rel="describedby" (plus a matching <link rel="alternate"> in the page head).
Claude Code's WebFetch sends Accept: text/markdown, text/html, */*, so it gets
markdown. The q-value comparison is done in nginx maps with no arithmetic —
npm run test:nginx-negotiation runs the built site under a real nginx:alpine
with this nginx.conf and asserts the whole contract (CI: nginx-negotiation.yml).
The machine surface is self-contained. In every twin, every internal page
link is an absolute https://developer.civitai.com/<page>.md URL and a one-line
index note sits under the frontmatter; llms.txt entries are absolute; the
generated API operation pages get twins rendered from the OpenAPI spec (the
plugin cannot emit them — their body is one component tag); and
/apps/llms.txt, /orchestration/llms.txt, /site/llms.txt each index one
section. There is deliberately no per-section llms-full.txt: at ~0.2-1.0 MB
each they would be far over the ~100K-character fetch they would exist to fit. All of it is buildEnd post-processing
(scripts/lib/agent-surface.mjs, scripts/lib/operation-twins.mjs), asserted
on the build by npm run check:agent-surface.
/agent-setup/prompt.md is a verbatim static route. It is
public/agent-setup/prompt.md; VitePress copies public/ into the output with no
markdown pipeline, so the served bytes equal the source bytes. It gets an exact
location = match in nginx.conf with text/markdown; charset=utf-8 and
X-Content-Type-Options: nosniff, and no redirect — Claude Code does not
follow a cross-host redirect, it returns a description of the redirect instead,
which would break every first fetch. public/** is in srcExclude so VitePress
does not also compile the raw prompt into a page.
The .md type has one human-facing cost, and the landing page absorbs it.
text/markdown + nosniff means a browser offers to SAVE a .md URL rather than
render it. Site-wide that is nearly free — humans visit /apps/guide, and
/apps/guide.md is the machine channel by convention. The exception is
/agent-setup/prompt.md, whose whole point is that a human can read it before
pasting it into an agent. So agent-setup/index.md renders the
prompt's full text inline, generated from the served file by
npm run gen:agent-setup-page and graded byte-for-byte by
npm run check:agent-setup; the raw URL stays on the page, labelled as the
machine copy. Run the generator after any edit to public/agent-setup/prompt.md
— the check blocks a PR that forgot. See scripts/agent-setup-page.mjs for why
the inline copy is a fence rather than spliced markdown.
✅ RESOLVED — Cloudflare used to 403 two user agents; it no longer does. This
section said "Nothing in this repository can change it … a naive
urllib.request fetch … is hard blocked", and that has been false since the fix
below. Re-measured 2026-09-11 against the live zone:
curl -sI -A 'Python-urllib/3.11' https://developer.civitai.com/ -> 200
curl -sI -A 'Python-urllib/3.12' https://developer.civitai.com/ -> 200
curl -sI -A 'libwww-perl/6.0' https://developer.civitai.com/ -> 200
All three also return 200 on /llms.txt and /agent-setup/prompt.md, with
python-requests and default curl as controls.
The cause was Cloudflare's Browser Integrity Check (error 1010), a
zone-level setting — never this repo, which is why the same nginx.conf run
locally always returned 200 to the blocked UAs. It was fixed with a
Configuration Rule on the civitai.com zone scoped to
http.host eq "developer.civitai.com", setting bic: false; browser_check
stays on zone-wide, so civitai.com and image.civitai.com keep it.
✅ RESOLVED — Cloudflare used to eat version numbers out of the published HTML.
Email Address Obfuscation rewrites anything matching user@domain into an
obfuscated [email protected] link, and a version literal such as
@civitai/theme@0.3.1 matches that shape. Measured 2026-09-19 over all 194
published pages: 26 rewrites on 9 pages, and every decoded blob was an
identifier, not an address — 24 @civitai/* pins plus an AIR URN
(spine-comfy@v1.0.0) and a Docker tag (python@3.12-slim). Worst case was
apps/guide/theming, where it truncated the copy-pasteable CDN URLs to
unpkg.com/@civitai/ — that page's whole purpose. Readers with JS got the text
back; no-JS readers, scrapers and anyone copy-pasting a URL did not.
It was fixed on the same Configuration Rule as bic above, by adding
email_obfuscation: false. email_obfuscation stays on zone-wide, so
civitai.com keeps it. Verified after the change with an HTTP-200 + closing-tag
control, so a zero could not be a failed fetch: rewrites across those 9 pages
went 26 → 0, and theming went from 0 to 6 occurrences of theme@0.3.1
with the unpkg URLs intact.
🔴 The tell that this has regressed is a version number rendering as
[email protected] — in view-source or with JS off; with JS on, Cloudflare's
own script decodes it and the page looks fine. Check with
curl -s <page> | grep -c __cf_email__ and a positive control that the page
actually loaded, because a bare 0 is indistinguishable from a failed fetch.
⚠ Do not "fix" this in the repo. A build-time wrapper using Cloudflare's
<!--email_off--> markers was written, proven to work locally, and then closed
unmerged (#93) in favour of the rule: the markers cannot be placed from markdown
at all (HTML comments do not survive the build, and most affected literals sit
inside fenced code blocks), a transformHtml hook reaches only <body> so
<head> stays exposed, and whether Cloudflare honours or strips the markers is
not observable before deploying.
🔴 If either of these ever regresses, re-probe rather than re-reading
Cloudflare's docs. No Cloudflare documentation states that a Configuration Rule
overrides the zone-level browser_check — that precedence was established
empirically, by reading browser_check: "on" while the exempted host served 200
to a UA that setting blocks. The same holds for email_obfuscation, which also
reads on at the zone while this host is exempt.
This site ships a machine-discovery catalog, and also advertises it. The
artifacts are /llms.txt, /llms-full.txt and /agent-setup/prompt.md;
public/.well-known/ai-catalog.json names all three in one document, and
nginx.conf sends a Link: header pointing at the catalog and the two llms
files from every document route. Measured 2026-09-11, no precedent does both:
developers.cloudflare.com ships the same two .well-known artifacts and
advertises neither, while mintlify.com/docs advertises six rels. Doing both
costs three header lines.
Two things about it are load-bearing and easy to undo by tidying:
Linkis repeated in everylocationthat sets any header. nginx inheritsadd_headerfrom an outer block only if the inner block declares none of its own, so one server-levelLinkis silently dropped by any location that sets a header. Measured on a real nginx: with a single server-level copy, four of five document routes served noLink— including/, which resolves through an internal redirect intolocation ~* \.html$. A one-URL smoke test of the homepage would have passed on a config that advertises almost nowhere.- The catalog's
locationcarries an emptytypes { }block..jsonis in nginx'smime.types, sodefault_type application/ai-catalog+jsonalone is never consulted and the response comes backapplication/json. The empty block clears the map sodefault_typeapplies. It looks like a no-op; deleting it silently reverts the declared type.
npm run check:ai-catalog asserts the catalog and the header describe the same
site, and — on a schedule, never as a PR gate — that every URL either names
actually resolves. Advertising a dead target is worse than advertising nothing:
on 2026-09-11 docs.mintlify.com advertised six rels whose targets all returned
530, with nothing on their side red, because a Link header is consumed by
fetchers and no human ever sees the result.
Not published, and deliberately: .well-known/agent-skills/index.json. Every
entry in that format is a tar.gz or SKILL.md with a sha256: digest, and
this project publishes no skill bundles — llms.txt is already the flat index,
and a measured comparison put a flat index at 0.462 against 0.267 for a
hierarchy. An index with zero or invented entries would read as coverage while
providing none. The two MCP endpoints are absent for a different reason: a GET
to mcp.civitai.com/mcp is a 405 and orchestration.civitai.com/mcp a 401, so
they are transports rather than fetchable documents. The entry that would
represent them is a .well-known/mcp/server-card.json, which this site does not
serve yet.
Recipe pages include Try It widgets (<RecipeRun>) that let readers preview cost, submit real workflows, and see results inline. Set an API token via the Token button in the navbar.
The API reference uses vitepress-openapi for interactive playgrounds on each operation page.
The spec at public/openapi/v2-consumers.json is gitignored and resolved at dev/build time by scripts/copy-spec.mjs. The build is hermetic — the default path makes no network request:
- Sibling repo, if the dev stack is checked out beside this one:
../../civitai-orchestration/repo/src/.../wwwroot/openapi/v2-consumers.json - The committed snapshot:
openapi-snapshots/v2-consumers.json
If neither exists the build fails immediately with a message naming both paths — it never falls back to the network, so a orchestration.civitai.com outage can no longer fail a build or a CI job.
To refresh the committed snapshot from the live spec (the only path that fetches):
npm run copy:spec -- --refresh # rewrites openapi-snapshots/v2-consumers.jsonBounded: 20 s per attempt, 3 attempts. If it cannot reach the host it warns and falls back to the snapshot rather than failing.
Staleness is caught by npm run check:spec-drift, which runs daily from appblocks-drift.yml and goes red when the published spec no longer matches the snapshot. Same doctrine as the App Blocks snapshots: upstream freshness belongs on a schedule, never on a PR gate.
appblocks-snapshots/civitai-cli-help.txt is the only source the published CLI
reference is generated from: the production image has no civitai binary, so
gen-appblocks-cli.mjs always takes the snapshot path. A stale snapshot means
developer.civitai.com silently serves wrong content — the v0.1.92 re-capture
(#56) turned out to carry 2.0 MB → 2.0 MiB in 10 places.
This is the policy the whole mechanism follows, and it was not written down anywhere until it caused a misunderstanding worth recording.
Why. developer.civitai.com documents the binary a reader can actually
install — brew install civitai, npm i -g @civitai/cli, a GitHub release
archive. All three are releases. Capturing from civitai/cli's main would
publish help text for flags and commands nobody can obtain, on a site whose
whole job is to describe the tool in the reader's hands.
So the trigger is tag-lag, and that is sufficient: there is no between-release build a reader could be holding for the docs to be wrong about.
The consequence, which is deliberate and not a bug. Drift on civitai/cli's
main between releases is invisible to this machinery. check:cli-snapshot
compares tags; so does the refresher. Two earlier hand re-captures (#48, #52)
happened while the tag was unchanged and that check was green — under this
policy those were re-captures of unreleased main state, which is the thing
that should not have happened, rather than incidents the automation missed.
Changing this is a policy decision (a docs PR per upstream merge, and a snapshot
header that stops naming an installable version), not a trigger swap.
Two halves, and neither is a PR gate (a civitai/cli release is upstream movement, unrelated to any docs PR):
| DETECT | npm run check:cli-snapshot — daily from appblocks-drift.yml, red on drift. Read-only; safe to run anywhere. |
| REPAIR | cli-snapshot-refresh.yml, daily. Builds, re-captures, opens a PR. A workflow, not a local command — see below. |
cli-snapshot-refresh.yml builds a civitai binary at the latest release tag,
re-captures, and pushes the one stable branch bot/cli-snapshot-refresh — reused
rather than recreated, so there is one PR rather than one per day, and so a
commit you push there (the empty "trigger checks" commit, a fixup) is not
deleted by the next run. Each run also merges main into that branch before it
captures. Without that, the first PR you accept leaves the branch permanently
at odds with main — the squash-merge puts its bytes on main as a commit
outside the branch's own history — and every later run opens a conflicted PR
on a green run. If the two genuinely diverge, the run fails with both sides named
and a compare URL, rather than opening a PR nobody can merge. It never pushes
to main: the human read of that diff is what makes a real user-facing change
legible as one.
The same commit carries the generated reference pages the new snapshot makes
stale — the CLI index region in apps/reference/cli.md and the sub-pages under
apps/reference/cli/ — regenerated with exactly the command
check:md-regions prints (APPBLOCKS_SNAPSHOT_ONLY=1 npm run gen:appblocks && npm run gen:appblocks:md), so a release that adds a command no longer needs a
human commit to get test-md-regions green. Only pages that generator owns are
staged; anything else it touched is listed in the PR body and left out. If the
regeneration itself fails, its writes are rolled back and the PR still opens
with the snapshot alone, its body naming the failed step, the error and the
command to run — the PR is this workflow's only announcement, so it must not
go silent. (If even the rollback is incomplete, nothing is committed and the run
fails, naming the leftovers.) A human had to do this by hand three times: #126,
#178, #220.
The one thing it does not write is the hand-written intro on
apps/reference/cli.md that names every top-level command: when the CLI gains
(or drops) one, the PR body names it under "A human has to write one sentence
here", and test-cli stays red until somebody does — that gate exists to catch
prose rot, and a bot filling it in would defeat it.
🔴 npm run refresh:cli-snapshot is not a repair command you run. It commits
to a shared remote branch and leaves your checkout sitting on it. It refuses
outside CI for that reason; --dry-run (below) is the local form.
A capture that is SHORT (fewer ===CMD blocks than the snapshot it replaces),
carries NUL bytes, or came from a binary whose version disagrees with the target
tag fails the job instead of opening a PR — a short capture otherwise looks
like an ordinary refresh whose diff has quietly deleted whole command subtrees.
permissions:
block can grant it. While it is off the job pushes the branch and then exits RED
with the setting to flip and a compare URL — measured live, gh pr create fails
with GitHub Actions is not permitted to create or approve pull requests.
GITHUB_TOKEN does not get its checks run,
so that PR shows zero checks and main's required contexts never report — the
same diff opened by a human ran all seven. Close and reopen it (or push an empty
commit) before reviewing. The PR body says so in its first section.
To exercise the drift path on demand while the snapshot is current, run the
workflow from the Actions tab with a force_tag input, or locally with
--dry-run, which captures, validates, prints the PR body it would open, and
restores the tree — no git writes, no gh:
CIVITAI_CLI_BIN=/path/to/civitai CLI_SNAPSHOT_REFRESH_TAG=v0.1.92 \
npm run refresh:cli-snapshot -- --dry-runThe refresher's own tests run on every PR from cli-snapshot-refresh-test.yml
(npm run test:refresh-cli-snapshot). They are repo-local and offline — a
throwaway clone with a bare local origin and a civitai stand-in that replays
the committed bundle — so they block a PR under the same doctrine as
appblocks-cli.yml, while the workflow they guard stays scheduled.
- Create a top-level directory (e.g.
signals/) - Add sidebar and nav entries in
.vitepress/config.mts - Add a feature card to
index.md
<ApiTry> widgets (site docs) and <RecipeRun> widgets (orchestrator recipes)
can be exercised against the live APIs via:
CIVITAI_TOKEN=your-token npm run test:samples # all
CIVITAI_TOKEN=your-token npm run test:samples:site # /site only
CIVITAI_TOKEN=your-token npm run test:samples:orch # /orchestration/recipes onlyOrchestrator samples are submitted with ?whatif=true — no Buzz is spent and
no jobs actually run. Site samples are plain GETs. CI runs this on every PR
that touches the samples and on a daily cron (see .github/workflows/test-samples.yml)
so API drift surfaces even when docs don't change.
When a sample is intentionally broken (orchestrator-side bug, placeholder URL
awaiting a real asset, etc.), precede the widget with a <!-- test-skip: -->
HTML comment on the line immediately before it:
<!-- test-skip: editVideo whatif returns empty-body 500 — unskip once fixed -->
<RecipeRun :body="editBody" />The widget still renders in the docs, but the test script reports it as skipped (with the reason) instead of failing. Skipped samples are listed in the test summary as a reminder to revisit.
Proprietary. Copyright Civitai.