# Akiva vocabulary API

A small, public, AI-first store for words you are learning. No registration: creating a
*space* returns a secret key, and that key is the only credential. A space holds
*dictionaries* (one per language you learn); a dictionary holds *words* with a learning
status and a confidence level.

Base URL: `https://akiva.vishmidt.com` (`PUBLIC_URL`, which the links the server makes use). The old
address, `https://akiva.akiva-server.workers.dev`, reaches the same Worker and stays on for good:
Macs up to 0.33.0 update and sync there, and Claude connectors added before the move use it.

- REST API for apps: `/v1/...`
- MCP server for Claude and other assistants: `/mcp/<key>`
- Live docs: `GET /` returns this file (a browser, asking for HTML, gets Akiva's home page instead: `src/home.ts`). `GET /v1/meta` returns statuses, levels, parts of speech and limits.
- Downloads: `GET /download/mac` redirects to the DMG of the version `/mac/latest.json` offers (`src/releases.ts`).

## Concepts

| Thing | What it is |
|---|---|
| Space | Your account, identified only by a secret key `ak_...`. Lose the key, lose the space. |
| Dictionary | A named word list for one language, e.g. "Greek" (`language: "el"`, `translation_language: "en"`). |
| Word | A term with translation, status, confidence and optional details. Unique per dictionary by term (case-insensitive). |
| Share code | Optional read-only code for a dictionary, like a shared Anki deck. It exposes content only, never the owner's progress. Anyone with it can import a copy with fresh progress; the copy remembers its source (`source_share_code`) and can pull the owner's later edits. Revoking the code stops both. |
| Join link | `/join/<share code>`: a web page for someone new to Akiva. One button creates their space with a copy of the deck and shows the connector URL for Claude. |
| Snapshot | A compressed full copy of a dictionary (words, statuses, review history). Taken on demand or automatically before risky changes. Restorable. |

### Word fields

| Field | Type | Notes |
|---|---|---|
| `term` | string, required | In the learned language's script. Unique per dictionary, case-insensitive. |
| `translation` | string, required | |
| `transliteration` | string | Latin transcription, e.g. `kaliméra`. |
| `part_of_speech` | enum | `noun`, `verb`, `adjective`, `adverb`, `pronoun`, `preposition`, `conjunction`, `interjection`, `article`, `numeral`, `particle`, `phrase`, `other`. |
| `level` | enum | CEFR level: `A1`, `A2`, `B1`, `B2`, `C1`, `C2`. |
| `tags` | string[] | Up to 20 free-form themes such as `food`, `travel`, `verbs-group-b`. Stored lowercase and de-duplicated. On update the list is replaced, not merged. |
| `examples` | string[] | Up to 10 example sentences. |
| `notes` | string | Anything: gender, irregular forms, mnemonics. |
| `extra` | object | Up to 20 key-value pairs (string, number or boolean values). Your escape hatch. Writes merge: keys you send are set, a key sent as `null` is removed, keys you don't mention are kept. So an app can own `order` while Claude edits `gender` without either wiping the other. |
| `status` | enum | `new` → `learning` → `known` → `mastered`, or `ignored`. Default `new`. |
| `confidence` | int 0..5 | 0 = never recalled, 5 = solid. Default 0. |
| `status_changed_at`, `last_reviewed_at`, `review_count` | read-only | Maintained by the server. |
| `id`, `dictionary_id`, `created_at`, `updated_at` | read-only | |

### Limits

50 dictionaries per space, 5000 words per dictionary, 200 words per request, 256 KB request
body, 120 requests per minute per key, 3 space creations per minute per IP. See `GET /v1/meta`.

## Authentication

Send the key in a header on every `/v1` call except `POST /v1/spaces`, `GET /v1/decks/...`, `GET /v1/catalog` and `GET /v1/shared/...`:

```
Authorization: Bearer ak_...
```

`X-Api-Key: ak_...` also works.

## REST reference

All bodies and responses are JSON. Errors look like `{"error": {"code": "not_found", "message": "..."}}`.
Where a route takes `:ref`, pass a dictionary id **or** its name (case-insensitive).

### Spaces

| Method | Path | Body | Returns |
|---|---|---|---|
| POST | `/v1/spaces` | `{ "name"?: string }` | `{ space, key, mcp_url }`. Save `key`. |
| GET | `/v1/space` | | `{ space, dictionaries }` |
| DELETE | `/v1/space?confirm=<space id>` | | 204. Deletes everything. |

### Dictionaries

| Method | Path | Body | Returns |
|---|---|---|---|
| GET | `/v1/dictionaries` | | `{ dictionaries: [...] }` each with `counts` per status |
| POST | `/v1/dictionaries` | `{ name, language, translation_language, description? }` | `{ dictionary, created }`. 201 when created, 200 when a dictionary with that name already existed (names are unique per space, case-insensitive). |
| GET | `/v1/dictionaries/:ref` | | `{ dictionary }` |
| PATCH | `/v1/dictionaries/:ref` | any of the create fields | `{ dictionary }` |
| DELETE | `/v1/dictionaries/:ref` | | `{ snapshot }` taken just before deletion |
| POST | `/v1/dictionaries/:ref/share` | | `{ share_code, join_url, url }`. Send `join_url` to people new to Akiva. |
| DELETE | `/v1/dictionaries/:ref/share` | | 204, revokes the code |
| POST | `/v1/dictionaries/import` | `{ share_code, name? }` | `{ dictionary }` (a fresh copy in your space, sentence bank included; gets a " (2)" suffix if the name is taken) |
| POST | `/v1/dictionaries/:ref/sync` | | `{ dictionary, added, updated, unchanged, not_in_source }`. Pulls the source deck's current content into an imported copy. Translations, examples, notes, tags, the sentence bank and other content are overwritten; status, confidence and review history are never touched. Words the source removed stay and are listed in `not_in_source`. 400 if the dictionary was not imported, 404 if the source stopped sharing. |
| GET | `/v1/dictionaries/:ref/sentences` | | The deck's sentence bank, `{ sentences: [...] }` (empty when it has none) |
| PUT | `/v1/dictionaries/:ref/sentences` | a sentence bank, up to 1.5 MB | `{ sentences }`, the count. Replaces the bank. The format is the Mac's `sentence_bank.json`: `lang` names the language the sentences are written in, the one the dictionary teaches (two letters, `pt` for a `pt-BR` dictionary; Greek, `el`, when absent, and 400 when it does not match). Each sentence has its text under that key, translations under the other two-letter keys (`en`, `uk`), the `primary` lemma it was written for, and `teaches: [{ lemma, surface, en?, uk? }]`. Apps turn it into example sentences on the cards. Example: `{ "lang": "pt", "sentences": [{ "pt": "A casa é velha.", "en": "The house is old.", "primary": "a casa", "teaches": [{ "lemma": "a casa", "surface": "casa", "en": "house" }] }] }`. |
| DELETE | `/v1/dictionaries/:ref/sentences` | | 204, removes the bank |
| GET | `/v1/decks?from=uk&learn=el` | | `{ decks: [{ id, slug, name, source_lang, target_lang, description, latest, word_count, sentence_count, note, updated_at }] }`: the published decks (decision 53) anyone can start from, optionally only those teaching `learn` to a speaker of `from`. No auth needed. |
| GET | `/v1/decks/:id` | | One published deck, as listed; apps check `latest` for a newer version |
| GET | `/v1/decks/:id/:version` | | The version, the whole deck as JSON (engine/core/src/published.rs), as stored; immutable, so cached for good. No auth needed. |
| GET | `/v1/catalog?from=uk&learn=el` | | The catalog of shared decks from before published decks, which apps no longer read: `{ decks: [{ share_code, name, language, translation_language, description, word_count, sentences }] }`: the decks anyone can start from, optionally only those teaching `learn` to a speaker of `from`. No auth needed. A deck is listed by adding its share code to the `catalog` table by hand (see `content/tools/catalog/`); unsharing it takes it off. |
| GET | `/v1/shared/:code` | | `{ share_code, name, language, translation_language, description, word_count, updated_at, words }`, content fields only, no auth needed |
| GET | `/v1/shared/:code/sentences` | | The shared deck's sentence bank, as `/v1/dictionaries/:ref/sentences` gives it; no auth needed. The Mac starts a catalog deck from this and `/v1/shared/:code`, without a space. |

Web pages (HTML, no auth): `GET /join/:code` shows the deck and a "Create my copy" button; `POST /join/:code` (form field `name`, optional) creates a space, imports the deck and shows the key and connector URL once. Rate-limited like `POST /v1/spaces`.

### Snapshots

| Method | Path | Body | Returns |
|---|---|---|---|
| POST | `/v1/dictionaries/:ref/snapshots` | `{ label? }` | `{ snapshot }` |
| GET | `/v1/dictionaries/:ref/snapshots` | | `{ snapshots: [...] }` newest first |
| GET | `/v1/snapshots` | | all snapshots in the space, including those of deleted dictionaries |
| GET | `/v1/snapshots/:id` | | `{ snapshot, dictionary, words }` full content |
| POST | `/v1/snapshots/:id/restore` | | `{ dictionary, restored_words, pre_restore_snapshot }` |
| DELETE | `/v1/snapshots/:id` | | 204 |

Automatic snapshots (`reason` field): `auto` before a bulk update or add that overwrites more than 10 existing words and before any removal of words (at most one per dictionary every 10 minutes); `pre-delete` before a dictionary is deleted (the `DELETE` response returns it); `pre-restore` before a restore. The newest 30 snapshots per dictionary are kept. Restoring replaces the dictionary's words with the snapshot's, keeping word ids, statuses and review counts; dictionary name and languages are not changed unless the dictionary had been deleted, in which case it is recreated.

### Words

| Method | Path | Body | Returns |
|---|---|---|---|
| GET | `/v1/dictionaries/:ref/words` | query filters, see below | `{ dictionary, words, total }` |
| POST | `/v1/dictionaries/:ref/words` | `{ "words": [Word, ...] }` | `{ dictionary, created, updated }` |
| PATCH | `/v1/dictionaries/:ref/words` | `{ "updates": [{ id or term, ...fields, reviewed?: true }, ...] }` | `{ dictionary, updated, missing }` |
| DELETE | `/v1/dictionaries/:ref/words` | `{ "words": [{ id } or { term }, ...] }` | `{ deleted, missing }` |
| GET | `/v1/words/:id` | | `{ word }` |
| PATCH | `/v1/words/:id` | word fields, `reviewed?: true` | `{ word }` |
| DELETE | `/v1/words/:id` | | 204 |

#### Word filters

All filters combine with AND. List filters take comma-separated values.

| Query param | Meaning |
|---|---|
| `status=new,learning` | Any of these statuses |
| `level=A1,A2` | Any of these CEFR levels; `none` selects words without a level |
| `part_of_speech=noun,verb` | Any of these; `none` selects unset |
| `tag=food,travel` | Words carrying any of these tags; `none` selects untagged words |
| `q=bread` | Substring search across term, translation, transliteration, notes and examples |
| `confidence_min=2`, `confidence_max=4` | Inclusive confidence range |
| `reviewed_before=2026-09-01T00:00:00Z` | Last reviewed before this time, including never-reviewed words |
| `reviewed_after=2026-09-01T00:00:00Z` | Reviewed at or after this time |
| `sort` | `created_at` (default), `updated_at`, `status_changed_at`, `last_reviewed_at`, `term`, `confidence`, `level`, `part_of_speech`, `review_count` |
| `order` | `asc` (default) or `desc` |
| `limit`, `offset` | Paging; limit defaults to 500, max 5000 |

Adding a word whose term already exists updates that word with the fields you sent instead of
creating a duplicate, so clients and assistants can add without checking first.

Changing `status` stamps `status_changed_at`. Sending `reviewed: true` increments
`review_count` and sets `last_reviewed_at`.

### Example session

```bash
# 1. Create a space once, keep the key
curl -s -X POST https://akiva.vishmidt.com/v1/spaces -H 'content-type: application/json' -d '{"name":"Alec"}'

# 2. Create a dictionary
curl -s -X POST https://akiva.vishmidt.com/v1/dictionaries \
  -H "Authorization: Bearer $AKIVA_KEY" -H 'content-type: application/json' \
  -d '{"name":"Greek","language":"el","translation_language":"en"}'

# 3. Add words
curl -s -X POST https://akiva.vishmidt.com/v1/dictionaries/Greek/words \
  -H "Authorization: Bearer $AKIVA_KEY" -H 'content-type: application/json' \
  -d '{"words":[{"term":"καλημέρα","translation":"good morning","transliteration":"kaliméra","part_of_speech":"interjection","level":"A1","tags":["greetings"]}]}'

# 4. Fetch what to practise
curl -s "https://akiva.vishmidt.com/v1/dictionaries/Greek/words?status=new,learning" -H "Authorization: Bearer $AKIVA_KEY"

# 5. Record progress
curl -s -X PATCH https://akiva.vishmidt.com/v1/dictionaries/Greek/words \
  -H "Authorization: Bearer $AKIVA_KEY" -H 'content-type: application/json' \
  -d '{"updates":[{"term":"καλημέρα","status":"learning","confidence":2,"reviewed":true}]}'
```

## MCP (Claude Desktop, Claude Code, others)

The same service speaks MCP over Streamable HTTP. The key goes in the URL because desktop
clients cannot add headers:

```
https://akiva.vishmidt.com/mcp/ak_...
```

Claude Desktop: Settings → Connectors → Add custom connector → paste the URL. No OAuth.
Claude Code:

```bash
claude mcp add --transport http akiva https://akiva.vishmidt.com/mcp/ak_...
```

Study progress, as the apps show it (read-only, from the space's sync log through the engine):

| Tool | Returns |
|---|---|
| `get_progress` | Today's plan (goal, cleared, reviews and new words planned, minutes, streak, backlog) and each deck's words by phase: new, learning, relearning, review, learned, mastered, problem, retired, due today; the level being learned |
| `get_study_words` | The words the learner has started, with recall now, stability, difficulty, due date, lapses, spelling slips and answers; filters by deck, phase and state (`due_today`, `problem`, `learned`, `mastered`, `retired`); one word's answer history by `term` |
| `get_study_history` | Per learner day: answers given and passed, words, new words, the day's planned size and reviews, how it felt |

They read the progress the apps report (`packages/client/src/progress.ts`): a device sends a report
with a sync round at most every 10 minutes and when the app goes to the background, and the server
keeps the latest and one row a learner day (`progress_reports`, `progress_days`). A call reads one
row, moves recall and due words on to now, and answers with `as_of` and `source: "app"`. When
another device has studied since the report, or none has sent one, the server builds the same report
from part of the log (`source: "replay"`): settings and decks, the words some event names, and the
next 200 new words by rank; how many words a deck has comes from the id rows. One word's answers
(`get_study_words` with `term`) come from its events.

Word store: `list_dictionaries`, `create_dictionary`, `update_dictionary`, `get_words`, `get_word`,
`add_words`, `update_words`, `remove_words`, `delete_dictionary`, `share_dictionary`,
`import_shared_dictionary`, `sync_dictionary`, `snapshot_dictionary`, `list_snapshots`,
`restore_snapshot`. A dictionary word's `status` and `confidence` are notes for practice in a
conversation, not the apps' progress.
The server's instructions tell the assistant which tools answer what.

## Telegram

For the Telegram Mini App (`apps/telegram/`, served at `/app/`). Signed routes take `init_data`, the
Mini App's `Telegram.WebApp.initData`; the server checks it against the bot token. That signature
links a Telegram account to a space; the key stays the credential for everything else.

| Route | What it does |
|---|---|
| `POST /telegram/start` `{init_data, share_code?}` | New space, the shared deck copied in, this Telegram account linked. Returns the key. The share code defaults to the Mini App's `start_param`; with neither, 400 `invite_required`. 409 `already_linked` if the account has a space. |
| `POST /telegram/link` `{init_data}` + key | Links this Telegram account to the key's space, replacing earlier links. |
| `POST /telegram/recover` `{init_data}` | A new key for the linked space; the old key stops working. 404 `not_linked`. |
| `POST /telegram/unlink` `{init_data}` | Start over: the Telegram account forgets its space (reminders stop with the link). The space and its key stay. 204. |
| `POST /telegram/reminders` `{init_data, on \| refresh, lang, hour, time_zone}` | Daily reminders on or off, with the hour the learner usually studies and their time zone. With `refresh: true` instead of `on`, only the hour, time zone and language change. Answers `{on}` as stored. 404 `not_linked`. |
| `POST /telegram/webhook` | The bot (`src/bot.ts`): `/start` (the welcome picture, or an invite's deck for `/start join_<share code>`), `/help`, `/reminders` with its on/off button, and a pointer to the app for anything else. Needs the `X-Telegram-Bot-Api-Secret-Token` header. |

Only the numeric Telegram user id is stored, plus the reminder hour, time zone and language while
reminders are on. An hourly cron trigger runs the reminder job (`src/reminders.ts`): for each learner
whose usual hour it is, it replays their log with the engine and, when reviews are due and nothing
has been answered that learner day, the bot sends one line such as "Greek today: about 6 minutes"
with an Open button. At most one a day; a learner who blocked the bot has reminders turned off.
Setup and deploy: `apps/telegram/README.md`.

## Admin

For the admin panel (`apps/admin/`, served at `/admin/`): the operator's view across every space,
under `/admin/api`, with `Authorization: Bearer <ADMIN_TOKEN>` (a secret; without it every route
answers 503). Changes go through the same code as the owner's own routes, scoped to the deck's space,
so the usual snapshots are taken before anything destructive.

| Route | What it does |
|---|---|
| `POST /admin/api/telegram/setup` | Applies the bot's webhook and profile (descriptions, commands, menu button; `src/bot.ts`, `setUpBot`) and lists what it set |
| `GET /admin/api/published` | Every published deck, listed or not, with its version count, followers (spaces whose log follows it) and `deleted_by` (followers that deleted it since) |
| `GET /admin/api/published/:slug` | One published deck: its versions newest first, and the spaces that follow it, at which version |
| `GET /admin/api/published/:slug/latest` | The newest version of a published deck, for the publish tool to build on |
| `GET /admin/api/published/adopt/:space/:deck` | A space's id rows for one of its decks, for a version 1 that adopts it |
| `POST /admin/api/published/:slug/rows?id=` | Up to 1000 id rows for the create decider, kept in `sync_ids` under the space `pub:<deck id>` |
| `PUT /admin/api/published/:slug/:version/full\|slim/:part?id=` | One part (at most 1 MB) of a version's text, or of its slim copy for the reminder job |
| `POST /admin/api/published/:slug/:version` | Finishes a version once every part is up and makes it the deck's latest; only the next version is taken (409 otherwise) |
| `PATCH /admin/api/published/:slug` `{listed}` | Shows or hides a published deck in the catalog; its followers keep it |
| `GET /admin/api/overview` | Counts (spaces, seen and studied in 7 days, Telegram links, published and listed decks, learners following one, progress reports), the latest report's time, and the legacy word store's counts (decks, words, shared, catalog, snapshots, sentence banks) with its decks per language pair |
| `GET /admin/api/catalog` | The legacy share-code catalog (apps before 0.26): every row with its deck, including rows whose share code no longer resolves (`deck: null`) |
| `POST /admin/api/catalog` `{dictionary_id}` | Lists a deck, sharing it first if needed. 201 |
| `DELETE /admin/api/catalog/:code` | Unlists. 204 |
| `GET /admin/api/spaces?q=&limit=&offset=` | Spaces, most recently seen first; `q` is a name, space id or Telegram user id. Each with the published decks it follows, its newest `progress_days` row, when its last event happened and its report arrived, its sync log size (content, events), and its legacy decks and words |
| `GET /admin/api/spaces/:id` | The space, its Telegram link and reminders, followed and own decks (from the log and its id rows), the latest progress report's small parts and whether it is current, its last 14 days, sync log totals, and its legacy decks and snapshots |
| `GET /admin/api/spaces/:id/rows` | How many rows of each table belong to the space: what deleting it removes |
| `DELETE /admin/api/spaces/:id` | Deletes the space and every row of it (`SPACE_ROWS` in `src/db.ts`: decks, words, sentence banks, catalog rows, snapshots, Telegram link, sync log, id rows, progress) in one batch, and answers the rows deleted per table. No snapshot is kept. Its key then gets 401 `unauthorized`, so the apps show "Your key became invalid." with only Start over, and its Telegram account gets a new space from `/telegram/start` |
| `GET /admin/api/decks?q=&filter=&language=&translation_language=` | Decks across spaces; `filter` is `all`, `shared`, `listed` or `imported`; `q` matches a name, deck id, share code, source share code or space id |
| `GET /admin/api/decks/:id` | The deck, status counts, snapshots, the deck it was imported from, share links |
| `PATCH`, `DELETE /admin/api/decks/:id` | As the owner's routes (delete keeps a pre-delete snapshot) |
| `POST`, `DELETE /admin/api/decks/:id/share` | Share or unshare |
| `GET /admin/api/decks/:id/words` | The owner's word filters |
| `GET`, `DELETE /admin/api/decks/:id/sentences` | The sentence bank, or remove it |
| `POST /admin/api/decks/:id/snapshots` `{label?}` | A manual snapshot |
| `GET /admin/api/snapshots/:id`, `POST /admin/api/snapshots/:id/restore` | Snapshot content, restore |
| `PATCH`, `DELETE /admin/api/words/:id` | Edit or delete one word |

Every deck carries `word_count`, `has_sentences`, `listed` and `copies` (how many decks were imported
from its share code).

## Engine sync

`POST /v1/sync` with the key: the engine's sync (engine design, build step 10; the client is
`packages/client/src/sync.ts`). The server keeps each space's content changes and events in the
order it accepted them, decides the id of every create, and otherwise runs no engine.

Request `{cursor, content, events, progress?}`: where the last round's pull ended (`null` on a new
device), and this device's unsynced content changes and events, up to 1000 of each. `progress`, now
and then, is the device's report for the Claude connector (`{report, day}`, the report as JSON text):
kept as sent, as reflecting the whole log when the device held every event before the round. Every event's `user` must
be the space id. Response `{receipt, conflicts, remapped, content, events, cursor, more}`: the content
ids and event ids now held (events with their `seq`), content and event ids already held with a
different body (the device then moves to a new device id), the creates that must take another id,
then up to 1000 entries after the cursor from any device (250 when the round sent something), the
new cursor, and whether more wait. Sending the same entries again is harmless.

The server decides every create it has not seen with the engine's own rule (`decide_creates`, engine
design, "Hash ids"), run as WebAssembly. A create whose id the space holds live, renamed or not, is
the same create; otherwise one whose name or term key matches a live deck or word maps to it, and
one whose id was deleted gets the next free generation. Such a create comes back in `remapped`
(`{kind, change, from, to}`, words with their `generation`), and the changes that refer to it wait;
neither is stored. The device rewrites its unsynced changes and events and sends them again, holding
its events back meanwhile.

