Admin API
The admin server exposes an HTTP API under /api/. The admin site, the MCP server, and any client you write use the same routes. Unless a route says otherwise, every request needs an authenticated session.
The page starts with the conventions every route shares (response messages, error bodies, authentication), then documents one route per section.
The message triple
Section titled “The message triple”Every response body, success or refusal, carries its user-facing prose as up to three fields:
| Field | Presence | Meaning |
|---|---|---|
message |
always | The sentence, in English; what curl, scripts, and the MCP server read. It never follows the operator’s UI locale. |
messageKey |
optional | The message-catalog key message was rendered from. Locale-invariant. Absent when the sentence has no catalog entry. |
messageParams |
optional | The parameters messageKey interpolates. Absent for a message that takes none. |
Match on messageKey, never on the English text.
A few responses build their sentence from counted nouns — an import’s “Applied 3 changes across 2 lists” — and there a param may itself be a message ({ messageKey, messageParams }) or a list ({ list, join }, where join is and, unit, clauses or concat), rendered in the same locale as the sentence around it, so the counts pluralize in the reader’s language. Import CSV, Import Changes and Import Deck say so where they do, and so do the refusals of change JSON — a change bundle, a save’s changes and a history rewrite’s events — whose domain.changes.problem.* sentence nests the record it is about (Move #1 "from", changes[2]) as its subject, and an error a route did not expect, such as a cache-lock time-out. Every other response’s params are plain strings and numbers.
Error responses
Section titled “Error responses”Every route refuses a request with the same body, whatever the status — and so does the server itself when it refuses a request before any route runs (an address or User-Agent its allow/deny lists turn away, a signed-in route before the first account exists or without a session, a path with no route or file when no admin site is built, an unexpected failure):
{ "success": false, "message": "…", "messageKey": "admin.api.…", "messageParams": { "…": "…" }}messageKey and messageParams follow the message triple: present on a keyed refusal, absent when the prose has no catalog entry. A refusal naming a list that does not resolve (Diff Lists, Export Cards) carries the list resolver’s errors.resolveList.* key, and its params may nest (the ambiguous matches are a list of messages).
An error a route did not expect — one raised deep in an engine — is answered the same way, in English plus its key whenever it has one. A card-cache error is one: a cache-lock time-out (domain.cache.lockTimedOut, whose timeout param is a duration { durationMs } and whose holder is a message of its own) or a failed request to a --cache-server (domain.cache.serverRequestFailed, …). These params nest the way a sync’s do.
499 is the status a route answers when an in-process caller cancels the request part way (the MCP tools do this on a client’s notifications/cancelled). HTTP requests are never cancelled this way: closing the connection leaves the handler running to completion.
A few routes carry extra fields on failure where they are part of the wire contract:
- Card Details adds
card: null. - Card Search keeps its paging fields and an empty
cardsarray. - Card Autocomplete and Card Printings use one shape for success and failure.
- A save that loses an optimistic-concurrency race carries
conflict: truewith its409. - A save refused because a change missed its card carries
unmatched, one entry per change.
Request size limits
Section titled “Request size limits”Every route caps its request body and answers 413 past the cap. The bytes are counted as they arrive, so the cap holds for a chunked body or one that declares no Content-Length.
| Routes | Cap |
|---|---|
| Credentials, settings, list names, and other small bodies | 10 KiB |
| List metadata (a description can be long) | 1 MiB |
| Saves (at most 50,000 changes), moves, removals, sync and export requests | about 9.5 MiB |
| Buylist quotes (at most 500 printings) | about 117 KiB |
| Uploads: CSV and decklist imports, change bundles, history rewrites | 16 MiB |
Beneath the per-route caps, the server refuses any body larger than 16 MiB plus a small allowance; that refusal comes from the runtime and has no JSON body.
Authentication
Section titled “Authentication”The admin uses session-based authentication. Send POST /api/login with your credentials (and a TOTP code if TOTP is enabled). The response sets a session cookie in a Set-Cookie header, and later requests authenticate with that cookie.
- Sessions expire after 24 hours.
POST /api/logoutends one early. - Unauthenticated requests to protected routes receive
401with the shared error envelope (messageKey: admin.api.server.unauthorized). Before the first admin account exists they receive403(admin.api.server.setupRequired). - Rate-limited sign-ins receive
429 Too Many Requests: the shared error envelope (messageKey: admin.api.login.rateLimited) plusretryAfterSeconds, the remaining lockout, which theRetry-Afterheader also carries.
Server Status
Section titled “Server Status”GET /api/statusNo authentication is required.
Returns server health, whether first-time setup is needed, and which optional capabilities this server offers.
Response:
{ "ok": true, "setupRequired": false, "totpEnabled": true, "sellMode": false, "about": { "version": "1.0.0", "license": "AGPL-3.0-only", "sourceUrl": "https://github.com/sloshy/ritual", "revisionUrl": "https://github.com/sloshy/ritual/tree/v1.0.0" }}about is the running Ritual’s source offer: its version, its license, the repository holding its source, and revisionUrl, the source of exactly this version (the release tag’s tree, or the commit’s for a development build). The admin UI shows it at the foot of its navigation. See Source Code.
sellMode is the effective value: site.sellMode, or true when the server was started with --sell-mode. When it is false a client hides its sell surfaces. The /api/sell/* and /api/buylist/* routes themselves stay open while the cardkingdom price store is enabled, because Card Kingdom retail prices come from the same feed.
Create Admin Account
Section titled “Create Admin Account”POST /api/setupNo session is required. Instead the request carries the one-time setup token ritual admin printed when it started (see First-Time Setup). A missing or wrong token returns 403 Forbidden. Only works when no admin user exists; returns 409 Conflict otherwise.
Request body:
{ "username": "admin", "password": "mypassword", "setupToken": "Qm9yb3NDaGFybUJvbHQxMjM0"}Response:
{ "success": true, "message": "Admin account created successfully"}Log In
Section titled “Log In”POST /api/loginNo authentication is required.
Authenticate with username, password, and optionally a TOTP code. On success, the response sets a session cookie.
Request body:
{ "username": "admin", "password": "mypassword", "totpCode": "123456"}| Field | Type | Required | Description |
|---|---|---|---|
username |
string | Yes | Admin username |
password |
string | Yes | Admin password |
totpCode |
string | No | TOTP code (required if TOTP is enabled) |
Response (success):
{ "success": true}The response includes a Set-Cookie header with the session token.
Response (TOTP required):
{ "success": false, "message": "TOTP code required", "messageKey": "admin.api.login.totpRequired", "totpRequired": true}Response (locked out, 429):
{ "success": false, "message": "Too many failed attempts. Try again later.", "messageKey": "admin.api.login.rateLimited", "retryAfterSeconds": 240}Log Out
Section titled “Log Out”POST /api/logoutDestroys the current session.
Request body: None
Response:
{ "success": true}Audit Log
Section titled “Audit Log”GET /api/audit-logReturns recent login attempts, most recent first.
Query parameters:
| Parameter | Type | Default | Description |
|---|---|---|---|
limit |
number | 100 |
Max entries to return (1–1000) |
Response:
{ "success": true, "entries": [ { "timestamp": "2026-02-26T19:00:00.000Z", "ip": "127.0.0.1", "username": "admin", "success": true, "reason": "Login successful", "userAgent": "Mozilla/5.0 ..." } ]}Every login attempt, successful or failed, is written to .logins/admin-audit.log with timestamp, IP address, username, result, reason, and user agent. The admin UI shows the same log under “Audit Log”.
Create Deck
Section titled “Create Deck”POST /api/deck/createCreate a new deck file, named after the deck — see List file names. A name with no usable file-name characters returns 400.
Request Body:
{ "name": "My Commander Deck", "format": "commander"}| Field | Description | Required |
|---|---|---|
name |
Deck name (used to generate the slug) | Yes |
format |
Deck format (default: "commander") |
No |
format must be a canonical deck format key — see Deck Format. An unrecognized value returns 400 and creates nothing.
Response:
{ "success": true, "message": "Created deck 'My Commander Deck'", "slug": "My Commander Deck"}List lifecycle responses
Section titled “List lifecycle responses”Create, rename, and delete answer identically for decks, collections, and wanted lists:
| Operation | Success body |
|---|---|
| Create | { success: true, message, slug } |
| Rename | { success: true, message, newSlug, newFilePath, oldFilePath } |
| Delete | { success: true, message, deletedFiles, trashDir } |
newFilePath/oldFilePath are the list’s paths after and before the rename. deletedFiles is every path the delete removed: the list plus its sidecar files. They are moved, not erased: trashDir is the trash folder now holding them, where each keeps its workspace-relative path. All of these paths are absolute; message names the trash folder relative to the workspace.
A refusal is the shared error envelope at one of these statuses:
400— a missing or invalid argument.404— a list that does not exist.409— a target name already taken.
409 covers more than an identical file name. Create and rename refuse any name that resolves to an existing list of the same type: atraxa superfriends is refused while Atraxa Superfriends exists, with A deck named 'Atraxa Superfriends' already exists (it matches 'atraxa superfriends' under list-name folding). Renaming a list to another spelling of its own name (a capitalization or punctuation fix) is not a collision. It succeeds and moves the file and its sidecars, even on a case-insensitive file system.
Rename Deck
Section titled “Rename Deck”POST /api/deck/:slug/renameRename a deck. Rewrites the # Title heading and renames the .md together with every sidecar it has — see List lifecycle responses.
Request Body:
{ "newName": "New Deck Name"}Response:
{ "success": true, "message": "Renamed deck to 'New Deck Name'", "newSlug": "New Deck Name", "newFilePath": "/home/me/mtg/decks/New Deck Name.md", "oldFilePath": "/home/me/mtg/decks/My Commander Deck.md"}Delete Deck
Section titled “Delete Deck”DELETE /api/deck/:slugDelete a deck and every sidecar it has — see List lifecycle responses.
Request Body:
{ "confirmName": "My Commander Deck"}confirmName must match the deck’s # Title (its first H1) exactly; 400 otherwise.
Response:
{ "success": true, "message": "Deleted deck 'My Commander Deck'; its files are in .trash/2026-10-05T14-03-22Z-deck-My Commander Deck", "deletedFiles": [ "/home/me/mtg/decks/My Commander Deck.md", "/home/me/mtg/decks/My Commander Deck.md.sha256" ], "trashDir": "/home/me/mtg/.trash/2026-10-05T14-03-22Z-deck-My Commander Deck"}Card Autocomplete
Section titled “Card Autocomplete”GET /api/autocomplete?q=<query>Search card names in the in-memory card cache. The query is split on whitespace and every term must appear in the name, in any order — the same matching the CLI prompts use, so in tre finds “In the Trenches”. Matching ignores case, accents, and punctuation (jotun matches Jötun Grunt; jaces archivist matches Jace's Archivist).
Returns up to 20 results, ranked by how directly each name answers the query:
- A name the query spells out in full (the front face of a double-faced card counts as its whole name).
- Names the query prefixes (
sol ri→ “Sol Ring”). - Names whose words the terms begin, in order, then in any order.
- Names matched mid-word (
in tre→ “Kin-Tree Warden”).
Equally ranked names are alphabetical.
Query Parameters:
| Parameter | Description | Required |
|---|---|---|
q |
Search query (minimum 2 characters) | Yes |
Response:
{ "success": true, "names": ["Sol Ring", "Soltari Champion"]}Load Deck
Section titled “Load Deck”GET /api/deck/:slug?view=<full|cards|summary>§ion=<name>&nameContains=<terms>&limit=<n>&offset=<n>Load a deck at the depth view asks for. The same parameters apply to Load Collection and Load Wanted List.
List load parameters
Section titled “List load parameters”| Parameter | Description | Required |
|---|---|---|
view |
full (default), cards, or summary. Anything else is a 400 naming the three |
No |
section |
Exact ## Section heading, case-sensitive. A section that does not exist yields no entries, not an error |
No |
nameContains |
Whitespace-separated name terms; every term must appear, in any order (as /api/autocomplete matches) |
No |
limit |
Max entries returned. A positive integer; anything else is a 400 |
No |
offset |
Entries to skip before limit applies. A non-negative integer (0 is allowed) |
No |
summary and cards return before the changelog-name pass, the Scryfall card/printing/price load, and the mana-symbol fetch, so a filtered read is cheap for the server. full applies the filters and then loads card data for the filtered names only.
totalCount is always present: the number of entries that matched before limit/offset applied (the list’s whole line count when nothing was filtered), so a client can page. offset/limit count lines, and a line is never split: a 4 Lightning Bolt deck entry travels whole.
Any of section, nameContains, limit, or offset makes the body a slice: the response carries "partial": true and no contentHash, because only a full load shows the whole list a save’s changes are replayed over. Reload without filters to get a hash you can save with. This applies to every view, summary included.
warnings is always present on all three views. It lists everything in the file a re-serializing write would not reproduce, as an array of messages (empty for a clean file):
- Body lines the parser could not read — malformed card lines, prose, comments, or any other text the list grammar does not model.
- One summary entry per file holding a fenced code block (
Fenced code block content (N line(s)) — …). A fenced block parses cleanly, so it gets no per-line warning, but the serializers cannot emit it.
A deck’s empty extras section (## Maybeboard, ## Tokens) is deliberately not listed: it holds nothing to lose and the next write clears it.
A non-empty warnings also blocks saving that list — see Unreadable lines block a save.
A missing list is a 404 whose message points at GET /api/lists for the real slugs. A slug carrying a path separator is a 400 (Invalid list slug) on all three routes.
Response (view=full, the default):
{ "success": true, "view": "full", "deck": { "name": "...", "sections": [] }, "totalCount": 42, "cards": { "Sol Ring": {} }, "printings": { "Sol Ring": [] }, "cardsCardKingdom": { "Sol Ring": {} }, "symbolMap": { "{W}": "https://..." }, "frontMatter": {}, "slug": "my-deck", "contentHash": "...", "warnings": []}A full deck load also carries lowestPriceCards, lowestPriceCardsEur and lowestPriceCardsTix: the cheapest printing per card name, per currency.
cardsCardKingdom (and, on decks, lowestPriceCardsCardKingdom) is Card Kingdom’s own printing pick per card name — the printing CK sells, chosen at CK’s prices — which a client shows instead of the Scryfall pick while the Card Kingdom price store is selected. Both are sparse (a card CK stocks no printing of has no entry; fall back to the Scryfall pick) and both are absent entirely unless priceSources includes cardkingdom and a buylist feed is cached. A load never downloads the feed.
Response (view=cards):
{ "success": true, "slug": "my-deck", "view": "cards", "deck": { "name": "...", "sections": [] }, "frontMatter": {}, "totalCount": 42, "contentHash": "...", "warnings": []}A deck’s cards view also returns its front matter. A collection or wanted list returns entries + sectionOrder instead, plus a top-level description (the list’s front-matter blurb, absent when none is declared; a deck’s is inside deck.description). Per-card fields:
labels— a deck and a collection carry a top-levellabels(the list’s front-matter default; a deck’s can only beproxy), and each of their cards may carry its own signedlabelsoverride (["no-proxy"]), which adjusts that default. Every deck card and collection entry also carrieseffectiveLabels: the labels in force, resolved by the server.tags— every card on every list type carriestagswhen its line has any: its tags in canonical order (trimmed, sorted, without the#). There is no list-level tag default.
A narrowed request replaces contentHash with "partial": true.
Custom art. Every non-summary load carries customArt when the list has any: a { "<cardId>": { "file": … } | { "url": … } } record of the raw custom art references for the cards in the body. Clients derive display URLs themselves, since an editor needs the path the user typed. Omitted when none of the returned cards has art.
Problems with the .art.json sidecar — it cannot be read, or it holds art under a card id the whole list no longer has — come back in a separate artWarnings array rather than failing the load. Unlike warnings, they never block a save. artWarnings is omitted when the sidecar is clean or absent. The orphan check ignores the filters, so a paged read never reports cards it did not ask for.
Categories. Every non-summary load carries the list’s categories when it has any, as categories — { "order": ["Ramp", "Artifacts"], "cards": { "Sol Ring": ["Ramp", "Artifacts"] } } — and every returned card carries its own resolved categories, primary first. Absent means none at both levels: never an empty array. order is the resolved order the next Ritual write persists. categories always describes the whole list, never just the returned page: a filtered read still reports every categorized name, while each card’s own categories covers only the cards in the body.
Keys in cards are card names as the sidecar stores them, matched case- and whitespace-insensitively. Read each card’s own categories field rather than joining cards by raw name, or you will miss entries the list still resolves.
Problems with the .categories.json sidecar — it cannot be read, or it records categories for names the list no longer holds — come back in a separate categoryWarnings array, like artWarnings. A read reports stale names and never removes them; the next save prunes them. The stale-name check is skipped when the list has unreadable lines, since those lines may still hold the named cards.
A view=summary body carries neither categories nor categoryWarnings: it returns before the sidecar is read.
Response (view=summary):
{ "success": true, "slug": "my-deck", "view": "summary", "counts": { "entryCount": 42, "cardCount": 99, "sections": [{ "name": "Commander", "entryCount": 1, "cardCount": 1 }] }, "contentHash": "...", "warnings": []}entryCount is lines, cardCount is copies (summed quantity). Collections and wanted lists hold one card per line, so the two are equal there. A summary honours section/nameContains and ignores limit/offset: the counts describe the whole filtered set. A narrowed summary is "partial": true with no contentHash, like every other narrowed view.
Card Printings
Section titled “Card Printings”GET /api/card-printings?name=<cardName>&limit=<n>Get the printings of a card, newest first, from the card cache with a fallback to the Scryfall API.
Query Parameters:
| Parameter | Description | Required |
|---|---|---|
name |
Exact card name | Yes |
limit |
Max printings returned. A positive integer; anything else is a 400 |
No |
Omitting limit returns every printing. limit and totalPrintings count distinct printings (set + collector number). With an all_cards-backed cache (a non-English defaultLanguage) a printing can hold several card objects, one per language, each carrying its lang; every language object of an included printing is returned. When limit truncates the list, totalPrintings reports how many distinct printings there were.
There is no includePrices parameter; each client drops the price block itself if it does not need it.
Response:
{ "success": true, "printings": [{ "id": "...", "set": "2xm" }], "totalPrintings": 37, "languages": ["en"], "complete": true}languages lists every language the card’s full printing list exists in (before any limit truncation), en first, folding an absent lang to en — ["en"] for any default_cards-backed lookup.
complete is false when the card cache holds no printing list for the name and the response came from the single-card Scryfall fallback. The one printing returned is whatever that lookup found, not the card’s only printing, so do not present the list as exhaustive. Run ritual cache preload-all to get a real one.
A refusal keeps the shape — success: false, printings: [] — plus the message triple: message (English), and messageKey and messageParams when the failure has a catalog entry — a lookup that failed inside the card cache, such as a failed --cache-server request (domain.cache.serverRequestFailed, …), whose params may nest as an error response’s do.
Card Price
Section titled “Card Price”GET /api/card-price?name=<cardName>Get price data for a card, including representative and cheapest printings for all currencies. Cached data older than 24 hours is refreshed from Scryfall. A card name with no printings returns 404.
Query Parameters:
| Parameter | Description | Required |
|---|---|---|
name |
Exact card name | Yes |
Response:
{ "success": true, "printings": [{ "id": "...", "set": "2xm", "prices": { "usd": "1.23" } }], "representative": { "id": "...", "set": "2xm" }, "lowestPriceCard": { "id": "...", "set": "a25" }, "lowestPriceCardEur": { "id": "...", "set": "a25" }, "lowestPriceCardTix": { "id": "...", "set": "vma" }}| Field | Description |
|---|---|
representative |
The USD representative printing (recent, mid-priced) |
lowestPriceCard |
The cheapest USD printing across all printings |
lowestPriceCardEur |
The cheapest EUR printing |
lowestPriceCardTix |
The cheapest MTGO Tix printing |
A failure answers success: false, printings: [] and the three printing fields null, plus the message triple: message (English), and messageKey and messageParams when the failure has a catalog entry — a lookup that failed inside the card cache, such as a failed --cache-server request (domain.cache.serverRequestFailed, …), whose params may nest as an error response’s do.
Card Details
Section titled “Card Details”GET /api/card-details?name=<cardName>Everything Ritual knows about one card: oracle text, type line, mana cost and CMC, colors and color identity, keyword abilities, format legalities, and Scryfall Tagger oracle/art tags. The local card cache is read first, with a single-card Scryfall fetch when the cache holds no printings for the name.
Oracle fields are the same on every printing, so the response describes the card; the identity fields (set, collectorNumber, prices) come from its most recent printing and printingCount reports how many printings were found. printingsComplete is false when that count came from the single-card fallback rather than the cache; printingCount is then always 1 and says nothing about the card. Set codes are lowercase.
colors, keywords, and legalities are only present on cards written by a cache from this version onward; run ritual cache preload-all to backfill them.
Query Parameters:
| Parameter | Description | Required |
|---|---|---|
name |
Exact card name | Yes |
A name that matches nothing returns 404 with a message pointing at /api/autocomplete. A missing or blank name is a 400. Every error response keeps the success shape — success: false and card: null — plus the message triple: message (English), and messageKey and messageParams when the failure has a catalog entry — a lookup that failed inside the card cache, such as a failed --cache-server request (domain.cache.serverRequestFailed, …), whose params may nest as an error response’s do.
Response:
{ "success": true, "card": { "scryfallId": "...", "name": "Lightning Bolt", "set": "2xm", "collectorNumber": "129", "rarity": "uncommon", "releasedAt": "2020-08-07", "finishes": ["nonfoil"], "prices": { "usd": "1.23", "eur": "0.99", "tix": "0.03" }, "manaCost": "{R}", "cmc": 1, "typeLine": "Instant", "oracleText": "Lightning Bolt deals 3 damage to any target.", "colorIdentity": ["R"], "layout": "normal", "colors": ["R"], "keywords": [], "legalities": { "commander": "legal", "standard": "not_legal" }, "oracleTags": ["burn"], "artTags": ["lightning"], "printingCount": 42 }}A multi-faced card also carries faces, one { name, manaCost, typeLine, oracleText } object per face.
Card Search
Section titled “Card Search”GET /api/card-search?q=<query>&page=<n>&limit=<n>&warm=<true|false>Run a raw Scryfall search query and return one page of card summaries, most popular first — the same lookup as the scry CLI command.
One page is fetched per request. Walk further pages by incrementing page while hasMore is true.
Plain read vs warm=true
Section titled “Plain read vs warm=true”By default the route returns Scryfall’s page verbatim and touches no cache: tokens, Arena-only printings, and Art Series cards are not filtered out, the order is Scryfall’s own, and a page carries up to 175 cards.
With warm=true it instead:
- filters the page to real printings and maps them to the cache’s card shape;
- writes each result into the local card cache under any name the cache does not already hold, leaving an already-cached name untouched (a warm-up, not a refresh);
- promotes a card whose whole name the query spells out ahead of Scryfall’s popularity order;
- caps the result at 20 cards unless
limitsays otherwise.
The response’s warmed field says which contract ran. The error contract below is the same in both modes.
Query Parameters:
| Parameter | Description | Required |
|---|---|---|
q |
Scryfall search query | Yes |
page |
1-based page number (defaults to 1) |
No |
limit |
Max cards returned, at most 175. Defaults to 20 when warm=true, otherwise the whole page |
No |
warm |
true or false (default). Any other value is a 400 — it is validated, never coerced |
No |
A missing or blank q is a 400; q is trimmed before it is sent on. page and limit may be omitted or left blank; any other value that is not a positive integer is a 400.
Error handling:
- A Scryfall
404(no matches) is a200with an emptycardsarray. - A query Scryfall itself refuses (a syntax error, an unknown filter) is a
400whosemessagecarries Scryfall’s explanation. - A failure on Scryfall’s side (a
5xx, a network error) is a500— inwarm=truemode too, never an empty200.
Every error response keeps the success shape — success: false, the requested page, hasMore: false, an empty cards array — plus the message triple: message (English), and messageKey and messageParams when the failure has a catalog entry — a lookup that failed inside the card cache, such as a failed --cache-server request (domain.cache.serverRequestFailed, …), whose params may nest as an error response’s do. An invalid warm is keyed too (errors.enum.invalid).
Response:
{ "success": true, "page": 1, "hasMore": true, "totalCards": 412, "warmed": false, "cards": [ { "scryfallId": "...", "name": "Lightning Bolt", "set": "2xm", "collectorNumber": "129", "rarity": "uncommon", "releasedAt": "2020-08-07", "finishes": ["nonfoil"], "prices": { "usd": "1.23" }, "manaCost": "{R}", "cmc": 1, "typeLine": "Instant", "oracleText": "Lightning Bolt deals 3 damage to any target.", "colorIdentity": ["R"] } ]}totalCards is absent when Scryfall reported no matches.
Cache Status
Section titled “Cache Status”GET /api/cache/statusReport the card cache’s size, freshness, tag coverage, and source — the same payload ritual cache status --output json prints. Diagnostic only: it never refreshes or writes the cache. Tag presence is checked over a bounded sample of cached cards, not a full scan.
Response:
{ "success": true, "empty": false, "cardCount": 31240, "lastCardRefresh": "2026-07-28T04:00:00.000Z", "priceAgeHours": 6, "priceStale": false, "tagsPresent": true, "source": "local", "defaultLanguage": "en", "cardBulkType": "default_cards", "bulkTypeStale": false}| Field | Description |
|---|---|
empty |
Whether the cache holds no cards at all |
cardCount |
Distinct card names cached (each holds an array of printings) |
lastCardRefresh |
ISO-8601 time of the last bulk refresh, or null until one has run |
priceAgeHours |
Whole hours since that refresh (prices come with the bulk data), or null |
priceStale |
true when prices are older than 24 hours, or their age is unknown |
tagsPresent |
Whether any sampled card carries oracle/art tags |
source |
local, or cache-server when a cache server is configured |
defaultLanguage |
The configured defaultLanguage |
cardBulkType |
Which bulk built the cache (default_cards/all_cards), or null when no ingest has recorded provenance |
bulkTypeStale |
true when the cache’s bulk disagrees with defaultLanguage; a full refresh is needed (see bulk selection) |
Price Summary
Section titled “Price Summary”GET /api/price/summaryPrice every deck, collection, and wanted list from the local card cache and return per-list, per-type, and grand totals plus any list-parser warnings — the same payload as price --summary --output json. Prices are read strictly from the cache: an empty cache returns 503 without downloading anything (run ritual cache preload-all first), and a card the cache does not hold is reported as unpriced rather than fetched from Scryfall. lastRefreshedAt is the cache’s last bulk-refresh time in Unix milliseconds, or null when unknown.
Query Parameters:
| Parameter | Description | Required |
|---|---|---|
type |
Only price deck, collection, or wanted lists |
No |
source |
The store, which sets the currency: tcgplayer (Scryfall USD), cardmarket (Scryfall EUR), cardhoarder (Scryfall MTGO tix), or cardkingdom (Card Kingdom NM retail from the cached feed). Default: the configured defaultPriceSource |
No |
An unknown type or source returns 400. There is no currency parameter: each store has one (tcgplayer/cardkingdom → usd, cardmarket → eur, cardhoarder → tix), and every response names both its source and its currency.
When the store resolves to cardkingdom (source=cardkingdom, or the configured default), the route reads the cached buylist feed. With a feed cached, printings Card Kingdom does not sell are reported unpriced. With no feed downloaded it returns 503 with the refresh advice rather than falling back to Scryfall. The parameter is not gated on the priceSources config key; it needs only a cached feed.
mode discriminates the two price bodies: "summary" here, "list" on Price List.
unpricedCount counts copies whose price the data could not supply, and only those. estimatedCount counts deck and wanted-list copies whose price is an estimate: the line pins no printing (priced at a representative one) or has no price. Any at all makes total an estimate, which the CLI and sites render with a ~ prefix (~$12.34). Collections never estimate. Cards that carry no price by rule — a proxy label, custom art, or both — are priced at 0 and counted in cardCount, but are left out of unpricedCount: they are not a gap in the price data.
Response:
{ "success": true, "mode": "summary", "source": "tcgplayer", "currency": "usd", "lastRefreshedAt": 1752600000000, "lists": [ { "type": "deck", "name": "my-deck", "cardCount": 100, "total": 245.1, "lowestTotal": 199.9, "unpricedCount": 2, "estimatedCount": 12 } ], "typeTotals": [ { "type": "deck", "listCount": 1, "cardCount": 100, "total": 245.1, "lowestTotal": 199.9, "unpricedCount": 2, "estimatedCount": 12 } ], "totals": { "listCount": 1, "cardCount": 100, "total": 245.1, "lowestTotal": 199.9, "unpricedCount": 2, "estimatedCount": 12 }, "warnings": []}Price List
Section titled “Price List”GET /api/price/:type/:slugPrice a single list and return its summary plus every priced card entry in file order — the same payload as the CLI’s single-list price <name> --output json. :type is deck, collection, or wanted; :slug is the list’s file basename, as on the load routes. Takes the same source query parameter as Price Summary, with the same 503 when the card cache is empty (or when source=cardkingdom finds no cached feed). An unknown slug returns 404.
Response:
{ "success": true, "mode": "list", "source": "tcgplayer", "currency": "usd", "lastRefreshedAt": 1752600000000, "list": { "type": "deck", "name": "my-deck", "cardCount": 100, "total": 245.1, "lowestTotal": 199.9, "unpricedCount": 2, "estimatedCount": 12 }, "cards": [ { "listType": "deck", "listName": "my-deck", "section": "Main", "name": "Sol Ring", "quantity": 1, "set": "c21", "collectorNumber": "263", "pinned": true, "price": 2.5, "lowest": 1.1, "lowestSet": "cma", "lowestCollectorNumber": "215", "lowestFinish": "nonfoil", "cmc": 1, "edhrecRank": 1, "typeLine": "Artifact", "fileOrder": 0 } ], "warnings": []}An entry that could not be priced also carries unpricedReason, absent on every priced card:
- Data gaps, which
unpricedCountcounts:no-printings,printing-not-found,currency-unavailable,finish-unpriced-in-currency,no-price-data. - By-rule reasons, which it does not count (see Price Summary):
proxyandcustom-art.custom-artwins when a card is both.
By-rule entries still carry their printing and their set/collectorNumber: a proxy is a proxy of a card, and custom art replaced the picture, not the card.
Sell Report
Section titled “Sell Report”GET /api/sell/reportMatch listed cards against the locally cached Card Kingdom buylist and report what CK is buying, the cash quote per Near Mint copy, and their quantity caps — the same payload as sell --output json. Strictly cache-backed: the card cache and a downloaded feed are prerequisites (503 otherwise, each naming its remedy). This route never downloads anything; that is Sell Refresh’s job.
Copies that are priceless by rule — labeled proxy, wearing custom art, or both — are dropped before matching, so they are never quoted and never counted as cards the buyer declined. They are dropped rather than merged, so an otherwise-identical real copy in the same list keeps its own quote and quantity.
Parameters
Section titled “Parameters”| Parameter | Description | Required |
|---|---|---|
type |
Match every deck, collection, or wanted list (default: every collection) |
No |
lists |
Comma-separated type:slug refs to match exactly these lists (overrides type); unknown → 404 |
No |
tags |
Comma-separated card tags (exact); scopes matching to lines carrying one, before CK’s buy caps are shared out | No |
sets |
Comma-separated set codes to filter to | No |
min |
Minimum per-copy offer (USD) | No |
minOwned |
Minimum total owned copies (ownedCopies.collection + ownedCopies.deck, a whole number) |
No |
minRatio |
Minimum offer-to-market ratio (offerRatio, e.g. 0.8); entries with no market price drop |
No |
A blank parameter (?min=) reads as absent. A malformed one is a 400 carrying the parser’s message: a tag that breaks the tag shape rule, a min/minRatio that is not a plain non-negative decimal (1.5, not 1e1 or -1), or a minOwned that is not a whole number (Invalid minimum owned count '2.5' (expected a whole number)).
Response:
{ "success": true, "feedCreatedAt": "2026-08-04 06:06:09", "feedRetrievedAt": 1785850800000, "filters": { "sets": ["fdn"], "minPrice": 0.5 }, "lists": [ { "type": "collection", "name": "Red Binder", "cardCount": 45, "sellableCount": 12, "totalValue": 123.45, "notBuyingCount": 30, "noMatchCount": 3 } ], "entries": [ { "listType": "collection", "listName": "Red Binder", "section": "Main", "name": "Arahbo, the First Fang", "quantity": 1, "set": "fdn", "collectorNumber": "294", "finish": "nonfoil", "condition": "NM", "pinned": true, "cardIds": [14], "edhrecRank": 812, "ownedCopies": { "collection": 3, "deck": 1 }, "status": "buying", "matchVia": "scryfall-id", "ckProductId": 316734, "ckSku": "FDN-0294", "ckName": "Arahbo, the First Fang", "ckEdition": "Foundations Variants", "ckVariation": "0294 - Borderless", "ckUrl": "https://www.cardkingdom.com/mtg/foundations-variants/arahbo-the-first-fang", "ckFinish": "nonfoil", "priceBuy": 1.5, "priceRetail": 3.49, "qtyBuying": 25, "tcgplayerPrice": 2.1, "offerRatio": 0.714, "sellableQuantity": 1, "value": 1.5, "fileOrder": 1 } ], "totals": { "listCount": 1, "cardCount": 45, "sellableCount": 12, "totalValue": 123.45, "notBuyingCount": 30, "noMatchCount": 3 }, "warnings": []}Entry fields:
statusisbuying,not-buying(the product exists but CK’s buy quantity is 0), orno-match(withnoMatchReason:no-printings,printing-not-found, ornot-on-buylist).matchVianames the join key that located the product (scryfall-id,sku, orname).ambiguousis set when several products matched; the quote is the best-paying one.- An entry with
pinned: false(an unpinned deck/wanted line) is quoted at the best-paying printing, whose set/collector/ckFinishit reports. sellableQuantitydraws from a per-product budget of CK’sqtyBuying, so entries sharing a product never sum past the cap.valueprices only those copies.cardIdsare the&Nids of the list lines aggregated into the entry, in file order (one per line; lines with no id yet contribute none).tagsare those lines’ tags; identically tagged lines aggregate together.edhrecRankis the card’s EDHREC rank from the card cache, absent when unranked or uncached.languageis the lines’ language token (absent for English); a non-English entry is never quoted.ownedCopiescounts the card’s copies by name, across every printing, in all collections and all decks regardless of the report’s scope (proxies and deck extras sections excluded).tcgplayerPriceis the quoted printing’s TCGplayer market price (USD, from the card cache’s Scryfall prices) at the quoted finish, andofferRatioispriceBuy ÷ tcgplayerPriceto three places. Both are absent when the cache has no price for that printing or the product is not tied to a cached printing.
Sell Cart
Section titled “Sell Cart”GET /api/sell/cartThe entries CK is buying, rendered in their sell-cart CSV import format: card name, edition, foil, quantity, no header row, CK’s own listing titles with variant note, quantities capped at their buy limits. Takes the same ?type=/?lists=/?tags=/?sets=/?min=/?minRatio=/?minOwned= parameters and 503 prerequisites as Sell Report. Backs the CLI’s sell --output csv.
Response:
{ "success": true, "csv": "\"Arahbo, the First Fang (0294 - Borderless)\",Foundations Variants,false,3\n...", "titleCount": 12, "cardCount": 31, "warnings": []}warnings flags CK’s upload caps (500 unique titles / 5,000 cards) and etched foils the format cannot express (exported as foil).
Sell Refresh
Section titled “Sell Refresh”POST /api/sell/refreshDownload Card Kingdom’s pricelist feed (~70 MB) when the cached copy is missing or stale (older than a day); ?force=true redownloads regardless. The one sell route that reaches the network.
Response:
{ "success": true, "refreshed": true, "feedRetrievedAt": 1785850800000, "feedCreatedAt": "2026-08-04 06:06:09", "productCount": 149978, "warnings": []}A failed download returns 502 only when no feed is cached at all. With a stale cache the call answers 200 with the stale feed’s stamps, refreshed: false, and the failure in warnings. So refreshed: false with empty warnings means the cache was still fresh; with a warning it means you are still on the stale feed.
Buylist Quotes
Section titled “Buylist Quotes”POST /api/buylist/quotesThe buyer’s current offer for specific printings, keyed by set:collectorNumber:finish (set lowercased). Use it to price an arbitrary set of cards without building a whole Sell Report. Strictly cache-backed: 503 with the remedy when no feed has been downloaded.
Request:
{ "buyer": "cardkingdom", "printings": [{ "set": "dsk", "collectorNumber": "136", "finish": "nonfoil", "scryfallId": "…" }]}buyerdefaults tocardkingdom(the only buyer today).scryfallIdis optional but is the primary join key when present.set/collectorNumberalways form the response key and drive the sku fallback for the ~0.5% of Card Kingdom products with no Scryfall id.language(optional) is the entry’s language code; absent means English. The buyer feeds are English-only, so a non-enprinting is never matched: its key is absent fromquotes, never quoted at the English product’s price.- At most 500 printings per request.
Response:
{ "success": true, "buyer": "cardkingdom", "quotes": { "dsk:136:nonfoil": { "priceBuy": 2.5, "qtyBuying": 8, "priceRetail": 5.99, "qtyRetail": 12, "buying": true, "finish": "nonfoil", "matchVia": "scryfall-id", "productId": 281234, "name": "Overlord of the Balemurk", "edition": "Duskmourn: House of Horror", "variation": "298 - Borderless", "url": "https://www.cardkingdom.com/mtg/..." } }, "feedCreatedAt": "2026-08-04 06:06:09", "feedRetrievedAt": 1785850800000, "stale": false, "productCount": 149978}quotesis sparse: a requested printing the buyer has no product for is absent.buyingis false when Card Kingdom publishes a price but has paused buying (qtyBuying: 0). Treat it as no offer. A present quote is not by itself an offer, since much of CK’s catalog is paused at any time, so always checkbuying. Both cases read as “not on the buylist”, which is the rule the sites’ On buylist chip and grouping use.variationis CK’s variant note for the matched product, present only when they publish one. A client rendering a cart CSV row builds CK’s listed title asname (variation).priceRetail/qtyRetailare the buyer’s own NM retail price and stock, which the sites’ Card Kingdom price view displays. AqtyRetailof0means out of stock with the listed price standing; apriceRetailof0means no published retail price.
The public site server (ritual serve --api) also mounts this route, unauthenticated, and answers 404 there too unless sell mode is on or priceSources includes cardkingdom. The public site itself does not call it (sell mode reads buy prices built into each list’s data); the admin editors are the one client that quotes live, as cards are added. The public server has no refresh route, so an unauthenticated caller can never trigger the ~70 MB download.
Buylist Status
Section titled “Buylist Status”GET /api/buylist/statusWhich buyers this server can quote against and how fresh the cached feed is, without quoting anything. Backs the admin Refresh Cache page’s buylist card.
Response:
{ "success": true, "buyer": "cardkingdom", "buyers": ["cardkingdom"], "feedCreatedAt": "2026-08-04 06:06:09", "feedRetrievedAt": 1785850800000, "stale": false, "productCount": 149978}503 with the remedy when no feed has been downloaded — a normal first-run state.
Save a List
Section titled “Save a List”POST /api/deck/:slug/savePOST /api/collection/:slug/savePOST /api/wanted/:slug/saveSave an editing session: the request carries only the changes, never the list. The route reads the file, replays the changes over it with the same rules the editors apply, and writes the result plus a changelog entry. A save therefore scales with the edit rather than the list, and a client cannot write content its changes do not describe.
What the route takes from the file rather than the request:
- Front matter is never taken from the request (edit it with List Metadata). A collection or wanted list keeps its block byte for byte. A deck’s is re-written in canonical form: a known field of the wrong type, or a legacy
name:/created:key, is dropped, and an inferred format is made explicit. A deck whose front matter is not readable YAML is a400, rather than a write that would drop it. - The title (a deck’s name, a flat list’s
# Title), or the file’s name when it has none, as the load route reports it. - Section order: the file’s order with the session’s
add-section/rename-section/remove-sectionchanges replayed over it, so a section the session added survives the write while still empty.
Request Body:
{ "changes": [{ "action": "add", "cardName": "Sol Ring", "set": "c21", "collectorNumber": "263" }], "contentHash": "…", "continueSession": false, "validateCardNames": false}changesare change events without theid/timestampenvelope; the route stamps its own, and ignores any a client sends. At most 50,000 per save.contentHashis required: the hash the load returned. A file that changed since is a409withconflict: true; reload and retry. A filtered or paged load has no hash, because only a full load shows the whole list.continueSession(optional boolean) merges this save into the previous save’s changelog entry, bumping its timestamp, instead of opening a new one. The editor sets it on every save after the first within an editing session.validateCardNames(optional boolean) checks every named card against the card cache.
Validation. Each change is decoded with the same rules the changelog and change bundles use, before the file is read. Any failure is a 400 naming the change (changes[2] has an invalid "set".), and nothing is written. A decode refusal is keyed domain.changes.problem.*, with the change it names nested as the subject param (see the message triple). Two checks answer differently: a line break is refused first, keyed admin.api.save.lineBreak with the JSON path as field (see Line breaks), and a collection change missing its printing names the card, keyed domain.changes.collectionPrinting.add or .setPrinting with a flat name:
- Actions, finishes, conditions, boards and languages must be known values. A
set-languagechange requires itslanguage. - Labels are signed (
["trade", "no-sale"]; see Card labels) and must be ones the list type carries:proxy/no-proxyalone on a deck, none on a wanted list. Thekeep/proxyexclusivity rule applies, and no label may be both set and negated. Amove-from/move-tois the exception: itslabelsdescribe the moving copy as its source states them, so only the vocabulary and the conflict rules are checked, and the destination lands them on arrival. - Tags must be tag-shaped and are canonicalized (trimmed, single-spaced, deduplicated, sorted). Category names must be category-shaped; a
set-categorieslist keeps its order (the first is the primary category), and an empty one clears. - No string may contain a line break (see Line breaks).
- A collection change that adds a line (
add,move-to,set-printing) must carry a printing.
All or nothing. A change that does not apply to the list as it stands (its card is not there, or it needs a printing pinned first) refuses the whole batch with a 400. The body names each change that missed in an unmatched array (each described in English, followed by its reason in parentheses: no-target, not-applicable or needs-printing), and nothing is written. Its message (1 change matched no card in deck "burn": "Add Sol Ring &3" — card names match exactly (case-sensitive). Nothing was saved.) is keyed domain.changes.unmatched.summary, and its params nest: each reason, and each change description inside it, is a message of its own, so the editor shows the whole refusal in the reader’s language. A save never reports success while dropping changes.
Cross-list moves in changes write the other list too, each with its own changelog entry:
- A
move-from {to}adds the copy to its destination. The copy keeps the event’stagsandnote, and itslabels(the copy’s labels as this list states them) land by the move rule: a proxy stays a proxy and a real card real wherever both types carryproxy, a stated label keeps its state, an unstated one takes the destination’s default, as far as the destination type accepts (proxyinto a deck, all of them into a collection, none into a wanted list), written as the smallest override that says so. On a deck, the copy merges onto a line whose effective labels, printing and tags match, only within thesectionthe move names (any section when it names none). A copy that merges onto a deck line with a different note keeps the line’s note, and the dropped one is reported indroppedNotes. - A
move-to {from}(an incoming move) takes the copy out of its source: by thesourceCardIdline when it still holds the card, else by printing, else by name for a printing-less source line. Each fallback prefers a line whose effective labels equal the move’s positivelabels, so a[proxy]copy leaves from the proxy line. The copy lands here with its labels written against this list’s default, and a merge onto a deck line with a different note is reported indroppedNotestoo. - A
move-tocarryingreplacesCardIdpins one of this list’s own name-only lines rather than adding a copy. When it equals the move’scardId, the line is converted in place — on a deck only when the copy’s labels, landed against the deck default, equal the line’s effective labels (proxyis a fact a pin never changes); an in-place pin that fails this is refused. Otherwise one copy leaves that line and lands oncardIdas a normal arrival, with its own labels, tags and note. - A
move-tothat pins a line (replacesCardId) may also carryreplacement({ set, collectorNumber, finish?, language? }): that printing is added to the source list in place of the copy taken, logged there as anAddedline. AreplacementwithoutreplacesCardIdis a400.
Every move is validated in memory before anything is written. A missing list, a source with no copy to take, or a printing-less card headed into a collection fails the save with nothing written.
Response:
{ "success": true, "message": "Saved 3 changes to My Collection", "contentHash": "…", "droppedNotes": [], "effects": [ { "action": "added", "cardId": 7, "name": "Sol Ring", "section": "Main", "quantity": 1, "printing": { "set": "c21", "collectorNumber": "167" } } ]}See effects for what the array reports.
Unreadable lines block a save
Section titled “Unreadable lines block a save”All three save routes parse the file on disk before applying anything, and refuse with 400 when that parse yields any warnings. A line the parser cannot read, or a fenced code block, is content the re-serializing write would delete, releasing any &N ids it held back into the reuse pool. The message names the file and each entry. Nothing is written. Fix the line, or remove the fenced block, and retry; GET /api/{type}/:slug reports the same list in its warnings field. MCP mutations surface the same refusal as a tool error.
Line breaks
Section titled “Line breaks”Card names, printings, sections, and list names are written into single-line card lines, headings, and changelog entries, so none of them may contain a line break (newline, carriage return, vertical tab, form feed, NEL, or U+2028/U+2029). The save route checks every string in changes, and Move Selected Cards and POST /api/move/commit check their moves. A line break anywhere in those is a 400 with messageKey: admin.api.save.lineBreak naming the JSON path, such as changes[2].cardName or moves[0].cardName. A save’s notes are trimmed first, so a pasted trailing newline on a note is not refused. Nothing is written. Import changes refuses a bundle carrying one the same way. MCP mutations surface the refusal as a tool error.
validateCardNames
Section titled “validateCardNames”The save routes and the Move Selected Cards / Remove Cards routes accept an optional boolean validateCardNames, default false. It is validated, never coerced: any non-boolean value is a 400.
With it set, every card name the request mentions is checked against the local Scryfall card cache before anything is written:
- A name already present in the affected list is accepted with no lookup, so a custom, proxied, or unreleased card stays removable and editable.
- Any other name must be one the cache knows. An unknown one is a
400naming up to three of the closest cached spellings. - An empty cache is also a
400(not a503as on Price List), naming both remedies: the MCPrefresh_cachetool, orritual cache preload-all.
The admin UI leaves it off. The MCP write tools set it on every request, except the import tools, whose engines report per-row failures.
effects
Section titled “effects”The save route answers with an effects array describing what the save did to individual card lines, and a contentHash for the next save. Each entry is { action, cardId, name, section?, quantity, printing?, previousCardId? }, where action is added, removed, or updated.
previousCardId appears only on an updated effect whose line was renumbered: another entry in the same save claimed its &N (a cross-list move carrying its source id, a replayed change bundle), so the serializer gave the older line a fresh number.
A card’s &N id is allocated inside the save, so the response is the only place a client that added a card can learn the id its line got. Set codes inside printing are lowercase.
A save also re-files the list’s custom-art sidecar from the changes, not from the file it produced: a card the changes remove loses its art even when they re-add the same card and the new line takes the same &N back. To put art on a card you are adding, wait for the save to answer, then aim a Card Art write at the id the effects report (following previousCardId where a line was renumbered). The admin editors’ add-card dialog does this.
artWarnings
Section titled “artWarnings”When that re-filing could not happen, the save still succeeds and reports the problem in an artWarnings array, one message per sidecar it had to leave alone. Causes:
- The list’s own
.art.jsoncannot be read. - The
.art.jsonof a list this save’s cross-list moves (Move to list…, Swap Printings…) send cards to or take them from cannot be read. - A moved copy’s art has no destination line to follow onto.
The field is omitted when everything re-filed cleanly, and shares its name with the load routes’ sidecar problems channel.
The card lines were written correctly; the only casualty is that art may now sit under an &N the save freed or renumbered. Fix the sidecar by hand (the message names the file and the parse failure) and the art applies again, or comes off the list with the next art write.
categoryWarnings and prunedCategories
Section titled “categoryWarnings and prunedCategories”A save also commits the list’s categories sidecar. The sidecar is keyed by card name, so the save is what tells it which entries no card backs any more:
| Field | Type | Meaning |
|---|---|---|
categoryWarnings |
string[] |
The categories sidecar could not be read or written. A warning, never a failure — the card lines were written. |
prunedCategories |
string[] |
Card names whose category assignments this save dropped, because the list no longer holds a line of that name. |
Both are omitted when there is nothing to report. A save that carries no category change and prunes nothing leaves the sidecar byte-identical, including a hand-edited one, whose stale .sha256 survives so detect-changes still records the edit.
List Collections
Section titled “List Collections”GET /api/collectionsReturns the available collections.
Response:
{ "success": true, "collections": ["my-collection", "trade-binder"]}Load Collection
Section titled “Load Collection”GET /api/collection/:slugLoad a collection with full card data, printings, and mana symbol map. Accepts the same list load parameters as Load Deck; the cards view returns entries + sectionOrder rather than a deck.
- Top-level
description— the collection’s front-matter blurb, absent when none is declared. - Top-level
labels— its default card labels, absent when none are declared. An entry’s ownlabelsis its per-card override, signed (["trade", "no-sale"]); its effective labels are the default adjusted by it: the positives added, the negated labels taken away. Each entry also carrieseffectiveLabels, those labels resolved by the server (empty when it has none), so a client never re-implements the rule. - Entry
tags— the line’s tags, canonical and without the#, absent when it has none. - Entry
categories— the card name’s categories in this list, primary first, absent when it has none. The list’s own vocabulary is the top-levelcategories— see Load Deck.
Response:
{ "success": true, "view": "full", "entries": [ { "name": "Sol Ring", "set": "2xm", "collectorNumber": "270", "labels": ["keep"], "effectiveLabels": ["keep"], "tags": ["Binder: Trade", "Ramp"], "cardId": 1 } ], "sectionOrder": ["Main"], "description": "Everything I will trade away.", "labels": ["sale", "trade"], "totalCount": 42, "cards": { "Sol Ring": {} }, "printings": { "Sol Ring": [] }, "symbolMap": { "{W}": "https://..." }, "slug": "my-collection", "contentHash": "...", "warnings": []}Create Collection
Section titled “Create Collection”POST /api/collection/createCreate a new collection file, named after the collection — see List file names.
Request Body:
{ "name": "My Collection"}Response:
{ "success": true, "message": "Created collection 'My Collection'", "slug": "My Collection"}Rename Collection
Section titled “Rename Collection”POST /api/collection/:slug/renameRename a collection. Replaces the first # <Title> line in the file and renames the .md together with every sidecar it has — see List lifecycle responses.
Request Body:
{ "newName": "Renamed Collection"}Response:
{ "success": true, "message": "Renamed collection to 'Renamed Collection'", "newSlug": "Renamed Collection", "newFilePath": "collections/Renamed Collection.md", "oldFilePath": "collections/My Collection.md"}Delete Collection
Section titled “Delete Collection”DELETE /api/collection/:slugDelete a collection file and every sidecar it has — see List lifecycle responses. confirmName must match the parsed # Title exactly.
Request Body:
{ "confirmName": "My Collection"}Response:
{ "success": true, "message": "Deleted collection 'My Collection'; its files are in .trash/2026-10-05T14-03-22Z-collection-My Collection", "deletedFiles": [ "/home/me/mtg/collections/My Collection.md", "/home/me/mtg/collections/My Collection.md.sha256" ], "trashDir": "/home/me/mtg/.trash/2026-10-05T14-03-22Z-collection-My Collection"}List Wanted Lists
Section titled “List Wanted Lists”GET /api/wantedReturns the available wanted lists.
Response:
{ "wantedLists": [{ "slug": "high-priority", "name": "High Priority" }]}Load Wanted List
Section titled “Load Wanted List”GET /api/wanted/:slugLoad a wanted list with full card data, printings, and mana symbol map. Accepts the same list load parameters as Load Deck; the cards view returns entries + sectionOrder rather than a deck, plus the top-level description when the list declares one.
- Entry
tags— the line’s tags, canonical and without the#, absent when it has none. Wanted entries carry tags exactly as deck and collection cards do; they are the one list type withoutlabels. - Entry
categories— the card name’s categories in this list, primary first, absent when it has none. The list’s own vocabulary is the top-levelcategories— see Load Deck.
Response:
{ "success": true, "view": "full", "entries": [ { "name": "Sol Ring", "set": "2xm", "collectorNumber": "270", "tags": ["Ramp"], "cardId": 1 } ], "sectionOrder": ["Main"], "description": "Cards I still need.", "totalCount": 42, "cards": { "Sol Ring": {} }, "printings": { "Sol Ring": [] }, "symbolMap": { "{W}": "https://..." }, "slug": "high-priority", "contentHash": "...", "warnings": []}Create Wanted List
Section titled “Create Wanted List”POST /api/wanted/createCreate a new wanted list file, named after the list — see List file names.
Request Body:
{ "name": "Holiday Wishlist"}Response:
{ "success": true, "message": "Created wanted list 'Holiday Wishlist'", "slug": "Holiday Wishlist"}Rename Wanted List
Section titled “Rename Wanted List”POST /api/wanted/:slug/renameRename a wanted list. Replaces the first # <Title> line in the file and renames the .md together with every sidecar it has — see List lifecycle responses.
Request Body:
{ "newName": "Renamed Wishlist"}Response:
{ "success": true, "message": "Renamed wanted list to 'Renamed Wishlist'", "newSlug": "Renamed Wishlist", "newFilePath": "wanted/Renamed Wishlist.md", "oldFilePath": "wanted/Holiday Wishlist.md"}Delete Wanted List
Section titled “Delete Wanted List”DELETE /api/wanted/:slugDelete a wanted list file and every sidecar it has — see List lifecycle responses. confirmName must match the parsed # Title exactly.
Request Body:
{ "confirmName": "Holiday Wishlist"}Response:
{ "success": true, "message": "Deleted wanted list 'Holiday Wishlist'; its files are in .trash/2026-10-05T14-03-22Z-wanted-Holiday Wishlist", "deletedFiles": [ "/home/me/mtg/wanted/Holiday Wishlist.md", "/home/me/mtg/wanted/Holiday Wishlist.md.sha256" ], "trashDir": "/home/me/mtg/.trash/2026-10-05T14-03-22Z-wanted-Holiday Wishlist"}Card Index
Section titled “Card Index”GET /api/card-index?name=&listType=&slug=&set=Returns every list (deck, collection, wanted) and every physical card across them. Used by the Move Cards page and by any client that needs to find where a card physically lives. The lightweight cards payload carries no Scryfall data; each card’s key is a path-free session identifier echoed back on commit. Deck entries with quantity > 1 expand to one card per copy (copyIndex).
Query Parameters: every filter is optional, and they intersect. A blank value is treated as absent, not as “match nothing”.
| Parameter | Description |
|---|---|
name |
Whitespace-separated name terms, matched as autocomplete matches them: case-, accent-, and punctuation-insensitive, in any order (in tre finds “In the Trenches”) |
listType |
deck, collection, or wanted; anything else is a 400 |
slug |
Exact list slug (the file basename); a value with a path separator is a 400 |
set |
Set code, matched lowercase (LEA and lea both match a card stored as lea). A malformed code (anything but letters and digits) is a 400 |
Only cards is filtered. lists is always the full roster, because clients render move destinations from it.
warnings is always present (possibly empty). It names each list file that could not be fully read — an unparseable card line, or a deck file that could not be read at all — so an empty result is never silently wrong. One bad file never fails the whole index; the other lists are still returned.
Response:
{ "success": true, "lists": [{ "type": "collection", "slug": "binder", "name": "Binder" }], "cards": [ { "key": "collection:binder:1:0", "listType": "collection", "listSlug": "binder", "name": "Lightning Bolt", "set": "lea", "collectorNumber": "161", "finish": "nonfoil", "condition": "NM", "note": "signed", "cardId": 1, "copyIndex": 0 } ], "warnings": ["decks/burn.md: could not be read or parsed; its cards are missing from the index."]}set, collectorNumber, finish, condition, language, and note are each present only when the card line carries them.
Commit Moves
Section titled “Commit Moves”POST /api/move/commitApply a batch of queued moves atomically. The move state is rebuilt from disk and each move is applied through the shared move engine, writing the source/destination files and their changelogs. Moves whose cardKey or destination can no longer be resolved are skipped and reported. When git auto-commit is enabled, the written files are committed in a single commit, as the editor save routes do.
Optional fields per move:
- Printing fields (
set,collectorNumber,finish,condition) override the destination printing, for a printing-less card moved into a collection. languageoverrides the card’s language on arrival (enclears the token; a bare line means English). Without it the card’s existing language is kept.toSection(deck destinations only;400otherwise) places the card in that deck section, matched by exact name and created when missing. Without it the default section is used.
Request Body:
{ "moves": [ { "cardKey": "collection:binder:1:0", "toType": "deck", "toSlug": "my-deck", "toSection": "Sideboard", "set": "2xm", "collectorNumber": "270", "finish": "nonfoil", "condition": "NM", "language": "ja" } ]}Response:
{ "success": true, "moved": 1, "requested": 1, "skipped": 0, "droppedNotes": [{ "cardName": "Sol Ring", "cardId": 3, "note": "from trade" }], "warnings": [], "message": "Moved 1 card."}droppedNoteslists each note discarded by a deck quantity-merge: the card landed on an existing line whose single note slot already held a different value, and the existing note wins.warningsis always present (possibly empty). It names each list file that could not be fully read while the card index was rebuilt, so a skipped move is never unexplained.- Categories never follow a moved card (a category belongs to a card name in one list), so the move engine prunes each list it writes down to the names that list still holds. When it dropped any, the response carries
prunedCategories(omitted otherwise), with the same meaning as on the save routes;<list>.categories.jsonand its.sha256join the auto-commit. A list holding a card line the parser could not read is skipped entirely, keeping every entry.
Move Selected Cards
Section titled “Move Selected Cards”POST /api/move/selectedMove a batch of selected cards across lists atomically — backs the cross-list Move all selected action. Each item addresses its source card by list + identity (the same cardId/copyIndex scheme as Remove Cards) and its destination by toType + toSlug. The optional printing fields, language, and toSection behave exactly as in Commit Moves. Cards or destinations that can no longer be resolved, or whose destination is the list they already live in, are skipped and reported. The optional validateCardNames flag applies here too.
Request Body:
{ "moves": [ { "listType": "deck", "listSlug": "my-deck", "name": "Sol Ring", "cardId": 1, "copyIndex": 0, "toType": "collection", "toSlug": "binder", "set": "c21", "collectorNumber": "167" } ]}Response:
{ "success": true, "moved": 1, "requested": 1, "skipped": 0, "droppedNotes": [], "warnings": [], "message": "Moved 1 card."}warnings is always present (possibly empty) and works as on Commit Moves. As there, the written lists’ categories sidecars are pruned to the names they still hold and prunedCategories is returned when anything was dropped.
Remove Cards
Section titled “Remove Cards”POST /api/remove/commitRemove a batch of cards across lists atomically — backs the cross-list Remove all selected action. The state is rebuilt from disk, each requested card is resolved to its physical key, and the source files and their changelogs are written in a single pass. Deck copies are addressed by copyIndex (one item per copy); collection and wanted entries use their cardId at copyIndex 0. Cards that can no longer be resolved are skipped and reported. The optional validateCardNames flag applies here too. When git auto-commit is enabled, the written files are committed in a single commit (Remove N cards).
Request Body:
{ "removes": [ { "listType": "collection", "listSlug": "binder", "name": "Lightning Bolt", "cardId": 1, "copyIndex": 0 } ]}Response:
{ "success": true, "removed": 1, "requested": 1, "skipped": 0, "warnings": [], "message": "Removed 1 card."}warnings is always present (possibly empty) and works as on Commit Moves. A removal that takes a list’s last copy of a card name also drops that name’s categories entry; the dropped names come back as prunedCategories when there were any.
List All Lists
Section titled “List All Lists”GET /api/listsReturns every list (deck, collection, wanted) as a slug-keyed summary. The single canonical enumeration route; it populates the Change History page’s list picker and the cross-list move targets.
Response:
{ "success": true, "lists": [{ "type": "deck", "slug": "my-deck", "name": "My Deck" }]}Diff Lists
Section titled “Diff Lists”GET /api/diff?a=<[type:]name>&b=<[type:]name>&by=<name|printing>Compare two lists (any mix of deck, collection, and wanted list) and return the matched identities with per-side quantities plus the entries only one side has. Exposed as the MCP diff_lists tool; shares its engine with the diff CLI command — see that page for the identity rules (nonfoil folding, the no-printing bucket, all sections included).
Query Parameters:
| Parameter | Description | Required |
|---|---|---|
a |
First list, resolved like CLI list arguments; a deck:/collection:/wanted: prefix pins the type |
Yes |
b |
Second list, same form as a |
Yes |
by |
Identity to compare by: name (default) or printing |
No |
A missing a/b, an invalid by, or a name that resolves to no list (or ambiguously) returns 400.
Response:
{ "success": true, "a": { "listType": "deck", "slug": "burn", "name": "Burn" }, "b": { "listType": "collection", "slug": "binder", "name": "Binder" }, "by": "name", "matches": [ { "name": "Lightning Bolt", "a": { "quantity": 2, "printings": [ { "set": "lea", "collectorNumber": "161", "finish": "nonfoil", "quantity": 2 } ] }, "b": { "quantity": 1, "printings": [ { "set": "lea", "collectorNumber": "161", "finish": "nonfoil", "quantity": 1 } ] } } ], "onlyInA": [ { "name": "Fireblast", "quantity": 1, "printings": [{ "set": "vis", "collectorNumber": "78", "finish": "foil", "quantity": 1 }] } ], "onlyInB": [], "warnings": []}matches includes identities whose quantities are equal on both sides; clients decide what counts as interesting. warnings carries list parse warnings from either side, and names a list’s .categories.json sidecar that could not be read (the diff still runs).
Load Change History
Section titled “Load Change History”GET /api/history/:type/:slugReturns the parsed change sets of a list’s change log (newest first) plus the typed change events a “rewrite with defaults” would produce. :type is deck, collection, or wanted.
- Each set carries its prose
linesand itsevents: the typed events from the entry’sritual-changesblock, one per line in the same order (empty for a legacy entry with no block). - The list file and its
.categories.jsonsidecar are read only to derivedefaultEvents. A list with no change log yet returns an emptysetsarray. - A sidecar that exists but cannot be read contributes no category events and is reported in a
categoryWarningsarray (absent otherwise). - A set followed by hand-written non-change text carries it in a
trailingarray (absent otherwise). The text survives an edit-and-save round trip: each line is kept as written and re-emitted after the set’s change lines; blank lines between them are not kept.
Response:
{ "success": true, "header": "# Changelog for My Deck", "sets": [ { "timestamp": "2026-05-29T12:00:00.000Z", "lines": ["- Added \"Sol Ring\" (LEA:1) &1"], "events": [ { "action": "add", "cardName": "Sol Ring", "cardId": 1, "set": "lea", "collectorNumber": "1" } ], "trailing": ["NOTE TO SELF: the FNM tuning session."] } ], "defaultEvents": [ { "action": "add", "cardName": "Sol Ring", "cardId": 1, "set": "lea", "collectorNumber": "1" } ]}Save Change History
Section titled “Save Change History”POST /api/history/:type/:slug/saveOverwrite the list’s change log with the supplied change sets. Each set needs:
- a valid ISO-8601
timestamp; - a
linesarray of strings, each starting with-; - an
eventsarray of typed change events, one per line in the same order, echoed back fromGET(empty only for a legacy set that had none). An event that does not decode is a400naming it (Set #1 event #2 is missing its "id".), keyeddomain.changes.problem.*with the event nested as itssubject. - optionally a
trailingarray of preserved hand-written lines. These must not start with-or##(they would be re-parsed as change lines or set headers on the next load) and are written back verbatim after the set’s change lines.
Only the .changes.md file is written; the list’s own .md is never touched, and the existing header is preserved. When git auto-commit is enabled, the change log is committed (Rewrite change history for <slug>).
Request Body:
{ "sets": [ { "timestamp": "2026-05-29T12:00:00.000Z", "lines": ["- Added \"Sol Ring\" (LEA:1) &1"], "events": [ { "action": "add", "cardName": "Sol Ring", "cardId": 1, "set": "lea", "collectorNumber": "1" } ] } ]}Response:
{ "success": true, "message": "Saved 1 change set.", "setCount": 1}List Metadata
Section titled “List Metadata”PUT /api/metadata/:type/:slugWrite a list’s YAML front matter. Decks take the deck vocabulary below; decks and collections take labels (their default card labels); all three types take description (the blurb the built site prints above the cards) and image (the list’s cover image). Those two are the only fields a wanted list accepts; a labels key on a wanted body is a 400 naming the field. Shares its engine with the metadata CLI command (which covers all three types and does not write image), set-list-image, and the MCP set_list_metadata tool.
Only the fields present in the body are written; every other front-matter key (including user-authored ones) round-trips untouched. A field sent as null is deleted, as is a description sent as an empty string. The markdown body below the front matter is left byte for byte as it was: card lines are never re-serialized and no card IDs are assigned. No changelog entry is written, since metadata is not a card change.
Request Body (deck):
{ "description": "A ramp deck.", "tags": ["ramp", "budget"], "format": "commander", "image": { "card": 12 }, "sourceId": "123456", "sourceUrl": "https://archidekt.com/decks/123456", "contentHash": "abc123..."}Request Body (collection):
{ "description": "Everything I will trade away.", "labels": ["sale", "trade"], "image": { "card": 12 }, "contentHash": "abc123..."}Request Body (wanted list):
{ "description": "Cards I still need.", "image": { "url": "https://example.com/cover.jpg" }, "contentHash": "abc123..."}| Field | Validation |
|---|---|
description |
All three types. String (trimmed) or null; an empty (or blank) string clears it |
tags |
Deck only. Array of non-empty strings (trimmed, deduplicated, order preserved); null or [] clears the key |
format |
Deck only. A deck format name, canonicalized (EDH → commander); null clears it and the deck falls back to section inference |
sourceId |
Deck only. Non-empty string or null |
sourceUrl |
Deck only. An http/https URL or null |
labels |
Deck and collection. Array of sale/trade (combinable) or keep/proxy (each alone), case-insensitive, normalized to canonical order; a deck accepts proxy alone. A default is positive only (no-… is refused). null or [] clears the default |
image |
All three types. A single-key mapping — {"card": N}, {"file": "rel/path"} or {"url": "https://…"} — or null to clear it. Scalars are rejected. See below |
contentHash |
Optional concurrency token from the list’s load route; a non-string value is a 400 |
Rejected fields, each a 400:
name— the message points atPOST /api/deck/:slug/rename, which also renames the file and its sidecars.createdandlastSynced— stamped by Ritual (deck creation and deck sync).- Any unknown field. A deck-only field on a collection (and vice versa) is an unknown field.
A flat-list (collection or wanted) write refuses with 400 when the file’s existing front matter cannot be read as a YAML mapping, since merging over keys it cannot see would clobber them.
image is validated as the front-matter grammar is (see List cover images), with one addition: a {"card": N} reference is checked against the file being written, so an &N the list does not carry is a 400 naming the raw id, and nothing is written. A {"file": …} path is checked for shape only (the image need not exist yet; a missing one is a build-time warning), and a {"url": …} is never fetched.
Setting sourceId together with an archidekt.com sourceUrl makes a deck sync-linked, so these fields change which decks POST /api/deck-sync operates on. The two must name the same Archidekt deck once merged over what the file already carries: a sync addresses the deck by sourceId while every surface shows sourceUrl, so a mismatched pair would push one deck’s cards into another. A request that produces one is a 400 (sourceUrl names Archidekt deck 999 but sourceId is 123. …) and writes nothing. A sourceUrl on another service is not constrained.
When contentHash is supplied and no longer matches the file, the response is 409 with "conflict": true, the same optimistic-concurrency contract as the editor save routes. Omit it for a plain read-modify-write. The write updates the file’s hash, so an editor that had the deck open sees a conflict on its next save rather than silently clobbering the new metadata.
Response:
{ "success": true, "slug": "my-deck", "frontMatter": { "name": "My Deck", "format": "commander", "created": "2026-01-01T00:00:00.000Z", "description": "A ramp deck.", "tags": ["ramp", "budget"], "image": { "card": 12 } }, "contentHash": "def456..."}An unknown deck is a 404.
When git auto-commit is enabled, the deck file and its .sha256 hash sidecar are committed with the message Update metadata for deck <slug>.
Card Art
Section titled “Card Art”PUT /api/art/:type/:slugSet or clear one card’s custom art on any list type. Like the metadata route this is a direct write: no change event, no changelog entry, and no contentHash round trip. Card lines and the <list>.art.json sidecar are separate files, so the write is safe alongside an editor’s pending card edits.
Request Body:
{ "cardId": 5, "art": { "file": "proxies/sol-ring.jpg" } }| Field | Validation |
|---|---|
cardId |
Required. A card line’s &N id (a positive integer) that the list actually holds |
art |
Required. { "file": "<art-dir-relative path>" } (ending in .avif/.gif/.jpeg/.jpg/.png/.webp), { "url": "<http(s) URL>" }, or null to clear the card’s art. Exactly one of file/url, and no other key, may be present |
Any other field is a 400 naming it.
The save, move, and remove routes re-file this sidecar themselves — see Art follows the card:
- A save or removal that drops a card drops its art, even when the same save re-adds that card.
- A renumbered line takes its entry with it.
- A cross-list move carries the entry to the destination list’s sidecar under the new line’s
&N. A copy that merges onto a line the destination already had keeps that line’s own art.
The sidecars land in the same auto-commit as the list files they describe.
Response:
{ "success": true, "slug": "my-deck", "cardId": 5, "art": { "file": "proxies/sol-ring.jpg" }, "message": "Set custom art on 'my-deck'"}Refusals are 400 for:
- a malformed body;
- a
cardIdthe list does not hold (for a card an editing session added but has not saved, save first and use the id the save’seffectsreport); - a reference that does not parse: a backslash, an absolute path, a
..escape, afilewhose extension is not one the art route serves, a non-http(s)URL; - a
filewith no image behind it in the configured art directory (the message names the exact path checked); - an existing
.art.jsonthat cannot be read. The route refuses rather than overwrite it, since that would erase art for cards the request never mentioned.
An unknown list is a 404.
Clearing the last card’s art removes the sidecar rather than writing {}. When git auto-commit is enabled the sidecar is committed with the message Update custom art for <type> <slug>, but only when a file was actually written or removed.
The images themselves are served read-only, behind the same login, at GET /art/<relpath> from the configured artDir, which lets the editor preview a local file. Only .avif, .gif, .jpeg, .jpg, .png, and .webp are served (the same allowlist a file reference is validated against), and any path leaving the art directory is a 404.
Deck Sync Status
Section titled “Deck Sync Status”GET /api/deck-syncReturns every Archidekt-linked deck that can be synced — those whose front matter has both an Archidekt sourceUrl and a sourceId — plus the stored Archidekt login. Backs the Sync Decks page and the MCP get_sync_status tool.
Response:
{ "success": true, "decks": [ { "slug": "Winota Stax", "name": "Winota Stax", "sourceId": "12345", "sourceUrl": "https://archidekt.com/decks/12345", "lastSynced": "2026-07-20T12:00:00.000Z" } ], "archidekt": { "loggedIn": true, "username": "someuser", "accessTokenExpiration": "2026-07-24T18:00:00.000Z", "accessTokenValid": true, "refreshTokenExpiration": "2026-08-20T12:00:00.000Z", "refreshTokenValid": true, "loginRequired": false }}lastSynced is null for a deck that has never synced.
Sync Decks
Section titled “Sync Decks”POST /api/deck-syncSync decks with Archidekt, using the same engine as the deck-sync CLI command. Exposed as the MCP sync_decks tool. Requires a stored Archidekt login; without one the response is 401 with loginRequired: true.
Request Body:
{ "direction": "pull", "decks": ["Winota Stax"], "dryRun": false}| Field | Description | Required |
|---|---|---|
direction |
pull (Archidekt → local) or push (local → Archidekt). Any other value returns 400. |
Yes |
decks |
Deck slugs or names, resolved like CLI list arguments. Omitted or empty syncs every linked deck; blank names are dropped, and a list of only blank names is a 400. |
No |
dryRun |
Report what would sync without writing files or pushing changes (default false). |
No |
ignoreUnreadableLines |
Sync decks whose files hold content a rewrite cannot reproduce — unreadable lines or a fenced code block — deleting it (default false). |
No |
only |
additions or removals — apply just one side of each deck’s diff, relative to the sync destination (see Change Filter). Omitted applies every change; any other value returns 400. |
No |
force |
Push a deck whose remote copy changed since its recorded sync, overwriting those remote changes (default false). Must be a boolean or 400. A pull ignores it. |
No |
syncPrintings |
Also sync each card’s exact printing — set, collector number, and foil/etched finish (default false). Must be a boolean or 400. See Printing Sync. |
No |
syncCategories |
Also sync each card’s categories — its role in the deck (default false). Must be a boolean or 400. See Category Sync. |
No |
Divergence guard. A push refuses any deck whose Archidekt updatedAt is newer than the sourceUpdatedAt its last sync recorded, so remote edits are never silently reverted. Such a deck is reported failed with Remote deck changed since last sync (…) — pull first, or pass --force to overwrite remote changes., and the rest of the run continues. A pull of that deck records the new baseline (even when it finds no card changes), after which the push succeeds; force: true overrides the guard. See Divergence Guard.
Unreadable lines. A sync rewrites each deck file, so a line the parser cannot read, or a fenced code block, would be deleted by the save. Over HTTP there is nobody to prompt, so such decks fail (N unreadable lines would be dropped by a sync) unless the request sets ignoreUnreadableLines — the API equivalent of the CLI’s --yes. The affected decks and their exact lines are reported in report.unreadable and, on the stream, as a progress frame with kind: "unreadable-lines" emitted before the decision is applied. A dryRun request is exempt: it writes nothing, so those decks are previewed rather than refused.
Response:
{ "success": true, "message": "Pushed 2 decks, 1 skipped.", "summary": { "clauses": [ { "message": "Pushed 2 decks", "messageKey": "admin.api.deckSync.pushed", "messageParams": { "count": 2 } }, { "message": "1 skipped", "messageKey": "admin.api.deckSync.skipped", "messageParams": { "count": 1 } } ] }, "report": { "direction": "push", "decks": [ { "name": "Winota Stax", "status": "synced", "outcome": "changed" }, { "name": "Oops All Soldiers", "status": "synced", "outcome": "unchanged", "reason": "no changes", "reasonKey": "domain.sync.reasonNoChanges" }, { "name": "Borrowed Deck", "status": "skipped", "reason": "you do not own Archidekt deck 12345", "reasonKey": "domain.sync.notDeckOwner", "reasonParams": { "deckId": "12345" } } ], "failedCount": 0, "unreadable": [], "cancelled": false }}summary is required on a completed run: the same sentence as message, split into ordered keyed clauses so a client with a translator can render it in the reader’s locale. Each clause is a message triple with no final punctuation. message is unchanged.
success reports whether the run could be performed, not whether every deck synced. A run with per-deck failures still returns 200 with success: true and a non-zero report.failedCount, so read each deck’s status and reason. Other report fields:
- Each deck carries
outcome, the code to branch on:changed(written or sent),unchanged(already matched, or a push that once planned had nothing to send),dry-run(a preview, whichreasonsummarizes) orcancelled(never reached). Collection lists addnot-run(see Sync Collection). It is absent on a failed deck and on one skipped for another reason. Never match onreasontext. reasonis English whatever the operator’s UI language — the message triple’s contract — withreasonKeyandreasonParamsbeside it, asmessageKeyandmessageParamsare besidemessage. A sync’s params may nest: a param is a string or number, another message ({ messageKey, messageParams }), a list ({ list, join }, wherejoinisand,unit,clausesorconcat), a duration ({ elapsedMs }for how long something took,{ durationMs }for an age or a time-out, such as the card cache’s age in a push’s CSV cache check) or a card language ({ language }), all rendered in the same locale as the message around them. A reason raised outside the catalog (a network error) has no key.- Each deck carries
printingsChanged(a count) when the request setsyncPrintings, orprintingsUnaligned(the card names whose printings the two sides disagree about) when it did not. - Each deck carries
categoriesChanged(a count of cards) when the request setsyncCategories. - Each deck carries
boardMoves(a count) when its diff moved cards between boards. Moves bypassonly: a move adds and removes no copies, so neither filter value drops it. See Board moves. - Each deck that had card changes carries
changes: every change the run applied (or would apply on a dry run), narrowed byonly. It lists what went through: a push leaves out what its plan could not send, or did not need to. When the push itself failed (nothing was sent), it lists what the push attempted, andreasonsays so. Entries are discriminated bykind:added/removed(name,quantity,board),quantity(name,board,from,to),move(name,quantity,from,toboards),printing(name,board,from,toprintings) andcategories(name,categories, primary first). The first four carry an optionalprinting(setlowercase,collectorNumber,finish) on a run syncing printings. These are the cards the CLI lists under--verbose. The SSE stream carries the same cards asdetaillog lines. report.unreadablelists any deck whose file holds lines the parser could not read ({ name, file, warnings }), so a caller that never sees the stream can still show what a retry withignoreUnreadableLineswould delete.report.cancelledistruewhen an in-process caller cancelled the run (the MCPsync_deckstool, on a client’snotifications/cancelled). Cancellation is honoured between decks only: the deck in flight finishes, every deck the run never reached is reportedskippedwith thecancelledoutcome (its Englishreasonreadscancelled before it started), and the summary ends on acancelled with N decks not startedclause. The response is still200with the report. HTTP callers cannot cancel a run: closing the stream leaves it running to completion.
When git auto-commit is enabled, deck files written by the run are committed (Sync decks with Archidekt (<direction>)).
Deck Sync Stream
Section titled “Deck Sync Stream”GET /api/deck-sync/stream?direction=pull&deck=<slug>&deck=<slug>&only=additions&dryRun=trueThe same sync as POST /api/deck-sync, streamed as server-sent events. EventSource can only issue a bodyless GET, so the request arrives as query parameters:
directionis required.deckrepeats once per deck; omit it entirely to sync all.onlytakesadditionsorremovals; omit it to apply every change.dryRun/ignoreUnreadableLines/force/syncPrintings/syncCategoriestaketrueorfalse. Any other value is rejected, so a flag that decides whether files are written can never be misread as “no”.
Three event types are emitted:
| Event | Payload |
|---|---|
progress |
One step of the run, in the vocabulary both syncs share — item is the deck being worked on: { kind: "item-start", item, index, total }, { kind: "log", level, item, message, messageKey?, messageParams? } (item is null for run-level lines; level is info, warn, error, or detail — one line per card change under a deck’s Changes: summary; message is English, and the key and params, which may nest as a deck’s reasonParams do, let the page render the line in the reader’s language), { kind: "item-result", result }, or { kind: "unreadable-lines", items: [{ name, file, warnings }] }. |
done |
{ message, messageKey?, messageParams?, summary, report } — the same message triple, keyed summary, and report the JSON route returns. |
error |
{ message, messageKey?, messageParams?, loginRequired } for a run that produced no report (bad parameters, no Archidekt login, or an unexpected failure); a message triple whose params may nest. |
Failures are reported inside the stream rather than as an HTTP status, since EventSource exposes no response body for a non-2xx open.
Collection Sync Status
Section titled “Collection Sync Status”GET /api/collection-syncReturns every collection list a run can be scoped to, the stored Archidekt login, when the account last synced, and the list a pull adds new cards to.
Response:
{ "success": true, "lists": [{ "slug": "binder", "name": "Blue Binder" }], "archidekt": { "loggedIn": true, "username": "someuser", "accessTokenExpiration": "2026-07-24T18:00:00.000Z", "accessTokenValid": true, "refreshTokenExpiration": "2026-08-20T12:00:00.000Z", "refreshTokenValid": true, "loginRequired": false }, "lastSynced": "2026-07-26T12:00:00.000Z", "pullTarget": "Inbox", "csvThreshold": 25}lastSyncedis account-level rather than per-list (an Archidekt account has one collection while Ritual has many collection lists). It isnulluntil a run applies something for real: a dry run records nothing, and neither does a run that stopped without writing (see Sync Collection). The stamp means “the lists and the account agreed at this time”.pullTargetis thecollectionSync.pullTargetconfig key: the list a pull adds new cards to unless the request names another.csvThresholdis how many new printings a push adds one at a time before the CSV import path takes over, so a caller can explain or decide thecsvfield.
Sync Collection
Section titled “Sync Collection”POST /api/collection-syncSync the account’s Archidekt collection with the local collection lists, using the same engine as the collection-sync CLI command. Requires a stored Archidekt login; without one the response is 401 with loginRequired: true. A login that predates recording which account it belongs to is refused the same way (a collection is fetched by numeric user id), so sign in again.
Request Body:
{ "direction": "pull", "lists": ["Blue Binder"], "into": "Inbox", "removalPriority": ["Long Box", "Blue Binder"], "dryRun": false}| Field | Description | Required |
|---|---|---|
direction |
pull (Archidekt → local) or push (local → Archidekt). Any other value returns 400. |
Yes |
lists |
Collection list slugs or names, resolved like CLI list arguments. Omitted or empty compares the whole collection (a list of only blank names is a 400); the remote side is always the entire Archidekt collection. |
No |
into |
The list a pull adds new cards to, created if it does not exist. A name two lists answer to fails the run before anything is fetched or written. Omitted uses the collectionSync.pullTarget config key. A push ignores it. |
No |
only |
additions or removals — apply just one side of the diff, relative to the sync destination. Omitted applies every change; any other value returns 400. |
No |
removalPriority |
Collection list names in priority order — the only lists an ambiguous removal may take copies from (see below). Must be an array of non-blank names or 400. A push ignores it. |
No |
removalAssignments |
An explicit decision per ambiguous removal: [{ key, choices: [{ list, copies }] }], where key is the removal’s key as report.ambiguous reports it and each choice names a list losing copies (a whole number ≥ 1). Shape-validated here (400); whether the lists hold those copies and the counts add up is reported through the run. Cannot be combined with removalPriority (400). A push ignores it. |
No |
csv |
Upload a push’s new cards as one CSV import instead of adding them one at a time (see below). Must be a boolean or 400. A pull ignores it. |
No |
dryRun |
Report what would sync without writing files or touching Archidekt (default false). |
No |
ignoreUnreadableLines |
Sync lists whose files contain lines the parser cannot read, dropping those lines (default false). |
No |
A csvFile field is rejected with 400 rather than ignored: writing a CSV to a caller-named path is a CLI-only feature (--csv-file), and a caller mirroring the CLI’s flags should be told instead of having its additions uploaded.
CSV import for new cards
Section titled “CSV import for new cards”Adding a printing Archidekt does not have costs a paced search plus a create, so a first push of a real collection would take hundreds of requests. csv: true sends those additions through Archidekt’s own collection importer instead — one upload, with every row built from the local Scryfall cache — exactly as the CLI’s --csv does, however few there are.
A push adding more new printings than csvThreshold without csv: true fails before writing anything to Archidekt: the guidance lands in report.errors, report.csv stays null, and no record is created, grown, or deleted. A dryRun never needs the flag: over the threshold it reports the upload it would make and resolves nothing. Quantity changes and removals never use the CSV (removals use Archidekt’s bulk-delete endpoint). Additions whose printing the local cache does not hold cannot become rows; they are added one at a time and counted in report.csv.uncached.
The rows are keyed by the Scryfall ids the local card cache holds, so a run taking this path treats cache freshness as --refresh auto: an empty or day-old cache is redownloaded before the file is built, reported as a log event on the stream and in the run’s messages.
Ambiguous removals. A pull removal is ambiguous when only some of a printing’s copies are going and those copies live in several lists, so nothing says which list the card physically left. (Taking every copy, or copies held in a single list, is never ambiguous.) The decision is made up front, as either:
removalPriority— copies come only from the lists it names, walking them in the order given. Names are matched exactly, never by the substring rule other list lookups use, and an unknown name fails the run.removalAssignments— an explicit per-removal decision.
Without either, or with one that does not cover a removal, the run fails and writes nothing at all: the reason lands in report.errors, report.unresolvedAmbiguity is true, report.ambiguous carries each removal with its per-list copy counts, no list file is touched, and the account’s lastSynced is left alone. A dryRun request never fails on an ambiguity itself; it reports it instead. (An unknown removalPriority name still fails a dryRun: that is a bad argument, not an unresolved removal.)
Unreadable lines. A pull rewrites each list file and a push treats those files as the truth, so a line the parser cannot read would be lost either way. Such lists fail unless the request sets ignoreUnreadableLines, the API equivalent of the CLI’s --yes. The affected lists and their exact lines are reported in report.unreadable and, on the stream, as a progress frame with kind: "unreadable-lines". A dryRun request is exempt: it writes nothing, so those lists are previewed rather than refused.
Response:
{ "success": true, "message": "Pulled +1 added, -0 removed into \"Inbox\".", "summary": { "clauses": [ { "message": "Pulled +1 added, -0 removed into \"Inbox\"", "messageKey": "admin.api.collectionSync.pulled", "messageParams": { "added": 1, "removed": 0, "into": "Inbox" } } ] }, "report": { "direction": "pull", "into": "Inbox", "dryRun": false, "lists": [ { "name": "binder", "status": "synced", "outcome": "changed", "added": 1, "removed": 0, "pending": 0, "changes": [ { "kind": "added", "name": "Lightning Bolt", "quantity": 1, "printing": { "set": "lea", "collectorNumber": "161", "finish": "nonfoil", "condition": "NM" } } ] } ], "failedCount": 0, "errors": [], "errorMessages": [], "unreadable": [], "ambiguous": [], "localIncomplete": false, "csv": null, "totals": { "added": 1, "removed": 0, "skipped": 0, "pending": 0 }, "cancelled": false, "unresolvedAmbiguity": false }}summary is required and works exactly as it does for Sync Decks.
success reports whether the run could be performed, not whether every list synced: a run with per-list failures still returns 200 with success: true and a non-zero report.failedCount. Report fields:
report.lists[].outcome,reason,reasonKeyandreasonParamswork as a deck’s do in Sync Decks: branch onoutcome;reasonis English, the key and params render it in the reader’s language. Several reasons for one list are joined into one. A run that refuses before reaching its lists (a badintoorremovalPriority, an unresolved ambiguity, or a push’s additions it will not send) reports themskippedwith the outcomenot-runand the reasonnot synced: the run stopped before this list.changedincludes copies a CLI push wrote to its--csv-filefor upload.report.lists[].changes, present when a list had any: every card behind its counts, which are its totals. Each entry has akind(added, withvia: "csv"when a push’s CSV import created it, orremoved),name,quantityin copies, andprinting(setlowercase,collectorNumber,finish,condition, andlanguagewhen not English). There is one entry per card, printing and kind, narrowed byonly. Thependingkind comes only from the CLI’s--csv-fileand never appears on this surface. It lists what went through, or would on a dry run: a card whose write failed is left out, and the list’sreasonsays what failed. On a push a card held in several lists is one key, so its entry, with the key’s whole quantity, is listed under each of them. A list a cancellation skipped may still carry changes an earlier list’s turn made for it. The SSE stream carries the same cards asdetaillog lines.report.unlisted— a push’s deletions of records for cards no in-scope list holds any more (the same shape as aremovedchange), present only when there is at least one; a failed one is inerrorsinstead.report.errors— failures that belong to the run rather than to one list (the collection fetch, or deleting records for cards no list holds any more), in English.report.errorMessages— the same failures, in the same order, as{ message, messageKey?, messageParams? }messages for a client that renders them in its own language.report.ambiguous— every removal a pull could not place on its own, whether a strategy then placed them or the run failed on them. Counts are in copies, not lists.report.unresolvedAmbiguity—truewhen the run stopped because of them: noremovalPriorityorremovalAssignmentswas given, the priority could not cover a removal, or the assignments did not account for every copy. Nothing was written; rerun with a decision. Branch on this flag rather than the prose inerrors(the MCPsync_collectiontool asks the client for the decision when it can).report.localIncomplete—truewhen a list in scope did not make it into the comparison: an unresolvable name, a file that could not be read, or one held back for unreadable lines. The local side is then short of cards it really holds, so a pull adds nothing (those cards would be duplicated into the target list) and a push removes nothing (they would be deleted from Archidekt). Fix or accept the listed lists and run again.report.cancelled— works as for Sync Decks: an in-process caller (the MCPsync_collectiontool) cancelled the run between lists, or before the remote collection was fetched (either direction). The list in flight finishes, the lists never reached areskippedwith thecancelledoutcome (Englishreasoncancelled before it started), the summary ends on acancelled with N lists not startedclause, and nolastSyncedis recorded. A cancelled push has still sent every change it made before stopping.
When git auto-commit is enabled, list files written by the run are committed (Sync collection with Archidekt (<direction>)).
report.csv describes what the CSV import did with a push’s new cards. It is null on any run that did not take that path: every pull, a push that added nothing new, a push with no more than csvThreshold new printings and no csv: true (which adds them one at a time), and one refused for lacking csv: true. Every shape carries cards (copies), rows (one per printing), and uncached (additions the cache could not resolve, added one at a time instead), plus:
status |
Extra fields | Meaning |
|---|---|---|
uploaded |
chunks, failures[], unconfirmedChunks |
Imported; failures names the rows Archidekt refused |
planned |
destination (upload) |
What a dryRun would have done — nothing was sent |
failed |
message, messageKey, messageParams |
The whole import failed; the rest of the run still applied |
empty |
— | No row could be keyed at all: uncached covers every new card |
unconfirmedChunks counts chunk responses Ritual could not read. Their rows are counted as imported because nothing said otherwise, so a non-zero value means part of that outcome is assumed rather than confirmed (the run log carries what Archidekt replied).
Each entry of failures is { row, card, ambiguous, notFound, errors }: the 0-based row of the uploaded CSV, the card it carried, and why it was dropped. A row Ritual cannot match to a card is named by an English label in card (CSV row 3), with cardKey and cardParams beside it. The lists holding those cards are reported as failed. (exported, the CLI’s --csv-file outcome, cannot occur here since the request parser refuses csvFile, so report.totals.pending stays 0 on this surface.)
Collection Sync Stream
Section titled “Collection Sync Stream”GET /api/collection-sync/stream?direction=pull&list=<slug>&into=Inbox&only=additions&removalPriority=<slug>&csv=true&dryRun=trueThe same sync as POST /api/collection-sync, streamed as server-sent events. EventSource can only issue a bodyless GET, so the request arrives as query parameters:
directionis required.listrepeats once per list; omit it entirely to sync the whole collection.removalPriorityrepeats once per list in priority order (parameter order is the priority; a blank one is rejected).removalAssignmentsis a single JSON-encoded parameter, validated by the same rules as the body’s.onlyandintoare omitted (or empty) to accept their defaults.csv/dryRun/ignoreUnreadableLinestaketrueorfalse. Any other value is rejected, so a flag that decides whether files are written, or how a large batch of cards reaches Archidekt, can never be misread as “no”.- A
csvFileparameter is rejected here too.
Three event types are emitted:
| Event | Payload |
|---|---|
progress |
One step of the run, in the vocabulary both syncs share — item is the collection list being worked on: { kind: "item-start", item, index, total }, { kind: "log", level, item, message, messageKey?, messageParams? } (message English, key and params as for Sync Decks; item is null for run-level lines; level is info, warn, error, or detail, one line per card under a list’s Changes: count, while a run-level detail line is a card a push’s CSV import created or a record it deleted for a card no list holds), { kind: "item-result", result }, or { kind: "unreadable-lines", items }. |
done |
{ message, messageKey?, messageParams?, summary, report } — the same message triple, keyed summary, and report the JSON route returns. |
error |
{ message, messageKey?, messageParams?, loginRequired } for a run that produced no report (bad parameters, no Archidekt login, or an unexpected failure); a message triple whose params may nest. |
Failures are reported inside the stream rather than as an HTTP status, since EventSource exposes no response body for a non-2xx open.
Build Site
Section titled “Build Site”POST /api/build-siteBuild and publish the public static site — the same build as ritual build-site, run as a child process so the server stays responsive. The build writes into a scratch directory beside dist/ and swaps it into place only once the child exits cleanly, so dist/ always holds either the previous site or the new one. Exposed as the MCP build_site tool.
Request Body: None.
Response:
{ "success": true, "message": "Site built successfully", "messageKey": "admin.api.buildSite.built", "outDir": "/home/user/ritual/dist", "durationMs": 42000}- One build runs at a time: a second request while one is in flight is refused with
503. - A failed build answers
500with the tail of the child’s stderr inmessage, anddist/is left as it was. - An in-process caller can cancel the build through its request context (the MCP tool does, on a client’s
notifications/cancelled): the child is killed, the scratch directory removed, and the response is499withdist/untouched.
Build Site Stream
Section titled “Build Site Stream”GET /api/build-site/streamThe same build as POST /api/build-site, streamed as server-sent events. The admin Build Site page’s progress bar and live log read it. It takes no parameters.
| Event | Payload |
|---|---|
progress |
{ kind: "step", progress, total, message } for one of the build’s structural steps on a 0–3 scale (starting → building → publishing → done), or { kind: "output", line } for one line of the child’s stdout or stderr, forwarded as it is printed. |
done |
{ message, messageKey?, messageParams?, outDir, durationMs } — the same message triple and publish details the JSON route returns. |
error |
{ message, messageKey?, messageParams? } for a build that was refused (one is already running) or failed; message carries the same text the JSON route would, and it is a message triple whose params may nest (an error the build raised, such as a cache-lock time-out). |
Failures ride the stream rather than an HTTP status, since EventSource exposes no response body for a non-2xx open. Closing the stream does not cancel the build: it runs to completion and publishes on its own, as a dropped sync stream leaves its run in flight.
Import CSV
Section titled “Import CSV”POST /api/import-csvImport cards from CSV text into a deck, collection, or wanted list. Used by the admin site’s Import CSV page and exposed as the MCP import_csv tool; shares its parsing, normalization, and column-mapping engine with the import CLI command’s CSV mode.
Request Body:
{ "listType": "collection", "name": "Red Binder", "mode": "append", "content": "Name,Set,Collector Number,Quantity\nSol Ring,C19,221,2", "columns": "name=1,set=2,collector-number=3,quantity=4", "hasHeader": true}| Field | Description | Required |
|---|---|---|
listType |
deck, collection, or wanted |
Yes |
name |
New list name (create/overwrite) or an existing list to append to (append) |
Yes |
mode |
create (default — fails if the list exists), overwrite, or append |
No |
format |
Deck format; required when creating or overwriting a deck | No |
content |
Raw CSV text | Yes |
columns |
1-based column mapping spec, e.g. name=1,set=2,collector-number=3. Fields: name, set, collector-number, condition, finish, language, tags, categories, section, quantity |
Yes |
hasHeader |
Whether the first row is a header row (default true) |
No |
format, when given, must be a canonical deck format key — see Deck Format. An unrecognized value returns 400.
Response:
{ "success": true, "message": "Appended 2 cards to collection 'Red Binder'; 1 row failed validation", "messageKey": "admin.api.importCsv.partial", "messageParams": { "result": { "messageKey": "admin.api.importCsv.appended", "messageParams": { "listType": "collection", "cards": { "messageKey": "domain.count.cards", "messageParams": { "count": 2 } }, "name": "Red Binder" } }, "failed": { "messageKey": "admin.api.importCsv.rowsFailed", "messageParams": { "count": 1 } } }, "cardCount": 2, "failures": [ { "lineNumber": 3, "raw": "No Printing,,", "reason": "Missing set code (required for collections)", "reasonKey": "domain.import.csv.missingSet" } ], "failedCount": 1, "warnings": [ { "message": "Skipped header row: Name,Set,Collector Number,Quantity", "messageKey": "admin.api.importCsv.headerSkipped", "messageParams": { "row": "Name,Set,Collector Number,Quantity" } } ]}message’s counts are nested messages (see the message triple), so a client renders them in its reader’s language. A partial import names the failed rows in the same sentence.
failures. Each row’s reason is English — several reasons joined with ; — with reasonKey/reasonParams beside it, the message triple under the field’s own name. A row failing for several reasons carries reasonKey: "domain.sync.reasonList" and the reasons nested as reasonParams.reasons ({ list: [{ messageKey, messageParams }, …], join: "clauses" }), so a client joins them in its reader’s language. A reason worded outside the catalog (a tag refusal) carries no key.
Merging. In a deck, rows naming the same card and the same printing merge into one line with their quantities summed, in every mode. Collections and wanted lists keep one line per physical copy in every mode, so N rows of the same printing stay N lines.
warnings is a list of message triples — the English message, plus messageKey/messageParams (which may nest) when the notice has a catalog entry. It reports:
- the row that was skipped as a header, plus a second entry when that row does not look like one (
… — set hasHeader to false to import it as a card.). A row looks like a header when a cell matches a known column name: the English ones, plus the spellings of every language this build ships — never just the server’s own interface language, so the answer, andwarnings, are the same bytes whoever runs the server; - category values a row’s
categoriescell refused (the row still imported), and, on a deck, values that named a board and set the row’s section instead (domain.import.csv.rowNotice, whosenoticeparam nests what happened to the value); - anything that went wrong writing the list’s categories sidecar, including the card names whose entries an overwrite pruned.
It is empty when hasHeader is false and nothing else had news.
Categories. A mapped categories column (headers category or categories) writes the list’s <name>.categories.json sidecar: comma-separated values, the first primary. On a deck, a cell value that names a board (Sideboard, Commander, Tokens, …) sets the row’s section instead of becoming a category, and an explicit non-empty section cell wins. The sidecar and its .sha256 join the auto-commit set.
Status codes. cardCount, failures, failedCount, and warnings are always present. Rows that fail validation are returned in failures while the valid rows still import, and success is a pure envelope flag: a request where every row failed is still a 200 carrying cardCount: 0 and the per-row report. The response is 400 only when the request is invalid: bad body shape, unknown listType/format, an unparseable column spec, a mapped column number the file has no column for (Column 99 (mapped to 'name') does not exist: the file has 6 columns), an unparseable CSV, no data rows, or an append to a list that does not exist. Each of those refusals is keyed; an unparseable CSV’s (admin.api.importCsv.parseFailed) nests the CSV reader’s own message as its reason param. Appends record each added card in the list’s changelog. When git auto-commit is enabled, the list file (and changelog) are committed.
Import Changes
Section titled “Import Changes”POST /api/import-changesApply a change bundle exported from the site editor to the underlying lists. Used by the admin site’s Import Changes page and exposed as the MCP import_change_bundle tool; shares its apply engine with the import-changes CLI command.
Request Body: the exported JSON, verbatim — a version-3 ritual-change-bundle covering one or more lists. Each list’s own edits sit in lists[].changes. Cross-list moves are normalized into the top-level moves array and never appear as move-from/move-to inside a list’s changes. Each move is one copy, naming its source and destination list by kind + name (the slug is a best-effort hint), and carries:
- the copy’s printing fields (
set,collectorNumber,finish,condition,language); - optional
tags(the card’s tags, canonical and without the#), landing on the destination line; - optional
labels(the card’s labels as its source list states them, signed: its effective labels there plus ano-for each label its source stated that it lacks), landed by the move rule — a proxy stays a proxy and a real card real, a stated label keeps its state, an unstated one takes the destination’s default — as far as the destination type accepts, and optionalnote, landing on the destination line; - optional
toCardId, the destination line’s&Nas the exporting editor allocated it, which the import re-targets like anadd’s id; - for a copy pinning a name-only destination line,
pinsCardIdandreplacement.
{ "format": "ritual-change-bundle", "version": 3, "exportedAt": "2026-06-04T00:00:00.000Z", "lists": [ { "kind": "deck", "slug": "Winota Stax", "name": "Winota Stax", "changes": [{ "id": "a1", "timestamp": 1, "action": "add", "cardName": "Counterspell" }] } ], "moves": [ { "id": "m1", "timestamp": 2, "cardName": "Sol Ring", "from": { "kind": "deck", "slug": "Winota Stax", "name": "Winota Stax" }, "to": { "kind": "collection", "slug": "binder", "name": "Binder" }, "set": "c19", "collectorNumber": "221", "cardId": 5, "toCardId": 9 } ]}Response:
{ "success": true, "message": "Applied 2 changes across 2 lists", "messageKey": "admin.api.importChanges.applied", "messageParams": { "changes": { "messageKey": "domain.count.changes", "messageParams": { "count": 2 } }, "lists": { "messageKey": "domain.count.lists", "messageParams": { "count": 2 } } }, "failedCount": 0, "lists": [ { "kind": "deck", "slug": "Winota Stax", "name": "Winota Stax", "applied": 1, "conflicts": [], "droppedNotes": [{ "cardName": "Sol Ring", "cardId": 3, "note": "from trade" }] }, { "kind": "collection", "slug": "binder", "name": "Binder", "applied": 1, "conflicts": [], "droppedNotes": [] } ]}How changes apply. Every list’s changes and every move are merged into one timestamp-ordered stream and applied in batches (consecutive events aimed at the same list); each batch loads its list fresh immediately before saving it. Changes are re-targeted to the list’s current card IDs: by ID when it still exists, otherwise by card name (a copy the same import just added first).
Conflicts. Changes whose target card no longer exists, whose action cannot apply to that list (a commander change aimed at a collection), or which would set a foil/etched finish on a card that pins no printing are skipped and reported in that list’s conflicts as { change, reason }, where reason is "target-not-found", "not-applicable", or "needs-printing".
Moves. Each entry of moves is applied on its destination list as a move-to: that save adds the copy there, takes it out of the source list (by the source line id the move names, else by the exact printing, else by name for a printing-less source line), and writes both changelogs. A destination named only by a move is resolved by its slug, then by its name, and reported as a list of its own (slug is the file basename it resolved to). lists[].applied counts moves arriving in the list; a replacement printing written back to a move’s source list is not counted.
Dropped notes. Each list’s droppedNotes gathers its saves’ own droppedNotes (see Save a List): a moved card that merged onto a deck line already holding a different note keeps the line’s note. Each distinct note is listed once. Empty when none.
Failures. A list that fails to resolve, load, or save carries an error string (English) and is counted in failedCount. When the failure has a catalog entry — the load or save route’s own keyed refusal, such as admin.api.list.notFoundListRoute — errorKey and errorParams ride beside it, as messageKey and messageParams do beside message. The failing batch applied nothing, that list’s later batches are skipped, and batches already applied stay applied and counted; the other lists continue. success stays true on a partial import, so read failedCount (and each list’s error), not the envelope. Every list that received changes gets a changelog entry, through the same save path as the editors. The response is 400 when the body is not a valid change bundle; its message says why (Move #1 "from" is not an object., List #2: Missing or invalid "slug".), keyed domain.changes.problem.*, and when the problem is about one change or move that record is nested as the subject param.
Export Cards
Section titled “Export Cards”POST /api/exportRender a CSV, JSON, plain-text, or Markdown export of cards from decks, collections, and wanted lists. Exposed as the MCP export_cards tool; shares its engine with the export CLI command. By default the rendered export is returned inline as a string; with write: true it is written to a server-named file instead.
Request Body: every field is optional. With no lists and no cards, every list is exported.
{ "lists": [{ "type": "deck", "name": "Winota Stax" }], "cards": ["sol ring"], "filters": { "name": "sol", "set": "c21", "finish": "foil", "conditions": ["NM", "none"], "labels": ["trade"], "tags": ["Signed"] }, "format": "csv", "columns": ["name", "set", "collectorNumber", "quantity"], "header": true, "quoteAll": false, "dialect": "ritual", "preset": "trade-sheet", "write": false}Selection:
listsnames resolve like CLI list arguments; the optionaltypepins an ambiguous name.- Each
cardsentry is whitespace-separated name terms. Every entry across all lists whose name matches all terms is added, deduplicated against the selected lists. filters.conditionstakes condition grades and/ornone(cards with no condition marked), matching the CLI’s--condition.filters.labelstakes label values (sale,trade,keep,proxy) and/ornone(unlabeled), matched against each deck and collection card’s effective labels like the CLI’s--labels. Wanted entries carry no labels and never match.filters.tagstakes card tags and keeps every card carrying any of them, matched exactly and case-sensitively on every list type, wanted lists included. A card with no tags never matches, and there is nononevalue (noneis an ordinary tag). A malformed tag is a400.presetstarts from a saved or built-in export preset. The built-inarchidektpreset needs no config, and a saved preset of that name shadows it. Explicit fields override the preset’s values.
Output:
formatiscsv(default),json,text(a plain-text decklist, quantities aggregated), ormd(canonical list markdown without&Nids) — see export formats.columns,header, andquoteAllshapecsv/jsonoutput only and are ignored fortext/md(unlike the CLI, the route does not reject the combination).dialectis the output vocabulary, ignored formd— see dialects. Forcsv/jsonit spells finish and condition:ritual(the default;nonfoil/foil/etched,NM…DMG) orarchidekt(Normal/Foil/Etchedunder aVariantheader, andNM|LP|MP|HP|D). Fortextit picks the decklist form:arenaandmoxfieldwrite bare board markers over1 Name (SET) CNlines (moxfield splices*F*/*E*between the set and the collector number, and closes each line with the card’s categories as#tagtokens) and omit maybeboard/token cards, naming them in the response’swarnings.- An unknown
dialectorpresetis a400. - Selected
scryfallIdandedhrecRankcolumns are resolved from the local Scryfall cache. - The
categoriescolumn exports the card name’s categories in that list, comma-joined primary first, andprimaryCategoryjust the first of them. Both are empty for an uncategorized card. Themdformat and everytextdialect butmoxfielddrop them;moxfieldtext writes them as#tagtokens.
Response: the body is discriminated by mode.
Content mode (the default, or write: false):
{ "success": true, "mode": "content", "format": "csv", "entryCount": 2, "warnings": [], "content": "Name,Set,Collector Number,Quantity\nSol Ring,C21,263,1\n..."}File mode (write: true):
{ "success": true, "mode": "file", "format": "csv", "entryCount": 2, "warnings": [], "path": "exports/Binder-20260728.csv", "bytes": 214}write must be a boolean; anything else is a 400.
The file lands under an exports/ directory in the base dir, which init-site adds to .gitignore. The server picks the name, <scope>-<YYYYMMDD>.<ext>: scope is the single selected list’s sanitized name, cards for a card-pick-only export, or all-lists otherwise, and the date is UTC. A name already taken gains the lowest free -2, -3, … suffix, so a write never overwrites an earlier export. path is base-dir-relative, so a caller that trusts it cannot be walked outside the workspace. The written file is newline-terminated and byte-identical to what the CLI’s export --out writes.
warnings carries:
- list parse warnings;
cardsterms that matched nothing;- one entry naming the maybeboard/token sections a
textexport in thearenaormoxfielddialect left out (with per-section counts); - one entry naming the categories a
textexport in themoxfielddialect could not write as tags (a space, a leading!, or a character a Ritual tag cannot hold); - one entry per list whose categories sidecar could not be read (that list exports with empty category cells);
- when the
scryfallIdcolumn is selected, one entry per printing the local Scryfall cache does not hold (that cell renders empty); - when the
edhrecRankcolumn is selected, one entry per card name the local Scryfall cache does not hold (an unranked card is not warned about — its cell is simply empty).
The response is 400 for an unknown list, preset, column, dialect, or filter value.
List Decks
Section titled “List Decks”GET /api/decksList all deck files in the decks directory.
Response:
{ "decks": ["burn", "elves", "mono-red-aggro"]}Import Deck
Section titled “Import Deck”POST /api/import-deckImport a deck from a supported URL, or from decklist text supplied directly (pasted in the UI or read from an uploaded file). The request is one of two shapes, distinguished by mode.
Request body (URL):
{ "mode": "url", "url": "https://archidekt.com/decks/123456", "overwrite": false, "syncPrintings": true}Request body (text):
{ "mode": "text", "content": "4 Lightning Bolt\n1 Sol Ring\n\n## Sideboard\n2 Pyroblast", "name": "My Burn Deck", "overwrite": false}| Field | Type | Required | Default | Description |
|---|---|---|---|---|
mode |
string | Yes | — | "url" or "text" |
url |
string | When url mode |
— | Archidekt, Moxfield, or MTGGoldfish URL |
content |
string | When text mode |
— | Decklist text (QTY Name per line; ## Heading lines start sections) |
name |
string | No | — | Deck name for text mode; ignored if the text defines its own # Title |
overwrite |
boolean | No | false |
Overwrite existing deck on conflict |
syncPrintings |
boolean | When url mode |
— | true keeps the exact printings (set, collector number, finish) the source lists; false imports bare card names. Rejected in text mode. See Printing choice |
syncPrintings is required in url mode because the CLI asks interactively and over HTTP the caller must decide. In text mode the printings come from the pasted lines.
Response:
{ "success": true, "message": "Successfully imported 'My Deck'", "messageKey": "admin.api.importDeck.imported", "messageParams": { "name": "My Deck", "notes": { "list": [], "join": "concat" } }, "deckName": "My Deck", "syncPrintings": true, "warnings": [], "advisories": []}warningslists text lines the parser skipped — content that was not imported (always empty for URL imports). Likeadvisories, it is a list of message triples: the Englishmessage, plusmessageKey/messageParamswhen the text has a catalog entry (a line the card-line grammar refused is still worded outside the catalog, and carries no key). A warning whose card was imported, only without the labels it carried, has"labelsDropped": true; the others are lines that were skipped.advisorieslists content that was read but is worth a word: a card name still carrying a printing token, a skipped MTG ArenaAboutline, or an empty## Maybeboard/## Tokensheader the write drops. On a URL import it carries notes about the source’s categories: names Ritual refused, or a categories file that could not be written.- When either array is non-empty,
messagenotes the count, as a nested message per kind of note (see the message triple).
A URL import from Archidekt or Moxfield also writes the deck’s <deck>.categories.json (and its .sha256), replacing the file a replaced deck had; with git auto-commit enabled, the categories and primer files are committed with the deck.
Pasted/uploaded text is read with the same dialects as ritual import: Ritual’s own format plus MTG Arena/MTGO/Moxfield exports (4 Lightning Bolt (M10) 146, bare Deck/Sideboard markers, and a *F*/*E* finish marker either trailing or between the set and the collector number).
A name/ID conflict without overwrite, or a deck name with no characters usable in a file name, fails with a 400 (the same usage classification the CLI turns into exit code 2), not a 500.
Refresh Cache
Section titled “Refresh Cache”POST /api/cache/refreshDownload and cache all Scryfall card data — the Scryfall half of ritual cache preload-all, without the buylist refresh that command also runs under sell mode (that is POST /api/sell/refresh). Returns a JSON response when complete. A refresh that fails answers a non-2xx with the failure’s message. A refresh cancelled by its caller (only an in-process caller such as the MCP refresh_cache tool can cancel one) answers 499: the download stops, nothing is written, the previous cache is left as it was, and the cache lock is released.
Request body: None
Response:
{ "success": true, "message": "Cache refreshed successfully", "messageKey": "admin.api.cache.refreshed"}Refresh Cache Stream
Section titled “Refresh Cache Stream”GET /api/cache/refresh/streamStream cache refresh progress as Server-Sent Events (SSE). The UI uses it for its real-time progress bar.
Response: text/event-stream with the following event types:
| Event | Data Fields | Description |
|---|---|---|
progress |
stage, percentage?, message |
Progress update during refresh |
done |
message, messageKey |
Refresh completed successfully |
error |
message, messageKey?, messageParams? |
Refresh failed |
done and error are message triples: message is English, and an error with a catalog entry (a cache-lock time-out) carries its key and params as an error response does. A progress line’s message is English text with no key.
Stage values: metadata, tags, download, save, done, info. Parsing and processing happen inline while the gzipped-JSONL bulk streams, so download covers them. The stages come from the refresh engine itself, and percentage is present on a download event whenever the compressed download size is known.
Example event stream:
event: progressdata: {"stage":"download","percentage":45,"message":"Downloading: 45% (32.50/72.50 MiB)"}
event: progressdata: {"stage":"save","message":"Saving to cache..."}
event: donedata: {"message":"Cache refreshed successfully","messageKey":"admin.api.cache.refreshed"}Archidekt Login
Section titled “Archidekt Login”POST /api/login/archidektLog in to Archidekt. The server authenticates with the Archidekt API and stores the session token locally.
Request body:
{ "username": "myuser", "password": "mypassword"}Response:
{ "success": true, "message": "Logged in as myuser", "username": "myuser"}Archidekt Login Status
Section titled “Archidekt Login Status”GET /api/login/archidektReport the stored Archidekt login, including how long the access and refresh tokens remain valid. Expirations come from the tokens’ JWT exp claims. When neither token is valid, loginRequired is true and the user must sign in again.
Response:
{ "loggedIn": true, "username": "myuser", "accessTokenExpiration": "2026-05-24T19:53:49.000Z", "accessTokenValid": true, "refreshTokenExpiration": "2026-07-02T18:53:49.000Z", "refreshTokenValid": true, "loginRequired": false}TOTP Setup
Section titled “TOTP Setup”POST /api/totp/setupGenerate a new TOTP secret for two-factor authentication. The secret stays pending until verified.
Request body: None
Response:
{ "success": true, "secret": "JBSWY3DPEHPK3PXP", "uri": "otpauth://totp/Ritual:admin?secret=JBSWY3DPEHPK3PXP&issuer=Ritual&algorithm=SHA1&digits=6&period=30"}TOTP Verify
Section titled “TOTP Verify”POST /api/totp/verify-setupVerify a TOTP code to activate the pending secret. Call it after /api/totp/setup to confirm the authenticator app is configured.
Request body:
{ "code": "123456"}Response:
{ "success": true, "message": "TOTP enabled successfully"}TOTP Disable
Section titled “TOTP Disable”POST /api/totp/disableDisable TOTP two-factor authentication.
Request body: None
Response:
{ "success": true, "message": "TOTP disabled"}TOTP Status
Section titled “TOTP Status”GET /api/totp/statusCheck whether TOTP is enabled for the admin account.
Response:
{ "enabled": true}Get Config
Section titled “Get Config”GET /api/configReturns the current application configuration.
Response:
{ "success": true, "config": { "decksDir": "./decks", "collectionsDir": "./collections", "wantedDir": "./wanted", "priceSources": ["tcgplayer"], "defaultLanguage": "en", "uiLocale": "en", "cacheLockTimeoutSeconds": 300, "cacheSource": "scryfall", "searchDebounceMs": 500, "updateCheck": true, "admin": { "gitEnabled": false, "gitAutoCommit": false, "gitAutoPush": false, "trustProxy": false, "secureCookies": false, "ipAllowList": [], "ipDenyList": [], "userAgentAllowList": [], "userAgentDenyList": [], "rateLimitEnabled": true, "rateLimitMaxAttempts": 5, "rateLimitWindowMinutes": 5, "failedAuthDelayMs": 3000 }, "collectionSync": { "pullTarget": "Inbox" } }}config is the stored configuration. When this server was started with a session flag that displaces one of those values — today that means --sell-mode — the response also carries an overrides object saying what the running process is actually operating with, keyed by the config path each override displaces:
{ "success": true, "config": { "site": {} }, "overrides": { "site.sellMode": true }}The flag writes nothing, so config.site.sellMode keeps reporting the stored value (usually unset) while the server’s sell routes answer anyway. overrides is absent entirely when no override is in force. It is a process-local fact, so ritual config get has no equivalent and PUT /api/config never returns it: a write echoes back what it persisted.
Update Config
Section titled “Update Config”PUT /api/configUpdate the application configuration. Partial updates are supported: only the fields you include change. The nested admin object is merged field by field, so you can send just the admin settings you want to change.
Every key in the request body is validated before the merge is persisted; a malformed update is rejected with a 400 and never written to disk:
- Unknown top-level keys are rejected (
Unknown config key "x"). - Unknown keys inside
adminare rejected (Unknown admin config key "x"), matchingconfig set admin.<field>. - When
adminorsiteis present, its fields are validated field by field with the config loader’s rules, and any malformed field rejects the whole update. collectionSyncreplaces wholesale likesiterather than merging likeadmin. Its fields are validated (pullTargetmust be a non-empty list name) and any absent field takes its default, so a partial object round-trips to a complete one. A malformed value rejects the whole update.defaultPriceSource(an empty string clears it),priceSources(store names only — lowercased and deduped, unknown stores rejected),defaultLanguage(canonical Scryfall codes only, no aliases),uiLocale(a BCP-47 tag naming the interface language, not the card language; see Localization),cacheLockTimeoutSeconds,cacheSource,cacheFeedUrl,searchDebounceMs, andupdateCheck(a boolean) are validated asconfig setvalidates them.cacheFeedUrlhas one extra rule: sending it as an empty string clears a previously-set override (falling back to the built-in default). Omitting the field leaves the current value untouched.managedis accepted so a client can send back the whole config it read, but it is ignored: the storedmanagedblock belongs toinit-siteandmigrateand is never taken from a request.
Request body:
{ "admin": { "gitEnabled": true, "gitAutoCommit": true }}Response:
{ "success": true, "config": { "decksDir": "./decks", "collectionsDir": "./collections", "wantedDir": "./wanted", "priceSources": ["tcgplayer"], "defaultLanguage": "en", "uiLocale": "en", "cacheLockTimeoutSeconds": 300, "cacheSource": "scryfall", "searchDebounceMs": 500, "updateCheck": true, "admin": { "gitEnabled": true, "gitAutoCommit": true, "gitAutoPush": false, "trustProxy": false, "secureCookies": false, "ipAllowList": [], "ipDenyList": [], "userAgentAllowList": [], "userAgentDenyList": [], "rateLimitEnabled": true, "rateLimitMaxAttempts": 5, "rateLimitWindowMinutes": 5, "failedAuthDelayMs": 3000 }, "collectionSync": { "pullTarget": "Inbox" } }}Ritual is unofficial Fan Content permitted under the Fan Content Policy. Not approved or endorsed by Wizards of the Coast. Portions of the materials used are property of Wizards of the Coast. ©Wizards of the Coast LLC.
Card data, images and symbols come from Scryfall, which is not affiliated with this project. Ritual is free software under the AGPL-3.0. License and attribution.