The rule reads `sync_ids` (`src/sync-ids.ts`, `engine/core/src/held.rs`), not the whole log: one row
per deck and word id with what a decision reads, merged from every accepted change in the same batch
that stores it. A round fetches the few rows its creates can see in one indexed query, so it stays
within the free plan's 10 ms of CPU (`test/cpu.test.ts` measures it on real shapes). A space whose
log is older than the table answers a round with creates `503 sync_ids_pending` until its rows are
filled from the log: `GET /admin/api/sync-ids` lists such spaces, `POST /admin/api/sync-ids/:space`
`{after}` fills one in steps (repeat with the `after` it returns until `done`), and
`ADMIN_TOKEN=… node scripts/backfill-sync-ids.mjs <origin>` does every one. The merge does not
depend on order, so filling a space while its devices sync is safe.

Content keeps no history (engine design, decision 55). Content settles field by field, the latest
stamp winning and a delete holding for good, so a round drops a field write that a later stamp beats
and any field write to a deleted record, whether it was just sent or already held; the receipt still
lists it, so its device never sends it again. Creates, deletes, follows and change types the server
does not know are kept. `migrations/0016` derives each change's record and field from its body.

A log from before is compacted once: `ADMIN_TOKEN=… node scripts/compact-space.mjs <origin> <space>`
reads it (`GET /admin/api/spaces/:id/log`), asks the engine which content changes decide nothing
(`redundantContent`, `engine/core/src/compact.rs`: the two rules above, and what a followed published
deck already writes), checks that the rest reads exactly as the whole log does, and reports. With
`--apply` it deletes them (`POST /admin/api/spaces/:id/compact`, never an event or the space's last
entry). Run it once every device shows Synced.

## Mac updates

`GET /mac/latest.json` (and `/mac/preview/latest.json` for Akiva Preview), no key: Tauri's update
manifest for the Mac app, and the signed bundles it points to (`GET /mac/<bundle>.app.tar.gz`). They
live in the `RELEASES` KV namespace (`src/releases.ts`), put there by `npm run release:update` in
`apps/mac` ([its README](../apps/mac/README.md), "Updates"). The manifest is never cached; a bundle,
named by its version, is cached for good.

## Running it

```bash
pnpm install
pnpm db:migrate:local
pnpm build:web                # the Mini App and the admin panel into public/ (/app/, /admin/)
pnpm dev                      # http://localhost:8787
```

Deploy (once, then just `pnpm deploy`):

```bash
npx wrangler login
npx wrangler d1 create akiva   # paste the database_id into wrangler.jsonc
npx wrangler secret put ADMIN_TOKEN   # e.g. `openssl rand -hex 24`; the admin panel's sign-in
pnpm db:migrate
pnpm build:web
pnpm deploy
```

Custom domain: `routes` in `wrangler.jsonc` serves the Worker at `akiva.vishmidt.com` (a zone on
the same Cloudflare account; deploying creates the DNS record and certificate). `workers_dev: true`
keeps `akiva.akiva-server.workers.dev` on as well: routes would turn it off, and installed Macs up to
0.33.0 and Mini Apps opened there before the move still use it. Never turn it off.
