{"openapi":"3.1.0","tags":[{"name":"start","description":"The front door (`GET /api/v1`) — the task-routing document you should have read before this spec."},{"name":"dictionary","description":"Read the dictionary's metadata (gloss languages, orthographies, entry count) — call this first — and write its own catalog fields: the About prose, citation, where it is spoken, language codes, and the home-page cover photo."},{"name":"entries","description":"Create, read, update, and delete entries, their senses, and example sentences."},{"name":"media","description":"Attach or remove audio, photos, and videos on entries, senses, sentences, and texts."},{"name":"texts","description":"Connected passages/stories with their own ordered sentences, `…/texts/collections` hierarchical grouping (series › book › chapter), plus `…/texts/{textId}/tags` classification (genre/motif/tale-type)."},{"name":"grammar","description":"Structured, hierarchical grammar sections: parallel-language markdown prose linked to entries/senses, example sentences by reference, clause-template slots, usage conditions, and the glossing-abbreviations legend."},{"name":"relationships","description":"Typed links between two entries (optionally narrowed to senses), plus the per-dictionary custom-type registry (`…/relationship-types`)."},{"name":"cognate-sets","description":"Cognate sets (\"word families\"): N-ary groups of entries descending from one ancestor form, with the reconstruction, its level, sources, and per-member uncertainty."},{"name":"places","description":"Places: named, located localities (village, town, county, river valley…) with coordinate precision and nesting, linked to the entries attested there, the texts recorded there, the dialects spoken there, and the speakers who live there — the data behind map views."},{"name":"dialects","description":"Dialect labels and their areal-extent geometry."},{"name":"tags","description":"Entry tags."},{"name":"speakers","description":"Speaker records for audio/video attribution."},{"name":"sources","description":"The citation/source registry entries and sentences reference by slug."},{"name":"files","description":"Uploaded import resources (original spreadsheets, FLEx/LIFT exports, PDF scans…) with per-file import instructions — list, download, upload, link to sources. Write scope required for everything (never public)."},{"name":"conversations","description":"Import conversations: the durable page where a dictionary manager and the Living Dictionaries team work one import request together — claim/resolve the job, post messages, file the report artifact, and ask answerable questions."},{"name":"guides","description":"The task playbooks (markdown) — the primary documentation layer: api-basics, the import runbook + its format guides, cleanup, consume, media, corpus."},{"name":"orthographies","description":"Alternate writing systems."},{"name":"featured-entries","description":"The starred entries shown on the dictionary home page."},{"name":"suggestions","description":"The word→entry matching review queue: unmatched/ambiguous/ignored forms aggregated across all tokenized sentences, plus per-token and form-wide review actions."},{"name":"alignment","description":"Forced alignment: auto-generate karaoke word-timings for text/sentence audio from the dictionary's tokenized sentences (requires team-configured romanization)."},{"name":"feedback","description":"Send feedback/requests to the Living Dictionaries team."}],"info":{"title":"Living Dictionaries Write API","version":"1.0.0","description":"Endpoint reference for the Living Dictionaries Write API — programmatic read/write access to ONE dictionary. An agent can do anything a human editor can.\n\n## Read your task guide before this document\nThe starting point is `GET /api/v1` — a small front door that routes you to the GUIDE for the job you are actually doing (importing, cleanup, bulk reads, media, corpus). If you came straight here, go there first. The guides carry the judgement calls (what to ask your human, what never to guess, how not to corrupt a language's data); this document carries only field shapes. `GET /api/v1/guides/api-basics` covers the mechanics every job shares: auth, the dictionary id, multilingual fields, generating your own ids for idempotency, batch results, errors, and size limits.\n\n## Auth\nEvery request carries `Authorization: Bearer ldk_…` — a key minted on the dictionary's Agents page, scoped to ONE dictionary, granting either read or read & write. A key for dictionary A cannot touch dictionary B (403). Every path's `{id}` is that dictionary's id or url-slug.\n\n## Fetching this document\nBy default `GET /api/v1/openapi.json` returns a COMPACT INDEX: every path with its method summaries and tag, plus the list of schema names. Read that, then fetch just the group you need WITH full ($ref-complete) schemas via `?tag=<name>` (tag names are in the top-level `tags` list). `?view=full` returns the complete ~200KB document — rarely what you want."},"servers":[{"url":"https://livingdictionaries.app"}],"security":[{"bearerAuth":[]}],"paths":{"/api/v1/dictionaries/{id}/entries/{entryId}/audio":{"post":{"summary":"Attach audio to an entry","description":"Upload a pronunciation recording for the headword (multipart `file` or JSON `url`). Attribution required: `speaker_id` and/or `source` (a registry slug). Use `replace: true` for one-audio-per-headword imports.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"},"description":"Dictionary id or url-slug. Must match the dictionary your API key is scoped to."},{"name":"entryId","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"multipart/form-data":{"schema":{"type":"object","required":["file"],"properties":{"file":{"type":"string","format":"binary","description":"The media file bytes."},"speaker_id":{"type":"string","description":"An existing speaker id (list via `GET …/speakers`, create via `POST …/speakers`). REQUIRED unless `source` is provided."},"source":{"type":"string","description":"A sources-registry slug — must already exist (list via `GET …/sources`, create via `POST …/sources`). REQUIRED unless `speaker_id` is provided."},"timings":{"type":"string","description":"Optional karaoke word-timings as a JSON string (see the `MediaTimings` schema). Can also be set/corrected later via `PATCH …/audio/{audioId}` (e.g. after forced alignment)."},"id":{"type":"string","format":"uuid","description":"Optional client-generated UUID — the idempotency key. A re-POST of an existing id is a safe no-op (returns the existing media, `created: false`)."},"replace":{"type":"boolean","description":"Remove existing media of THIS medium on THIS owner first, then add — e.g. to keep exactly one pronunciation per headword across re-runs."}}}},"application/json":{"schema":{"type":"object","required":["url"],"properties":{"url":{"type":"string","description":"A public http(s) URL the server fetches instead of an uploaded file (size-capped, see Limits)."},"from_audio_id":{"type":"string","description":"Instead of `url`: an existing audio row whose file this row reuses, playing only `clip_start_ms`–`clip_end_ms` of it (no second copy of the bytes). For a text heard inside a longer recording. Send `timings` relative to `clip_start_ms`. Windowed audio cannot be sent to `…/align`."},"clip_start_ms":{"type":"integer","minimum":0,"description":"With `from_audio_id`: window start, in ms of that row's playback."},"clip_end_ms":{"type":"integer","description":"With `from_audio_id`: window end (after the start)."},"speaker_id":{"type":"string","description":"An existing speaker id (list via `GET …/speakers`, create via `POST …/speakers`). REQUIRED unless `source` is provided."},"source":{"type":"string","description":"A sources-registry slug — must already exist (list via `GET …/sources`, create via `POST …/sources`). REQUIRED unless `speaker_id` is provided."},"timings":{"type":"object","additionalProperties":{"type":"string"},"description":"Optional. Karaoke word-timings: a map of sentence id → compact timing string `\"offset,duration|offset,duration|…\"` (milliseconds). Entries align 1:1 with that sentence's default-orthography tokens; each offset is relative to the END of the previous timed token (chainable across sentences for text-level audio); an empty entry marks an untimed token (punctuation). Can also be set/corrected later via `PATCH …/audio/{audioId}` (e.g. after forced alignment).","example":{"sentence-uuid-1":"0,320|40,280|","sentence-uuid-2":"120,300|"}},"id":{"type":"string","format":"uuid","description":"Optional client-generated UUID — the idempotency key. A re-POST of an existing id is a safe no-op (returns the existing media, `created: false`)."},"replace":{"type":"boolean","description":"Remove existing media of THIS medium on THIS owner first, then add — e.g. to keep exactly one pronunciation per headword across re-runs."}}}}}},"responses":{"200":{"description":"{ audio, created }","content":{"application/json":{"schema":{"type":"object","properties":{"audio":{"$ref":"#/components/schemas/AudioMedia"},"created":{"type":"boolean","description":"false = idempotent no-op (the supplied `id` already existed)."}}}}}},"400":{"description":"Bad input (no file/url, missing attribution — audio/video need speaker_id and/or source, unknown speaker, unknown source slug, bad hosted link)"},"403":{"description":"Read-scope key, or the dictionary is a constructed language/glossary — those store no new media files (a hosted video link is still accepted)"},"404":{"description":"Owner not found"},"413":{"description":"File exceeds the upload size limit"},"415":{"description":"The uploaded/fetched bytes are not valid media of this type (e.g. a `url` that returned an HTML error page)"},"503":{"description":"Media storage not configured on the server"}},"tags":["media"]}},"/api/v1/dictionaries/{id}/entries/{entryId}/audio/{audioId}":{"delete":{"summary":"Delete a audio from this entry","description":"Removes the audio (FK cascade sweeps its links + speaker junctions). Verifies the audio is linked to THIS entry.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"},"description":"Dictionary id or url-slug. Must match the dictionary your API key is scoped to."},{"name":"entryId","in":"path","required":true,"schema":{"type":"string"}},{"name":"audioId","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"{ result: 'deleted' }"},"404":{"description":"audio not linked to this entry"}},"tags":["media"]}},"/api/v1/dictionaries/{id}/senses/{senseId}/photos":{"post":{"summary":"Attach a photo to a sense","description":"Upload an illustrative photo for the sense (multipart `file` or JSON `url`), optionally with `source`/`photographer` (shown as the caption).","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"},"description":"Dictionary id or url-slug. Must match the dictionary your API key is scoped to."},{"name":"senseId","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"multipart/form-data":{"schema":{"type":"object","required":["file"],"properties":{"file":{"type":"string","format":"binary","description":"The media file bytes."},"source":{"type":"string"},"photographer":{"type":"string"},"latitude":{"type":"number","description":"Optional explicit GPS (overrides EXIF). Stored blunted to 2 decimals (~1.1 km, village-level) — finer precision is never kept."},"longitude":{"type":"number","description":"Optional explicit GPS (overrides EXIF). Stored blunted to 2 decimals (~1.1 km, village-level) — finer precision is never kept."},"taken_at":{"type":"string","format":"date-time","description":"Optional explicit capture time (overrides EXIF)."},"id":{"type":"string","format":"uuid","description":"Optional client-generated UUID — the idempotency key. A re-POST of an existing id is a safe no-op (returns the existing media, `created: false`)."},"replace":{"type":"boolean","description":"Remove existing media of THIS medium on THIS owner first, then add — e.g. to keep exactly one pronunciation per headword across re-runs."}}}},"application/json":{"schema":{"type":"object","required":["url"],"properties":{"url":{"type":"string","description":"A public http(s) URL the server fetches instead of an uploaded file (size-capped, see Limits)."},"source":{"type":"string","description":"Free-text attribution/description shown under the photo (NOT a registry slug)."},"photographer":{"type":"string"},"latitude":{"type":"number","description":"Optional explicit GPS (overrides EXIF). Stored blunted to 2 decimals (~1.1 km, village-level) — finer precision is never kept."},"longitude":{"type":"number","description":"Optional explicit GPS (overrides EXIF). Stored blunted to 2 decimals (~1.1 km, village-level) — finer precision is never kept."},"taken_at":{"type":"string","format":"date-time","description":"Optional explicit capture time (overrides EXIF)."},"id":{"type":"string","format":"uuid","description":"Optional client-generated UUID — the idempotency key. A re-POST of an existing id is a safe no-op (returns the existing media, `created: false`)."},"replace":{"type":"boolean","description":"Remove existing media of THIS medium on THIS owner first, then add — e.g. to keep exactly one pronunciation per headword across re-runs."}}}}}},"responses":{"200":{"description":"{ photo, created }","content":{"application/json":{"schema":{"type":"object","properties":{"photo":{"$ref":"#/components/schemas/PhotoMedia"},"created":{"type":"boolean","description":"false = idempotent no-op (the supplied `id` already existed)."}}}}}},"400":{"description":"Bad input (no file/url, missing attribution — audio/video need speaker_id and/or source, unknown speaker, unknown source slug, bad hosted link)"},"403":{"description":"Read-scope key, or the dictionary is a constructed language/glossary — those store no new media files (a hosted video link is still accepted)"},"404":{"description":"Owner not found"},"413":{"description":"File exceeds the upload size limit"},"415":{"description":"The uploaded/fetched bytes are not valid media of this type (e.g. a `url` that returned an HTML error page)"},"503":{"description":"Media storage not configured on the server"}},"tags":["media"]}},"/api/v1/dictionaries/{id}/senses/{senseId}/photos/{photoId}":{"delete":{"summary":"Delete a photo from this sense","description":"Removes the photo (FK cascade sweeps its links + speaker junctions). Verifies the photo is linked to THIS sense.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"},"description":"Dictionary id or url-slug. Must match the dictionary your API key is scoped to."},{"name":"senseId","in":"path","required":true,"schema":{"type":"string"}},{"name":"photoId","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"{ result: 'deleted' }"},"404":{"description":"photo not linked to this sense"}},"tags":["media"]}},"/api/v1/dictionaries/{id}/senses/{senseId}/videos":{"post":{"summary":"Attach a video to a sense","description":"Upload a video (`file`/`url`) OR link a hosted one (`hosted_url`/`hosted_elsewhere`). Attribution required: `speaker_id` and/or `source` (a registry slug).","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"},"description":"Dictionary id or url-slug. Must match the dictionary your API key is scoped to."},{"name":"senseId","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"multipart/form-data":{"schema":{"type":"object","properties":{"file":{"type":"string","format":"binary","description":"The media file bytes."},"hosted_url":{"type":"string","description":"A YouTube/Vimeo watch URL (parsed to `hosted_elsewhere`) — use instead of a file for hosted video."},"speaker_id":{"type":"string","description":"An existing speaker id (list via `GET …/speakers`, create via `POST …/speakers`). REQUIRED unless `source` is provided."},"source":{"type":"string","description":"A sources-registry slug — must already exist (list via `GET …/sources`, create via `POST …/sources`). REQUIRED unless `speaker_id` is provided."},"videographer":{"type":"string"},"id":{"type":"string","format":"uuid","description":"Optional client-generated UUID — the idempotency key. A re-POST of an existing id is a safe no-op (returns the existing media, `created: false`)."},"replace":{"type":"boolean","description":"Remove existing media of THIS medium on THIS owner first, then add — e.g. to keep exactly one pronunciation per headword across re-runs."}}}},"application/json":{"schema":{"type":"object","properties":{"url":{"type":"string","description":"A public http(s) URL the server fetches instead of an uploaded file (size-capped, see Limits)."},"hosted_url":{"type":"string","description":"A YouTube/Vimeo watch URL — parsed to `hosted_elsewhere`."},"hosted_elsewhere":{"type":"object","required":["type","video_id"],"description":"Structured hosted-video link (alternative to `hosted_url`). Provide exactly one of `url` / `hosted_url` / `hosted_elsewhere`.","properties":{"type":{"type":"string","enum":["youtube","vimeo"]},"video_id":{"type":"string","description":"Provider id (YouTube watch id / Vimeo numeric id)."},"start_at_seconds":{"type":["integer","null"]}}},"speaker_id":{"type":"string","description":"An existing speaker id (list via `GET …/speakers`, create via `POST …/speakers`). REQUIRED unless `source` is provided."},"source":{"type":"string","description":"A sources-registry slug — must already exist (list via `GET …/sources`, create via `POST …/sources`). REQUIRED unless `speaker_id` is provided."},"videographer":{"type":"string"},"id":{"type":"string","format":"uuid","description":"Optional client-generated UUID — the idempotency key. A re-POST of an existing id is a safe no-op (returns the existing media, `created: false`)."},"replace":{"type":"boolean","description":"Remove existing media of THIS medium on THIS owner first, then add — e.g. to keep exactly one pronunciation per headword across re-runs."}}}}}},"responses":{"200":{"description":"{ video, created }","content":{"application/json":{"schema":{"type":"object","properties":{"video":{"$ref":"#/components/schemas/VideoMedia"},"created":{"type":"boolean","description":"false = idempotent no-op (the supplied `id` already existed)."}}}}}},"400":{"description":"Bad input (no file/url, missing attribution — audio/video need speaker_id and/or source, unknown speaker, unknown source slug, bad hosted link)"},"403":{"description":"Read-scope key, or the dictionary is a constructed language/glossary — those store no new media files (a hosted video link is still accepted)"},"404":{"description":"Owner not found"},"413":{"description":"File exceeds the upload size limit"},"415":{"description":"The uploaded/fetched bytes are not valid media of this type (e.g. a `url` that returned an HTML error page)"},"503":{"description":"Media storage not configured on the server"}},"tags":["media"]}},"/api/v1/dictionaries/{id}/senses/{senseId}/videos/{videoId}":{"delete":{"summary":"Delete a video from this sense","description":"Removes the video (FK cascade sweeps its links + speaker junctions). Verifies the video is linked to THIS sense.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"},"description":"Dictionary id or url-slug. Must match the dictionary your API key is scoped to."},{"name":"senseId","in":"path","required":true,"schema":{"type":"string"}},{"name":"videoId","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"{ result: 'deleted' }"},"404":{"description":"video not linked to this sense"}},"tags":["media"]}},"/api/v1/dictionaries/{id}/sentences/{sentenceId}/audio":{"post":{"summary":"Attach audio to a sentence","description":"Upload audio for an example/text sentence (`file`/`url`). Attribution required: `speaker_id` and/or `source`.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"},"description":"Dictionary id or url-slug. Must match the dictionary your API key is scoped to."},{"name":"sentenceId","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"multipart/form-data":{"schema":{"type":"object","required":["file"],"properties":{"file":{"type":"string","format":"binary","description":"The media file bytes."},"speaker_id":{"type":"string","description":"An existing speaker id (list via `GET …/speakers`, create via `POST …/speakers`). REQUIRED unless `source` is provided."},"source":{"type":"string","description":"A sources-registry slug — must already exist (list via `GET …/sources`, create via `POST …/sources`). REQUIRED unless `speaker_id` is provided."},"timings":{"type":"string","description":"Optional karaoke word-timings as a JSON string (see the `MediaTimings` schema). Can also be set/corrected later via `PATCH …/audio/{audioId}` (e.g. after forced alignment)."},"id":{"type":"string","format":"uuid","description":"Optional client-generated UUID — the idempotency key. A re-POST of an existing id is a safe no-op (returns the existing media, `created: false`)."},"replace":{"type":"boolean","description":"Remove existing media of THIS medium on THIS owner first, then add — e.g. to keep exactly one pronunciation per headword across re-runs."}}}},"application/json":{"schema":{"type":"object","required":["url"],"properties":{"url":{"type":"string","description":"A public http(s) URL the server fetches instead of an uploaded file (size-capped, see Limits)."},"from_audio_id":{"type":"string","description":"Instead of `url`: an existing audio row whose file this row reuses, playing only `clip_start_ms`–`clip_end_ms` of it (no second copy of the bytes). For a text heard inside a longer recording. Send `timings` relative to `clip_start_ms`. Windowed audio cannot be sent to `…/align`."},"clip_start_ms":{"type":"integer","minimum":0,"description":"With `from_audio_id`: window start, in ms of that row's playback."},"clip_end_ms":{"type":"integer","description":"With `from_audio_id`: window end (after the start)."},"speaker_id":{"type":"string","description":"An existing speaker id (list via `GET …/speakers`, create via `POST …/speakers`). REQUIRED unless `source` is provided."},"source":{"type":"string","description":"A sources-registry slug — must already exist (list via `GET …/sources`, create via `POST …/sources`). REQUIRED unless `speaker_id` is provided."},"timings":{"type":"object","additionalProperties":{"type":"string"},"description":"Optional. Karaoke word-timings: a map of sentence id → compact timing string `\"offset,duration|offset,duration|…\"` (milliseconds). Entries align 1:1 with that sentence's default-orthography tokens; each offset is relative to the END of the previous timed token (chainable across sentences for text-level audio); an empty entry marks an untimed token (punctuation). Can also be set/corrected later via `PATCH …/audio/{audioId}` (e.g. after forced alignment).","example":{"sentence-uuid-1":"0,320|40,280|","sentence-uuid-2":"120,300|"}},"id":{"type":"string","format":"uuid","description":"Optional client-generated UUID — the idempotency key. A re-POST of an existing id is a safe no-op (returns the existing media, `created: false`)."},"replace":{"type":"boolean","description":"Remove existing media of THIS medium on THIS owner first, then add — e.g. to keep exactly one pronunciation per headword across re-runs."}}}}}},"responses":{"200":{"description":"{ audio, created }","content":{"application/json":{"schema":{"type":"object","properties":{"audio":{"$ref":"#/components/schemas/AudioMedia"},"created":{"type":"boolean","description":"false = idempotent no-op (the supplied `id` already existed)."}}}}}},"400":{"description":"Bad input (no file/url, missing attribution — audio/video need speaker_id and/or source, unknown speaker, unknown source slug, bad hosted link)"},"403":{"description":"Read-scope key, or the dictionary is a constructed language/glossary — those store no new media files (a hosted video link is still accepted)"},"404":{"description":"Owner not found"},"413":{"description":"File exceeds the upload size limit"},"415":{"description":"The uploaded/fetched bytes are not valid media of this type (e.g. a `url` that returned an HTML error page)"},"503":{"description":"Media storage not configured on the server"}},"tags":["media"]}},"/api/v1/dictionaries/{id}/sentences/{sentenceId}/audio/{audioId}":{"delete":{"summary":"Delete a audio from this sentence","description":"Removes the audio (FK cascade sweeps its links + speaker junctions). Verifies the audio is linked to THIS sentence.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"},"description":"Dictionary id or url-slug. Must match the dictionary your API key is scoped to."},{"name":"sentenceId","in":"path","required":true,"schema":{"type":"string"}},{"name":"audioId","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"{ result: 'deleted' }"},"404":{"description":"audio not linked to this sentence"}},"tags":["media"]},"patch":{"summary":"Set/update an audio's timings on this sentence","description":"Set, replace, or clear (`null`) the audio row's karaoke word-timings — e.g. writing corrected forced-alignment output back after upload. Whole-object replace. Verifies the audio is linked to THIS sentence.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"},"description":"Dictionary id or url-slug. Must match the dictionary your API key is scoped to."},{"name":"sentenceId","in":"path","required":true,"schema":{"type":"string"}},{"name":"audioId","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["timings"],"properties":{"timings":{"type":["object","null"],"additionalProperties":{"type":"string"},"description":"Karaoke word-timings: a map of sentence id → compact timing string `\"offset,duration|offset,duration|…\"` (milliseconds). Entries align 1:1 with that sentence's default-orthography tokens; each offset is relative to the END of the previous timed token (chainable across sentences for text-level audio); an empty entry marks an untimed token (punctuation). `null` clears.","example":{"sentence-uuid-1":"0,320|40,280|","sentence-uuid-2":"120,300|"}}}}}}},"responses":{"200":{"description":"{ audio }","content":{"application/json":{"schema":{"type":"object","properties":{"audio":{"$ref":"#/components/schemas/AudioMedia"}}}}}},"400":{"description":"Malformed timings (must map sentence id → timing string)"},"404":{"description":"audio not linked to this sentence"}},"tags":["media"]}},"/api/v1/dictionaries/{id}/sentences/{sentenceId}/photos":{"post":{"summary":"Attach a photo to a sentence","description":"Upload a photo for a sentence (`file`/`url`), optionally with `source`/`photographer`.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"},"description":"Dictionary id or url-slug. Must match the dictionary your API key is scoped to."},{"name":"sentenceId","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"multipart/form-data":{"schema":{"type":"object","required":["file"],"properties":{"file":{"type":"string","format":"binary","description":"The media file bytes."},"source":{"type":"string"},"photographer":{"type":"string"},"latitude":{"type":"number","description":"Optional explicit GPS (overrides EXIF). Stored blunted to 2 decimals (~1.1 km, village-level) — finer precision is never kept."},"longitude":{"type":"number","description":"Optional explicit GPS (overrides EXIF). Stored blunted to 2 decimals (~1.1 km, village-level) — finer precision is never kept."},"taken_at":{"type":"string","format":"date-time","description":"Optional explicit capture time (overrides EXIF)."},"id":{"type":"string","format":"uuid","description":"Optional client-generated UUID — the idempotency key. A re-POST of an existing id is a safe no-op (returns the existing media, `created: false`)."},"replace":{"type":"boolean","description":"Remove existing media of THIS medium on THIS owner first, then add — e.g. to keep exactly one pronunciation per headword across re-runs."}}}},"application/json":{"schema":{"type":"object","required":["url"],"properties":{"url":{"type":"string","description":"A public http(s) URL the server fetches instead of an uploaded file (size-capped, see Limits)."},"source":{"type":"string","description":"Free-text attribution/description shown under the photo (NOT a registry slug)."},"photographer":{"type":"string"},"latitude":{"type":"number","description":"Optional explicit GPS (overrides EXIF). Stored blunted to 2 decimals (~1.1 km, village-level) — finer precision is never kept."},"longitude":{"type":"number","description":"Optional explicit GPS (overrides EXIF). Stored blunted to 2 decimals (~1.1 km, village-level) — finer precision is never kept."},"taken_at":{"type":"string","format":"date-time","description":"Optional explicit capture time (overrides EXIF)."},"id":{"type":"string","format":"uuid","description":"Optional client-generated UUID — the idempotency key. A re-POST of an existing id is a safe no-op (returns the existing media, `created: false`)."},"replace":{"type":"boolean","description":"Remove existing media of THIS medium on THIS owner first, then add — e.g. to keep exactly one pronunciation per headword across re-runs."}}}}}},"responses":{"200":{"description":"{ photo, created }","content":{"application/json":{"schema":{"type":"object","properties":{"photo":{"$ref":"#/components/schemas/PhotoMedia"},"created":{"type":"boolean","description":"false = idempotent no-op (the supplied `id` already existed)."}}}}}},"400":{"description":"Bad input (no file/url, missing attribution — audio/video need speaker_id and/or source, unknown speaker, unknown source slug, bad hosted link)"},"403":{"description":"Read-scope key, or the dictionary is a constructed language/glossary — those store no new media files (a hosted video link is still accepted)"},"404":{"description":"Owner not found"},"413":{"description":"File exceeds the upload size limit"},"415":{"description":"The uploaded/fetched bytes are not valid media of this type (e.g. a `url` that returned an HTML error page)"},"503":{"description":"Media storage not configured on the server"}},"tags":["media"]}},"/api/v1/dictionaries/{id}/sentences/{sentenceId}/photos/{photoId}":{"delete":{"summary":"Delete a photo from this sentence","description":"Removes the photo (FK cascade sweeps its links + speaker junctions). Verifies the photo is linked to THIS sentence.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"},"description":"Dictionary id or url-slug. Must match the dictionary your API key is scoped to."},{"name":"sentenceId","in":"path","required":true,"schema":{"type":"string"}},{"name":"photoId","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"{ result: 'deleted' }"},"404":{"description":"photo not linked to this sentence"}},"tags":["media"]}},"/api/v1/dictionaries/{id}/sentences/{sentenceId}/videos":{"post":{"summary":"Attach a video to a sentence","description":"Upload (`file`/`url`) or link (`hosted_url`/`hosted_elsewhere`) a video for a sentence. Attribution required: `speaker_id` and/or `source`.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"},"description":"Dictionary id or url-slug. Must match the dictionary your API key is scoped to."},{"name":"sentenceId","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"multipart/form-data":{"schema":{"type":"object","properties":{"file":{"type":"string","format":"binary","description":"The media file bytes."},"hosted_url":{"type":"string","description":"A YouTube/Vimeo watch URL (parsed to `hosted_elsewhere`) — use instead of a file for hosted video."},"speaker_id":{"type":"string","description":"An existing speaker id (list via `GET …/speakers`, create via `POST …/speakers`). REQUIRED unless `source` is provided."},"source":{"type":"string","description":"A sources-registry slug — must already exist (list via `GET …/sources`, create via `POST …/sources`). REQUIRED unless `speaker_id` is provided."},"videographer":{"type":"string"},"id":{"type":"string","format":"uuid","description":"Optional client-generated UUID — the idempotency key. A re-POST of an existing id is a safe no-op (returns the existing media, `created: false`)."},"replace":{"type":"boolean","description":"Remove existing media of THIS medium on THIS owner first, then add — e.g. to keep exactly one pronunciation per headword across re-runs."}}}},"application/json":{"schema":{"type":"object","properties":{"url":{"type":"string","description":"A public http(s) URL the server fetches instead of an uploaded file (size-capped, see Limits)."},"hosted_url":{"type":"string","description":"A YouTube/Vimeo watch URL — parsed to `hosted_elsewhere`."},"hosted_elsewhere":{"type":"object","required":["type","video_id"],"description":"Structured hosted-video link (alternative to `hosted_url`). Provide exactly one of `url` / `hosted_url` / `hosted_elsewhere`.","properties":{"type":{"type":"string","enum":["youtube","vimeo"]},"video_id":{"type":"string","description":"Provider id (YouTube watch id / Vimeo numeric id)."},"start_at_seconds":{"type":["integer","null"]}}},"speaker_id":{"type":"string","description":"An existing speaker id (list via `GET …/speakers`, create via `POST …/speakers`). REQUIRED unless `source` is provided."},"source":{"type":"string","description":"A sources-registry slug — must already exist (list via `GET …/sources`, create via `POST …/sources`). REQUIRED unless `speaker_id` is provided."},"videographer":{"type":"string"},"id":{"type":"string","format":"uuid","description":"Optional client-generated UUID — the idempotency key. A re-POST of an existing id is a safe no-op (returns the existing media, `created: false`)."},"replace":{"type":"boolean","description":"Remove existing media of THIS medium on THIS owner first, then add — e.g. to keep exactly one pronunciation per headword across re-runs."}}}}}},"responses":{"200":{"description":"{ video, created }","content":{"application/json":{"schema":{"type":"object","properties":{"video":{"$ref":"#/components/schemas/VideoMedia"},"created":{"type":"boolean","description":"false = idempotent no-op (the supplied `id` already existed)."}}}}}},"400":{"description":"Bad input (no file/url, missing attribution — audio/video need speaker_id and/or source, unknown speaker, unknown source slug, bad hosted link)"},"403":{"description":"Read-scope key, or the dictionary is a constructed language/glossary — those store no new media files (a hosted video link is still accepted)"},"404":{"description":"Owner not found"},"413":{"description":"File exceeds the upload size limit"},"415":{"description":"The uploaded/fetched bytes are not valid media of this type (e.g. a `url` that returned an HTML error page)"},"503":{"description":"Media storage not configured on the server"}},"tags":["media"]}},"/api/v1/dictionaries/{id}/sentences/{sentenceId}/videos/{videoId}":{"delete":{"summary":"Delete a video from this sentence","description":"Removes the video (FK cascade sweeps its links + speaker junctions). Verifies the video is linked to THIS sentence.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"},"description":"Dictionary id or url-slug. Must match the dictionary your API key is scoped to."},{"name":"sentenceId","in":"path","required":true,"schema":{"type":"string"}},{"name":"videoId","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"{ result: 'deleted' }"},"404":{"description":"video not linked to this sentence"}},"tags":["media"]}},"/api/v1/dictionaries/{id}/texts/{textId}/audio":{"post":{"summary":"Attach audio to a text","description":"Upload audio for a whole text/passage (`file`/`url`). Attribution required: `speaker_id` and/or `source`.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"},"description":"Dictionary id or url-slug. Must match the dictionary your API key is scoped to."},{"name":"textId","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"multipart/form-data":{"schema":{"type":"object","required":["file"],"properties":{"file":{"type":"string","format":"binary","description":"The media file bytes."},"speaker_id":{"type":"string","description":"An existing speaker id (list via `GET …/speakers`, create via `POST …/speakers`). REQUIRED unless `source` is provided."},"source":{"type":"string","description":"A sources-registry slug — must already exist (list via `GET …/sources`, create via `POST …/sources`). REQUIRED unless `speaker_id` is provided."},"timings":{"type":"string","description":"Optional karaoke word-timings as a JSON string (see the `MediaTimings` schema). Can also be set/corrected later via `PATCH …/audio/{audioId}` (e.g. after forced alignment)."},"id":{"type":"string","format":"uuid","description":"Optional client-generated UUID — the idempotency key. A re-POST of an existing id is a safe no-op (returns the existing media, `created: false`)."},"replace":{"type":"boolean","description":"Remove existing media of THIS medium on THIS owner first, then add — e.g. to keep exactly one pronunciation per headword across re-runs."}}}},"application/json":{"schema":{"type":"object","required":["url"],"properties":{"url":{"type":"string","description":"A public http(s) URL the server fetches instead of an uploaded file (size-capped, see Limits)."},"from_audio_id":{"type":"string","description":"Instead of `url`: an existing audio row whose file this row reuses, playing only `clip_start_ms`–`clip_end_ms` of it (no second copy of the bytes). For a text heard inside a longer recording. Send `timings` relative to `clip_start_ms`. Windowed audio cannot be sent to `…/align`."},"clip_start_ms":{"type":"integer","minimum":0,"description":"With `from_audio_id`: window start, in ms of that row's playback."},"clip_end_ms":{"type":"integer","description":"With `from_audio_id`: window end (after the start)."},"speaker_id":{"type":"string","description":"An existing speaker id (list via `GET …/speakers`, create via `POST …/speakers`). REQUIRED unless `source` is provided."},"source":{"type":"string","description":"A sources-registry slug — must already exist (list via `GET …/sources`, create via `POST …/sources`). REQUIRED unless `speaker_id` is provided."},"timings":{"type":"object","additionalProperties":{"type":"string"},"description":"Optional. Karaoke word-timings: a map of sentence id → compact timing string `\"offset,duration|offset,duration|…\"` (milliseconds). Entries align 1:1 with that sentence's default-orthography tokens; each offset is relative to the END of the previous timed token (chainable across sentences for text-level audio); an empty entry marks an untimed token (punctuation). Can also be set/corrected later via `PATCH …/audio/{audioId}` (e.g. after forced alignment).","example":{"sentence-uuid-1":"0,320|40,280|","sentence-uuid-2":"120,300|"}},"id":{"type":"string","format":"uuid","description":"Optional client-generated UUID — the idempotency key. A re-POST of an existing id is a safe no-op (returns the existing media, `created: false`)."},"replace":{"type":"boolean","description":"Remove existing media of THIS medium on THIS owner first, then add — e.g. to keep exactly one pronunciation per headword across re-runs."}}}}}},"responses":{"200":{"description":"{ audio, created }","content":{"application/json":{"schema":{"type":"object","properties":{"audio":{"$ref":"#/components/schemas/AudioMedia"},"created":{"type":"boolean","description":"false = idempotent no-op (the supplied `id` already existed)."}}}}}},"400":{"description":"Bad input (no file/url, missing attribution — audio/video need speaker_id and/or source, unknown speaker, unknown source slug, bad hosted link)"},"403":{"description":"Read-scope key, or the dictionary is a constructed language/glossary — those store no new media files (a hosted video link is still accepted)"},"404":{"description":"Owner not found"},"413":{"description":"File exceeds the upload size limit"},"415":{"description":"The uploaded/fetched bytes are not valid media of this type (e.g. a `url` that returned an HTML error page)"},"503":{"description":"Media storage not configured on the server"}},"tags":["media"]}},"/api/v1/dictionaries/{id}/texts/{textId}/audio/{audioId}":{"delete":{"summary":"Delete a audio from this text","description":"Removes the audio (FK cascade sweeps its links + speaker junctions). Verifies the audio is linked to THIS text.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"},"description":"Dictionary id or url-slug. Must match the dictionary your API key is scoped to."},{"name":"textId","in":"path","required":true,"schema":{"type":"string"}},{"name":"audioId","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"{ result: 'deleted' }"},"404":{"description":"audio not linked to this text"}},"tags":["media"]},"patch":{"summary":"Set/update an audio's timings on this text","description":"Set, replace, or clear (`null`) the audio row's karaoke word-timings — e.g. writing corrected forced-alignment output back after upload. Whole-object replace. Verifies the audio is linked to THIS text.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"},"description":"Dictionary id or url-slug. Must match the dictionary your API key is scoped to."},{"name":"textId","in":"path","required":true,"schema":{"type":"string"}},{"name":"audioId","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["timings"],"properties":{"timings":{"type":["object","null"],"additionalProperties":{"type":"string"},"description":"Karaoke word-timings: a map of sentence id → compact timing string `\"offset,duration|offset,duration|…\"` (milliseconds). Entries align 1:1 with that sentence's default-orthography tokens; each offset is relative to the END of the previous timed token (chainable across sentences for text-level audio); an empty entry marks an untimed token (punctuation). `null` clears.","example":{"sentence-uuid-1":"0,320|40,280|","sentence-uuid-2":"120,300|"}}}}}}},"responses":{"200":{"description":"{ audio }","content":{"application/json":{"schema":{"type":"object","properties":{"audio":{"$ref":"#/components/schemas/AudioMedia"}}}}}},"400":{"description":"Malformed timings (must map sentence id → timing string)"},"404":{"description":"audio not linked to this text"}},"tags":["media"]}},"/api/v1/dictionaries/{id}/texts/{textId}/videos":{"post":{"summary":"Attach a video to a text","description":"Upload (`file`/`url`) or link (`hosted_url`/`hosted_elsewhere`) a video for a text. Attribution required: `speaker_id` and/or `source`.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"},"description":"Dictionary id or url-slug. Must match the dictionary your API key is scoped to."},{"name":"textId","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"multipart/form-data":{"schema":{"type":"object","properties":{"file":{"type":"string","format":"binary","description":"The media file bytes."},"hosted_url":{"type":"string","description":"A YouTube/Vimeo watch URL (parsed to `hosted_elsewhere`) — use instead of a file for hosted video."},"speaker_id":{"type":"string","description":"An existing speaker id (list via `GET …/speakers`, create via `POST …/speakers`). REQUIRED unless `source` is provided."},"source":{"type":"string","description":"A sources-registry slug — must already exist (list via `GET …/sources`, create via `POST …/sources`). REQUIRED unless `speaker_id` is provided."},"videographer":{"type":"string"},"id":{"type":"string","format":"uuid","description":"Optional client-generated UUID — the idempotency key. A re-POST of an existing id is a safe no-op (returns the existing media, `created: false`)."},"replace":{"type":"boolean","description":"Remove existing media of THIS medium on THIS owner first, then add — e.g. to keep exactly one pronunciation per headword across re-runs."}}}},"application/json":{"schema":{"type":"object","properties":{"url":{"type":"string","description":"A public http(s) URL the server fetches instead of an uploaded file (size-capped, see Limits)."},"hosted_url":{"type":"string","description":"A YouTube/Vimeo watch URL — parsed to `hosted_elsewhere`."},"hosted_elsewhere":{"type":"object","required":["type","video_id"],"description":"Structured hosted-video link (alternative to `hosted_url`). Provide exactly one of `url` / `hosted_url` / `hosted_elsewhere`.","properties":{"type":{"type":"string","enum":["youtube","vimeo"]},"video_id":{"type":"string","description":"Provider id (YouTube watch id / Vimeo numeric id)."},"start_at_seconds":{"type":["integer","null"]}}},"speaker_id":{"type":"string","description":"An existing speaker id (list via `GET …/speakers`, create via `POST …/speakers`). REQUIRED unless `source` is provided."},"source":{"type":"string","description":"A sources-registry slug — must already exist (list via `GET …/sources`, create via `POST …/sources`). REQUIRED unless `speaker_id` is provided."},"videographer":{"type":"string"},"id":{"type":"string","format":"uuid","description":"Optional client-generated UUID — the idempotency key. A re-POST of an existing id is a safe no-op (returns the existing media, `created: false`)."},"replace":{"type":"boolean","description":"Remove existing media of THIS medium on THIS owner first, then add — e.g. to keep exactly one pronunciation per headword across re-runs."}}}}}},"responses":{"200":{"description":"{ video, created }","content":{"application/json":{"schema":{"type":"object","properties":{"video":{"$ref":"#/components/schemas/VideoMedia"},"created":{"type":"boolean","description":"false = idempotent no-op (the supplied `id` already existed)."}}}}}},"400":{"description":"Bad input (no file/url, missing attribution — audio/video need speaker_id and/or source, unknown speaker, unknown source slug, bad hosted link)"},"403":{"description":"Read-scope key, or the dictionary is a constructed language/glossary — those store no new media files (a hosted video link is still accepted)"},"404":{"description":"Owner not found"},"413":{"description":"File exceeds the upload size limit"},"415":{"description":"The uploaded/fetched bytes are not valid media of this type (e.g. a `url` that returned an HTML error page)"},"503":{"description":"Media storage not configured on the server"}},"tags":["media"]}},"/api/v1/dictionaries/{id}/texts/{textId}/videos/{videoId}":{"delete":{"summary":"Delete a video from this text","description":"Removes the video (FK cascade sweeps its links + speaker junctions). Verifies the video is linked to THIS text.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"},"description":"Dictionary id or url-slug. Must match the dictionary your API key is scoped to."},{"name":"textId","in":"path","required":true,"schema":{"type":"string"}},{"name":"videoId","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"{ result: 'deleted' }"},"404":{"description":"video not linked to this text"}},"tags":["media"]}},"/api/v1/dictionaries/{id}/texts/{textId}/audio/{audioId}/align":{"post":{"summary":"Auto-align this text's audio (forced alignment)","description":"Start a fire-and-forget forced-alignment job: the server romanizes the text's tokenized words per the dictionary's alignment configuration, runs the MMS aligner against this audio, and writes the resulting karaoke `timings` onto the audio row. Returns immediately with the job + romanization coverage — poll `GET …/align-jobs/{jobId}`. Replaces any existing timings (including manual adjustments). Requires the dictionary's alignment to be configured by the Living Dictionaries team; rate-limited per dictionary per day.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"},"description":"Dictionary id or url-slug. Must match the dictionary your API key is scoped to."},{"name":"textId","in":"path","required":true,"schema":{"type":"string"}},{"name":"audioId","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"{ job, coverage }","content":{"application/json":{"schema":{"type":"object","properties":{"job":{"$ref":"#/components/schemas/AlignJob"},"coverage":{"$ref":"#/components/schemas/AlignCoverage"}}}}}},"400":{"description":"Alignment not configured / nothing to align / no romanizable tokens"},"404":{"description":"audio not linked to this text"},"409":{"description":"An alignment is already running for this audio"},"429":{"description":"Daily alignment limit reached"}},"tags":["alignment"]}},"/api/v1/dictionaries/{id}/sentences/{sentenceId}/audio/{audioId}/align":{"post":{"summary":"Auto-align this sentence's audio (forced alignment)","description":"Start a fire-and-forget forced-alignment job: the server romanizes the sentence's tokenized words per the dictionary's alignment configuration, runs the MMS aligner against this audio, and writes the resulting karaoke `timings` onto the audio row. Returns immediately with the job + romanization coverage — poll `GET …/align-jobs/{jobId}`. Replaces any existing timings (including manual adjustments). Requires the dictionary's alignment to be configured by the Living Dictionaries team; rate-limited per dictionary per day.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"},"description":"Dictionary id or url-slug. Must match the dictionary your API key is scoped to."},{"name":"sentenceId","in":"path","required":true,"schema":{"type":"string"}},{"name":"audioId","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"{ job, coverage }","content":{"application/json":{"schema":{"type":"object","properties":{"job":{"$ref":"#/components/schemas/AlignJob"},"coverage":{"$ref":"#/components/schemas/AlignCoverage"}}}}}},"400":{"description":"Alignment not configured / nothing to align / no romanizable tokens"},"404":{"description":"audio not linked to this sentence"},"409":{"description":"An alignment is already running for this audio"},"429":{"description":"Daily alignment limit reached"}},"tags":["alignment"]}},"/api/v1/dictionaries/{id}/align-jobs/{jobId}":{"get":{"summary":"Poll an alignment job","description":"Status of a forced-alignment run started via `POST …/audio/{audioId}/align`. On `done`, re-read the text/sentence — the audio `timings` were updated.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"},"description":"Dictionary id or url-slug. Must match the dictionary your API key is scoped to."},{"name":"jobId","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"{ job }","content":{"application/json":{"schema":{"type":"object","properties":{"job":{"$ref":"#/components/schemas/AlignJob"}}}}}},"404":{"description":"Resource not found"}},"tags":["alignment"]}},"/api/v1/dictionaries/{id}/media/{storagePath}":{"get":{"summary":"Download media bytes by storage_path","description":"The stable download URL for stored media bytes (this is what `download_url` in read shapes points at). Verifies the `storage_path` belongs to a media row in THIS dictionary, then 302-redirects to the storage backend — follow redirects and keep sending the same Bearer key.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"},"description":"Dictionary id or url-slug. Must match the dictionary your API key is scoped to."},{"name":"storagePath","in":"path","required":true,"schema":{"type":"string"},"description":"The media row's `storage_path` (slashes allowed, e.g. `{dictId}/audio/{ownerId}/file.mp3`)."}],"responses":{"302":{"description":"Redirect to the bytes"},"404":{"description":"No media row in this dictionary has that storage_path"}},"tags":["media"]}},"/api/v1/dictionaries/{id}":{"get":{"summary":"Dictionary metadata","description":"Returns gloss_languages, orthographies, entry_count, featured_image, and the current `about` / `citation` text. Call this first so glosses/translations use valid locale codes, and before any PATCH of `about` (PATCH replaces it whole — read it here and send the full text back when you only mean to add to it). NOTE: `entry_count` is eventually-consistent (updated asynchronously) — it lags after a bulk import; do not rely on it to verify a fresh import. Paginate `/entries` for a live count.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"},"description":"Dictionary id or url-slug. Must match the dictionary your API key is scoped to."}],"responses":{"200":{"description":"Dictionary metadata"},"401":{"description":"Missing/invalid key"},"403":{"description":"Key scoped to another dictionary"},"404":{"description":"Not found"}},"tags":["dictionary"]},"patch":{"summary":"Update the dictionary's catalog fields","description":"Write the dictionary's OWN metadata — most importantly `about`, the long-form markdown shown on its About page (the natural home for an imported work's preface, acknowledgements, and bibliography), and `citation`, how the dictionary asks to be cited. Send only the fields you are changing. A field you send is REPLACED whole: to add an imported preface under what the manager already wrote, GET the dictionary, then send their text followed by yours. Four things live behind their own doors instead: `gloss_languages` (`…/gloss-languages`), `orthographies` (`…/orthographies`), the cover photo (`…/cover-image`), and publication / `print_access` (human decisions; ask rather than flip them).","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"},"description":"Dictionary id or url-slug. Must match the dictionary your API key is scoped to."}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/DictionaryCatalogPatch"}}}},"responses":{"200":{"description":"The updated dictionary metadata"},"400":{"description":"Unknown or non-updatable field"},"401":{"description":"Missing/invalid key"},"403":{"description":"Read-scope key, or key scoped to another dictionary"},"404":{"description":"Not found"}},"tags":["dictionary"]}},"/api/v1/dictionaries/{id}/cover-image":{"post":{"summary":"Set the dictionary cover photo","description":"Upload the hero image shown at the top of the dictionary home page — multipart `file`, or JSON `{ \"url\": \"https://…\" }` and we fetch it. One call uploads and sets it; any previous cover is replaced. Max 10 MB. Prefer a photograph of the community or its place over a book jacket or logo, and make sure whoever holds the rights has agreed to it being published.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"},"description":"Dictionary id or url-slug. Must match the dictionary your API key is scoped to."}],"requestBody":{"required":true,"content":{"multipart/form-data":{"schema":{"type":"object","properties":{"file":{"type":"string","format":"binary"}}}},"application/json":{"schema":{"type":"object","properties":{"url":{"type":"string","description":"Public image url we fetch server-side."}},"required":["url"]}}}},"responses":{"200":{"description":"The stored cover image"},"400":{"description":"No image provided"},"401":{"description":"Missing/invalid key"},"403":{"description":"Read-scope key, or the dictionary is a constructed language/glossary (no media uploads)"},"413":{"description":"Larger than 10 MB"},"415":{"description":"Not a supported image"},"503":{"description":"Media storage not configured"}},"tags":["dictionary"]},"delete":{"summary":"Remove the dictionary cover photo","description":"Clears `featured_image`. The stored bytes are collected later by the media sweep.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"},"description":"Dictionary id or url-slug. Must match the dictionary your API key is scoped to."}],"responses":{"200":{"description":"Cleared"},"401":{"description":"Missing/invalid key"},"403":{"description":"Read-scope key"},"404":{"description":"Not found"}},"tags":["dictionary"]}},"/api/v1/dictionaries/{id}/entries":{"get":{"summary":"List/filter entries","description":"Entry summaries for verification / live counts. Ordered by `updated_at` ASC. Paginated via `limit` (default 100, max 500) + `offset`; `has_more` tells you when to fetch the next page. GOTCHA: `limit` is silently capped at 500 — advance `offset` by the number of entries RETURNED while `has_more` is true, never by your requested limit (a `returned < limit` break-condition silently truncates when limit > 500). (For idempotent imports, prefer a local `external_id`→`entry_id` ledger over paginating for dedupe.)","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"},"description":"Dictionary id or url-slug. Must match the dictionary your API key is scoped to."},{"name":"elicitation_id","in":"query","schema":{"type":"string"},"description":"Exact match."},{"name":"lexeme","in":"query","schema":{"type":"string"},"description":"Match the headword. Substring (case-insensitive) by default; pass `match=exact` for an exact, case-sensitive match against any orthography's spelling."},{"name":"match","in":"query","schema":{"type":"string","enum":["substring","exact"],"default":"substring"},"description":"How `lexeme` is matched. `exact` matches a whole spelling in any locale (use it to check whether a specific headword already exists)."},{"name":"updated_since","in":"query","schema":{"type":"string","format":"date-time"},"description":"ISO timestamp, exclusive."},{"name":"include","in":"query","schema":{"type":"string","enum":["senses"]},"description":"Pass `senses` to attach each entry's senses (glosses/definition/POS/domains) in one batched query — the efficient way to bulk-read/export a dictionary's meanings without a per-entry request."},{"name":"limit","in":"query","schema":{"type":"integer","default":100,"maximum":500}},{"name":"offset","in":"query","schema":{"type":"integer","default":0}}],"responses":{"200":{"description":"Entry summaries + pagination","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EntriesListResponse"}}}}},"tags":["entries"]},"post":{"summary":"Create entries (bulk)","description":"Body: a single entry, a bare array, or `{ entries: EntryInput[], import_id? }`. Max 1000/request. Per-item best-effort — read `results` for per-entry outcomes.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"},"description":"Dictionary id or url-slug. Must match the dictionary your API key is scoped to."}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"oneOf":[{"$ref":"#/components/schemas/EntryInput"},{"type":"array","items":{"$ref":"#/components/schemas/EntryInput"}},{"type":"object","properties":{"entries":{"type":"array","items":{"$ref":"#/components/schemas/EntryInput"}},"import_id":{"type":"string"}},"required":["entries"]}]},"example":{"import_id":"swahili-pdf-2026","entries":[{"id":"3f1b8c9a-0d2e-4a6b-9c1f-2e5d7a8b4c10","lexeme":"mbwa","phonetic":"ˈᵐbwa","dialects":["Coastal"],"senses":[{"glosses":{"en":"dog","sw":"mbwa"},"parts_of_speech":["n"],"example_sentences":[{"text":"Mbwa wangu ni mkubwa","translation":{"en":"My dog is big"}}]}]}]}}}},"responses":{"200":{"description":"Per-item write report","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EntriesWriteResponse"}}}},"400":{"description":"Bad input"},"401":{"description":"Missing/invalid key"},"403":{"description":"Insufficient role or wrong dictionary"}},"tags":["entries"]}},"/api/v1/dictionaries/{id}/entries/{entryId}":{"get":{"summary":"Read one entry (full nested)","description":"Returns the full nested entry (the READ shape — see `EntryResponse`). NOTE the input→output asymmetry: scalars you POST at the top level are read back under `entry.main`; `senses[].example_sentences` are read back as `senses[].sentences`. Pass `?include=relationships` to also attach `entry.relationships` (see `RelationshipView`).","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"},"description":"Dictionary id or url-slug. Must match the dictionary your API key is scoped to."},{"name":"entryId","in":"path","required":true,"schema":{"type":"string"}},{"name":"include","in":"query","schema":{"type":"string","enum":["relationships"]},"description":"Pass `relationships` to attach this entry's typed relationships as `entry.relationships`."}],"responses":{"200":{"description":"The full nested entry","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EntryResponse"}}}},"404":{"description":"Not found"}},"tags":["entries"]},"patch":{"summary":"Update an entry (field-merge)","description":"Field-merge: provided fields overwrite, omitted ones stay. `senses` are a true upsert by client `id` (id on this entry → field-merge; unknown id or none → create the sense WITH that id; an id on a different entry → 400); example sentences upsert by id (no duplicate links) / append without one; `dialects`/`tags` are additive links (this call never removes them). For surgical single-row edits/deletes — one sentence, sense, tag, or dialect — use the dedicated `…/sentences/{id}`, `…/senses/{id}`, `…/tags/{id}`, `…/dialects/{id}` routes (see \"Edits & deletes\"). A row whose content already matches is not rewritten (no sync churn, no history). Patching many entries? Use `POST …/entries/batch-update`. Returns the updated entry in the READ shape (`EntryResponse`).","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"},"description":"Dictionary id or url-slug. Must match the dictionary your API key is scoped to."},{"name":"entryId","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/EntryPatch"}}}},"responses":{"200":{"description":"The updated nested entry","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EntryResponse"}}}},"400":{"description":"Invalid request"},"404":{"description":"Resource not found"}},"tags":["entries"]},"delete":{"summary":"Delete an entry (cascades senses/junctions)","description":"Tombstones the entry; its senses and their links cascade. Example sentences themselves are kept — only their links to the deleted senses go. Delete a sentence explicitly with `DELETE …/sentences/{sentenceId}`.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"},"description":"Dictionary id or url-slug. Must match the dictionary your API key is scoped to."},{"name":"entryId","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"{ result: 'deleted' }"},"404":{"description":"Resource not found"}},"tags":["entries"]}},"/api/v1/dictionaries/{id}/entries/batch-delete":{"post":{"summary":"Delete every entry from one bulk import (dry-run + confirm)","description":"Bad-import recovery: removes the whole batch identified by `import_id` (the private tag stamped on every entry of a bulk `POST …/entries`; matched case-insensitively). Two-step by design: call with `\"dry_run\": true` first — it reports `{ count, sample_entry_ids }` and writes NOTHING — then arm the real run by echoing that count back as `confirm_count` (a mismatch with the live count → 409, so a stale script can't delete a re-imported batch). Deletes cascade each entry's senses and links, the emptied private tag is removed too, and orphaned standalone example sentences are deliberately left in place. Unknown `import_id` → 404. Write key / manager+.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"},"description":"Dictionary id or url-slug. Must match the dictionary your API key is scoped to."}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["import_id"],"properties":{"import_id":{"type":"string","description":"The `import_id` used on the original bulk entry POST."},"dry_run":{"type":"boolean","description":"true → report the blast radius only, no writes."},"confirm_count":{"type":"integer","description":"Required for a real run: the `count` a dry-run just reported."}}}}}},"responses":{"200":{"description":"{ import_id, count, sample_entry_ids, deleted, tag_deleted? }","content":{"application/json":{"schema":{"type":"object","properties":{"import_id":{"type":"string"},"count":{"type":"integer","description":"Dry-run: entries that WOULD be deleted. Real run: entries actually deleted."},"sample_entry_ids":{"type":"array","items":{"type":"string"},"maxItems":20},"deleted":{"type":"boolean"},"tag_deleted":{"type":"boolean","description":"Real run only: the emptied private import tag was removed."}}}}}},"400":{"description":"Missing import_id, or a real run without confirm_count"},"404":{"description":"No private import tag with that name"},"409":{"description":"confirm_count does not match the live count — re-run dry_run"}},"tags":["entries"]}},"/api/v1/dictionaries/{id}/entries/batch-update":{"post":{"summary":"Patch many entries in one request (ordered per-item results, retry-safe)","description":"Up to 1000 entry PATCHes per request — each item is `{ id, …EntryPatch }` with exactly the single `PATCH …/entries/{entryId}` semantics (field-merge, `null` clears, senses upsert by id, additive dialects/tags). Per-item best-effort: one bad item is rolled back and reported, the rest apply. Results come back one per item, in request order: `updated`, `unchanged` (the patch already matched — nothing was written), `not_found`, or `failed` with `error`. RETRY-SAFE: re-send the SAME batch after a timeout, a 5xx or a `halted_error` — items that already landed report `unchanged` and write nothing. To keep that true, every `senses[]` item and every `example_sentences[]` item MUST carry an `id` (mint UUIDs client-side; an id-less one fails that item), and an entry id may appear only once per batch. Write key / manager+.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"},"description":"Dictionary id or url-slug. Must match the dictionary your API key is scoped to."}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["entries"],"properties":{"entries":{"type":"array","maxItems":1000,"items":{"allOf":[{"type":"object","required":["id"],"properties":{"id":{"type":"string","description":"The entry to patch."}}},{"$ref":"#/components/schemas/EntryPatch"}]}}}}}}},"responses":{"200":{"description":"Per-item report","content":{"application/json":{"schema":{"type":"object","properties":{"updated":{"type":"integer"},"unchanged":{"type":"integer","description":"Items whose patch already matched stored state — nothing written (e.g. the retry of an applied item)."},"not_found":{"type":"integer"},"failed":{"type":"integer"},"results":{"type":"array","description":"One per request item, in order.","items":{"type":"object","properties":{"status":{"type":"string","enum":["updated","unchanged","not_found","failed"]},"entry_id":{"type":"string","description":"Absent only when the item had no usable id."},"error":{"type":"string"}}}},"halted_error":{"type":"string","description":"A transaction-level fault (e.g. the database was locked) stopped the batch; every item not yet committed is `failed` with this error. Re-send the same batch."},"warnings":{"type":"array","items":{"type":"string"}}}}}}},"400":{"description":"Body is not `{ entries: [...] }`, empty, or over 1000 items"},"401":{"description":"Missing/invalid key"},"403":{"description":"Insufficient role or wrong dictionary"}},"tags":["entries"]}},"/api/v1/dictionaries/{id}/entries/{entryId}/tags/{tagId}":{"delete":{"summary":"Unlink a tag from this entry","description":"Removes ONE tag from ONE entry; the tag (and its links to other entries) survives. To delete the tag everywhere use `DELETE …/tags/{tagId}`.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"},"description":"Dictionary id or url-slug. Must match the dictionary your API key is scoped to."},{"name":"entryId","in":"path","required":true,"schema":{"type":"string"}},{"name":"tagId","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"{ result: 'unlinked' }"},"404":{"description":"Tag not linked to this entry"}},"tags":["entries"]}},"/api/v1/dictionaries/{id}/relationships":{"get":{"summary":"List an entry's relationships","description":"Every relationship touching `entry_id` (both directions), shaped from that entry's viewpoint (`direction` + inverse label already resolved). Read access.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"},"description":"Dictionary id or url-slug. Must match the dictionary your API key is scoped to."},{"name":"entry_id","in":"query","required":true,"schema":{"type":"string"},"description":"The entry to list relationships for."}],"responses":{"200":{"description":"{ relationships: RelationshipView[] }","content":{"application/json":{"schema":{"type":"object","properties":{"relationships":{"type":"array","items":{"$ref":"#/components/schemas/RelationshipView"}}}}}}},"400":{"description":"Missing entry_id"}},"tags":["relationships"]},"post":{"summary":"Create relationships (single or batch)","description":"Link two entries (optionally narrowed to senses) with a global or custom type. Body: ONE relationship object (response `{ relationship, created }`), or a batch — a bare array or `{ relationships: [...] }`, ≤1000/request (response `{ created, existed, failed, results }` with per-item results in input order — same contract as bulk entries; use batches for cognate ledgers and other large relationship sets). Idempotent either way: an identical relationship is a no-op (`created: false` / status `exists`), symmetric types also dedupe the reverse direction, and a batch is per-item best-effort (one bad item doesn't abort the rest — re-POST only the `failed` ones). Write key / manager+.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"},"description":"Dictionary id or url-slug. Must match the dictionary your API key is scoped to."}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"oneOf":[{"$ref":"#/components/schemas/RelationshipInput"},{"type":"array","items":{"$ref":"#/components/schemas/RelationshipInput"}},{"type":"object","properties":{"relationships":{"type":"array","items":{"$ref":"#/components/schemas/RelationshipInput"}}},"required":["relationships"]}]}}}},"responses":{"200":{"description":"Single: { relationship, created }. Batch: { created, existed, failed, results }.","content":{"application/json":{"schema":{"oneOf":[{"type":"object","properties":{"relationship":{"$ref":"#/components/schemas/RelationshipView"},"created":{"type":"boolean","description":"false = idempotent no-op (an identical relationship already existed)."}}},{"type":"object","properties":{"created":{"type":"integer"},"existed":{"type":"integer","description":"Idempotent no-ops (an identical relationship already existed)."},"failed":{"type":"integer"},"results":{"type":"array","description":"Per-item, in input order.","items":{"type":"object","properties":{"status":{"type":"string","enum":["created","exists","failed"]},"relationship_id":{"type":"string"},"error":{"type":"string"}}}}}}]}}}},"400":{"description":"Bad input (single: missing/duplicate type, unknown entry/sense/source, self-link; batch: empty or >1000 items)"}},"tags":["relationships"]}},"/api/v1/dictionaries/{id}/relationships/{relationshipId}":{"delete":{"summary":"Delete a relationship","description":"Removes ONE relationship by id (tombstone → cascade). Write key / manager+.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"},"description":"Dictionary id or url-slug. Must match the dictionary your API key is scoped to."},{"name":"relationshipId","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"{ result: 'deleted' }"},"404":{"description":"Relationship not found"}},"tags":["relationships"]}},"/api/v1/dictionaries/{id}/relationship-types":{"get":{"summary":"List custom relationship types","description":"The per-dictionary custom-type registry (global slugs like `synonym` are not listed here). `used_by` is how many relationships currently use each type. Read access.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"},"description":"Dictionary id or url-slug. Must match the dictionary your API key is scoped to."}],"responses":{"200":{"description":"{ relationship_types }","content":{"application/json":{"schema":{"type":"object","properties":{"relationship_types":{"type":"array","items":{"$ref":"#/components/schemas/RelationshipTypeView"}}}}}}}},"tags":["relationships"]}},"/api/v1/dictionaries/{id}/relationship-types/{typeId}":{"patch":{"summary":"Edit a custom relationship type","description":"Rename the type and/or fix `inverse_name` / `symmetric`. Affects EVERY relationship using it — this is how you correct a misspelled label. `inverse_name: null` clears; omit → untouched. `symmetric: true` also clears `inverse_name`. A non-null `inverse_name` implies directed unless `symmetric: true` is also sent (400). Name collision → 400. Write key / manager+.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"},"description":"Dictionary id or url-slug. Must match the dictionary your API key is scoped to."},{"name":"typeId","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"name":{"oneOf":[{"type":"string"},{"$ref":"#/components/schemas/MultiString"}]},"inverse_name":{"anyOf":[{"type":"string"},{"$ref":"#/components/schemas/MultiString"},{"type":"null"}],"description":"Label from the `to` side for a directed type. `null` clears; omit → untouched."},"symmetric":{"type":"boolean","description":"`true` also clears `inverse_name`."}}}}}},"responses":{"200":{"description":"{ relationship_type }","content":{"application/json":{"schema":{"type":"object","properties":{"relationship_type":{"$ref":"#/components/schemas/RelationshipTypeView"}}}}}},"400":{"description":"Empty name / name collision / symmetric+inverse conflict"},"404":{"description":"Resource not found"}},"tags":["relationships"]},"delete":{"summary":"Delete a custom relationship type","description":"Deletes the type and EVERY relationship using it (FK cascade). The linked entries survive. To remove one link, `DELETE …/relationships/{relationshipId}` instead. Write key / manager+.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"},"description":"Dictionary id or url-slug. Must match the dictionary your API key is scoped to."},{"name":"typeId","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"{ result: 'deleted' }"},"404":{"description":"Resource not found"}},"tags":["relationships"]}},"/api/v1/dictionaries/{id}/entries/{entryId}/dialects/{dialectId}":{"delete":{"summary":"Unlink a dialect from this entry","description":"Removes ONE dialect from ONE entry; the dialect survives globally. To delete it everywhere use `DELETE …/dialects/{dialectId}`.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"},"description":"Dictionary id or url-slug. Must match the dictionary your API key is scoped to."},{"name":"entryId","in":"path","required":true,"schema":{"type":"string"}},{"name":"dialectId","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"{ result: 'unlinked' }"},"404":{"description":"Dialect not linked to this entry"}},"tags":["entries"]}},"/api/v1/dictionaries/{id}/sentences":{"post":{"summary":"Create a standalone sentence","description":"Create a first-class sentence without attaching it to a sense or text. Use this for free-standing grammar examples, then attach the returned id to a grammar section (`POST …/grammar/sections/{sectionId}/sentences`) and/or link it to a sense (`PATCH …/entries/{entryId}` with `example_sentences: [{ id }]`). A client-supplied id makes retries idempotent.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"},"description":"Dictionary id or url-slug. Must match the dictionary your API key is scoped to."}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SentenceInput"}}}},"responses":{"200":{"description":"{ sentence, created }","content":{"application/json":{"schema":{"type":"object","properties":{"sentence":{"$ref":"#/components/schemas/SentenceFull"},"created":{"type":"boolean"}}}}}},"400":{"description":"Missing content / bad id / unknown source"}},"tags":["entries"]}},"/api/v1/dictionaries/{id}/sentences/{sentenceId}":{"get":{"summary":"Read one sentence","description":"Read any sentence by id, including a standalone grammar example.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"},"description":"Dictionary id or url-slug. Must match the dictionary your API key is scoped to."},{"name":"sentenceId","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"{ sentence }","content":{"application/json":{"schema":{"type":"object","properties":{"sentence":{"$ref":"#/components/schemas/SentenceFull"}}}}}},"404":{"description":"Resource not found"}},"tags":["entries"]},"patch":{"summary":"Edit one sentence","description":"Field-merge one sentence, whether it is sense-linked, text-owned, or standalone. Returns `{ sentence }`.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"},"description":"Dictionary id or url-slug. Must match the dictionary your API key is scoped to."},{"name":"sentenceId","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SentencePatch"}}}},"responses":{"200":{"description":"The updated sentence","content":{"application/json":{"schema":{"type":"object","properties":{"sentence":{"$ref":"#/components/schemas/SentenceFull"}}}}}},"400":{"description":"Invalid request"},"404":{"description":"Resource not found"}},"tags":["entries"]},"delete":{"summary":"Delete one sentence","description":"Deletes the sentence and its sense/grammar links.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"},"description":"Dictionary id or url-slug. Must match the dictionary your API key is scoped to."},{"name":"sentenceId","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"{ result: 'deleted' }"},"404":{"description":"Resource not found"}},"tags":["entries"]}},"/api/v1/dictionaries/{id}/senses/{senseId}":{"delete":{"summary":"Delete one sense","description":"Deletes a single sense and its sentence/media links. Refused (400) when it is the entry's ONLY sense — delete the entry instead.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"},"description":"Dictionary id or url-slug. Must match the dictionary your API key is scoped to."},{"name":"senseId","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"{ result: 'deleted' }"},"400":{"description":"Can't delete an entry's only sense"},"404":{"description":"Resource not found"}},"tags":["entries"]}},"/api/v1/dictionaries/{id}/texts":{"get":{"summary":"List texts","description":"Each text with its `sentence_count`, any classification `tags`, and (when set) its `collection_id` + in-collection `sort_key`. Pass `tag` for an exact, case-insensitive tag-name match, and/or `collection` to scope to one collection (that scope is ordered by `sort_key` instead of recency).","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"},"description":"Dictionary id or url-slug. Must match the dictionary your API key is scoped to."},{"name":"tag","in":"query","schema":{"type":"string"},"description":"Exact classification tag name, matched case-insensitively after trimming."},{"name":"collection","in":"query","schema":{"type":"string"},"description":"Collection id — only that collection's texts, ordered by `sort_key`."}],"responses":{"200":{"description":"{ texts }","content":{"application/json":{"schema":{"type":"object","properties":{"texts":{"type":"array","items":{"$ref":"#/components/schemas/TextSummary"}}}}}}}},"tags":["texts"]},"post":{"summary":"Create a text (with ordered sentences)","description":"Create a connected text/story plus its ordered sentences (sort_keys assigned in array order). Supply your own `id` (UUID) for idempotency — a re-POST of an existing id is a no-op (`created: false`). A text-sentence is standalone (not attached to a sense).","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"},"description":"Dictionary id or url-slug. Must match the dictionary your API key is scoped to."}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/TextInput"}}}},"responses":{"200":{"description":"{ text, created }","content":{"application/json":{"schema":{"type":"object","properties":{"text":{"$ref":"#/components/schemas/TextFull"},"created":{"type":"boolean"}}}}}},"400":{"description":"Missing title / bad id"}},"tags":["texts"]}},"/api/v1/dictionaries/{id}/texts/{textId}":{"get":{"summary":"Read one text (sentences + tags + audio + speakers)","description":"The text with its ordered sentences, classification tags, attached audio (text- and sentence-level, each with `timings` + `download_url`), and the full records of every referenced speaker — one call serves a complete text read.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"},"description":"Dictionary id or url-slug. Must match the dictionary your API key is scoped to."},{"name":"textId","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"{ text }","content":{"application/json":{"schema":{"type":"object","properties":{"text":{"$ref":"#/components/schemas/TextFull"}}}}}},"404":{"description":"Resource not found"}},"tags":["texts"]},"patch":{"summary":"Update a text (title / append / reorder)","description":"Field-merge the title, append new sentences, and/or reorder existing sentences (`sentence_order`). Edit ONE sentence's text/translation/paragraph-break via `PATCH …/sentences/{id}`; delete one via `DELETE …/sentences/{id}`.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"},"description":"Dictionary id or url-slug. Must match the dictionary your API key is scoped to."},{"name":"textId","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/TextPatch"}}}},"responses":{"200":{"description":"{ text }","content":{"application/json":{"schema":{"type":"object","properties":{"text":{"$ref":"#/components/schemas/TextFull"}}}}}},"400":{"description":"Invalid request"},"404":{"description":"Resource not found"}},"tags":["texts"]},"delete":{"summary":"Delete a text (and its sentences)","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"},"description":"Dictionary id or url-slug. Must match the dictionary your API key is scoped to."},{"name":"textId","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"{ result: 'deleted' }"},"404":{"description":"Resource not found"}},"tags":["texts"]}},"/api/v1/dictionaries/{id}/texts/collections":{"get":{"summary":"List text collections","description":"Every collection (flat, sibling-ordered) with its `parent_id`, direct `text_count`, and `child_ids` — nest client-side via `parent_id`. Attach texts by setting `collection_id` on text create/patch.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"},"description":"Dictionary id or url-slug. Must match the dictionary your API key is scoped to."}],"responses":{"200":{"description":"{ collections }","content":{"application/json":{"schema":{"type":"object","properties":{"collections":{"type":"array","items":{"$ref":"#/components/schemas/TextCollection"}}}}}}}},"tags":["texts"]},"post":{"summary":"Create a text collection (series / book / chapter grouping)","description":"Create a collection; nest via `parent_id` (max 3 levels). Supply your own `id` (UUID) for idempotency — a re-POST of an existing id is a no-op (`created: false`).","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"},"description":"Dictionary id or url-slug. Must match the dictionary your API key is scoped to."}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/TextCollectionInput"}}}},"responses":{"200":{"description":"{ collection, created }","content":{"application/json":{"schema":{"type":"object","properties":{"collection":{"$ref":"#/components/schemas/TextCollection"},"created":{"type":"boolean"}}}}}},"400":{"description":"Missing title / unknown parent / bad id"},"422":{"description":"Nesting past 3 levels"}},"tags":["texts"]}},"/api/v1/dictionaries/{id}/texts/collections/{collectionId}":{"get":{"summary":"Read one collection","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"},"description":"Dictionary id or url-slug. Must match the dictionary your API key is scoped to."},{"name":"collectionId","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"{ collection }","content":{"application/json":{"schema":{"type":"object","properties":{"collection":{"$ref":"#/components/schemas/TextCollection"}}}}}},"404":{"description":"Resource not found"}},"tags":["texts"]},"patch":{"summary":"Update a collection (rename / re-parent / reorder)","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"},"description":"Dictionary id or url-slug. Must match the dictionary your API key is scoped to."},{"name":"collectionId","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/TextCollectionPatch"}}}},"responses":{"200":{"description":"{ collection }","content":{"application/json":{"schema":{"type":"object","properties":{"collection":{"$ref":"#/components/schemas/TextCollection"}}}}}},"400":{"description":"Invalid request"},"404":{"description":"Resource not found"},"422":{"description":"The collection would become its own ancestor, or nest past 3 levels"}},"tags":["texts"]},"delete":{"summary":"Delete a collection (children + texts detach, never deleted)","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"},"description":"Dictionary id or url-slug. Must match the dictionary your API key is scoped to."},{"name":"collectionId","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"{ result: 'deleted' }"},"404":{"description":"Resource not found"}},"tags":["texts"]}},"/api/v1/dictionaries/{id}/sentences/{sentenceId}/tokens/actions":{"post":{"summary":"Review token↔entry links on one sentence","description":"Apply confirm/ignore/unlink actions to individual tokens of a sentence's `tokens` value (same semantics as the reader UI popovers). `confirm` requires `entry_id`; adding `sense_id` mirrors the link into `senses_in_sentences`; `unlink` also drops a stale junction row on text sentences. Gold IGT gloss/morphemes always survive. Returns the updated `{ sentence }`.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"},"description":"Dictionary id or url-slug. Must match the dictionary your API key is scoped to."},{"name":"sentenceId","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["actions"],"properties":{"actions":{"type":"array","items":{"$ref":"#/components/schemas/TokenAction"}}}}}}},"responses":{"200":{"description":"{ sentence }","content":{"application/json":{"schema":{"type":"object","properties":{"sentence":{"$ref":"#/components/schemas/SentenceFull"}}}}}},"400":{"description":"Bad token index / missing entry_id"},"404":{"description":"Resource not found"}},"tags":["suggestions"]}},"/api/v1/dictionaries/{id}/suggestions":{"get":{"summary":"The suggestions queue (unmatched / ambiguous / ignored word forms)","description":"Aggregates every tokenized sentence into three frequency-sorted facets: `unmatched` (word forms with no entry link — entry-creation candidates), `ambiguous` (multi-candidate homographs awaiting a pick), `ignored` (occurrence ignores + the dictionary-level ignore list, `everywhere: true`). Same aggregation the queue UI shows humans.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"},"description":"Dictionary id or url-slug. Must match the dictionary your API key is scoped to."}],"responses":{"200":{"description":"{ unmatched, ambiguous, ignored }","content":{"application/json":{"schema":{"type":"object","properties":{"unmatched":{"type":"array","items":{"$ref":"#/components/schemas/SuggestionRow"}},"ambiguous":{"type":"array","items":{"$ref":"#/components/schemas/SuggestionRow"}},"ignored":{"type":"array","items":{"$ref":"#/components/schemas/SuggestionRow"}}}}}}}},"tags":["suggestions"]}},"/api/v1/dictionaries/{id}/suggestions/actions":{"post":{"summary":"Act on a word form everywhere (ignore / restore / link / create_entry)","description":"`ignore` marks every non-confirmed occurrence ignored AND persists the form in the dictionary-level ignore list (future ingests stay ignored). `restore` undoes that (re-matches occurrences). `link` (requires `entry_id`) confirms every non-confirmed occurrence entry-level — no sense/junction writes; refine senses per-occurrence via `…/tokens/actions`. `create_entry` (requires `lexeme`) mints an entry + first sense, then links the form everywhere.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"},"description":"Dictionary id or url-slug. Must match the dictionary your API key is scoped to."}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["action","form"],"properties":{"action":{"type":"string","enum":["ignore","restore","link","create_entry"]},"form":{"type":"string","description":"Surface or normalized form — matching is by normalized-word equality."},"entry_id":{"type":"string"},"lexeme":{"$ref":"#/components/schemas/MultiString"}}}}}},"responses":{"200":{"description":"{ sentences_changed, occurrences, entry_id? }"},"400":{"description":"Bad action / missing entry_id or lexeme"}},"tags":["suggestions"]}},"/api/v1/dictionaries/{id}/analyze":{"post":{"summary":"Re-run word→entry matching on demand (the API twin of \"Refresh matches\")","description":"Every sentence write already tokenizes + matches what it wrote, and every entry write re-matches the words it names, so this is CATCH-UP only. A scope is required: `text_id` re-analyzes one text, `sentence_ids` (≤ 500) specific sentences, `unanalyzed: true` every sentence that was never tokenized. Idempotent; confirmed links, ignores, and gold IGT survive.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"},"description":"Dictionary id or url-slug. Must match the dictionary your API key is scoped to."}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"text_id":{"type":"string"},"sentence_ids":{"type":"array","items":{"type":"string"},"maxItems":500},"unanalyzed":{"type":"boolean"}}}}}},"responses":{"200":{"description":"{ analyzed, changed }"},"400":{"description":"No scope given"}},"tags":["suggestions"]}},"/api/v1/dictionaries/{id}/feedback":{"post":{"summary":"Send feedback to the LD team","description":"Blocked, missing a field, or something's awkward? Tell us here — it reaches the Living Dictionaries team directly (you do NOT need your human to relay it). Works with read OR write keys. Rate-limited. After sending, TELL YOUR HUMAN what you requested: the response includes a `relay_to_human` sentence to pass along (if we adopt it, we notify your human directly).","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"},"description":"Dictionary id or url-slug. Must match the dictionary your API key is scoped to."}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["message"],"properties":{"message":{"type":"string","maxLength":4000,"description":"What you need / what's wrong. Max 4000 characters."},"kind":{"type":"string","enum":["missing_field","bug","awkward","other"]}}}}}},"responses":{"200":{"description":"{ received: true, relay_to_human }"},"400":{"description":"Empty/too-long message"},"429":{"description":"Rate-limited (non-blocking — earlier feedback was received)"}},"tags":["feedback"]}},"/api/v1/dictionaries/{id}/speakers":{"get":{"summary":"List speakers","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"},"description":"Dictionary id or url-slug. Must match the dictionary your API key is scoped to."}],"responses":{"200":{"description":"{ speakers }"}},"tags":["speakers"]},"post":{"summary":"Create a speaker","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"},"description":"Dictionary id or url-slug. Must match the dictionary your API key is scoped to."}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["name"],"properties":{"name":{"type":"string"},"decade":{"type":"integer","minimum":0,"maximum":9,"description":"AGE-RANGE bucket 0-9 (0 = ages 0-10, 1 = 11-20, … 9 = 91-100) — NOT a birth decade. Out-of-range integers are rejected."},"gender":{"type":"string","enum":["m","f","o"]},"birthplace":{"type":"string"}}}}}},"responses":{"200":{"description":"{ speaker }"},"400":{"description":"Missing name / invalid gender / decade outside 0-9"}},"tags":["speakers"]}},"/api/v1/dictionaries/{id}/speakers/{speakerId}":{"patch":{"summary":"Edit a speaker","description":"Updates name / `decade` (age-range bucket 0-9) / gender / birthplace. `null` clears an optional field. Returns `{ speaker }`.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"},"description":"Dictionary id or url-slug. Must match the dictionary your API key is scoped to."},{"name":"speakerId","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"name":{"type":"string"},"decade":{"type":["integer","null"],"minimum":0,"maximum":9,"description":"Age-range bucket 0-9; `null` clears."},"gender":{"type":["string","null"],"enum":["m","f","o",null]},"birthplace":{"type":["string","null"]}}}}}},"responses":{"200":{"description":"{ speaker }"},"400":{"description":"Empty name / invalid gender / decade outside 0-9"},"404":{"description":"Resource not found"}},"tags":["speakers"]},"delete":{"summary":"Delete an unused speaker","description":"Deletes a speaker with NO attached audio and NO attached video (video links protect exactly like audio links). While any recording still references the speaker this refuses with 409 and the usage counts — merge the duplicate into the surviving speaker instead.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"},"description":"Dictionary id or url-slug. Must match the dictionary your API key is scoped to."},{"name":"speakerId","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"{ result: 'deleted' }"},"404":{"description":"Resource not found"},"409":{"description":"{ message, audio_count, video_count } — still attributed on media; merge instead"}},"tags":["speakers"]}},"/api/v1/dictionaries/{id}/speakers/{speakerId}/merge":{"post":{"summary":"Merge a duplicate speaker into another","description":"Moves EVERY audio and video attribution from the path speaker (the duplicate) onto `destination_speaker_id` (the survivor), deduplicating links the survivor already holds, then deletes the duplicate record. Atomic. The survivor's own fields are never changed and media files are untouched. Returns `{ result: 'merged', moved_audio, moved_video, deduped_audio, deduped_video }`.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"},"description":"Dictionary id or url-slug. Must match the dictionary your API key is scoped to."},{"name":"speakerId","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["destination_speaker_id"],"properties":{"destination_speaker_id":{"type":"string","description":"The surviving speaker id (must differ from the path speaker)."}}}}}},"responses":{"200":{"description":"{ result, moved_audio, moved_video, deduped_audio, deduped_video }"},"400":{"description":"Missing destination / destination equals source"},"404":{"description":"Either speaker not found"}},"tags":["speakers"]}},"/api/v1/dictionaries/{id}/tags":{"get":{"summary":"List tags","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"},"description":"Dictionary id or url-slug. Must match the dictionary your API key is scoped to."}],"responses":{"200":{"description":"{ tags }"}},"tags":["tags"]},"post":{"summary":"Find-or-create a tag","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"},"description":"Dictionary id or url-slug. Must match the dictionary your API key is scoped to."}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["name"],"properties":{"name":{"type":"string"},"private":{"type":"boolean"}}}}}},"responses":{"200":{"description":"{ tag, created }"}},"tags":["tags"]}},"/api/v1/dictionaries/{id}/tags/{tagId}":{"patch":{"summary":"Rename a tag / set private","description":"Renames the tag and/or flips its `private` flag. Affects EVERY entry the tag is on. Returns `{ tag }`.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"},"description":"Dictionary id or url-slug. Must match the dictionary your API key is scoped to."},{"name":"tagId","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"name":{"type":"string"},"private":{"type":"boolean"}}}}}},"responses":{"200":{"description":"{ tag }"},"400":{"description":"Name collides with another tag"},"404":{"description":"Resource not found"}},"tags":["tags"]},"delete":{"summary":"Delete an unused tag","description":"Deletes the tag — only while NO entry or text still carries it (409 otherwise, with the counts). To retire a tag that is in use, merge it into another via `POST …/tags/{tagId}/merge`; to remove it from just one entry use `DELETE …/entries/{entryId}/tags/{tagId}`.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"},"description":"Dictionary id or url-slug. Must match the dictionary your API key is scoped to."},{"name":"tagId","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"{ result: 'deleted' }"},"404":{"description":"Resource not found"},"409":{"description":"Still linked to entries/texts — merge instead"}},"tags":["tags"]}},"/api/v1/dictionaries/{id}/tags/{tagId}/merge":{"post":{"summary":"Merge a duplicate tag into another","description":"The path tag is the DUPLICATE; `destination_tag_id` is the survivor. Every entry/text link moves to the destination (links it already holds are dropped, never doubled), then the duplicate is deleted. The destination's own name/private flag never change. Both tags must share a `kind` (an entry tag cannot merge into a text-classification tag). Atomic. Write key / manager+.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"},"description":"Dictionary id or url-slug. Must match the dictionary your API key is scoped to."},{"name":"tagId","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["destination_tag_id"],"properties":{"destination_tag_id":{"type":"string"}}}}}},"responses":{"200":{"description":"{ result: 'merged', moved_entries, moved_texts, deduped_entries, deduped_texts }"},"400":{"description":"Same id for both / different kinds"},"404":{"description":"Resource not found"}},"tags":["tags"]}},"/api/v1/dictionaries/{id}/featured-entries":{"get":{"summary":"List featured (starred) entries","description":"Editor-starred favorites shown on the dictionary home page, in strip order. Returns `{ featured_entries: [{ id, entry_id, sort_key }] }`.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"},"description":"Dictionary id or url-slug. Must match the dictionary your API key is scoped to."}],"responses":{"200":{"description":"{ featured_entries }"}},"tags":["featured-entries"]},"post":{"summary":"Star an entry","description":"Adds the entry to the END of the dictionary-home featured strip. Idempotent: re-starring returns the existing row with `created: false`. Write key / manager+.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"},"description":"Dictionary id or url-slug. Must match the dictionary your API key is scoped to."}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["entry_id"],"properties":{"entry_id":{"type":"string"}}}}}},"responses":{"200":{"description":"{ featured_entry, created }"},"404":{"description":"Unknown entry"}},"tags":["featured-entries"]},"patch":{"summary":"Reorder the featured strip","description":"`order` must list EVERY currently-starred entry id exactly once, in the desired display order. Write key / manager+.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"},"description":"Dictionary id or url-slug. Must match the dictionary your API key is scoped to."}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["order"],"properties":{"order":{"type":"array","items":{"type":"string"},"description":"Entry ids (not featured-row ids) in the desired order."}}}}}},"responses":{"200":{"description":"{ featured_entries } in the new order"},"400":{"description":"Order incomplete / lists an unstarred entry"}},"tags":["featured-entries"]}},"/api/v1/dictionaries/{id}/featured-entries/{entryId}":{"delete":{"summary":"Unstar an entry","description":"Removes the entry from the dictionary-home featured strip (by ENTRY id — one star per entry).","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"},"description":"Dictionary id or url-slug. Must match the dictionary your API key is scoped to."},{"name":"entryId","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"{ result: 'deleted' }"},"404":{"description":"Entry is not starred"}},"tags":["featured-entries"]}},"/api/v1/dictionaries/{id}/dialects":{"get":{"summary":"List dialects","description":"Each dialect with its `name` and `coordinates` (DEPRECATED: its places as points/regions, or `null`; list them properly with `…/places?dialect_id=`).","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"},"description":"Dictionary id or url-slug. Must match the dictionary your API key is scoped to."}],"responses":{"200":{"description":"{ dialects }"}},"tags":["dialects"]},"post":{"summary":"Find-or-create a dialect","description":"Found-or-created by case-insensitive name. Map it with places (`POST …/places` + `links.dialects`). Deprecated `coordinates` still work on CREATE: each point/region becomes a place linked to the dialect. Returns `{ dialect, created, warnings? }`.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"},"description":"Dictionary id or url-slug. Must match the dictionary your API key is scoped to."}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["name"],"properties":{"name":{"type":"string"},"coordinates":{"oneOf":[{"$ref":"#/components/schemas/Coordinates"},{"type":"null"}],"deprecated":true,"description":"DEPRECATED — use places. Saved as the new dialect's places."}}}}}},"responses":{"200":{"description":"{ dialect, created }"},"400":{"description":"Invalid coordinates"}},"tags":["dialects"]}},"/api/v1/dictionaries/{id}/dialects/{dialectId}":{"patch":{"summary":"Rename a dialect","description":"Rename the dialect (plain string or locale map). Affects EVERY entry it is on. Deprecated `coordinates` are ADDED as the dialect's places (`null` changes nothing) — map it with `…/places` instead. Returns `{ dialect, warnings? }`.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"},"description":"Dictionary id or url-slug. Must match the dictionary your API key is scoped to."},{"name":"dialectId","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"name":{"oneOf":[{"type":"string"},{"$ref":"#/components/schemas/MultiString"}]},"coordinates":{"oneOf":[{"$ref":"#/components/schemas/Coordinates"},{"type":"null"}],"deprecated":true,"description":"DEPRECATED — added as the dialect's places."}}}}}},"responses":{"200":{"description":"{ dialect }"},"400":{"description":"Name collides / invalid coordinates"},"404":{"description":"Resource not found"}},"tags":["dialects"]},"delete":{"summary":"Delete an unused dialect","description":"Deletes the dialect — only while NO entry or text still carries it (409 otherwise, with the counts). To retire a dialect that is in use, merge it into another via `POST …/dialects/{dialectId}/merge`; to remove it from just one entry use `DELETE …/entries/{entryId}/dialects/{dialectId}`.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"},"description":"Dictionary id or url-slug. Must match the dictionary your API key is scoped to."},{"name":"dialectId","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"{ result: 'deleted' }"},"404":{"description":"Resource not found"},"409":{"description":"Still linked to entries/texts — merge instead"}},"tags":["dialects"]}},"/api/v1/dictionaries/{id}/dialects/{dialectId}/merge":{"post":{"summary":"Merge a duplicate dialect into another","description":"The path dialect is the DUPLICATE; `destination_dialect_id` is the survivor. Every entry/text link moves to the destination (links it already holds are dropped, never doubled), then the duplicate is deleted. The destination's own name/coordinates never change. Atomic. Write key / manager+.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"},"description":"Dictionary id or url-slug. Must match the dictionary your API key is scoped to."},{"name":"dialectId","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["destination_dialect_id"],"properties":{"destination_dialect_id":{"type":"string"}}}}}},"responses":{"200":{"description":"{ result: 'merged', moved_entries, moved_texts, deduped_entries, deduped_texts }"},"400":{"description":"Same id for both"},"404":{"description":"Resource not found"}},"tags":["dialects"]}},"/api/v1/dictionaries/{id}/gloss-languages":{"post":{"summary":"Add a gloss language","description":"Registers another locale code glosses/translations can be keyed by (e.g. importing a French-glossed source into an en/zh dictionary → add `fr` first). The code must be in Living Dictionaries' supported glossing-languages list. Write key / manager+.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"},"description":"Dictionary id or url-slug. Must match the dictionary your API key is scoped to."}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["code"],"properties":{"code":{"type":"string","description":"A supported gloss-language code, e.g. `fr`."}}}}}},"responses":{"200":{"description":"{ gloss_languages } — the updated full list","content":{"application/json":{"schema":{"type":"object","properties":{"gloss_languages":{"type":"array","items":{"type":"string"}}}}}}},"400":{"description":"Unsupported code or already present"}},"tags":["dictionary"]}},"/api/v1/dictionaries/{id}/gloss-languages/{code}":{"delete":{"summary":"Remove a gloss language","description":"Refused while any sense/sentence still stores gloss/definition/translation text under the code (clear those first).","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"},"description":"Dictionary id or url-slug. Must match the dictionary your API key is scoped to."},{"name":"code","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"{ gloss_languages }"},"400":{"description":"Not on this dictionary, or still in use"}},"tags":["dictionary"]}},"/api/v1/dictionaries/{id}/orthographies":{"get":{"summary":"List orthographies","description":"The dictionary's ALTERNATE writing systems (the primary headword, keyed `default`, is implicit and excluded), each with `used_by` counts of entries/sentences that store text under it.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"},"description":"Dictionary id or url-slug. Must match the dictionary your API key is scoped to."}],"responses":{"200":{"description":"{ orthographies }"}},"tags":["orthographies"]},"post":{"summary":"Add an orthography","description":"Registers an alternate writing system. `code` is required, unique, and immutable; a `code` that is a known writing system auto-wires its Keyman keyboard. After creating it, write each spelling under that `code` key inside an entry's `lexeme` (or a sentence's `text`).","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"},"description":"Dictionary id or url-slug. Must match the dictionary your API key is scoped to."}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/OrthographyInput"}}}},"responses":{"200":{"description":"{ orthography }"},"400":{"description":"Reserved/duplicate/invalid code"}},"tags":["orthographies"]},"put":{"summary":"Reorder orthographies","description":"Sets the display order of the alternate orthographies. Send `{ order: [code, …] }` listing every alternate code exactly once.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"},"description":"Dictionary id or url-slug. Must match the dictionary your API key is scoped to."}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["order"],"properties":{"order":{"type":"array","items":{"type":"string"}}}}}}},"responses":{"200":{"description":"{ orthographies }"},"400":{"description":"Order must list every alternate code exactly once"}},"tags":["orthographies"]}},"/api/v1/dictionaries/{id}/orthographies/{code}":{"patch":{"summary":"Relabel an orthography","description":"Edits `name` / `short_name` / `bcp` / `notes` / `characters` / `tone_letters` (the `code` itself never changes). Works on the primary too — PATCH `default` to label the main headword, attach a keyboard, register the special characters shown as tap-buttons beside search, or shape its tone letters. `characters` and `tone_letters` each replace the whole value; `[]` / `{}` or `null` clears it.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"},"description":"Dictionary id or url-slug. Must match the dictionary your API key is scoped to."},{"name":"code","in":"path","required":true,"schema":{"type":"string"},"description":"The orthography's immutable code (a BCP-47 tag, a custom slug, or `default` for the primary)."}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"name":{"type":"string"},"short_name":{"type":["string","null"],"maxLength":12,"description":"`null` or empty clears it (derive from `name`)."},"bcp":{"type":["string","null"]},"notes":{"type":["string","null"]},"characters":{"type":["array","null"],"items":{"type":"string"},"description":"Characters a normal keyboard cannot type, rendered as tap-buttons beside the entries search box."},"tone_letters":{"oneOf":[{"$ref":"#/components/schemas/ToneLetters"},{"type":"null"}]}}}}}},"responses":{"200":{"description":"{ orthography }"},"400":{"description":"Not found"}},"tags":["orthographies"]},"delete":{"summary":"Delete an orthography","description":"Removes an alternate orthography. Refused while any entry/sentence still stores text under its `code` (clear those first). The primary `default` cannot be deleted.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"},"description":"Dictionary id or url-slug. Must match the dictionary your API key is scoped to."},{"name":"code","in":"path","required":true,"schema":{"type":"string"},"description":"The orthography's immutable code (a BCP-47 tag, a custom slug, or `default` for the primary)."}],"responses":{"200":{"description":"{ deleted, was_using }"},"400":{"description":"In use, primary, or not found"}},"tags":["orthographies"]}},"/api/v1/dictionaries/{id}/sources":{"get":{"summary":"List sources","description":"The dictionary's citation registry, each with `used_by` reference counts.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"},"description":"Dictionary id or url-slug. Must match the dictionary your API key is scoped to."}],"responses":{"200":{"description":"{ sources }"}},"tags":["sources"]},"post":{"summary":"Create a source","description":"Adds a citation record. `slug` is required and must be unique. Entries/sentences reference sources by slug, so create sources here BEFORE citing them on a write (an unknown slug rejects the write).","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"},"description":"Dictionary id or url-slug. Must match the dictionary your API key is scoped to."}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SourceInput"}}}},"responses":{"200":{"description":"{ source }"},"400":{"description":"Missing/duplicate slug or invalid type"}},"tags":["sources"]}},"/api/v1/dictionaries/{id}/sources/{sourceId}":{"patch":{"summary":"Edit source metadata","description":"Field-merges citation metadata (and optionally renames the `slug`). Avoid renaming a slug that is already in use — the rename does not rewrite referencing rows.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"},"description":"Dictionary id or url-slug. Must match the dictionary your API key is scoped to."},{"name":"sourceId","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SourceInput"}}}},"responses":{"200":{"description":"{ source }"},"400":{"description":"Slug collision or invalid type"},"404":{"description":"Resource not found"}},"tags":["sources"]},"delete":{"summary":"Delete a source","description":"Refuses with 409 while permanent source files are attached; reassign or unlink those files first. Also refuses while the source is cited unless `?remove_from_all=true`, which strips the slug from every referencing entry/sentence/text before deleting.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"},"description":"Dictionary id or url-slug. Must match the dictionary your API key is scoped to."},{"name":"sourceId","in":"path","required":true,"schema":{"type":"string"}},{"name":"remove_from_all","in":"query","required":false,"schema":{"type":"boolean"}}],"responses":{"200":{"description":"{ result: 'deleted', removed_from }"},"404":{"description":"Resource not found"},"409":{"description":"Permanent files attached, or still referenced (retry citation references with remove_from_all)"}},"tags":["sources"]}},"/api/v1/dictionaries/{id}/files":{"get":{"summary":"List uploaded import resources","description":"Every uploaded resource with its uploader-written `import_instructions` (authoritative — follow them), optional `source_note`, and request-batch summaries. Write scope required (file names + instructions are never public).","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"},"description":"Dictionary id or url-slug. Must match the dictionary your API key is scoped to."}],"responses":{"200":{"description":"{ files, requests }","content":{"application/json":{"schema":{"type":"object","properties":{"files":{"type":"array","items":{"$ref":"#/components/schemas/SourceFile"}},"requests":{"type":"array","items":{"$ref":"#/components/schemas/ImportRequest"}}}}}}}},"tags":["files"]},"post":{"summary":"Register an upload","description":"Registers the file and returns `{ file, upload_url }` — PUT the raw bytes to `upload_url` (Content-Type must match `mimetype`), then `POST …/files/{fileId}/confirm`. 200MB cap per file.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"},"description":"Dictionary id or url-slug. Must match the dictionary your API key is scoped to."}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["filename","mimetype","size_bytes"],"properties":{"filename":{"type":"string"},"mimetype":{"type":"string"},"size_bytes":{"type":"integer"}}}}}},"responses":{"200":{"description":"{ file, upload_url }"},"400":{"description":"Missing fields or over the 200MB cap"},"403":{"description":"Constructed-language/glossary dictionary — imports are unavailable"}},"tags":["files"]}},"/api/v1/dictionaries/{id}/files/{fileId}":{"get":{"summary":"Download the resource bytes","description":"Streams the original file (Content-Disposition: attachment).","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"},"description":"Dictionary id or url-slug. Must match the dictionary your API key is scoped to."},{"name":"fileId","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"The file bytes"},"404":{"description":"Resource not found"}},"tags":["files"]},"patch":{"summary":"Update file metadata / complete it under a source","description":"Update `import_instructions`, `source_note`, `filename`, or set `source_id` to an EXISTING dict source (create it first via `POST …/sources`). Set `source_id` only after the imported data passes verification: it is the completion marker that removes the resource from the active Import queue and makes it downloadable beneath that source on the Sources page (the page itself is public — it is the dictionary's citation list; only the file downloads are manager-only). The bytes keep their existing private R2 key. Set `source_id: null` to unlink. After an import is requested, only the original uploader or a site admin may update it; the update appends to/reopens the request thread and notifies its assignee. The reader-view fields (`readers_may_open`, `page_anchors`, `pages_per_sheet`) are presentation, not part of the import record: any write-scoped caller may set them at any time, quietly.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"},"description":"Dictionary id or url-slug. Must match the dictionary your API key is scoped to."},{"name":"fileId","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"filename":{"type":"string"},"import_instructions":{"type":["string","null"]},"source_note":{"type":["string","null"]},"source_id":{"type":["string","null"],"description":"Existing source id. Set only after successful import verification; this completes the resource. Null unlinks it."},"readers_may_open":{"type":"boolean","description":"Let anyone who can read the dictionary open this PDF in the page viewer; citations of its source (`locator` \"p. 89\", \"pp. 89–91\", \"pdf p. 62\") then link to the page. PDF only, and the file must be linked to a source. Off by default: an uploaded resource is not public until you turn this on."},"page_anchors":{"type":["array","null"],"items":{"type":"object","required":["printed","pdf"],"properties":{"printed":{"type":["integer","string"],"description":"The page number printed on the page, or a label such as \"xii\"."},"pdf":{"type":"integer","minimum":1,"description":"1-based PDF page it is on."}}},"description":"Where printed pages fall in the PDF. Numbered pages between anchors are interpolated (with `pages_per_sheet`); labels match exactly. Give one anchor where numbering starts and one after every unnumbered plate or gap. Null = printed page is the PDF page."},"pages_per_sheet":{"type":"integer","enum":[1,2],"description":"2 for two-up scans (two printed pages per PDF page)."}}}}}},"responses":{"200":{"description":"{ file }"},"400":{"description":"Unknown source_id / empty filename / reader view on a non-PDF or unlinked file / bad page_anchors"},"403":{"description":"Requested resource owned by another uploader"},"404":{"description":"Resource not found"}},"tags":["files"]},"delete":{"summary":"Delete an uploaded resource","description":"Removes the row + stored bytes. After an import is requested, only the original uploader or a site admin may delete it; deletion appends to/reopens the original thread and notifies its assignee.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"},"description":"Dictionary id or url-slug. Must match the dictionary your API key is scoped to."},{"name":"fileId","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"{ result: 'deleted' }"},"403":{"description":"Requested resource owned by another uploader"},"404":{"description":"Resource not found"}},"tags":["files"]}},"/api/v1/dictionaries/{id}/files/{fileId}/confirm":{"post":{"summary":"Confirm an upload landed","description":"Verifies the bytes exist in storage (and enforces the size cap on what was actually stored). Call after the PUT.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"},"description":"Dictionary id or url-slug. Must match the dictionary your API key is scoped to."},{"name":"fileId","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"{ file }"},"400":{"description":"No bytes found / oversize (removed)"},"404":{"description":"Resource not found"}},"tags":["files"]}},"/api/v1/dictionaries/{id}/files/request-import":{"post":{"summary":"Ask the Living Dictionaries team to import these files","description":"Turns a batch of uploaded, instruction-carrying files into a request message for the LD team (humans normally do this from the Import page — agents rarely need it). Files already completed under a source cannot be requested again.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"},"description":"Dictionary id or url-slug. Must match the dictionary your API key is scoped to."}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["file_ids"],"properties":{"file_ids":{"type":"array","items":{"type":"string"}},"message":{"type":"string"}}}}}},"responses":{"200":{"description":"{ ok, thread_id }"},"400":{"description":"Unconfirmed / instruction-less / already-requested / completed file"}},"tags":["files"]}},"/api/v1/dictionaries/{id}/files/requests/{threadId}":{"patch":{"summary":"Update an import request note","description":"Updates the once-per-batch overall note, appends the change to/reopens the original request thread, and notifies its current assignee. Original requester or site admin only.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"},"description":"Dictionary id or url-slug. Must match the dictionary your API key is scoped to."},{"name":"threadId","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["request_note"],"properties":{"request_note":{"type":["string","null"]}}}}}},"responses":{"200":{"description":"{ request }"},"403":{"description":"Import request owned by another requester"},"404":{"description":"Resource not found"}},"tags":["files"]}},"/api/v1/dictionaries/{id}/conversations":{"get":{"summary":"List import conversations","description":"Every import request for this dictionary, newest first — including finished ones, which stay as the dictionary's permanent record. Each carries `unread`, `open_questions`, `artifact_count`, and `resource_count`.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"},"description":"Dictionary id or url-slug. Must match the dictionary your API key is scoped to."}],"responses":{"200":{"description":"{ conversations }"}},"tags":["conversations"]}},"/api/v1/dictionaries/{id}/conversations/{threadId}":{"get":{"summary":"Read one import conversation","description":"The whole conversation: `conversation`, `messages`, the `resources` it covers, `artifacts` (report/preview HTML), `questions`, plus `is_team` and `is_frozen`.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"},"description":"Dictionary id or url-slug. Must match the dictionary your API key is scoped to."},{"name":"threadId","in":"path","required":true,"schema":{"type":"string","format":"uuid"},"description":"The import conversation (import-request) id."}],"responses":{"200":{"description":"{ conversation, messages, resources, artifacts, questions, is_team, is_frozen }"},"404":{"description":"Resource not found"}},"tags":["conversations"]},"patch":{"summary":"Claim or resolve the job","description":"`{ \"started\": true }` claims the job (guide Phase 0) and FREEZES its resources — from then on the uploaded files, instructions, and request note are permanent history nobody can edit or delete. Idempotent. `{ \"resolved\": true|false }` just marks the job done for our own queue; it never affects the freeze or the manager's access. Team/API-key only.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"},"description":"Dictionary id or url-slug. Must match the dictionary your API key is scoped to."},{"name":"threadId","in":"path","required":true,"schema":{"type":"string","format":"uuid"},"description":"The import conversation (import-request) id."}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"started":{"type":"boolean","enum":[true]},"resolved":{"type":"boolean"}}}}}},"responses":{"200":{"description":"{ conversation }"},"400":{"description":"Neither started nor resolved supplied"},"403":{"description":"Not the Living Dictionaries team"}},"tags":["conversations"]}},"/api/v1/dictionaries/{id}/conversations/{threadId}/brief":{"get":{"summary":"Build the import job kickoff brief","description":"Returns the complete agent-ready kickoff runbook for this import request, derived fresh on every call from the request, its current resources, and the current import guides. It is never stored, so fetch it when starting the job instead of relying on a pasted copy. Team/API-key only.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"},"description":"Dictionary id or url-slug. Must match the dictionary your API key is scoped to."},{"name":"threadId","in":"path","required":true,"schema":{"type":"string","format":"uuid"},"description":"The import conversation (import-request) id."}],"responses":{"200":{"description":"{ brief }","content":{"application/json":{"schema":{"type":"object","required":["brief"],"properties":{"brief":{"type":"string","description":"The current markdown kickoff runbook for this import job."}}}}}},"403":{"description":"Not the Living Dictionaries team"},"404":{"description":"Import request not found, or it has no resources left"}},"tags":["conversations"]}},"/api/v1/dictionaries/{id}/conversations/{threadId}/messages":{"post":{"summary":"Post a message","description":"Adds a message to the conversation and notifies the other side (the manager gets an email linking back here; our assignee gets a push). Nothing in a conversation is private — there are no internal notes. Use this at the end of a job for a few warm sentences pointing at the report; keep the long-form content in the report artifact.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"},"description":"Dictionary id or url-slug. Must match the dictionary your API key is scoped to."},{"name":"threadId","in":"path","required":true,"schema":{"type":"string","format":"uuid"},"description":"The import conversation (import-request) id."}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["body_text"],"properties":{"body_text":{"type":"string"}}}}}},"responses":{"200":{"description":"{ message_id }"},"400":{"description":"Empty body_text"}},"tags":["conversations"]}},"/api/v1/dictionaries/{id}/conversations/{threadId}/read":{"post":{"summary":"Mark the conversation read","description":"Stamps the caller's read position. A team read also re-arms the internal activity notice, so the next manager post announces itself again.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"},"description":"Dictionary id or url-slug. Must match the dictionary your API key is scoped to."},{"name":"threadId","in":"path","required":true,"schema":{"type":"string","format":"uuid"},"description":"The import conversation (import-request) id."}],"responses":{"200":{"description":"{ result }"}},"tags":["conversations"]}},"/api/v1/dictionaries/{id}/conversations/{threadId}/withdraw":{"post":{"summary":"Withdraw an unstarted request","description":"The uploader's escape hatch for a mistaken request: un-stamps the resources (they become ordinary editable uploads again) and removes the conversation. Refused once the job has been started. Original requester or site admin only.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"},"description":"Dictionary id or url-slug. Must match the dictionary your API key is scoped to."},{"name":"threadId","in":"path","required":true,"schema":{"type":"string","format":"uuid"},"description":"The import conversation (import-request) id."}],"responses":{"200":{"description":"{ result }"},"403":{"description":"Already started, or not the requester"}},"tags":["conversations"]}},"/api/v1/dictionaries/{id}/conversations/{threadId}/artifacts":{"get":{"summary":"List the conversation's artifacts","description":"The generated preview/report HTML documents filed against this import.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"},"description":"Dictionary id or url-slug. Must match the dictionary your API key is scoped to."},{"name":"threadId","in":"path","required":true,"schema":{"type":"string","format":"uuid"},"description":"The import conversation (import-request) id."}],"responses":{"200":{"description":"{ artifacts }"}},"tags":["conversations"]},"post":{"summary":"File a report (or preview) HTML artifact","description":"Stores a self-contained HTML document and renders it on the manager's conversation page permanently — the durable home for the report described in guide §2.8. IMPORTANT: artifacts are served under `Content-Security-Policy: default-src 'none'`, so JavaScript never runs — build collapsible sections from `<details>`/`<summary>` and inline your CSS in a `<style>` tag. `stats` becomes the summary line (e.g. `{ \"entries\": 1827 }`). Max 5MB. Team/API-key only.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"},"description":"Dictionary id or url-slug. Must match the dictionary your API key is scoped to."},{"name":"threadId","in":"path","required":true,"schema":{"type":"string","format":"uuid"},"description":"The import conversation (import-request) id."}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["kind","html"],"properties":{"kind":{"type":"string","enum":["report","preview"]},"title":{"type":"string"},"html":{"type":"string"},"import_id":{"type":["string","null"]},"source_id":{"type":["string","null"]},"stats":{"type":["object","null"],"additionalProperties":true}}}}}},"responses":{"200":{"description":"{ artifact }"},"400":{"description":"Bad kind / empty html / over 5MB"},"403":{"description":"Not the Living Dictionaries team"}},"tags":["conversations"]}},"/api/v1/dictionaries/{id}/conversations/{threadId}/artifacts/{artifactId}":{"get":{"summary":"Download an artifact","description":"Serves the stored HTML (add `?download` for an attachment disposition). Script execution is blocked by CSP.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"},"description":"Dictionary id or url-slug. Must match the dictionary your API key is scoped to."},{"name":"threadId","in":"path","required":true,"schema":{"type":"string","format":"uuid"},"description":"The import conversation (import-request) id."},{"name":"artifactId","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"HTML"},"404":{"description":"Resource not found"}},"tags":["conversations"]}},"/api/v1/dictionaries/{id}/conversations/{threadId}/questions":{"get":{"summary":"List the conversation's questions","description":"Including each one's answer once the manager has given it — poll this to pick up their decisions.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"},"description":"Dictionary id or url-slug. Must match the dictionary your API key is scoped to."},{"name":"threadId","in":"path","required":true,"schema":{"type":"string","format":"uuid"},"description":"The import conversation (import-request) id."}],"responses":{"200":{"description":"{ questions }"}},"tags":["conversations"]},"post":{"summary":"Ask answerable questions","description":"Files the whole-import questions from your report as objects the manager answers in the app. `kind` is `text` (open prose), `choice` (one option), or `multi_choice`; the choice kinds need >= 2 `options` — always include an escape option so \"not sure\" is answerable. `report_anchor` is an `id` in your report HTML, deep-linked from the question. **Attach `entries_query` wherever you can** — an entries-view filter object (`sources`, `dialects`, `speakers`, `tags`, `orthographies`, `parts_of_speech`, `semantic_domains`, `review_categories`, the `has_*`/`no_*` booleans, `query`) that renders as a \"show me these entries\" button, so the manager answers from the rows instead of from memory; an unknown filter key is a 400 rather than a silently wrong button, and `entries_query_label` is your own button wording (\"Show me these 1,191 entries\"). Keep `title` short and leave the long explanation in the report. Per-entry questions belong in the `review` queue instead. Appended after any existing questions. Team/API-key only.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"},"description":"Dictionary id or url-slug. Must match the dictionary your API key is scoped to."},{"name":"threadId","in":"path","required":true,"schema":{"type":"string","format":"uuid"},"description":"The import conversation (import-request) id."}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["questions"],"properties":{"questions":{"type":"array","items":{"type":"object","required":["kind","title"],"properties":{"kind":{"type":"string","enum":["text","choice","multi_choice"]},"title":{"type":"string"},"body_html":{"type":"string"},"options":{"type":"array","items":{"type":"object","required":["value","label"],"properties":{"value":{"type":"string"},"label":{"type":"string"}}}},"report_anchor":{"type":"string"},"entries_query":{"type":"object","description":"Entries-view filter naming the rows this question is about, e.g. { \"sources\": [\"smith-1979\"], \"no_part_of_speech\": true }.","additionalProperties":true},"entries_query_label":{"type":"string","description":"Button wording, e.g. \"Show me these 1,191 entries\"."}}}}}}}}},"responses":{"200":{"description":"{ questions }"},"400":{"description":"Bad kind / missing title / choice question with < 2 options / unknown entries_query filter"},"403":{"description":"Not the Living Dictionaries team"}},"tags":["conversations"]}},"/api/v1/dictionaries/{id}/conversations/{threadId}/questions/{questionId}":{"patch":{"summary":"Answer or close a question","description":"Managers answer with `answer_text` (free text) or `answer_values` (chosen option values); an empty answer clears it and reopens the question. The answer is mirrored into the conversation as a message. `status` parks a question without an answer and is team-only.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"},"description":"Dictionary id or url-slug. Must match the dictionary your API key is scoped to."},{"name":"threadId","in":"path","required":true,"schema":{"type":"string","format":"uuid"},"description":"The import conversation (import-request) id."},{"name":"questionId","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"answer_text":{"type":["string","null"]},"answer_values":{"type":["array","null"],"items":{"type":"string"}},"status":{"type":"string","enum":["open","closed"]}}}}}},"responses":{"200":{"description":"{ question }"},"400":{"description":"Values sent for a text question, or two values for a single-choice one"},"403":{"description":"Only the team can close a question"},"404":{"description":"Resource not found"}},"tags":["conversations"]}},"/api/v1":{"get":{"summary":"START HERE — the front door","description":"Public, tiny, and the correct first call. Returns a task-routing document: what this API is, auth, and a menu of JOBS (import, cleanup, consume, media, corpus, ask-us) — each with the guide to read and the next call to make. Send your API key (optional) and it also names your dictionary, its gloss languages, and suggests which job you are here for. Responds with JSON unless `Accept` asks for `text/html`; `?format=json|html` overrides.","responses":{"200":{"description":"The front-door document"}},"tags":["start"]}},"/api/v1/guides":{"get":{"summary":"List the task playbooks","description":"Public. Returns `{ guides: [{ slug, title, description, url }] }` in recommended reading order (`api-basics` and `importing` first).","responses":{"200":{"description":"{ guides }"}},"tags":["guides"]}},"/api/v1/guides/{slug}":{"get":{"summary":"Read one guide (markdown)","description":"Public. Returns the guide as `text/markdown`.","parameters":[{"name":"slug","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Markdown text"},"404":{"description":"Resource not found"}},"tags":["guides"]}},"/api/v1/dictionaries/{id}/grammar/sections":{"get":{"summary":"List grammar sections","description":"Returns the ordered section tree. Filter with `?entry_id=` (sections documenting one headword) or `?parent_id=` (one level; empty = top-level).","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"},"description":"Dictionary id or url-slug. Must match the dictionary your API key is scoped to."},{"name":"entry_id","in":"query","required":false,"schema":{"type":"string"}},{"name":"parent_id","in":"query","required":false,"schema":{"type":"string"}}],"responses":{"200":{"description":"{ sections }","content":{"application/json":{"schema":{"type":"object","properties":{"sections":{"type":"array","items":{"$ref":"#/components/schemas/GrammarSectionFull"}}}}}}}},"tags":["grammar"]},"post":{"summary":"Create a grammar section","description":"Create a section (optionally nested under `parent_id`, positioned after `after_section_id`), optionally linked to an entry/sense with pre-attached example sentences. Supply your own `id` for idempotency. At least one of `title`/`body` is required.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"},"description":"Dictionary id or url-slug. Must match the dictionary your API key is scoped to."}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/GrammarSectionInput"}}}},"responses":{"200":{"description":"{ section, created }","content":{"application/json":{"schema":{"type":"object","properties":{"section":{"$ref":"#/components/schemas/GrammarSectionFull"},"created":{"type":"boolean"}}}}}},"400":{"description":"Empty section (needs a title or body) / bad parent/entry id"}},"tags":["grammar"]}},"/api/v1/dictionaries/{id}/grammar/sections/{sectionId}":{"get":{"summary":"Read a grammar section","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"},"description":"Dictionary id or url-slug. Must match the dictionary your API key is scoped to."},{"name":"sectionId","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"{ section }","content":{"application/json":{"schema":{"type":"object","properties":{"section":{"$ref":"#/components/schemas/GrammarSectionFull"}}}}}},"404":{"description":"Resource not found"}},"tags":["grammar"]},"patch":{"summary":"Update / move a grammar section","description":"Field-merge; `parent_id` + `after_section_id` re-nest/reorder.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"},"description":"Dictionary id or url-slug. Must match the dictionary your API key is scoped to."},{"name":"sectionId","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/GrammarSectionPatch"}}}},"responses":{"200":{"description":"{ section }"},"404":{"description":"Resource not found"}},"tags":["grammar"]},"delete":{"summary":"Delete a grammar section","description":"Cascades to descendant sections and detaches its example-sentence links (the sentences themselves are untouched).","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"},"description":"Dictionary id or url-slug. Must match the dictionary your API key is scoped to."},{"name":"sectionId","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"{ result: 'deleted' }"},"404":{"description":"Resource not found"}},"tags":["grammar"]}},"/api/v1/dictionaries/{id}/grammar/sections/{sectionId}/sentences":{"post":{"summary":"Attach an example sentence to a section","description":"Links an EXISTING sentence (by `sentence_id`) as evidence — a reference, not a copy. Order with `after_sentence_id`.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"},"description":"Dictionary id or url-slug. Must match the dictionary your API key is scoped to."},{"name":"sectionId","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["sentence_id"],"properties":{"sentence_id":{"type":"string"},"after_sentence_id":{"type":"string","description":"Existing attached sentence id to insert after; omit → append."}}}}}},"responses":{"200":{"description":"{ link }","content":{"application/json":{"schema":{"type":"object","properties":{"link":{"$ref":"#/components/schemas/SectionSentenceRef"}}}}}},"404":{"description":"Section or sentence not found"}},"tags":["grammar"]}},"/api/v1/dictionaries/{id}/grammar/sections/{sectionId}/sentences/{sentenceId}":{"delete":{"summary":"Detach an example sentence from a section","description":"Removes the reference only; the sentence survives.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"},"description":"Dictionary id or url-slug. Must match the dictionary your API key is scoped to."},{"name":"sectionId","in":"path","required":true,"schema":{"type":"string"}},{"name":"sentenceId","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"{ result: 'unlinked' }"},"404":{"description":"Sentence not attached to this section"}},"tags":["grammar"]}},"/api/v1/dictionaries/{id}/grammar/clause-slots":{"get":{"summary":"List clause-template slots","description":"The ordered slot set defining the clause template.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"},"description":"Dictionary id or url-slug. Must match the dictionary your API key is scoped to."}],"responses":{"200":{"description":"{ clause_slots }","content":{"application/json":{"schema":{"type":"object","properties":{"clause_slots":{"type":"array","items":{"$ref":"#/components/schemas/ClauseSlotFull"}}}}}}}},"tags":["grammar"]},"post":{"summary":"Create a clause-template slot","description":"Order with `after_slot_id`.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"},"description":"Dictionary id or url-slug. Must match the dictionary your API key is scoped to."}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ClauseSlotInput"}}}},"responses":{"200":{"description":"{ clause_slot, created }"},"400":{"description":"Invalid request"}},"tags":["grammar"]}},"/api/v1/dictionaries/{id}/grammar/clause-slots/{slotId}":{"patch":{"summary":"Rename / reorder a clause slot","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"},"description":"Dictionary id or url-slug. Must match the dictionary your API key is scoped to."},{"name":"slotId","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ClauseSlotInput"}}}},"responses":{"200":{"description":"{ clause_slot }"},"404":{"description":"Resource not found"}},"tags":["grammar"]},"delete":{"summary":"Delete a clause slot","description":"Sections referencing it have their `slot_id` cleared.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"},"description":"Dictionary id or url-slug. Must match the dictionary your API key is scoped to."},{"name":"slotId","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"{ result: 'deleted' }"},"404":{"description":"Resource not found"}},"tags":["grammar"]}},"/api/v1/dictionaries/{id}/entries/{entryId}/grammar":{"get":{"summary":"List grammar sections documenting an entry","description":"The reverse of a section's `entry_id` link — the \"grammar notes\" for this headword.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"},"description":"Dictionary id or url-slug. Must match the dictionary your API key is scoped to."},{"name":"entryId","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"{ sections }","content":{"application/json":{"schema":{"type":"object","properties":{"sections":{"type":"array","items":{"$ref":"#/components/schemas/GrammarSectionFull"}}}}}}}},"tags":["grammar"]}},"/api/v1/dictionaries/{id}/grammar/glossing-abbreviations":{"get":{"summary":"List glossing abbreviations","description":"The dictionary's gloss legend that makes IGT gloss lines self-documenting.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"},"description":"Dictionary id or url-slug. Must match the dictionary your API key is scoped to."}],"responses":{"200":{"description":"{ glossing_abbreviations }","content":{"application/json":{"schema":{"type":"object","properties":{"glossing_abbreviations":{"type":"array","items":{"$ref":"#/components/schemas/GlossingAbbreviationFull"}}}}}}}},"tags":["grammar"]},"post":{"summary":"Add a glossing abbreviation (find-or-create)","description":"Find-or-create by `code`. Seed the standard Leipzig set here, then add custom codes.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"},"description":"Dictionary id or url-slug. Must match the dictionary your API key is scoped to."}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/GlossingAbbreviationInput"}}}},"responses":{"200":{"description":"{ glossing_abbreviation, created }"},"400":{"description":"Invalid request"}},"tags":["grammar"]}},"/api/v1/dictionaries/{id}/grammar/glossing-abbreviations/{code}":{"patch":{"summary":"Edit a glossing abbreviation","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"},"description":"Dictionary id or url-slug. Must match the dictionary your API key is scoped to."},{"name":"code","in":"path","required":true,"schema":{"type":"string"},"description":"A glossing-abbreviation code (e.g. \"3PL\")."}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/GlossingAbbreviationInput"}}}},"responses":{"200":{"description":"{ glossing_abbreviation }"},"404":{"description":"Resource not found"}},"tags":["grammar"]},"delete":{"summary":"Delete a glossing abbreviation","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"},"description":"Dictionary id or url-slug. Must match the dictionary your API key is scoped to."},{"name":"code","in":"path","required":true,"schema":{"type":"string"},"description":"A glossing-abbreviation code (e.g. \"3PL\")."}],"responses":{"200":{"description":"{ result: 'deleted' }"},"404":{"description":"Resource not found"}},"tags":["grammar"]}},"/api/v1/dictionaries/{id}/cognate-sets":{"get":{"summary":"List cognate sets (word families)","description":"Every set with members inline. Filter with `?entry_id=` (sets an entry belongs to) or `?proto_level=`.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"},"description":"Dictionary id or url-slug. Must match the dictionary your API key is scoped to."},{"name":"entry_id","in":"query","required":false,"schema":{"type":"string"}},{"name":"proto_level","in":"query","required":false,"schema":{"type":"string"}}],"responses":{"200":{"description":"{ cognate_sets }","content":{"application/json":{"schema":{"type":"object","properties":{"cognate_sets":{"type":"array","items":{"$ref":"#/components/schemas/CognateSetFull"}}}}}}}},"tags":["cognate-sets"]},"post":{"summary":"Create cognate sets (single or batch)","description":"ONE set object (response `{ cognate_set, created }`), or a batch — a bare array or `{ cognate_sets: [...] }`, ≤1000/request (response `{ created, existed, failed, results }` in input order). Supply your own `id` per set: re-POSTing the same id is a safe no-op, so retries never duplicate.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"},"description":"Dictionary id or url-slug. Must match the dictionary your API key is scoped to."}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"oneOf":[{"$ref":"#/components/schemas/CognateSetInput"},{"type":"array","items":{"$ref":"#/components/schemas/CognateSetInput"}},{"type":"object","properties":{"cognate_sets":{"type":"array","items":{"$ref":"#/components/schemas/CognateSetInput"}}},"required":["cognate_sets"]}]}}}},"responses":{"200":{"description":"{ cognate_set, created } or the batch report"},"400":{"description":"No head (needs reconstruction or gloss) / unknown entry or source slug"}},"tags":["cognate-sets"]}},"/api/v1/dictionaries/{id}/cognate-sets/{setId}":{"get":{"summary":"Read a cognate set","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"},"description":"Dictionary id or url-slug. Must match the dictionary your API key is scoped to."},{"name":"setId","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"{ cognate_set }","content":{"application/json":{"schema":{"type":"object","properties":{"cognate_set":{"$ref":"#/components/schemas/CognateSetFull"}}}}}},"404":{"description":"Resource not found"}},"tags":["cognate-sets"]},"patch":{"summary":"Update a cognate set","description":"Field-merge: `gloss`/`notes` overlay language keys; `citations` union; scalars replace; `null` clears.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"},"description":"Dictionary id or url-slug. Must match the dictionary your API key is scoped to."},{"name":"setId","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CognateSetPatch"}}}},"responses":{"200":{"description":"{ cognate_set }"},"404":{"description":"Resource not found"}},"tags":["cognate-sets"]},"delete":{"summary":"Delete a cognate set","description":"Removes the set and its memberships. The member ENTRIES are untouched.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"},"description":"Dictionary id or url-slug. Must match the dictionary your API key is scoped to."},{"name":"setId","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"{ result: 'deleted' }"},"404":{"description":"Resource not found"}},"tags":["cognate-sets"]}},"/api/v1/dictionaries/{id}/cognate-sets/{setId}/members":{"post":{"summary":"Add or update a set member","description":"Idempotent upsert by `entry_id`: adds the entry to the family, or updates its `sense_id` / `note` / `uncertain` if already a member.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"},"description":"Dictionary id or url-slug. Must match the dictionary your API key is scoped to."},{"name":"setId","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CognateSetMemberInput"}}}},"responses":{"200":{"description":"{ member, created }","content":{"application/json":{"schema":{"type":"object","properties":{"member":{"$ref":"#/components/schemas/CognateSetMemberFull"},"created":{"type":"boolean"}}}}}},"404":{"description":"Set not found"}},"tags":["cognate-sets"]}},"/api/v1/dictionaries/{id}/cognate-sets/{setId}/members/{entryId}":{"delete":{"summary":"Remove an entry from a set","description":"Removes the membership only; the entry survives.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"},"description":"Dictionary id or url-slug. Must match the dictionary your API key is scoped to."},{"name":"setId","in":"path","required":true,"schema":{"type":"string"}},{"name":"entryId","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"{ result: 'removed' }"},"404":{"description":"Not a member"}},"tags":["cognate-sets"]}},"/api/v1/dictionaries/{id}/places":{"get":{"summary":"List places","description":"Every place with its dialect/text links and where-spoken link (`dictionary`). Filter with `?dialect_id=`, `?entry_id=` or `?text_id=` (places linked to that dialect / entry / text), or `?where_spoken=1` (where the language is spoken).","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"},"description":"Dictionary id or url-slug. Must match the dictionary your API key is scoped to."},{"name":"where_spoken","in":"query","required":false,"schema":{"type":"string","enum":["1"]}},{"name":"dialect_id","in":"query","required":false,"schema":{"type":"string"}},{"name":"entry_id","in":"query","required":false,"schema":{"type":"string"}},{"name":"text_id","in":"query","required":false,"schema":{"type":"string"}}],"responses":{"200":{"description":"{ places }","content":{"application/json":{"schema":{"type":"object","properties":{"places":{"type":"array","items":{"$ref":"#/components/schemas/PlaceFull"}}}}}}}},"tags":["places"]},"post":{"summary":"Create places (single or batch)","description":"ONE place object (response `{ place, created }`), or a batch — a bare array or `{ places: [...] }`, ≤1000/request (response `{ created, existed, failed, results }` in input order, per-item best-effort). Supply your own `id` per place so retries are no-ops; list parents before their children (counties before villages) and reference them by id. Then link with `…/places/{placeId}/links`. Write key / manager+.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"},"description":"Dictionary id or url-slug. Must match the dictionary your API key is scoped to."}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"oneOf":[{"$ref":"#/components/schemas/PlaceInput"},{"type":"array","items":{"$ref":"#/components/schemas/PlaceInput"}},{"type":"object","properties":{"places":{"type":"array","items":{"$ref":"#/components/schemas/PlaceInput"}}},"required":["places"]}]}}}},"responses":{"200":{"description":"{ place, created } or the batch report"},"400":{"description":"Missing name / bad coordinates or precision / unknown parent or source slug"}},"tags":["places"]}},"/api/v1/dictionaries/{id}/places/{placeId}":{"get":{"summary":"Read a place","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"},"description":"Dictionary id or url-slug. Must match the dictionary your API key is scoped to."},{"name":"placeId","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"{ place }","content":{"application/json":{"schema":{"type":"object","properties":{"place":{"$ref":"#/components/schemas/PlaceFull"}}}}}},"404":{"description":"Resource not found"}},"tags":["places"]},"patch":{"summary":"Update a place","description":"Field-merge: `name`/`notes` overlay language keys; `citations` union; scalars replace; `null` clears.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"},"description":"Dictionary id or url-slug. Must match the dictionary your API key is scoped to."},{"name":"placeId","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PlacePatch"}}}},"responses":{"200":{"description":"{ place }"},"400":{"description":"Validation error"},"404":{"description":"Resource not found"}},"tags":["places"]},"delete":{"summary":"Delete a place","description":"Removes the place and its entry/text/dialect links; child places and speakers who lived there are detached, never deleted.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"},"description":"Dictionary id or url-slug. Must match the dictionary your API key is scoped to."},{"name":"placeId","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"{ result: 'deleted' }"},"404":{"description":"Resource not found"}},"tags":["places"]}},"/api/v1/dictionaries/{id}/places/{placeId}/links":{"post":{"summary":"Link a place to entries, texts, dialects, speakers and the language","description":"Batch upsert of links from one place (≤1000 per request, one transaction). Repeats are no-ops; changed role/weight/note update the existing link.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"},"description":"Dictionary id or url-slug. Must match the dictionary your API key is scoped to."},{"name":"placeId","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PlaceLinksInput"}}}},"responses":{"200":{"description":"{ linked, unchanged }","content":{"application/json":{"schema":{"type":"object","properties":{"linked":{"type":"integer"},"unchanged":{"type":"integer"}}}}}},"400":{"description":"Unknown entry/text/dialect/speaker, bad role or weight (nothing written)"},"404":{"description":"Place not found"}},"tags":["places"]},"delete":{"summary":"Unlink a place","description":"Removes the named links (ids not linked are ignored). The entries/texts/dialects/speakers themselves survive.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"},"description":"Dictionary id or url-slug. Must match the dictionary your API key is scoped to."},{"name":"placeId","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PlaceUnlinkInput"}}}},"responses":{"200":{"description":"{ removed }","content":{"application/json":{"schema":{"type":"object","properties":{"removed":{"type":"integer"}}}}}},"404":{"description":"Place not found"}},"tags":["places"]}},"/api/v1/dictionaries/{id}/entries/{entryId}/etymologies":{"get":{"summary":"List an entry's etymology records","description":"The entry's typed etymology records, oldest first (multiple = competing hypotheses). Also embedded on entry reads as `entry.etymologies` when any exist.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"},"description":"Dictionary id or url-slug. Must match the dictionary your API key is scoped to."},{"name":"entryId","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"{ etymologies }","content":{"application/json":{"schema":{"type":"object","properties":{"etymologies":{"type":"array","items":{"$ref":"#/components/schemas/EtymologyFull"}}}}}}},"404":{"description":"Entry not found"}},"tags":["entries"]},"post":{"summary":"Add an etymology record","description":"Adds one typed record; `verdict` is required, everything else optional. Supply your own `id` (uuid): re-POSTing the same id is a safe no-op, so retries never duplicate.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"},"description":"Dictionary id or url-slug. Must match the dictionary your API key is scoped to."},{"name":"entryId","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/EtymologyInput"}}}},"responses":{"200":{"description":"{ etymology, created }"},"400":{"description":"Bad verdict/confidence, unknown entry, cognate set, or source slug"}},"tags":["entries"]}},"/api/v1/dictionaries/{id}/entries/{entryId}/etymologies/{etymologyId}":{"get":{"summary":"Read an etymology record","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"},"description":"Dictionary id or url-slug. Must match the dictionary your API key is scoped to."},{"name":"entryId","in":"path","required":true,"schema":{"type":"string"}},{"name":"etymologyId","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"{ etymology }","content":{"application/json":{"schema":{"type":"object","properties":{"etymology":{"$ref":"#/components/schemas/EtymologyFull"}}}}}},"404":{"description":"Resource not found"}},"tags":["entries"]},"patch":{"summary":"Update an etymology record","description":"Field-merge: `notes` overlays language keys; `citations` union; scalars replace; `null` clears. `verdict` can change but never to null.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"},"description":"Dictionary id or url-slug. Must match the dictionary your API key is scoped to."},{"name":"entryId","in":"path","required":true,"schema":{"type":"string"}},{"name":"etymologyId","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/EtymologyPatch"}}}},"responses":{"200":{"description":"{ etymology }"},"404":{"description":"Resource not found"}},"tags":["entries"]},"delete":{"summary":"Delete an etymology record","description":"Removes the record. The entry (and any linked word family) is untouched.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"},"description":"Dictionary id or url-slug. Must match the dictionary your API key is scoped to."},{"name":"entryId","in":"path","required":true,"schema":{"type":"string"}},{"name":"etymologyId","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"{ result: 'deleted' }"},"404":{"description":"Resource not found"}},"tags":["entries"]}},"/api/v1/dictionaries/{id}/texts/{textId}/tags":{"get":{"summary":"List a text's classification tags","description":"Genre / motif / tale-type tags on a text.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"},"description":"Dictionary id or url-slug. Must match the dictionary your API key is scoped to."},{"name":"textId","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"{ tags }","content":{"application/json":{"schema":{"type":"object","properties":{"tags":{"type":"array","items":{"$ref":"#/components/schemas/TextTagView"}}}}}}}},"tags":["texts"]},"post":{"summary":"Tag a text (find-or-create)","description":"Attaches a classification tag (reuses the entry-tag registry with `kind`/`code` classification columns).","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"},"description":"Dictionary id or url-slug. Must match the dictionary your API key is scoped to."},{"name":"textId","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/TextTagInput"}}}},"responses":{"200":{"description":"{ tag, created }"},"400":{"description":"Invalid request"}},"tags":["texts"]}},"/api/v1/dictionaries/{id}/texts/{textId}/embeds":{"get":{"summary":"List the texts heard inside a text","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"},"description":"Dictionary id or url-slug. Must match the dictionary your API key is scoped to."},{"name":"textId","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"{ embeds }","content":{"application/json":{"schema":{"type":"object","properties":{"embeds":{"type":"array","items":{"$ref":"#/components/schemas/TextEmbed"}}}}}}}},"tags":["texts"]},"post":{"summary":"Place a text inside another","description":"Embeds `child_text_id` in this text at a position among its sentences, optionally over a span of this text's audio. Re-POSTing the same child with the same `start_ms` updates it. A text cannot contain itself (directly or through other embeds). To let the child's own page play its span without a second copy of the bytes, attach audio to the child with `from_audio_id` + `clip_start_ms`/`clip_end_ms`.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"},"description":"Dictionary id or url-slug. Must match the dictionary your API key is scoped to."},{"name":"textId","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/TextEmbedInput"}}}},"responses":{"200":{"description":"{ embed, created }"},"400":{"description":"Unknown child text or sentence, a cycle, or a bad span"},"404":{"description":"Resource not found"}},"tags":["texts"]}},"/api/v1/dictionaries/{id}/texts/{textId}/embeds/{embedId}":{"delete":{"summary":"Take an embedded text out","description":"Removes the embed; both texts survive.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"},"description":"Dictionary id or url-slug. Must match the dictionary your API key is scoped to."},{"name":"textId","in":"path","required":true,"schema":{"type":"string"}},{"name":"embedId","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"{ result: 'deleted' }"},"404":{"description":"Resource not found"}},"tags":["texts"]}},"/api/v1/dictionaries/{id}/texts/{textId}/tags/{tagId}":{"delete":{"summary":"Untag a text","description":"Unlinks one tag from this text; the tag survives.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"},"description":"Dictionary id or url-slug. Must match the dictionary your API key is scoped to."},{"name":"textId","in":"path","required":true,"schema":{"type":"string"}},{"name":"tagId","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"{ result: 'unlinked' }"},"404":{"description":"Resource not found"}},"tags":["texts"]}}},"components":{"securitySchemes":{"bearerAuth":{"type":"http","scheme":"bearer","description":"A Living Dictionaries API key (`ldk_…`) minted on the dictionary's Agents page."}},"schemas":{"MultiString":{"type":"object","description":"A multilingual string: a map of locale code → text. Use `default` for the vernacular/headword writing system, and gloss-language codes (e.g. `en`, `es`) for glosses/translations. Most string inputs also accept a plain string, which is wrapped as `{ \"default\": \"…\" }`.","additionalProperties":{"type":"string"},"example":{"default":"mbwa","en":"dog"}},"LngLat":{"type":"object","required":["longitude","latitude"],"description":"A single WGS-84 geographic point.","properties":{"longitude":{"type":"number","minimum":-180,"maximum":180,"description":"Decimal degrees east, −180…180."},"latitude":{"type":"number","minimum":-90,"maximum":90,"description":"Decimal degrees north, −90…90."}},"example":{"longitude":77.2,"latitude":28.6}},"GeoPoint":{"type":"object","required":["coordinates"],"description":"A single located point (e.g. one elicitation/survey village).","properties":{"coordinates":{"$ref":"#/components/schemas/LngLat"},"label":{"type":"string","description":"Optional display label (e.g. the village name)."},"color":{"type":"string","description":"Optional marker color (any CSS color string)."}}},"GeoRegion":{"type":"object","required":["coordinates"],"description":"A closed area, given as one ring of 3–1000 vertices in drawing order (the ring is drawn as given, so concave outlines are fine; a ring that crosses itself is instead redrawn sorted by angle, which loses concavity). Repeating the first vertex at the end is optional. One outer ring only — no holes; give a multi-part area as several regions.","properties":{"coordinates":{"type":"array","items":{"$ref":"#/components/schemas/LngLat"},"minItems":3,"maxItems":1000,"description":"Ring vertices in drawing order; 3–1000."},"label":{"type":"string"},"color":{"type":"string"}}},"Coordinates":{"type":"object","description":"Where-spoken geometry, `{ points?, regions? }`. On an ENTRY these are the attestation/elicitation location(s) of that specific form; on a DIALECT they are the variety's areal extent (set once on the dialect, not repeated per entry). Longitude/latitude are validated to their real-world ranges; a bad point rejects the write. Limits: ≤100 points, ≤20 regions, each region 3–1000 vertices. On PATCH the whole object is REPLACED — send `null` to clear, or omit the field to leave it untouched.","properties":{"points":{"type":"array","items":{"$ref":"#/components/schemas/GeoPoint"},"maxItems":100},"regions":{"type":"array","items":{"$ref":"#/components/schemas/GeoRegion"},"maxItems":20}},"example":{"points":[{"coordinates":{"longitude":77.2,"latitude":28.6},"label":"Khirsu"}]}},"SentenceInput":{"type":"object","description":"A sentence write/reference. With content fields, creates or field-merges the sentence. Nested under a sense, `{ id: \"<existing sentence UUID>\" }` alone links that existing sentence without rewriting it; an unknown id-only reference fails loudly.","properties":{"id":{"type":"string","format":"uuid","description":"Optional client-generated UUID (any version — deterministic uuid5 ids work well). Supply it to know the id up front (for later edits) and to make writes idempotent — re-sending the same id is a safe no-op. Omit → the server mints one."},"text":{"oneOf":[{"type":"string"},{"$ref":"#/components/schemas/MultiString"}],"description":"The sentence in the vernacular."},"translation":{"oneOf":[{"type":"string"},{"$ref":"#/components/schemas/MultiString"}],"description":"Translation(s), keyed by gloss-language code."},"tokens":{"type":"object","additionalProperties":{"type":"array","items":{"$ref":"#/components/schemas/SentenceTokenInput"}},"description":"Orthography code → ordered token list (usually just `default`, the vernacular line) — the GOLD alignment from a glossed source, carrying the per-token gloss line + optional morphemes. If the sentence `text` for an orthography is omitted but its tokens are supplied, the server builds `text` by joining the token `form`s with a space — so a rows-only glossed source (aligned `[form, gloss]` rows, no separate vernacular line) imports without pre-assembling the text."},"citations":{"type":"array","items":{"$ref":"#/components/schemas/SourceCitation"},"description":"Source refs — `{ slug }` for bare membership, add `locator` for the page / example number. Each `slug` must already exist in the source registry (create via `POST …/sources`)."},"example_label":{"type":["string","null"],"description":"The author's own example number (e.g. \"(2a)\") for cross-referencing within a grammar."},"discourse_role":{"type":["string","null"],"enum":["storyline","backgrounded","flashback","setting","evaluation","reported_speech",null],"description":"Discourse salience / information role — lets a narrative render foreground vs. background and a grammar section point at the role a particle signals."},"speaker_id":{"type":["string","null"],"description":"Who says this sentence: an existing speaker id (`…/speakers`). `null` clears. The reader names the speaker where the voice changes."},"speaker":{"type":["string","null"],"description":"Who says this sentence, by name: a person or a role (\"Host\", \"Narrator\", \"Reader\"). Found case-insensitively among the dictionary's speakers, else created. `null` clears. Use instead of `speaker_id`."},"translation_meta":{"type":["object","null"],"additionalProperties":{"oneOf":[{"$ref":"#/components/schemas/TranslationProvenance"},{"type":"null"}]},"description":"Where each translation comes from, keyed by translation language. Merged per language on PATCH; a language set to `null` is removed; `null` clears all. The reader mutes `machine` translations (with a legend) and shows the citation of a `published` one."}}},"SenseInput":{"type":"object","description":"A meaning of the entry.","properties":{"id":{"type":"string","format":"uuid","description":"Optional client-generated UUID (any version — deterministic uuid5 ids work well). Supply it to know the id up front (for later edits) and to make writes idempotent — re-sending the same id is a safe no-op. Omit → the server mints one."},"glosses":{"oneOf":[{"type":"string"},{"$ref":"#/components/schemas/MultiString"}],"description":"Short glosses keyed by gloss-language code."},"definition":{"oneOf":[{"type":"string"},{"$ref":"#/components/schemas/MultiString"}],"description":"Longer definition(s)."},"parts_of_speech":{"oneOf":[{"type":"string"},{"type":"array","items":{"type":"string"}}],"description":"POS abbreviation(s). Send the abbreviation or its full English name — both are matched case-insensitively and stored as the canonical lowercase abbreviation (\"N\" / \"Noun\" → \"n\"). Values outside this list are stored verbatim, so only use custom values for genuinely language-specific categories. Supported: n (noun), n.f (noun, feminine), n.m (noun, masculine), pro (pronoun), v (verb), adj (adjective), adv (adverb), prep (preposition), conj (conjunction), det (determiner), int (interjection), no (number), dem (demonstrative), n pl (plural noun), phr (phrase), p (particle), neg (negative), quan (quantifier), rel (relative), v.aux (auxiliary verb), dem.a (animate demonstrative), dem.i (inanimate demonstrative), dem.loc (locative demonstrative), dim (diminutive), na (animate noun), ni (inanimate noun), pl (plural), pn (prenoun), pv (preverb), pn/v (prenoun / preverb), pro.a (animate pronoun), pro.i (inanimate pronoun), redup (reduplication), refl (reflexive), vai (animate intransitive verb), vai/ii (animate / inanimate intransitive verb), vii (inanimate intransitive verb), vta (transitive animate verb), vti (transitive inanimate verb), imp (imperative/command marker), pref (prefix), punc (punctuation), suff (suffix), vt (transitive verb), vi (intransitive verb), comp (complement(izer)), cpd (compound), pple (participle), sent (sentence), pr.n (proper noun), np (noun phrase), vp (verb phrase), ap (adjective phrase), adv.p (adverb phrase), q (question marker), ger (gerund), art (article), post (postposition), e.w (entry word / discourse particle), m.w (measure word), msd (masdar), idf (idafa), v.prdg (verbal paradigm), acr (acronym), cntr (contraction), num (numeral), act.v (active verb), stat.v (stative verb), caus.v (causative verb), v.suff (verb suffix), n.suff (noun suffix), n.pref (noun prefix), v.pref (verb prefix), ideo (ideophone (onomatoepia / expressives)), clf (classifier), tns (tense marker), asp (aspect marker), mod (mood marker), n.cls (noun class marker), quot (quotative), adp (adposition), def (definite article), indf (indefinite article), indfpro (indefinite pronoun), pers (personal pronoun), poss (possessive pronoun), reflpro (reflexive pronoun), relpro (relative pronoun), vd (ditransitive verb), va (ambitransitive), obj (object marker)."},"semantic_domains":{"oneOf":[{"type":"string"},{"type":"array","items":{"type":"string"}}],"description":"Semantic domain keys."},"write_in_semantic_domains":{"oneOf":[{"type":"string"},{"type":"array","items":{"type":"string"}}],"description":"Free-text semantic domains."},"noun_class":{"type":"string"},"plural_form":{"oneOf":[{"type":"string"},{"$ref":"#/components/schemas/MultiString"}]},"variant":{"oneOf":[{"type":"string"},{"$ref":"#/components/schemas/MultiString"}]},"citations":{"type":"array","items":{"$ref":"#/components/schemas/SourceCitation"},"description":"Source refs for THIS sense — per-sense provenance when an entry's senses are merged from several sources. `{ slug }` for bare membership, add `locator` for a page / example number; each slug must already exist (create via `POST …/sources`)."},"example_sentences":{"type":"array","items":{"$ref":"#/components/schemas/SentenceInput"},"description":"Sentence content to create/upsert, or `{ id }` alone to link an existing sentence. Re-linking is idempotent; an unknown id-only reference fails."}}},"EntryInput":{"type":"object","required":["lexeme"],"description":"A dictionary entry (headword) with nested senses.","properties":{"id":{"type":"string","format":"uuid","description":"Optional client-generated UUID (any version — deterministic uuid5 ids work well) — THE idempotency key. Generate it yourself and a re-POST of the same entry is a safe no-op (`status: \"exists\"`) instead of a duplicate, and you already know the id for later `PATCH …/entries/{id}` edits (no round-trip to discover it). Omit → the server mints one. Must be a valid UUID if provided."},"lexeme":{"oneOf":[{"type":"string"},{"$ref":"#/components/schemas/MultiString"}],"description":"The headword. Required."},"homograph":{"type":"string","description":"Homograph number for identically-spelled headwords — \"1\", \"2\" (some sources use \"a\"/\"b\"). Rendered as a superscript after the lexeme, so deliberate homographs are distinguishable from accidental duplicates. Orthogonal to the `homophone` relationship type (differently-spelled words that sound alike)."},"phonetic":{"type":"string"},"interlinearization":{"type":"string"},"morphology":{"type":"string"},"notes":{"oneOf":[{"type":"string"},{"$ref":"#/components/schemas/MultiString"}],"description":"Rich text stored as MARKDOWN (headings/bold/lists/links) — write markdown, not HTML."},"linguistic_history":{"oneOf":[{"type":"string"},{"$ref":"#/components/schemas/MultiString"}]},"citations":{"type":"array","items":{"$ref":"#/components/schemas/SourceCitation"},"description":"Source refs — `{ slug }` for bare membership, add `locator` for the page / example number this entry came from (always known for scanned dictionaries). Each slug must already exist in this dictionary's registry (create via `POST …/sources`); an unknown slug rejects the write."},"scientific_names":{"oneOf":[{"type":"string"},{"type":"array","items":{"type":"string"}}]},"elicitation_id":{"type":"string","description":"Source-side stable id for word-list/elicitation ordering. Persisted and queryable via `?elicitation_id=`; use it as a server-recoverable dedupe key ONLY if your source id is genuinely elicitation data — for generic idempotency, supply your own `id` instead."},"tone_class":{"type":"string","description":"Analytic tone-category label (a small per-language set, e.g. \"1\", \"A\", \"high\") — distinct from the phonetic tone spelled in the orthography. Free text; keep the label set consistent within a dictionary."},"uncertain_orthographies":{"oneOf":[{"type":"string"},{"type":"array","items":{"type":"string"}}],"description":"Orthography codes whose `lexeme` value is a best guess (e.g. converted by rule from another orthography) rather than an attested spelling. Readers draw those forms in the uncertain style."},"coordinates":{"oneOf":[{"$ref":"#/components/schemas/Coordinates"},{"type":"null"}],"deprecated":true,"description":"DEPRECATED — use places (`POST …/places` + `links.entries`). Still accepted: each point/region is saved as a place linked to the entry (response `warnings` says so)."},"dialects":{"oneOf":[{"type":"string"},{"type":"array","items":{"type":"string"}}],"description":"Dialect names — found-or-created on this dictionary."},"tags":{"oneOf":[{"type":"string"},{"type":"array","items":{"type":"string"}}],"description":"Tag names — found-or-created."},"review":{"anyOf":[{"$ref":"#/components/schemas/EntryReview"},{"type":"null"}],"description":"Editor-only \"needs review\" flag `{ category, note }` — set it for anything you had to guess/salvage so a human reviews it. Never shown to the public."},"senses":{"type":"array","items":{"$ref":"#/components/schemas/SenseInput"},"description":"Defaults to one empty sense if omitted."}}},"SensePatch":{"type":"object","description":"A sense within a PATCH — a true upsert by client id. With an `id` already on this entry → field-merge that sense; with an unknown `id` (or none) → create the sense WITH that id (deterministic import ids keep addressing the same sense across re-syncs). An `id` belonging to a different entry is a 400. On PATCH, provenance MERGES and language keys overlay: `citations` union with what is there, sending one gloss language leaves the others untouched, `\"\"` drops one language, `null` clears a field (every nullable property below). `parts_of_speech`/`semantic_domains` are replaced outright. Example sentences upsert by id; `{ id }` alone links an existing sentence without rewriting it, existing links are not duplicated, and an unknown id-only reference is a 400.","properties":{"id":{"type":"string","format":"uuid","description":"The sense to field-merge; an unknown id creates the sense with that id."},"glosses":{"anyOf":[{"oneOf":[{"type":"string"},{"$ref":"#/components/schemas/MultiString"}]},{"type":"null"}],"description":"Short glosses keyed by gloss-language code."},"definition":{"anyOf":[{"oneOf":[{"type":"string"},{"$ref":"#/components/schemas/MultiString"}]},{"type":"null"}],"description":"Longer definition(s)."},"parts_of_speech":{"anyOf":[{"oneOf":[{"type":"string"},{"type":"array","items":{"type":"string"}}]},{"type":"null"}],"description":"POS abbreviation(s). Send the abbreviation or its full English name — both are matched case-insensitively and stored as the canonical lowercase abbreviation (\"N\" / \"Noun\" → \"n\"). Values outside this list are stored verbatim, so only use custom values for genuinely language-specific categories. Supported: n (noun), n.f (noun, feminine), n.m (noun, masculine), pro (pronoun), v (verb), adj (adjective), adv (adverb), prep (preposition), conj (conjunction), det (determiner), int (interjection), no (number), dem (demonstrative), n pl (plural noun), phr (phrase), p (particle), neg (negative), quan (quantifier), rel (relative), v.aux (auxiliary verb), dem.a (animate demonstrative), dem.i (inanimate demonstrative), dem.loc (locative demonstrative), dim (diminutive), na (animate noun), ni (inanimate noun), pl (plural), pn (prenoun), pv (preverb), pn/v (prenoun / preverb), pro.a (animate pronoun), pro.i (inanimate pronoun), redup (reduplication), refl (reflexive), vai (animate intransitive verb), vai/ii (animate / inanimate intransitive verb), vii (inanimate intransitive verb), vta (transitive animate verb), vti (transitive inanimate verb), imp (imperative/command marker), pref (prefix), punc (punctuation), suff (suffix), vt (transitive verb), vi (intransitive verb), comp (complement(izer)), cpd (compound), pple (participle), sent (sentence), pr.n (proper noun), np (noun phrase), vp (verb phrase), ap (adjective phrase), adv.p (adverb phrase), q (question marker), ger (gerund), art (article), post (postposition), e.w (entry word / discourse particle), m.w (measure word), msd (masdar), idf (idafa), v.prdg (verbal paradigm), acr (acronym), cntr (contraction), num (numeral), act.v (active verb), stat.v (stative verb), caus.v (causative verb), v.suff (verb suffix), n.suff (noun suffix), n.pref (noun prefix), v.pref (verb prefix), ideo (ideophone (onomatoepia / expressives)), clf (classifier), tns (tense marker), asp (aspect marker), mod (mood marker), n.cls (noun class marker), quot (quotative), adp (adposition), def (definite article), indf (indefinite article), indfpro (indefinite pronoun), pers (personal pronoun), poss (possessive pronoun), reflpro (reflexive pronoun), relpro (relative pronoun), vd (ditransitive verb), va (ambitransitive), obj (object marker)."},"semantic_domains":{"anyOf":[{"oneOf":[{"type":"string"},{"type":"array","items":{"type":"string"}}]},{"type":"null"}],"description":"Semantic domain keys."},"write_in_semantic_domains":{"anyOf":[{"oneOf":[{"type":"string"},{"type":"array","items":{"type":"string"}}]},{"type":"null"}],"description":"Free-text semantic domains."},"noun_class":{"type":["string","null"]},"plural_form":{"anyOf":[{"oneOf":[{"type":"string"},{"$ref":"#/components/schemas/MultiString"}]},{"type":"null"}]},"variant":{"anyOf":[{"oneOf":[{"type":"string"},{"$ref":"#/components/schemas/MultiString"}]},{"type":"null"}]},"citations":{"type":["array","null"],"items":{"$ref":"#/components/schemas/SourceCitation"},"description":"Source refs for THIS sense — per-sense provenance when an entry's senses are merged from several sources. `{ slug }` for bare membership, add `locator` for a page / example number; each slug must already exist (create via `POST …/sources`)."},"example_sentences":{"type":"array","items":{"$ref":"#/components/schemas/SentenceInput"},"description":"Sentence content to create/upsert, or `{ id }` alone to link an existing sentence. Re-linking is idempotent; an unknown id-only reference fails."}}},"SentencePatch":{"type":"object","description":"Field-merge for one sentence (`PATCH …/sentences/{id}`) — works on both an entry's example sentence and a text-sentence. Omitted fields stay. `text`/`translation` overlay language keys and `citations` MERGE, so patching one language or one source never deletes another; `null` clears a field.","properties":{"text":{"anyOf":[{"oneOf":[{"type":"string"},{"$ref":"#/components/schemas/MultiString"}]},{"type":"null"}],"description":"The sentence in the vernacular."},"translation":{"anyOf":[{"oneOf":[{"type":"string"},{"$ref":"#/components/schemas/MultiString"}]},{"type":"null"}],"description":"Translation(s), keyed by gloss-language code."},"ends_paragraph":{"type":"boolean","description":"For a text-sentence: whether a paragraph break follows it."},"section_heading":{"anyOf":[{"oneOf":[{"type":"string"},{"$ref":"#/components/schemas/MultiString"}]},{"type":"null"}],"description":"For a text-sentence: a heading shown before it (it opens a new part of the text), per language. Language keys MERGE; `\"\"` drops one language; `null` clears."},"tokens":{"type":"object","additionalProperties":{"type":"array","items":{"$ref":"#/components/schemas/SentenceTokenInput"}},"description":"Orthography code → ordered token list (usually just `default`, the vernacular line) — the GOLD alignment from a glossed source, carrying the per-token gloss line + optional morphemes. If the sentence `text` for an orthography is omitted but its tokens are supplied, the server builds `text` by joining the token `form`s with a space — so a rows-only glossed source (aligned `[form, gloss]` rows, no separate vernacular line) imports without pre-assembling the text."},"citations":{"type":"array","items":{"$ref":"#/components/schemas/SourceCitation"},"description":"Source refs — `{ slug }` for bare membership, add `locator` for the page / example number. Each `slug` must already exist in the source registry (create via `POST …/sources`)."},"example_label":{"type":["string","null"],"description":"The author's own example number (e.g. \"(2a)\") for cross-referencing within a grammar."},"discourse_role":{"type":["string","null"],"enum":["storyline","backgrounded","flashback","setting","evaluation","reported_speech",null],"description":"Discourse salience / information role — lets a narrative render foreground vs. background and a grammar section point at the role a particle signals."},"speaker_id":{"type":["string","null"],"description":"Who says this sentence: an existing speaker id (`…/speakers`). `null` clears. The reader names the speaker where the voice changes."},"speaker":{"type":["string","null"],"description":"Who says this sentence, by name: a person or a role (\"Host\", \"Narrator\", \"Reader\"). Found case-insensitively among the dictionary's speakers, else created. `null` clears. Use instead of `speaker_id`."},"translation_meta":{"type":["object","null"],"additionalProperties":{"oneOf":[{"$ref":"#/components/schemas/TranslationProvenance"},{"type":"null"}]},"description":"Where each translation comes from, keyed by translation language. Merged per language on PATCH; a language set to `null` is removed; `null` clears all. The reader mutes `machine` translations (with a legend) and shows the citation of a `published` one."}}},"EntryPatch":{"type":"object","description":"Partial entry update. Provided fields overwrite; omitted ones are untouched; `null` clears any nullable field (every one except `lexeme`, `dialects`, `tags`, `senses`). `dialects`/`tags` are ADDITIVE links. `senses` upsert by client id (unknown id → created with that id — see SensePatch). Within a sense, `example_sentences: [{ id }]` links an existing sentence by reference without copying its content.","properties":{"lexeme":{"oneOf":[{"type":"string"},{"$ref":"#/components/schemas/MultiString"}]},"homograph":{"type":["string","null"],"description":"Homograph number (\"1\", \"2\", …); `\"\"` or `null` clears."},"phonetic":{"type":["string","null"]},"interlinearization":{"type":["string","null"]},"morphology":{"type":["string","null"]},"notes":{"anyOf":[{"oneOf":[{"type":"string"},{"$ref":"#/components/schemas/MultiString"}]},{"type":"null"}],"description":"Rich text stored as MARKDOWN (headings/bold/lists/links) — write markdown, not HTML. On PATCH, language keys MERGE: sending one language leaves the others untouched, `\"\"` drops just that language, and `null` clears the field."},"linguistic_history":{"anyOf":[{"type":"string"},{"$ref":"#/components/schemas/MultiString"},{"type":"null"}]},"citations":{"type":["array","null"],"items":{"$ref":"#/components/schemas/SourceCitation"},"description":"Source refs (`{ slug, locator? }`). On PATCH these MERGE with the citations already on the row (deduped by slug+locator) so you cannot delete another contributor's attribution; send `null` to clear them."},"scientific_names":{"anyOf":[{"oneOf":[{"type":"string"},{"type":"array","items":{"type":"string"}}]},{"type":"null"}],"description":"Whole-array replace — a scientific name states what the word IS, so a PATCH says the new list outright. `null` clears."},"elicitation_id":{"type":["string","null"]},"tone_class":{"type":["string","null"],"description":"Analytic tone-category label — `\"\"`/`null` clears it; omit → untouched."},"uncertain_orthographies":{"anyOf":[{"oneOf":[{"type":"string"},{"type":"array","items":{"type":"string"}}]},{"type":"null"}],"description":"Whole-array replace — the orthography codes whose lexeme is a best guess. `null` clears."},"coordinates":{"oneOf":[{"$ref":"#/components/schemas/Coordinates"},{"type":"null"}],"deprecated":true,"description":"DEPRECATED — use places. Each point/region is ADDED as a place linked to the entry; `null` changes nothing (unlink via `…/places/{placeId}/unlink`)."},"dialects":{"oneOf":[{"type":"string"},{"type":"array","items":{"type":"string"}}],"description":"ADDITIVE dialect links (found-or-created). Not clearable here — unlink with `DELETE …/entries/{entryId}/dialects/{dialectId}`."},"tags":{"oneOf":[{"type":"string"},{"type":"array","items":{"type":"string"}}],"description":"ADDITIVE tag links (found-or-created). Not clearable here — unlink with `DELETE …/entries/{entryId}/tags/{tagId}`."},"review":{"anyOf":[{"$ref":"#/components/schemas/EntryReview"},{"type":"null"}],"description":"Editor-only \"needs review\" flag — `{ category, note }` sets/replaces it, `null` clears it (\"Resolve\"), omit → untouched."},"senses":{"type":"array","items":{"$ref":"#/components/schemas/SensePatch"}}}},"OrthographyInput":{"type":"object","required":["code"],"description":"An alternate writing system. Its `code` is the immutable key each entry's `lexeme` (and each sentence's `text`) stores this spelling under.","properties":{"code":{"type":"string","description":"Immutable key. Prefer a BCP-47 tag (e.g. `sat-Olck`) — if it matches a Keyman-supported writing system it wires up a keyboard automatically; otherwise a custom slug (letters/numbers/hyphens). `default` (the primary headword) and `lo{n}` are reserved."},"name":{"type":"string","description":"Editable display label."},"short_name":{"type":"string","maxLength":12,"description":"A few letters (an abbreviation) shown where space is tight, like the reading-script switch on a phone. Omit to derive it from `name` (an acronym in or before parentheses, else the initials of a long name)."},"bcp":{"type":"string","description":"BCP-47 tag driving the Keyman keyboard. Defaults to `code` when `code` is a known writing system."},"notes":{"type":"string"},"characters":{"type":"array","items":{"type":"string"},"description":"Characters of this writing system a normal keyboard cannot type (`[\"đ\",\"ʼ\",\"ə\",\"ą́\"]`). They render as tap-buttons beside the entries search box so anyone can type the language. An item may be a multi-codepoint grapheme (`ą́` = `ą` + U+0301). Max 40."},"tone_letters":{"$ref":"#/components/schemas/ToneLetters"}}},"ToneLetters":{"type":"object","description":"Shaped tone letters, for an orthography that writes tone with a syllable-final letter. Key = that letter (1–3 characters, no spaces or hyphens; matched case-insensitively, longest key first); value = how to draw it wherever a word in this orthography is shown, so its shape hints at the pitch. A syllable (words split on spaces and hyphens) longer than its key whose last letters equal a key gets that style. The empty key `\"\"` styles nothing and only carries a `label` for syllables with no tone letter. Numbers are clamped to the stated ranges; unknown fields are dropped. Max 24 keys.","additionalProperties":{"type":"object","properties":{"shift_em":{"type":"number","minimum":-1,"maximum":1,"description":"Vertical offset in em of the word font size; negative raises the letter (a high tone), positive lowers it."},"nudge_em":{"type":"number","minimum":-0.5,"maximum":0.5,"description":"Horizontal offset in em (a little space after the syllable body)."},"rotate_deg":{"type":"number","minimum":-90,"maximum":90,"description":"Tilt in degrees, clockwise positive (e.g. a falling tone tilts down to the right)."},"weight":{"type":"number","minimum":100,"maximum":900,"description":"Font weight (e.g. 300 for a breathy tone)."},"opacity":{"type":"number","minimum":0.1,"maximum":1,"description":"Opacity (e.g. 0.55 for a breathy tone)."},"dotted":{"type":"boolean","description":"Dotted underline under the letter (e.g. a creaky tone)."},"label":{"type":"string","maxLength":12,"description":"Short name of the tone (e.g. a pitch number), shown in the settings preview."}}},"example":{"b":{"shift_em":-0.4},"j":{"shift_em":-0.3,"rotate_deg":25},"m":{"shift_em":0.25,"dotted":true,"label":"low creaky"},"":{"label":"mid"}}},"SourceInput":{"type":"object","required":["slug"],"description":"A citation record in the dictionary's source registry. Entries/sentences reference it by `slug`.","properties":{"slug":{"type":"string","description":"Stable id referenced by entries/sentences. Unique per dictionary."},"citation":{"type":"string","description":"Full display citation."},"abbreviation":{"type":"string","description":"Short label shown in badges + the search facet."},"author":{"type":"string"},"year":{"type":"string","description":"Text (allows ranges like \"1979–1985\")."},"url":{"type":"string"},"license":{"type":"string"},"type":{"type":"string","enum":["dictionary","wordlist","fieldwork","manuscript","video","grammar","phrasebook","hymnal","primer","corpus","other"]},"orthography":{"type":["string","null"],"description":"Which writing system this source's forms use — an orthography `code` from the dictionary's `orthographies` (or `default`), so multiple romanizations/scripts across a corpus are not conflated."}}},"SourceFile":{"type":"object","description":"An uploaded import resource (any format — spreadsheet, FLEx/LIFT export, PDF scan…). Bytes download from `GET …/files/{fileId}`. `import_instructions` is the uploader's authoritative brief for the import; `source_note` is their (optional) citation info. After importing, link the file to its permanent `sources` registry row via `PATCH …/files/{fileId}` with `source_id` when the resource is a real citable source.","properties":{"id":{"type":"string","format":"uuid"},"dictionary_id":{"type":"string"},"source_id":{"type":["string","null"],"description":"The dict `sources.id` this file is filed under, once linked."},"filename":{"type":"string"},"mimetype":{"type":"string"},"size_bytes":{"type":"integer"},"import_instructions":{"type":["string","null"]},"source_note":{"type":["string","null"]},"upload_confirmed_at":{"type":["string","null"],"format":"date-time"},"import_requested_at":{"type":["string","null"],"format":"date-time"},"import_thread_id":{"type":["string","null"],"format":"uuid"},"uploaded_by_user_id":{"type":"string"},"can_manage_requested":{"type":"boolean","description":"Whether this caller may edit/delete the resource after import was requested (original uploader or site admin)."},"readers_may_open":{"type":"integer","enum":[0,1],"description":"1 = anyone who can read the dictionary may open this PDF in the page viewer (`/{dict}/sources/{slug}/view`), and citations of its source link to their page."},"page_anchors":{"type":["string","null"],"description":"JSON `[{ printed, pdf }]` — where printed pages fall in the PDF (see PATCH)."},"pages_per_sheet":{"type":"integer","enum":[1,2],"description":"Printed pages per PDF page (2 for two-up scans)."},"created_at":{"type":"string","format":"date-time"},"updated_at":{"type":"string","format":"date-time"}}},"ImportRequest":{"type":"object","description":"One import-request batch. Multiple source files can share this thread and its editable overall note.","properties":{"thread_id":{"type":"string","format":"uuid"},"request_note":{"type":["string","null"]},"requested_at":{"type":"string","format":"date-time"},"can_manage":{"type":"boolean","description":"Whether this caller may edit the request note (original requester or site admin)."}}},"EntryWriteResult":{"type":"object","properties":{"status":{"type":"string","enum":["created","exists","updated","failed"],"description":"`created` = new entry written; `exists` = a client-supplied `id` already existed, so this item was skipped (idempotent no-op — edit via PATCH); `failed` = see `error`."},"entry_id":{"type":"string","description":"The entry id (the client-supplied one when given). Absent only on a pre-id failure."},"sense_ids":{"type":"array","items":{"type":"string"}},"error":{"type":"string","description":"Why this item failed (absent on success)."}}},"EntriesWriteResponse":{"type":"object","properties":{"created":{"type":"integer"},"skipped":{"type":"integer","description":"Items skipped because their client-supplied `id` already existed."},"updated":{"type":"integer"},"failed":{"type":"integer"},"results":{"type":"array","items":{"$ref":"#/components/schemas/EntryWriteResult"},"description":"One per input entry, in order."},"warnings":{"type":"array","items":{"type":"string"},"description":"Present only when the request used a deprecated input shape (every write endpoint does this), or (prose writes) carries entry-linked `[[word]]` refs that match several entries or none — the write still succeeded; fix the shape or pin the ref."}}},"SenseSummary":{"type":"object","description":"A sense's meaning fields — attached to list rows only when `?include=senses`.","properties":{"id":{"type":"string"},"glosses":{"type":["object","null"],"description":"A multilingual string: a map of locale code → text. Use `default` for the vernacular/headword writing system, and gloss-language codes (e.g. `en`, `es`) for glosses/translations. Most string inputs also accept a plain string, which is wrapped as `{ \"default\": \"…\" }`.","additionalProperties":{"type":"string"},"example":{"default":"mbwa","en":"dog"}},"definition":{"type":["object","null"],"description":"A multilingual string: a map of locale code → text. Use `default` for the vernacular/headword writing system, and gloss-language codes (e.g. `en`, `es`) for glosses/translations. Most string inputs also accept a plain string, which is wrapped as `{ \"default\": \"…\" }`.","additionalProperties":{"type":"string"},"example":{"default":"mbwa","en":"dog"}},"parts_of_speech":{"type":["array","null"],"items":{"type":"string"}},"semantic_domains":{"type":["array","null"],"items":{"type":"string"}}}},"EntrySummary":{"type":"object","properties":{"id":{"type":"string"},"lexeme":{"$ref":"#/components/schemas/MultiString"},"homograph":{"type":["string","null"]},"phonetic":{"type":["string","null"]},"elicitation_id":{"type":["string","null"]},"citations":{"type":["array","null"],"items":{"$ref":"#/components/schemas/SourceCitation"},"description":"Entry-level source refs — always included, so provenance checks don't need per-entry detail fetches."},"updated_at":{"type":"string","format":"date-time"},"senses":{"type":"array","items":{"$ref":"#/components/schemas/SenseSummary"},"description":"Present only when the request passes `?include=senses`."}}},"EntriesListResponse":{"type":"object","properties":{"entries":{"type":"array","items":{"$ref":"#/components/schemas/EntrySummary"}},"has_more":{"type":"boolean","description":"True when more rows exist past this page — bump `offset` by `limit` and re-request. Default order is `updated_at` ASC, so paginating to the end gives you every entry (handy for a live count or verifying an import)."}}},"SentenceFull":{"type":"object","description":"A stored example sentence (read shape). You POST these under `senses[].example_sentences`; they are returned here under `senses[].sentences`. Edit or remove one by its `id` via `PATCH`/`DELETE …/sentences/{id}`.","properties":{"id":{"type":"string"},"text":{"$ref":"#/components/schemas/MultiString"},"translation":{"$ref":"#/components/schemas/MultiString"},"text_id":{"type":["string","null"],"description":"Set when the sentence belongs to a longer connected `text`; `null` for a standalone example sentence (the usual case for imports)."},"sort_key":{"type":["string","null"],"description":"Fractional ordering index within its `text_id`; `null` for standalone example sentences."},"ends_paragraph":{"type":["integer","null"],"description":"1 when a paragraph break follows this sentence within a `text`; otherwise `null`."},"section_heading":{"type":["object","null"],"description":"Within a `text`: a heading shown before this sentence (opens a new part of the text); otherwise `null`.","additionalProperties":{"type":"string"},"example":{"default":"mbwa","en":"dog"}},"tokens":{"type":["object","null"],"additionalProperties":{"type":"array","items":{"$ref":"#/components/schemas/SentenceTokenFull"}},"description":"Stored interlinear tokens per orthography (the shared index for the gloss line, word→entry taps, and karaoke timings)."},"citations":{"type":["array","null"],"items":{"$ref":"#/components/schemas/SourceCitation"},"description":"Source refs (`{ slug, locator? }`)."},"example_label":{"type":["string","null"],"description":"The author's own example number."},"discourse_role":{"type":["string","null"],"description":"Discourse salience / information role."},"speaker_id":{"type":["string","null"],"description":"Who says this sentence."},"speaker":{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"}},"description":"The speaker (id + name). Present only when `speaker_id` is set."},"translation_meta":{"type":["object","null"],"additionalProperties":{"$ref":"#/components/schemas/TranslationProvenance"},"description":"Per translation language: machine / reviewed / published (+ citation)."},"updated_at":{"type":"string","format":"date-time"}}},"SenseFull":{"type":"object","description":"A stored sense (read shape). Mirrors `SenseInput` plus a server-assigned `id`; example sentences come back under `sentences` (not `example_sentences`). Media arrays (`photos`/`videos`) are read-only here — write them via `POST …/senses/{senseId}/photos` / `…/videos`.","properties":{"id":{"type":"string"},"glosses":{"$ref":"#/components/schemas/MultiString"},"definition":{"type":["object","null"],"description":"A multilingual string: a map of locale code → text. Use `default` for the vernacular/headword writing system, and gloss-language codes (e.g. `en`, `es`) for glosses/translations. Most string inputs also accept a plain string, which is wrapped as `{ \"default\": \"…\" }`.","additionalProperties":{"type":"string"},"example":{"default":"mbwa","en":"dog"}},"parts_of_speech":{"type":["array","null"],"items":{"type":"string"}},"semantic_domains":{"type":["array","null"],"items":{"type":"string"}},"write_in_semantic_domains":{"type":["array","null"],"items":{"type":"string"}},"noun_class":{"type":["string","null"]},"plural_form":{"type":["object","null"],"description":"A multilingual string: a map of locale code → text. Use `default` for the vernacular/headword writing system, and gloss-language codes (e.g. `en`, `es`) for glosses/translations. Most string inputs also accept a plain string, which is wrapped as `{ \"default\": \"…\" }`.","additionalProperties":{"type":"string"},"example":{"default":"mbwa","en":"dog"}},"variant":{"type":["object","null"],"description":"A multilingual string: a map of locale code → text. Use `default` for the vernacular/headword writing system, and gloss-language codes (e.g. `en`, `es`) for glosses/translations. Most string inputs also accept a plain string, which is wrapped as `{ \"default\": \"…\" }`.","additionalProperties":{"type":"string"},"example":{"default":"mbwa","en":"dog"}},"citations":{"type":["array","null"],"items":{"$ref":"#/components/schemas/SourceCitation"},"description":"Per-sense source refs."},"sentences":{"type":"array","items":{"$ref":"#/components/schemas/SentenceFull"},"description":"The sense's example sentences. Present only when sentences exist. (Input field name is `example_sentences`.)"},"photos":{"type":"array","items":{"$ref":"#/components/schemas/PhotoMedia"},"description":"Present only when photos exist. Write via `POST …/senses/{senseId}/photos`."},"videos":{"type":"array","items":{"$ref":"#/components/schemas/VideoMedia"},"description":"Present only when videos exist. Write via `POST …/senses/{senseId}/videos`."},"updated_at":{"type":"string","format":"date-time"},"created_at":{"type":"string","format":"date-time"}}},"EntryReview":{"type":"object","required":["category","note"],"description":"EDITOR-ONLY \"needs review\" task. Queues an entry for a human reviewer WITHOUT showing the public — it never appears in the reader UI or search for non-editors (though it does sync in the public snapshot, same as a `private` tag). Use it only for a question that remains unresolved after import cleanup, not as an audit log of deterministic transformations already completed. The note must be answerable from the entry page alone; structured source provenance belongs in the entry's `citations` and is rendered separately under collapsed Source details. A reviewer clears it (\"Resolve\"). On READ it is present under `entry.main.review` ONLY when the caller is an editor.","properties":{"category":{"type":"string","description":"Free bucket label for triage — drives the entries-list \"Needs review\" category facet. Reuse a small consistent vocabulary within an import, e.g. `truncated`, `headword_in_gloss`, `language_split`, `uncertain_gloss`, `dropped_text`, `missing_gloss`, `other`."},"note":{"type":"string","description":"A self-contained, plain-language question shown verbatim in the editor-only banner. Name the sense when useful; include the complete original and imported/omitted values needed to decide and end with a concrete question. Never use code paths (`glosses.gn`), internal flag/parser jargon, source-line tracebacks, or instructions to inspect the source file. When `comparisons` carry the competing values, keep the note to the framing sentence plus the question — do NOT repeat the values in prose."},"comparisons":{"type":"array","items":{"$ref":"#/components/schemas/EntryReviewComparison"},"description":"Competing values for one or more fields. The banner renders each pair with the differing spans highlighted (so the reviewer sees the difference instead of hunting for it) and, when `apply` is set, offers one-click \"Use this\" on each version."}}},"EntryReviewComparison":{"type":"object","required":["field","a","b"],"description":"One disputed field and its two versions. Use it whenever a review exists because two sources (or two printings of one source) disagree about a value.","properties":{"field":{"type":"string","description":"Human label for the disputed field, e.g. `Definition`, `Part of speech`, `Pronunciation guide`."},"a":{"$ref":"#/components/schemas/EntryReviewComparisonSide"},"b":{"$ref":"#/components/schemas/EntryReviewComparisonSide"},"apply":{"$ref":"#/components/schemas/EntryReviewApply"}}},"EntryReviewComparisonSide":{"type":"object","required":["value"],"properties":{"label":{"type":"string","description":"Where this version comes from, e.g. `Main dictionary (p62)`."},"value":{"type":"string","description":"The value exactly as that source prints it."}}},"EntryReviewApply":{"type":"object","required":["target"],"description":"Where a chosen version gets written when the reviewer clicks \"Use this\". Omit it when the two versions are not directly writable (e.g. the two halves may be different words entirely).","properties":{"target":{"type":"string","enum":["entry.lexeme","entry.phonetic","sense.glosses","sense.definition"]},"sense_id":{"type":"string","description":"Required for `sense.*` targets."},"key":{"type":"string","description":"Orthography code for `entry.lexeme` (e.g. `default`); language code for `sense.glosses` / `sense.definition` (e.g. `en`). Unused by `entry.phonetic`."}}},"EntryMain":{"type":"object","description":"The entry's top-level scalar fields. These are exactly the fields you POST at the top level of `EntryInput` (lexeme, phonetic, notes, citations, …) — on READ they live here, nested under `entry.main`.","properties":{"lexeme":{"$ref":"#/components/schemas/MultiString"},"homograph":{"type":["string","null"]},"phonetic":{"type":["string","null"]},"interlinearization":{"type":["string","null"]},"morphology":{"type":["string","null"]},"notes":{"type":["object","null"],"description":"Rich text as MARKDOWN.","additionalProperties":{"type":"string"},"example":{"default":"mbwa","en":"dog"}},"linguistic_history":{"type":["object","null"],"description":"A multilingual string: a map of locale code → text. Use `default` for the vernacular/headword writing system, and gloss-language codes (e.g. `en`, `es`) for glosses/translations. Most string inputs also accept a plain string, which is wrapped as `{ \"default\": \"…\" }`.","additionalProperties":{"type":"string"},"example":{"default":"mbwa","en":"dog"}},"citations":{"type":["array","null"],"items":{"$ref":"#/components/schemas/SourceCitation"}},"scientific_names":{"type":["array","null"],"items":{"type":"string"}},"elicitation_id":{"type":["string","null"]},"tone_class":{"type":["string","null"],"description":"Analytic tone-category label, when set."},"uncertain_orthographies":{"type":["array","null"],"items":{"type":"string"},"description":"Orthography codes whose lexeme value is a best guess, when set."},"coordinates":{"oneOf":[{"$ref":"#/components/schemas/Coordinates"},{"type":"null"}],"deprecated":true,"description":"Legacy geometry from before places (read-only, no longer written). Read `places` instead."},"review":{"anyOf":[{"$ref":"#/components/schemas/EntryReview"},{"type":"null"}],"description":"Editor-only \"needs review\" flag — present only for editor callers."}}},"EntryFull":{"type":"object","description":"A fully-assembled entry (read shape) — the read-side mirror of `EntryInput`. ASYMMETRY: scalars POSTed at the top level are read back under `main`; `senses[].example_sentences` are read back as `senses[].sentences`.","properties":{"id":{"type":"string"},"main":{"$ref":"#/components/schemas/EntryMain"},"senses":{"type":"array","items":{"$ref":"#/components/schemas/SenseFull"}},"tags":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"},"private":{"type":["boolean","null"]},"updated_at":{"type":"string","format":"date-time"}}},"description":"Entry-level tags (includes the `import_id` private tag from a bulk import). Present only when tags exist."},"dialects":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"name":{"$ref":"#/components/schemas/MultiString"},"coordinates":{"oneOf":[{"$ref":"#/components/schemas/Coordinates"},{"type":"null"}],"deprecated":true,"description":"The dialect's places in the old points/regions shape (legacy geometry when it has none). Read places with `…/places?dialect_id=`."},"updated_at":{"type":"string","format":"date-time"}}},"description":"Present only when dialects exist."},"audios":{"type":"array","items":{"$ref":"#/components/schemas/AudioMedia"},"description":"Present only when audio exists. Write via `POST …/entries/{entryId}/audio`."},"text_audio":{"type":"array","items":{"$ref":"#/components/schemas/TextAudioClip"},"description":"Up to 3 word clips BORROWED from timed text/sentence recordings (a token linked to this entry inside audio that carries `timings`). Derived on read, never stored — attach a real recording via `POST …/entries/{entryId}/audio`; this field is the fallback the UI plays when there is none. Present only when such clips exist."},"cognate_sets":{"type":"array","items":{"$ref":"#/components/schemas/CognateSetSummary"},"description":"Word families this entry belongs to (compact summaries). Present only when the entry is a member of at least one set; manage memberships via the `…/cognate-sets` endpoints (tag: cognate-sets)."},"places":{"type":"array","items":{"$ref":"#/components/schemas/PlaceSummary"},"description":"Where this entry is attested/recorded (compact). Present only when linked; manage via `…/places/{placeId}/links` (tag: places)."},"etymologies":{"type":"array","items":{"$ref":"#/components/schemas/EtymologyFull"},"description":"The entry's typed etymology records (multiple = competing hypotheses). Present only when at least one record exists; manage via the `…/entries/{entryId}/etymologies` endpoints. The free-prose `main.linguistic_history` is the untyped companion."},"updated_at":{"type":"string","format":"date-time"}}},"EntryResponse":{"type":"object","description":"Returned by `GET` and `PATCH /entries/{id}`.","required":["entry"],"properties":{"entry":{"$ref":"#/components/schemas/EntryFull"}}},"TextSentenceInput":{"type":"object","description":"One sentence within a text (a connected story/passage). Ordered by array position on create.","properties":{"id":{"type":"string","format":"uuid","description":"Optional client-generated UUID (any version — deterministic uuid5 ids work well). Supply it to know the id up front (for later edits) and to make writes idempotent — re-sending the same id is a safe no-op. Omit → the server mints one."},"text":{"oneOf":[{"type":"string"},{"$ref":"#/components/schemas/MultiString"}],"description":"The sentence in the vernacular."},"translation":{"oneOf":[{"type":"string"},{"$ref":"#/components/schemas/MultiString"}],"description":"Translation(s), keyed by gloss-language code."},"ends_paragraph":{"type":"boolean","description":"Whether a paragraph break follows this sentence."},"section_heading":{"oneOf":[{"type":"string"},{"$ref":"#/components/schemas/MultiString"}],"description":"A heading shown BEFORE this sentence: it opens a new part of the text (\"Introduction\", \"Chorus\", \"Second telling\"), per language. Put it on the first sentence of each part; the reader renders it as a section title and lists the parts as an outline."},"tokens":{"type":"object","additionalProperties":{"type":"array","items":{"$ref":"#/components/schemas/SentenceTokenInput"}},"description":"Orthography code → ordered token list (usually just `default`, the vernacular line) — the GOLD alignment from a glossed source, carrying the per-token gloss line + optional morphemes. If the sentence `text` for an orthography is omitted but its tokens are supplied, the server builds `text` by joining the token `form`s with a space — so a rows-only glossed source (aligned `[form, gloss]` rows, no separate vernacular line) imports without pre-assembling the text."},"citations":{"type":"array","items":{"$ref":"#/components/schemas/SourceCitation"},"description":"Source refs — `{ slug }` for bare membership, add `locator` for the page / example number. Each `slug` must already exist in the source registry (create via `POST …/sources`)."},"example_label":{"type":["string","null"],"description":"The author's own example number (e.g. \"(2a)\") for cross-referencing within a grammar."},"discourse_role":{"type":["string","null"],"enum":["storyline","backgrounded","flashback","setting","evaluation","reported_speech",null],"description":"Discourse salience / information role — lets a narrative render foreground vs. background and a grammar section point at the role a particle signals."},"speaker_id":{"type":["string","null"],"description":"Who says this sentence: an existing speaker id (`…/speakers`). `null` clears. The reader names the speaker where the voice changes."},"speaker":{"type":["string","null"],"description":"Who says this sentence, by name: a person or a role (\"Host\", \"Narrator\", \"Reader\"). Found case-insensitively among the dictionary's speakers, else created. `null` clears. Use instead of `speaker_id`."},"translation_meta":{"type":["object","null"],"additionalProperties":{"oneOf":[{"$ref":"#/components/schemas/TranslationProvenance"},{"type":"null"}]},"description":"Where each translation comes from, keyed by translation language. Merged per language on PATCH; a language set to `null` is removed; `null` clears all. The reader mutes `machine` translations (with a legend) and shows the citation of a `published` one."}}},"TextInput":{"type":"object","required":["title"],"description":"A text (long connected passage/story) with ordered sentences. Text-level metadata (citations, summary, dialects, work_id) lives HERE — don't repeat it on every sentence.","properties":{"id":{"type":"string","format":"uuid","description":"Optional client-generated UUID (any version — deterministic uuid5 ids work well). Supply it to know the id up front (for later edits) and to make writes idempotent — re-sending the same id is a safe no-op. Omit → the server mints one."},"title":{"oneOf":[{"type":"string"},{"$ref":"#/components/schemas/MultiString"}],"description":"The text's title. Required."},"citations":{"type":"array","items":{"$ref":"#/components/schemas/SourceCitation"},"description":"Source refs for the whole text — `{ slug }` for bare membership, add `locator` for the page / hymn number. Each slug must already exist (create via `POST …/sources`)."},"summary":{"oneOf":[{"type":"string"},{"$ref":"#/components/schemas/MultiString"}],"description":"Synopsis/abstract of the text, per language."},"body":{"oneOf":[{"type":"string"},{"$ref":"#/components/schemas/MultiString"}],"description":"Article text: markdown prose per language (an introduction, an essay, a lesson). The reader shows it as prose in place of a sentence list; `[[word]]` links words to entries, `[[s:<sentence-id>]]` quotes a sentence, `[@slug, p. N]` cites a source page. Usually sent with no `sentences`."},"dialects":{"oneOf":[{"type":"string"},{"type":"array","items":{"type":"string"}}],"description":"Dialect name(s) this text version is written in — found-or-created, linked via the text↔dialect junction."},"work_id":{"type":"string","description":"Parallel-texts grouping key: texts sharing a `work_id` are versions of ONE work (e.g. the same hymn in two dialects, each with its own wording + audio). Mint any stable id for the first version and reuse it on the others; reads then include `parallel_texts` siblings."},"collection_id":{"type":"string","description":"Collection this text belongs to (series › book › chapter grouping) — must exist; create via `POST …/texts/collections`."},"sort_key":{"type":"string","description":"Fractional ordering index WITHIN the collection (opaque string — read siblings to pick one between two others). Omit to append after the collection's current last text."},"sentences":{"type":"array","items":{"$ref":"#/components/schemas/TextSentenceInput"},"description":"Ordered sentences — sort_keys are assigned in array order."}}},"TextSentenceFull":{"type":"object","properties":{"id":{"type":"string"},"text":{"type":["object","null"],"description":"A multilingual string: a map of locale code → text. Use `default` for the vernacular/headword writing system, and gloss-language codes (e.g. `en`, `es`) for glosses/translations. Most string inputs also accept a plain string, which is wrapped as `{ \"default\": \"…\" }`.","additionalProperties":{"type":"string"},"example":{"default":"mbwa","en":"dog"}},"translation":{"type":["object","null"],"description":"A multilingual string: a map of locale code → text. Use `default` for the vernacular/headword writing system, and gloss-language codes (e.g. `en`, `es`) for glosses/translations. Most string inputs also accept a plain string, which is wrapped as `{ \"default\": \"…\" }`.","additionalProperties":{"type":"string"},"example":{"default":"mbwa","en":"dog"}},"tokens":{"type":["object","null"],"additionalProperties":{"type":"array","items":{"$ref":"#/components/schemas/SentenceTokenFull"}},"description":"Stored interlinear tokens per orthography."},"citations":{"type":["array","null"],"items":{"$ref":"#/components/schemas/SourceCitation"}},"example_label":{"type":["string","null"]},"discourse_role":{"type":["string","null"]},"ends_paragraph":{"type":["integer","null"]},"section_heading":{"type":["object","null"],"description":"Heading shown before this sentence (opens a new part of the text).","additionalProperties":{"type":"string"},"example":{"default":"mbwa","en":"dog"}},"speaker_id":{"type":["string","null"],"description":"Who says it; the full record is in the text's `speakers`."},"translation_meta":{"type":["object","null"],"additionalProperties":{"$ref":"#/components/schemas/TranslationProvenance"},"description":"Per translation language: machine / reviewed / published (+ citation)."},"sort_key":{"type":["string","null"],"description":"Fractional ordering index within the text."},"audio":{"type":"array","items":{"$ref":"#/components/schemas/AudioMedia"},"description":"This sentence's attached audio (each with `timings` + `download_url`). Present only when audio exists."}}},"TextSummary":{"type":"object","description":"One item in the text list.","properties":{"id":{"type":"string"},"title":{"$ref":"#/components/schemas/MultiString"},"sentence_count":{"type":"integer"},"updated_at":{"type":"string","format":"date-time"},"article":{"type":"boolean","enum":[true],"description":"An article text (it has a `body`). Present only when true."},"collection_id":{"type":"string","description":"Collection this text belongs to. Present only when set."},"sort_key":{"type":"string","description":"Fractional ordering index within its collection. Present only when set."},"tags":{"type":"array","items":{"$ref":"#/components/schemas/TextTagView"},"description":"Classification tags. Present only when non-empty."}}},"TextFull":{"type":"object","description":"A text with its ordered sentences, attached audio, and referenced speakers (read shape).","properties":{"id":{"type":"string"},"title":{"$ref":"#/components/schemas/MultiString"},"citations":{"type":["array","null"],"items":{"$ref":"#/components/schemas/SourceCitation"}},"summary":{"type":["object","null"],"description":"A multilingual string: a map of locale code → text. Use `default` for the vernacular/headword writing system, and gloss-language codes (e.g. `en`, `es`) for glosses/translations. Most string inputs also accept a plain string, which is wrapped as `{ \"default\": \"…\" }`.","additionalProperties":{"type":"string"},"example":{"default":"mbwa","en":"dog"}},"body":{"type":["object","null"],"description":"Article text: markdown prose per language.","additionalProperties":{"type":"string"},"example":{"default":"mbwa","en":"dog"}},"work_id":{"type":["string","null"],"description":"Parallel-texts grouping key (versions of one work share it)."},"collection_id":{"type":["string","null"],"description":"Collection this text belongs to (see `…/texts/collections`)."},"sort_key":{"type":["string","null"],"description":"Fractional ordering index within the collection."},"dialects":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"name":{"$ref":"#/components/schemas/MultiString"}}},"description":"Dialect(s) this version is written in. Present only when set."},"parallel_texts":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"title":{"$ref":"#/components/schemas/MultiString"},"dialects":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"name":{"$ref":"#/components/schemas/MultiString"}}}}}},"description":"Other versions of the same work (same `work_id`). Present only when siblings exist."},"tags":{"type":"array","items":{"$ref":"#/components/schemas/TextTagView"},"description":"Classification tags. Present only when non-empty."},"places":{"type":"array","items":{"allOf":[{"$ref":"#/components/schemas/PlaceSummary"},{"type":"object","properties":{"role":{"type":["string","null"],"enum":["recorded","setting",null]}}}]},"description":"Where the text was recorded / what place it is set in. Present only when linked."},"embeds":{"type":"array","items":{"allOf":[{"$ref":"#/components/schemas/TextEmbed"},{"type":"object","properties":{"child_title":{"$ref":"#/components/schemas/MultiString"},"child_sentence_count":{"type":"integer"}}}]},"description":"Texts heard inside this one (a song inside a recorded programme), in order. Present only when set."},"embedded_in":{"type":"array","items":{"allOf":[{"$ref":"#/components/schemas/TextEmbed"},{"type":"object","properties":{"parent_title":{"$ref":"#/components/schemas/MultiString"}}}]},"description":"Texts this one is heard inside. Present only when set."},"updated_at":{"type":"string","format":"date-time"},"sentences":{"type":"array","items":{"$ref":"#/components/schemas/TextSentenceFull"},"description":"Ordered by `sort_key` ascending."},"audio":{"type":"array","items":{"$ref":"#/components/schemas/AudioMedia"},"description":"TEXT-level audio (whole-passage recordings, each with `timings` + `download_url`). Present only when audio exists; sentence-level audio nests under `sentences[].audio`."},"speakers":{"type":"array","items":{"$ref":"#/components/schemas/SpeakerFull"},"description":"Full records for every speaker referenced by the included audio — one call serves text + sentences + audio + speakers."}}},"TextPatch":{"type":"object","description":"Edit a text: `title`/`summary`/`body`/`work_id` overwrite (`null` clears the nullable ones); `citations` MERGE (deduped by slug+locator; `null` clears); `dialects` are ADDITIVE links; `append_sentences` add after the last sentence; `sentence_order` (a full list of existing sentence ids) reassigns their order. Edit a single sentence via `PATCH …/sentences/{id}`.","properties":{"title":{"oneOf":[{"type":"string"},{"$ref":"#/components/schemas/MultiString"}]},"citations":{"type":["array","null"],"items":{"$ref":"#/components/schemas/SourceCitation"},"description":"MERGED with the text's existing citations (deduped by slug+locator); `null` clears."},"summary":{"anyOf":[{"type":"string"},{"$ref":"#/components/schemas/MultiString"},{"type":"null"}]},"body":{"anyOf":[{"type":"string"},{"$ref":"#/components/schemas/MultiString"},{"type":"null"}],"description":"Article text, per language like `summary`: a language's value overwrites, `\"\"` drops that language, `null` clears."},"dialects":{"oneOf":[{"type":"string"},{"type":"array","items":{"type":"string"}}],"description":"ADDITIVE dialect links (found-or-created)."},"work_id":{"type":["string","null"],"description":"Overwrites the parallel-texts grouping key; `null` clears."},"collection_id":{"type":["string","null"],"description":"Moves the text into a collection (`null` detaches). Must exist. A move without `sort_key` appends after the target collection's current last text."},"sort_key":{"type":["string","null"],"description":"Overwrites the ordering index within the collection; `null` clears."},"append_sentences":{"type":"array","items":{"$ref":"#/components/schemas/TextSentenceInput"}},"sentence_order":{"type":"array","items":{"type":"string"},"description":"Existing sentence ids in the desired order."}}},"TextCollection":{"type":"object","description":"A grouping of texts (series › book › chapter, hymnal sections, session transcripts). Self-nesting via `parent_id`, max 3 levels; siblings order by `sort_key`. Deleting one detaches (never deletes) its child collections and texts.","properties":{"id":{"type":"string"},"title":{"$ref":"#/components/schemas/MultiString"},"description":{"type":["object","null"],"description":"A multilingual string: a map of locale code → text. Use `default` for the vernacular/headword writing system, and gloss-language codes (e.g. `en`, `es`) for glosses/translations. Most string inputs also accept a plain string, which is wrapped as `{ \"default\": \"…\" }`.","additionalProperties":{"type":"string"},"example":{"default":"mbwa","en":"dog"}},"parent_id":{"type":["string","null"]},"sort_key":{"type":["string","null"],"description":"Fractional ordering index among siblings."},"text_count":{"type":"integer","description":"Texts DIRECTLY in this collection (not counting descendants)."},"child_ids":{"type":"array","items":{"type":"string"},"description":"Child collection ids, sibling-ordered."},"updated_at":{"type":"string","format":"date-time"}}},"TextCollectionInput":{"type":"object","required":["title"],"description":"Create a collection of texts. Nest via `parent_id` (max 3 levels). Attach texts by setting `collection_id` on text create/patch.","properties":{"id":{"type":"string","format":"uuid","description":"Optional client-generated UUID (any version — deterministic uuid5 ids work well). Supply it to know the id up front (for later edits) and to make writes idempotent — re-sending the same id is a safe no-op. Omit → the server mints one."},"title":{"oneOf":[{"type":"string"},{"$ref":"#/components/schemas/MultiString"}],"description":"Collection title (same MultiString convention as text titles). Required."},"description":{"oneOf":[{"type":"string"},{"$ref":"#/components/schemas/MultiString"}],"description":"Optional blurb about the collection, per language."},"parent_id":{"type":["string","null"],"description":"Parent collection — must exist. A parent that would make the collection its own ancestor (or exceed 3 levels) is a 422."},"sort_key":{"type":"string","description":"Fractional ordering index among siblings (opaque string). Omit to append after the current last sibling."}}},"TextCollectionPatch":{"type":"object","description":"Edit a collection: `title` field-merges per language; `description` overwrites (`null` clears); `parent_id` re-parents (`null` → top level; self-ancestry / too-deep → 422; a move without `sort_key` appends among the new siblings); `sort_key` reorders.","properties":{"title":{"oneOf":[{"type":"string"},{"$ref":"#/components/schemas/MultiString"}]},"description":{"anyOf":[{"type":"string"},{"$ref":"#/components/schemas/MultiString"},{"type":"null"}]},"parent_id":{"type":["string","null"]},"sort_key":{"type":"string"}}},"SuggestionRow":{"type":"object","description":"One aggregated word form in the suggestions queue.","properties":{"key":{"type":"string","description":"Normalized word key (grouping identity; what form-level actions match on)."},"display_form":{"type":"string","description":"Most frequent surface spelling."},"count":{"type":"integer","description":"Total occurrences across all tokenized sentences (exact even when `occurrences` is capped)."},"sentence_count":{"type":"integer"},"occurrences":{"type":"array","items":{"type":"object","properties":{"sentence_id":{"type":"string"},"text_id":{"type":["string","null"]},"orthography":{"type":"string"},"token_index":{"type":"integer"},"start":{"type":"integer"},"end":{"type":"integer"}}},"description":"Capped at 20 per row."},"candidates":{"type":"array","items":{"type":"string"},"description":"Ambiguous facet only: union of candidate entry ids."},"everywhere":{"type":"boolean","description":"Ignored facet only: true when the form is in the dictionary-level ignore list."}}},"TokenAction":{"type":"object","required":["orthography","token_index","action"],"description":"One review action on one token of a sentence.","properties":{"orthography":{"type":"string","description":"Orthography code of the token list (usually `default`)."},"token_index":{"type":"integer"},"action":{"type":"string","enum":["confirm","ignore","unlink","segment","unsegment","mark_uncertain","clear_uncertain"],"description":"`unlink`/`unsegment` re-run the matcher on the word (candidates / another match come back; a result identical to what was just removed does not). `mark_uncertain`/`clear_uncertain` flag the word itself as unsure / checked and change nothing else."},"entry_id":{"type":"string","description":"Required for `confirm`."},"sense_id":{"type":"string","description":"Optional on `confirm` — a confirmed sense link also mirrors into the sense↔sentence junction (feeds the entry-page concordance)."},"morphemes":{"type":"array","items":{"$ref":"#/components/schemas/Morpheme"},"description":"Required for `segment`: the word's morphemes in order (forms must spell the token form; separators and glosses per morpheme). Stored as gold (`confirmed`); replaces any word-level link."}}},"AlignJob":{"type":"object","description":"One forced-alignment run. Poll `GET …/align-jobs/{jobId}` until `status` leaves `running`; on `done` the audio row's `timings` are updated (re-read the text/sentence to get them).","properties":{"id":{"type":"string"},"status":{"type":"string","enum":["running","done","failed"]},"error":{"type":["string","null"],"description":"Failure detail when `status` is `failed`."},"tokens_total":{"type":["integer","null"],"description":"Word (non-punctuation) tokens in the aligned material."},"tokens_aligned":{"type":["integer","null"],"description":"Word tokens that could be romanized for the aligner — the rest are coverage gaps (left untimed)."},"created_at":{"type":"string"},"finished_at":{"type":["string","null"]}}},"AlignCoverage":{"type":"object","description":"Romanization coverage computed before the aligner runs.","properties":{"tokens_total":{"type":"integer"},"tokens_aligned":{"type":"integer"},"gap_forms":{"type":"array","items":{"type":"string"},"description":"Distinct word forms that could not be romanized (they stay untimed). Linking them to entries with Latin material closes the gaps."}}},"HostedElsewhere":{"type":"object","required":["type","video_id"],"description":"A video hosted on YouTube/Vimeo — no bytes are stored. Alternatively send a raw `hosted_url` (a watch/share URL) and the server parses it into this shape.","properties":{"type":{"type":"string","enum":["youtube","vimeo"]},"video_id":{"type":"string","description":"Provider id (YouTube watch id / Vimeo numeric id)."},"start_at_seconds":{"type":["integer","null"]}}},"SpeakerBrief":{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"}}},"SpeakerFull":{"type":"object","description":"A full speaker record (audio/video attribution — shown on the contributors page).","properties":{"id":{"type":"string"},"name":{"type":"string"},"decade":{"type":["integer","null"],"description":"AGE-RANGE bucket 0-9 (0 = ages 0-10, 1 = 11-20, … 9 = 91-100) — despite the column name it is NOT a birth decade. A few legacy records hold out-of-range literal ages; treat those as unknown."},"gender":{"type":["string","null"],"enum":["m","f","o",null]},"birthplace":{"type":["string","null"]}}},"MediaTimings":{"type":"object","additionalProperties":{"type":"string"},"description":"Karaoke word-timings: a map of sentence id → compact timing string `\"offset,duration|offset,duration|…\"` (milliseconds). Entries align 1:1 with that sentence's default-orthography tokens; each offset is relative to the END of the previous timed token (chainable across sentences for text-level audio); an empty entry marks an untimed token (punctuation).","example":{"sentence-uuid-1":"0,320|40,280|","sentence-uuid-2":"120,300|"}},"AudioMedia":{"type":"object","description":"A stored audio recording (the pronunciation/utterance). Attaches to an entry, sentence, or text. Requires attribution: a speaker and/or a registry `source`.","properties":{"id":{"type":"string"},"storage_path":{"type":"string"},"download_url":{"type":"string","description":"Absolute URL for the audio bytes (`GET …/media/{storage_path}`, same Bearer auth, 302-redirects to storage). Present in text reads."},"source":{"type":["string","null"],"description":"A sources-registry slug (see `GET …/sources`) — the speaker-less attribution path."},"timings":{"type":["object","null"],"additionalProperties":{"type":"string"},"description":"Karaoke word-timings: a map of sentence id → compact timing string `\"offset,duration|offset,duration|…\"` (milliseconds). Entries align 1:1 with that sentence's default-orthography tokens; each offset is relative to the END of the previous timed token (chainable across sentences for text-level audio); an empty entry marks an untimed token (punctuation).","example":{"sentence-uuid-1":"0,320|40,280|","sentence-uuid-2":"120,300|"}},"clip_start_ms":{"type":"integer","description":"Present only on a window of a longer recording: this row plays `storage_path` from `clip_start_ms` to `clip_end_ms`, and its `timings` are relative to `clip_start_ms`."},"clip_end_ms":{"type":"integer","description":"End of the window in the file (see `clip_start_ms`)."},"entry_id":{"type":["string","null"]},"sentence_id":{"type":["string","null"]},"text_id":{"type":["string","null"]},"speakers":{"type":"array","items":{"$ref":"#/components/schemas/SpeakerBrief"},"description":"Present only when a speaker is attached."},"created_at":{"type":"string","format":"date-time"},"updated_at":{"type":"string","format":"date-time"}}},"TextAudioClip":{"type":"object","description":"A word's audio borrowed from a timed text/sentence recording: the span of the token linked to the entry. Play `storage_path` from `start_ms` to `end_ms` (a media fragment `#t=start,end` works). Confirmed links sort before auto matches.","properties":{"audio_id":{"type":"string"},"storage_path":{"type":"string"},"start_ms":{"type":"integer"},"end_ms":{"type":"integer"},"sentence_id":{"type":"string"},"text_id":{"type":["string","null"],"description":"null for sentence-level audio."},"text_title":{"$ref":"#/components/schemas/MultiString"},"speaker_name":{"type":"string"},"status":{"type":"string","enum":["auto","confirmed"],"description":"Whether a human confirmed the word→entry link the clip rests on."}},"required":["audio_id","storage_path","start_ms","end_ms","sentence_id","text_id","status"]},"PhotoMedia":{"type":"object","description":"A stored photo. Attaches to a sense or a sentence. `source` + `photographer` are the free-text attribution shown under the image (there is no separate caption field; unlike audio/video, a photo `source` is NOT a registry slug). HEIC/HEIF uploads are rejected with 415 — convert to JPEG/PNG/WebP first (e.g. `magick photo.heic photo.jpg`). EXIF GPS + capture time are read from the bytes automatically; coordinates are stored at village-level precision (2 decimals, ~1.1 km) for contributor privacy.","properties":{"id":{"type":"string"},"storage_path":{"type":"string"},"source":{"type":["string","null"],"description":"Free-text attribution/caption prose."},"photographer":{"type":["string","null"]},"latitude":{"type":["number","null"],"description":"EXIF GPS, blunted to 2 decimals (~1.1 km, village-level) on ingest."},"longitude":{"type":["number","null"],"description":"EXIF GPS, blunted to 2 decimals (~1.1 km, village-level) on ingest."},"taken_at":{"type":["string","null"],"format":"date-time","description":"EXIF capture timestamp."},"created_at":{"type":"string","format":"date-time"},"updated_at":{"type":"string","format":"date-time"}}},"VideoMedia":{"type":"object","description":"A stored or hosted video. Attaches to a sense, sentence, or text. Either `storage_path` (uploaded bytes) OR `hosted_elsewhere` (YouTube/Vimeo) is set. Requires attribution: a speaker and/or a registry `source`.","properties":{"id":{"type":"string"},"storage_path":{"type":["string","null"]},"hosted_elsewhere":{"type":["object","null"],"required":["type","video_id"],"description":"A video hosted on YouTube/Vimeo — no bytes are stored. Alternatively send a raw `hosted_url` (a watch/share URL) and the server parses it into this shape.","properties":{"type":{"type":"string","enum":["youtube","vimeo"]},"video_id":{"type":"string","description":"Provider id (YouTube watch id / Vimeo numeric id)."},"start_at_seconds":{"type":["integer","null"]}}},"hosted_metadata":{"type":["object","null"],"description":"Best-effort provider metadata cached when a hosted video is attached. Metadata unavailability never prevents the reference from being saved.","properties":{"title":{"type":"string"},"description":{"type":"string"},"thumbnail_url":{"type":"string","format":"uri"},"duration_seconds":{"type":"number"}}},"source":{"type":["string","null"],"description":"A sources-registry slug (see `GET …/sources`) — the speaker-less attribution path."},"videographer":{"type":["string","null"]},"text_id":{"type":["string","null"]},"speakers":{"type":"array","items":{"$ref":"#/components/schemas/SpeakerBrief"},"description":"Present only when a speaker is attached."},"created_at":{"type":"string","format":"date-time"},"updated_at":{"type":"string","format":"date-time"}}},"RelationshipCustomType":{"type":"object","required":["name"],"properties":{"name":{"oneOf":[{"type":"string"},{"$ref":"#/components/schemas/MultiString"}],"description":"Display label the dictionary creator authors (their language)."},"inverse_name":{"oneOf":[{"type":"string"},{"$ref":"#/components/schemas/MultiString"}],"description":"Label shown from the `to` side for a DIRECTED custom type. Omit for symmetric."},"symmetric":{"type":"boolean","description":"Defaults true unless an `inverse_name` is given."}}},"RelationshipInput":{"type":"object","required":["from_entry_id","to_entry_id"],"description":"A typed relationship between two entries (optionally narrowed to senses). Provide exactly one of `type` (global slug) or `custom_type`.","properties":{"from_entry_id":{"type":"string"},"from_sense_id":{"type":"string","description":"Optional — narrows the `from` side to one sense (must belong to `from_entry_id`)."},"to_entry_id":{"type":"string"},"to_sense_id":{"type":"string","description":"Optional — narrows the `to` side to one sense (must belong to `to_entry_id`)."},"type":{"type":"string","enum":["synonym","antonym","cognate","dialectal_variant","see_also","spelling_variant","homophone","hypernym","holonym","derived_from","borrowed_from","hyponym","meronym","root_of","loaned_to"],"description":"A global relationship-type slug. Provide THIS or `custom_type`. Directed pairs (hypernym/hyponym, holonym/meronym, derived_from/root_of, borrowed_from/loaned_to) accept EITHER member — the inverse alias is canonicalized to its partner with endpoints flipped, so `hypernym` A→B and `hyponym` B→A dedupe to one row. `from` plays the named role of the stored slug (e.g. `from` is the hypernym/whole/derived word)."},"custom_type":{"type":"object","required":["name"],"properties":{"name":{"oneOf":[{"type":"string"},{"$ref":"#/components/schemas/MultiString"}],"description":"Display label the dictionary creator authors (their language)."},"inverse_name":{"oneOf":[{"type":"string"},{"$ref":"#/components/schemas/MultiString"}],"description":"Label shown from the `to` side for a DIRECTED custom type. Omit for symmetric."},"symmetric":{"type":"boolean","description":"Defaults true unless an `inverse_name` is given."}},"description":"A per-dictionary custom type — found-or-created (deduped by name). Reuse of an existing name is a no-op for `inverse_name`/`symmetric`; PATCH `…/relationship-types/{id}` to rename or fix labels. Provide THIS or `type`."},"note":{"oneOf":[{"type":"string"},{"$ref":"#/components/schemas/MultiString"}]},"citations":{"type":"array","items":{"$ref":"#/components/schemas/SourceCitation"},"description":"Source refs — `{ slug }` for bare membership, add `locator` for a page / example number; each slug must already exist (create via `POST …/sources`)."}}},"RelationshipView":{"type":"object","description":"One relationship as seen FROM a given entry: `direction`, and the label (`label_key` for globals / `name` for custom) already reflect the side being shown. `related` is the OTHER endpoint.","properties":{"id":{"type":"string"},"type":{"type":"string","description":"Global slug, or the custom type id when `custom` is true."},"custom":{"type":"boolean"},"symmetric":{"type":"boolean"},"direction":{"type":"string","enum":["forward","inverse"]},"label_key":{"type":"string","description":"Globals only: i18n key `relationship_type.<slug>` for the side being viewed."},"name":{"$ref":"#/components/schemas/MultiString","description":"Custom types only: the label for the side being viewed."},"related":{"type":"object","properties":{"entry_id":{"type":"string"},"sense_id":{"type":"string"},"lexeme":{"$ref":"#/components/schemas/MultiString"}}},"note":{"$ref":"#/components/schemas/MultiString"},"citations":{"type":"array","items":{"$ref":"#/components/schemas/SourceCitation"}}}},"RelationshipTypeView":{"type":"object","description":"One custom relationship type from the per-dictionary registry.","properties":{"id":{"type":"string"},"name":{"$ref":"#/components/schemas/MultiString"},"inverse_name":{"oneOf":[{"$ref":"#/components/schemas/MultiString"},{"type":"null"}],"description":"Directed types only — label shown from the `to` side. `null` when symmetric."},"symmetric":{"type":"boolean"},"used_by":{"type":"integer","description":"Count of relationships currently using this type."}}},"GrammarSectionInput":{"type":"object","description":"A node in the dictionary's hierarchical grammar. Usually documents ONE lexeme (a particle/affix/construction): link it with `entry_id` (+ optional `sense_id`) and it surfaces as \"grammar notes\" on that entry, while the section pulls the entry's lexeme/phonetic/audio. Prose is parallel-language MARKDOWN keyed by gloss/analysis language (the object-language forms come from the linked entry + referenced sentences, not from this prose). At least one of `title`/`body` is required.","properties":{"id":{"type":"string","format":"uuid","description":"Optional client-generated UUID (any version — deterministic uuid5 ids work well). Supply it to know the id up front (for later edits) and to make writes idempotent — re-sending the same id is a safe no-op. Omit → the server mints one."},"parent_id":{"type":["string","null"],"description":"Parent section id; omit/null for a top-level section."},"after_section_id":{"type":"string","description":"Insert immediately after this sibling (fractional ordering). Omit → append to the end of the parent's children."},"number_label":{"type":"string","description":"Explicit outline number (e.g. \"2.2.1.1\") — e.g. to preserve a source grammar's numbering. Omit → derived from tree position."},"title":{"oneOf":[{"type":"string"},{"$ref":"#/components/schemas/MultiString"}],"description":"Section heading (markdown), per gloss/analysis language. Optional — omit for a headless, body-only section."},"body":{"oneOf":[{"type":"string"},{"$ref":"#/components/schemas/MultiString"}],"description":"Main documentation prose as MARKDOWN, per gloss/analysis language."},"usage_conditions":{"oneOf":[{"type":"string"},{"$ref":"#/components/schemas/MultiString"}],"description":"When to include vs. omit this form (markdown, per language) — distinct from `body`."},"slot_id":{"type":["string","null"],"description":"A clause-template slot id (see `…/grammar/clause-slots`) positioning this particle within the clause."},"entry_id":{"type":["string","null"],"description":"The headword this section documents (the bidirectional entry↔grammar link)."},"sense_id":{"type":["string","null"],"description":"Narrow the link to a specific sense of `entry_id`."},"example_sentence_ids":{"type":"array","items":{"type":"string"},"description":"Existing sentence ids to attach as examples BY REFERENCE (not copies). One sentence can be a text line, a sense example, AND grammar evidence at once — inheriting its word→entry tokens and media timings. Manage later via `…/sections/{sectionId}/sentences`."}},"example":{"title":"Aspect particles","body":{"en":"The perfective particle *le* follows the verb…"},"entry_id":"…","slot_id":"…","example_sentence_ids":["…"]}},"GrammarSectionPatch":{"type":"object","description":"Field-merge edit of a grammar section: provided fields overwrite, omitted fields are unchanged. `parent_id` + `after_section_id` move/renest it.","properties":{"parent_id":{"type":["string","null"]},"after_section_id":{"type":"string"},"number_label":{"type":["string","null"]},"title":{"oneOf":[{"type":"string"},{"$ref":"#/components/schemas/MultiString"}]},"body":{"anyOf":[{"type":"string"},{"$ref":"#/components/schemas/MultiString"},{"type":"null"}]},"usage_conditions":{"anyOf":[{"type":"string"},{"$ref":"#/components/schemas/MultiString"},{"type":"null"}]},"slot_id":{"type":["string","null"]},"entry_id":{"type":["string","null"]},"sense_id":{"type":["string","null"]}}},"SectionSentenceRef":{"type":"object","description":"An example sentence attached to a grammar section BY REFERENCE.","properties":{"id":{"type":"string","description":"The link id (section_sentences row)."},"sentence_id":{"type":"string"},"sort_key":{"type":["string","null"],"description":"Fractional order within the section."}}},"GrammarSectionFull":{"type":"object","description":"A grammar section (read shape) with its ordered example-sentence refs and child ids.","properties":{"id":{"type":"string"},"parent_id":{"type":["string","null"]},"sort_key":{"type":["string","null"]},"number_label":{"type":["string","null"],"description":"Stored override, else derived from tree position."},"title":{"$ref":"#/components/schemas/MultiString"},"body":{"type":["object","null"],"description":"A multilingual string: a map of locale code → text. Use `default` for the vernacular/headword writing system, and gloss-language codes (e.g. `en`, `es`) for glosses/translations. Most string inputs also accept a plain string, which is wrapped as `{ \"default\": \"…\" }`.","additionalProperties":{"type":"string"},"example":{"default":"mbwa","en":"dog"}},"usage_conditions":{"type":["object","null"],"description":"A multilingual string: a map of locale code → text. Use `default` for the vernacular/headword writing system, and gloss-language codes (e.g. `en`, `es`) for glosses/translations. Most string inputs also accept a plain string, which is wrapped as `{ \"default\": \"…\" }`.","additionalProperties":{"type":"string"},"example":{"default":"mbwa","en":"dog"}},"slot_id":{"type":["string","null"]},"entry_id":{"type":["string","null"]},"sense_id":{"type":["string","null"]},"example_sentences":{"type":"array","items":{"$ref":"#/components/schemas/SectionSentenceRef"},"description":"Present when examples are attached."},"child_ids":{"type":"array","items":{"type":"string"},"description":"Ordered child section ids."},"updated_at":{"type":"string","format":"date-time"}}},"ClauseSlotInput":{"type":"object","required":["name"],"description":"One position in the language's clause template (e.g. pre-subject, pre-modal, modal, pre-verb, post-verb, phase, final). The ordered set of slots IS the template — used to render a particle-order diagram and to position grammar sections.","properties":{"id":{"type":"string","format":"uuid","description":"Optional client-generated UUID (any version — deterministic uuid5 ids work well). Supply it to know the id up front (for later edits) and to make writes idempotent — re-sending the same id is a safe no-op. Omit → the server mints one."},"code":{"type":"string","description":"Short stable code, e.g. \"pre_verb\"."},"name":{"oneOf":[{"type":"string"},{"$ref":"#/components/schemas/MultiString"}],"description":"Display label, per language. Required."},"after_slot_id":{"type":"string","description":"Insert after this slot (fractional order); omit → append."}}},"ClauseSlotFull":{"type":"object","description":"A clause-template slot (read shape).","properties":{"id":{"type":"string"},"code":{"type":["string","null"]},"name":{"$ref":"#/components/schemas/MultiString"},"sort_key":{"type":["string","null"]},"updated_at":{"type":"string","format":"date-time"}}},"TextTagInput":{"type":"object","required":["name"],"description":"A classification tag attached to a TEXT (find-or-created, mirroring entry tags): genre, motif, or tale-type — enabling a folklore-style motif index + cross-text browse. Reuses the shared tag registry with `kind`/`code` classification columns.","properties":{"name":{"type":"string","description":"Tag label (found-or-created). Required."},"kind":{"type":["string","null"],"enum":["motif","genre","tale_type",null],"description":"Classification namespace; omit for a plain label."},"code":{"type":"string","description":"Optional controlled-vocab code (e.g. an ATU/Thompson motif number)."}}},"TextTagView":{"type":"object","description":"A text's classification tag (read shape).","properties":{"id":{"type":"string"},"name":{"type":"string"},"kind":{"type":["string","null"]},"code":{"type":["string","null"]}}},"TextEmbed":{"type":"object","description":"A text heard inside another text (a song or story inside a recorded programme). The reader shows the child at its place among the parent's sentences and, over `start_ms`–`end_ms` of the parent's audio, follows along in the child's own sentences.","properties":{"id":{"type":"string"},"parent_text_id":{"type":"string"},"child_text_id":{"type":"string"},"sort_key":{"type":["string","null"],"description":"Position among the parent's sentences (compared with their `sort_key`s)."},"start_ms":{"type":["integer","null"],"description":"Where the child starts in the parent's text audio."},"end_ms":{"type":["integer","null"]},"label":{"type":["object","null"],"description":"Optional card label (e.g. the kind of piece), per language.","additionalProperties":{"type":"string"},"example":{"default":"mbwa","en":"dog"}}}},"TextEmbedInput":{"type":"object","required":["child_text_id"],"properties":{"id":{"type":"string","format":"uuid","description":"Optional: update this embed. Without it, the same child at the same `start_ms` updates the existing embed."},"child_text_id":{"type":"string"},"after_sentence_id":{"type":["string","null"],"description":"Place the child after this sentence of the parent (`null` = before the first). Omit to place it at the end (new embeds) or keep its place."},"sort_key":{"type":"string","description":"Or give the position directly."},"start_ms":{"type":["integer","null"],"minimum":0,"description":"The child's span in the parent's text audio (with `end_ms`)."},"end_ms":{"type":["integer","null"]},"label":{"oneOf":[{"type":"string"},{"$ref":"#/components/schemas/MultiString"},{"type":"null"}]}}},"SourceCitation":{"type":"object","required":["slug"],"description":"A source reference WITH a citation locus (page / example number, e.g. \"1981:31\"). A bare slug string is still accepted where a source ref is expected; this richer form preserves scholarly provenance.","properties":{"slug":{"type":"string","description":"An existing source-registry slug."},"locator":{"type":"string","description":"Page / example number / \"as quoted from X 1981:31\" note."}}},"TranslationProvenance":{"type":"object","required":["status"],"description":"Where one translation of a sentence comes from.","properties":{"status":{"type":"string","enum":["machine","reviewed","published"],"description":"`machine`: unreviewed machine output · `reviewed`: checked by a person · `published`: quoted from a published translation."},"citation":{"$ref":"#/components/schemas/SourceCitation","description":"For `published`: the source it is quoted from (an existing slug) and the locator (verse, page)."}}},"Morpheme":{"type":"object","required":["form"],"description":"Optional word-INTERNAL segmentation of a token, for polysynthetic/agglutinative data (isolating languages need only the token-level `gloss`).","properties":{"form":{"type":"string","description":"The morpheme's surface form."},"gloss":{"type":"object","description":"Aligned gloss for this morpheme, per analysis language.","additionalProperties":{"type":"string"},"example":{"default":"mbwa","en":"dog"}},"entry_id":{"type":["string","null"],"description":"Optional link to a headword (independent of `gloss`)."},"candidates":{"type":"array","items":{"type":"string"},"description":"Read-mostly: homograph entry ids the affix-aware matcher could not choose between (`-k¹ -k² -k³`). Resolve by writing `entry_id` via `segment`."},"separator":{"type":"string","enum":["-","=","~","."],"description":"Leipzig boundary preceding this morpheme: `-` affix, `=` clitic, `~` reduplication, `.` one-form-many-glosses. Omit for the root/first morpheme. Lets the gloss line render in faithful Leipzig notation."}}},"SentenceTokenInput":{"type":"object","required":["form"],"description":"A writable interlinear token — the GOLD alignment from a glossed source. `start`/`end` are OPTIONAL on write: omit them and the server derives offsets by walking the ORDERED `form`s against the sentence `text` with a LEFT-TO-RIGHT CURSOR that consumes each match in turn (a global search would collide — ~28% of real sentences repeat a form). Derivation therefore requires each `form` to be an exact substring of `text`, in order: keep surface forms byte-identical to the text (do not strip footnote/tone/transcription artifacts from the form but not the text, or vice-versa). On read, offsets are always present.","properties":{"form":{"type":"string","description":"Surface form exactly as it appears in the sentence text (byte-identical, so offset derivation can locate it)."},"start":{"type":"integer","description":"Char offset into the orthography's text. Optional on write (derived if omitted)."},"end":{"type":"integer","description":"Char offset, exclusive. Optional on write."},"gloss":{"type":"object","description":"The aligned interlinear gloss — the Leipzig gloss line, per analysis language. CONVENTION (locked): store language-neutral grammatical category codes (`3PL`, `PFV`, `CLF`) under the reserved `default` key, and per-language LEXICAL glosses (`tiger`, `虎`) under their language codes (`en`, `zh`). A reader on gloss-language X sees `gloss[X] ?? gloss.default`, so a neutral code kept under `default` survives every gloss-language switch (storing it under `en` would make it vanish for a `zh` reader). Independent of `entry_id` (grammatical morphemes/portmanteaux often have no headword but must still be glossable). A legend code found ANYWHERE in a gloss cell (matched as a substring, so portmanteaux like `eat PFV` / `can/ATT` still highlight) renders SMALL CAPS + tap-to-expand automatically — so no per-token \"grammatical?\" flag is needed.","additionalProperties":{"type":"string"},"example":{"default":"mbwa","en":"dog"}},"entry_id":{"type":["string","null"],"description":"Optional link to the dictionary entry for this token (independent of, and optional to, `gloss`). Multi-word→one-gloss = one token spanning the char range."},"sense_id":{"type":["string","null"]},"candidates":{"type":"array","items":{"type":"string"},"description":"Shortlist of candidate entry ids for an AMBIGUOUS match (homographs) — write it instead of dropping the link. In orthographies where spelling neutralizes a contrast (tone, length, stress) the highest-frequency words are exactly the ambiguous ones, so a matcher that can only write `entry_id` or nothing loses the words readers tap most. Candidates surface in the `ambiguous` facet of the suggestions queue for a human to resolve; leave `status` absent alongside them. Ids already resolved by `entry_id` are dropped."},"morphemes":{"type":"array","items":{"$ref":"#/components/schemas/Morpheme"},"description":"Optional word-internal segmentation."},"uncertain":{"type":"boolean","description":"The WORD itself is uncertain — an unsure machine transcription, a hard-to-hear stretch of a recording. Independent of `entry_id` (the link may be right for a possibly misheard word). Readers see the word in its own colour, with a one-line legend. Omit when the word is sure."},"forms":{"type":"object","additionalProperties":{"type":"string"},"description":"The word's spelling in OTHER orthographies (orthography code → form), typically converted by rule, for a word no linked entry can convert yet. A reader who switches script sees this form, drawn as uncertain; a linked entry's lexeme in that orthography always wins. Drop it once the word has an entry."},"status":{"type":"string","enum":["auto","confirmed","ignored"],"description":"Match state. OMIT IT to mean UNMATCHED — that absent state is what keeps a token reachable by the auto-matcher and by the suggestions review queue, so never stamp `confirmed` on a token no human confirmed. Omitted, a token with a resolved `entry_id`/`sense_id` (or a linked morpheme) defaults to `confirmed`; a bare, gloss-only, or candidates-only token stays statusless — a gloss is metadata, not a link, and the matcher links glossed words while keeping the gloss. `auto` = machine-matched, unconfirmed. `ignored` = punctuation (keeps offset/timing arrays aligned)."}}},"SentenceTokenFull":{"type":"object","description":"A stored interlinear token (read shape). The default-orthography token list is the shared index that the gloss line, word→entry taps, AND `audio.timings` karaoke all align to 1:1.","properties":{"form":{"type":"string"},"start":{"type":"integer"},"end":{"type":"integer"},"gloss":{"type":["object","null"],"description":"A multilingual string: a map of locale code → text. Use `default` for the vernacular/headword writing system, and gloss-language codes (e.g. `en`, `es`) for glosses/translations. Most string inputs also accept a plain string, which is wrapped as `{ \"default\": \"…\" }`.","additionalProperties":{"type":"string"},"example":{"default":"mbwa","en":"dog"}},"entry_id":{"type":["string","null"]},"sense_id":{"type":["string","null"]},"candidates":{"type":"array","items":{"type":"string"},"description":"Candidate entry ids when the match is ambiguous (homographs) and no single `entry_id` is resolved yet."},"morphemes":{"type":"array","items":{"$ref":"#/components/schemas/Morpheme"}},"status":{"type":["string","null"],"description":"Absent = unmatched (still reachable by the matcher + review queue)."},"uncertain":{"type":"boolean","description":"Present (true) when the word itself is uncertain."},"forms":{"type":"object","additionalProperties":{"type":"string"},"description":"Other-orthography spellings (code → form) for readers who switch script, when set."}}},"DictionaryCatalogPatch":{"type":"object","description":"Partial update of the dictionary's own catalog metadata. Send ONLY the keys you are changing; any key not listed here is rejected with a 400. `gloss_languages`, `orthographies`, the cover photo and publication have their own endpoints.","properties":{"name":{"type":"string","description":"Display name of the dictionary."},"url":{"type":"string","description":"URL slug. Changing it breaks existing links — confirm with the manager first."},"about":{"type":"string","description":"Long-form markdown shown on the About page. For an imported work this is where its preface, acknowledgements, contributor bios and bibliography belong — attribute them to their authors rather than presenting them as the dictionary's own voice. Replaces the whole text: append to the current value from `GET`, never send your section alone."},"citation":{"type":"string","description":"How this dictionary asks to be cited."},"alternate_names":{"type":"array","items":{"type":"string"},"description":"Other names the language is known by."},"location":{"type":"string","description":"Free-text description of where the language is spoken."},"coordinates":{"$ref":"#/components/schemas/Coordinates","deprecated":true,"description":"DEPRECATED — mark where-spoken places instead (`…/places/{placeId}/links` with `dictionary: {}`); the catalog location is derived from them. Still accepted: each point/region becomes a where-spoken place."},"iso_639_3":{"type":"string","description":"ISO 639-3 code."},"glottocode":{"type":"string","description":"Glottolog code."},"copyright":{"type":"string"},"author_connection":{"type":"string","description":"The dictionary owner's stated relationship to the community."},"community_permission":{"type":"string"},"con_language_description":{"type":"string","description":"Only for constructed languages."},"language_used_by_community":{"type":"integer","description":"1 = still used by the community."},"hide_living_tongues_logo":{"type":"integer"},"write_in_collaborators":{"type":"array","items":{"type":"string"},"description":"Contributor names with no user account — they appear on the contributors page."}}},"GlossingAbbreviationInput":{"type":"object","required":["code","name"],"description":"One entry in the dictionary's glossing-abbreviations legend (mirrors clause-slots). Makes gloss lines self-documenting: a code found here — matched as a SUBSTRING of a gloss cell, so portmanteaux like `eat PFV` still highlight — is tap-to-expand AND renders SMALL CAPS (so no per-token \"grammatical\" flag is needed). Seed from the standard Leipzig set + custom codes.","properties":{"code":{"type":"string","description":"The abbreviation as it appears in glosses, e.g. \"3PL\", \"PFV\", \"CLF\". Required."},"name":{"oneOf":[{"type":"string"},{"$ref":"#/components/schemas/MultiString"}],"description":"Expansion, per analysis language, e.g. \"third person plural\". Required."},"category":{"type":"string","description":"Optional grouping for the legend UI, e.g. person / number / tense / aspect / case."}}},"GlossingAbbreviationFull":{"type":"object","description":"A glossing-abbreviation legend entry (read shape).","properties":{"code":{"type":"string"},"name":{"$ref":"#/components/schemas/MultiString"},"category":{"type":["string","null"]},"updated_at":{"type":"string","format":"date-time"}}},"CognateSetMemberInput":{"type":"object","required":["entry_id"],"description":"One member (reflex) of a cognate set. Membership can be narrowed to one sense, annotated, and flagged uncertain.","properties":{"entry_id":{"type":"string"},"sense_id":{"type":["string","null"],"description":"Narrow the membership to ONE meaning of the entry."},"note":{"type":["string","null"],"description":"Per-reflex note (\"irregular tone reflex\", \"semantic narrowing: fresh water only\")."},"uncertain":{"type":"boolean","description":"Membership is plausible but not established — rendered as a \"?\" badge. Prefer this + a note over omitting a debatable member."}}},"CognateSetInput":{"type":"object","description":"A cognate set (\"word family\"): N entries that are reflexes of one ancestor form. The reconstruction lives HERE (it is not a word in any documented variety, so it is not an entry). Requires a `reconstruction` or a `gloss` (the set needs a head to display). Use pairwise `cognate` relationships instead when you only have two entries and no reconstruction-level claim.","properties":{"id":{"type":"string","format":"uuid","description":"Optional client-generated UUID (any version — deterministic uuid5 ids work well). Supply it to know the id up front (for later edits) and to make writes idempotent — re-sending the same id is a safe no-op. Omit → the server mints one."},"reconstruction":{"type":["string","null"],"description":"The proto-form WITHOUT the leading asterisk (display adds it), e.g. \"wai\". Optional — a family can be established on member evidence before an ancestor is reconstructed."},"proto_level":{"type":["string","null"],"description":"The reconstruction level, e.g. \"Proto-Polynesian\". Free text; keep one consistent spelling per level within a dictionary."},"gloss":{"oneOf":[{"type":"string"},{"$ref":"#/components/schemas/MultiString"}],"description":"The reconstructed meaning, per gloss language. Required when there is no `reconstruction`."},"notes":{"oneOf":[{"type":"string"},{"$ref":"#/components/schemas/MultiString"}],"description":"Curator notes as MARKDOWN, per analysis language."},"citations":{"type":"array","items":{"$ref":"#/components/schemas/SourceCitation"},"description":"Source refs — the comparative work(s) the set is taken from: `{ slug }` for bare membership, add `locator` for the page / set number. Each slug must already exist (`POST …/sources` first)."},"members":{"type":"array","items":{"$ref":"#/components/schemas/CognateSetMemberInput"},"description":"The member entries, inline. Manage later via `…/cognate-sets/{setId}/members`."}},"example":{"reconstruction":"wai","proto_level":"Proto-Polynesian","gloss":{"en":"water"},"citations":[{"slug":"pollex-2010","locator":"#1408"}],"members":[{"entry_id":"…"},{"entry_id":"…","uncertain":true,"note":"single attestation"}]}},"CognateSetPatch":{"type":"object","description":"Field-merge edit of a cognate set: `gloss`/`notes` overlay language keys, `citations` UNION with what is already there (attribution accumulates), scalars replace. Send `null` to clear a field. Members change via the members sub-resource.","properties":{"reconstruction":{"type":["string","null"]},"proto_level":{"type":["string","null"]},"gloss":{"anyOf":[{"type":"string"},{"$ref":"#/components/schemas/MultiString"},{"type":"null"}]},"notes":{"anyOf":[{"type":"string"},{"$ref":"#/components/schemas/MultiString"},{"type":"null"}]},"citations":{"type":["array","null"],"items":{"$ref":"#/components/schemas/SourceCitation"}}}},"CognateSetMemberFull":{"type":"object","description":"A set membership (read shape).","properties":{"id":{"type":"string","description":"The membership row id."},"entry_id":{"type":"string"},"sense_id":{"type":["string","null"]},"note":{"type":["string","null"]},"uncertain":{"type":"boolean"}}},"CognateSetFull":{"type":"object","description":"A cognate set (read shape) with its members inline.","properties":{"id":{"type":"string"},"reconstruction":{"type":["string","null"],"description":"WITHOUT the asterisk; render as *form."},"proto_level":{"type":["string","null"]},"gloss":{"$ref":"#/components/schemas/MultiString"},"notes":{"type":["object","null"],"description":"A multilingual string: a map of locale code → text. Use `default` for the vernacular/headword writing system, and gloss-language codes (e.g. `en`, `es`) for glosses/translations. Most string inputs also accept a plain string, which is wrapped as `{ \"default\": \"…\" }`.","additionalProperties":{"type":"string"},"example":{"default":"mbwa","en":"dog"}},"citations":{"type":["array","null"],"items":{"$ref":"#/components/schemas/SourceCitation"}},"members":{"type":"array","items":{"$ref":"#/components/schemas/CognateSetMemberFull"}},"updated_at":{"type":"string","format":"date-time"}}},"CognateSetSummary":{"type":"object","description":"The compact set shape embedded on entry reads (`entry.cognate_sets`, present only when the entry belongs to at least one set).","properties":{"id":{"type":"string"},"reconstruction":{"type":["string","null"]},"proto_level":{"type":["string","null"]},"gloss":{"$ref":"#/components/schemas/MultiString"},"member_count":{"type":"integer"}}},"PlaceInput":{"type":"object","required":["name"],"description":"A named, located place: a village where words were recorded, the town a story was told in, the county or valley a variety is spoken across. Places nest (`parent_id`: village → county → province) and are linked to entries, texts, dialects and speakers via `…/places/{placeId}/links`. Coordinates are optional (a place known only by name is still useful); when present, say how good they are with `precision`.","properties":{"id":{"type":"string","description":"Your own uuid — re-POSTing the same id is a safe no-op, so retries never duplicate."},"name":{"oneOf":[{"$ref":"#/components/schemas/MultiString"},{"type":"string"}],"description":"One key per language/script (local script, a romanization, English…). A plain string becomes `{ default }`. Required."},"kind":{"type":["string","null"],"description":"What sort of place: village, town, city, township, district, county, prefecture, province, state, country, region, river, mountain, island, site (free text — other values are accepted)."},"lat":{"type":["number","null"],"minimum":-90,"maximum":90,"description":"WGS84 latitude. Send both lat and lng, or neither."},"lng":{"type":["number","null"],"minimum":-180,"maximum":180,"description":"WGS84 longitude."},"precision":{"type":["string","null"],"enum":["exact","village","town","township","district","county","city","prefecture","province","region","country","approximate",null],"description":"How well lat/lng locate the place — `county` means \"somewhere in this county; the point is its seat/centroid\". Maps draw coarse places as soft areas rather than pins."},"uncertainty_m":{"type":["number","null"],"minimum":0,"description":"Optional uncertainty radius in metres."},"parent_id":{"type":["string","null"],"description":"The enclosing place (must already exist — in a batch, an EARLIER item counts). No cycles."},"boundary":{"type":["array","null"],"items":{"type":"array","items":{"type":"array","items":{"type":"number"},"minItems":2,"maxItems":2}},"description":"Optional outline of an area place (county, valley, a dialect's documented extent): GeoJSON-style polygon rings of `[lng, lat]` — first ring the outer edge, any further rings holes. Rings close automatically; ≥3 corners each, ≤4000 corners in all. Maps draw a place with a boundary as that outline instead of an estimated one."},"notes":{"$ref":"#/components/schemas/MultiString"},"citations":{"type":"array","items":{"$ref":"#/components/schemas/SourceCitation"},"description":"Where the place / its coordinates come from. Each slug must already exist (`…/sources`)."}}},"PlacePatch":{"type":"object","description":"Field-merge edit: `name`/`notes` overlay language keys, `citations` UNION with what is already there, scalars replace. Send `null` to clear a field (not `name`). Clearing coordinates needs both `lat: null` and `lng: null`.","properties":{"name":{"oneOf":[{"$ref":"#/components/schemas/MultiString"},{"type":"string"}]},"kind":{"type":["string","null"]},"lat":{"type":["number","null"]},"lng":{"type":["number","null"]},"precision":{"type":["string","null"],"enum":["exact","village","town","township","district","county","city","prefecture","province","region","country","approximate",null]},"uncertainty_m":{"type":["number","null"]},"parent_id":{"type":["string","null"]},"boundary":{"type":["array","null"],"items":{"type":"array","items":{"type":"array","items":{"type":"number"}}},"description":"Replaces the outline (`null` removes it)."},"notes":{"type":["object","null"],"description":"A multilingual string: a map of locale code → text. Use `default` for the vernacular/headword writing system, and gloss-language codes (e.g. `en`, `es`) for glosses/translations. Most string inputs also accept a plain string, which is wrapped as `{ \"default\": \"…\" }`.","additionalProperties":{"type":"string"},"example":{"default":"mbwa","en":"dog"}},"citations":{"type":["array","null"],"items":{"$ref":"#/components/schemas/SourceCitation"}}}},"PlaceFull":{"type":"object","description":"A place (read shape) with its dialect and text links inline, plus counts of linked entries and the speakers who live there.","properties":{"id":{"type":"string"},"name":{"$ref":"#/components/schemas/MultiString"},"kind":{"type":["string","null"]},"lat":{"type":["number","null"]},"lng":{"type":["number","null"]},"precision":{"type":["string","null"],"enum":["exact","village","town","township","district","county","city","prefecture","province","region","country","approximate",null]},"uncertainty_m":{"type":["number","null"]},"parent_id":{"type":["string","null"]},"notes":{"type":["object","null"],"description":"A multilingual string: a map of locale code → text. Use `default` for the vernacular/headword writing system, and gloss-language codes (e.g. `en`, `es`) for glosses/translations. Most string inputs also accept a plain string, which is wrapped as `{ \"default\": \"…\" }`.","additionalProperties":{"type":"string"},"example":{"default":"mbwa","en":"dog"}},"citations":{"type":["array","null"],"items":{"$ref":"#/components/schemas/SourceCitation"}},"boundary":{"type":["array","null"],"items":{"type":"array","items":{"type":"array","items":{"type":"number"}}},"description":"Outline rings of `[lng, lat]` (closed), or null."},"dictionary":{"anyOf":[{"type":"object","properties":{"role":{"type":["string","null"],"enum":["documented","spoken","reported","diaspora",null]},"weight":{"type":["number","null"]}}},{"type":"null"}],"description":"Set when this place is one of where the LANGUAGE is spoken (the dictionary home map + catalog location), with its role and weight."},"dialects":{"type":"array","items":{"type":"object","properties":{"dialect_id":{"type":"string"},"role":{"type":["string","null"],"enum":["documented","spoken","reported","diaspora",null]},"weight":{"type":["number","null"]}}}},"texts":{"type":"array","items":{"type":"object","properties":{"text_id":{"type":"string"},"role":{"type":["string","null"],"enum":["recorded","setting",null]}}}},"entry_count":{"type":"integer","description":"How many entries are linked to this place. Each entry read carries its own `places`."},"speaker_ids":{"type":"array","items":{"type":"string"},"description":"Speakers whose home place this is."},"updated_at":{"type":"string","format":"date-time"}}},"PlaceSummary":{"type":"object","description":"The compact place shape embedded on entry and text reads (`places`, present only when linked).","properties":{"id":{"type":"string"},"name":{"$ref":"#/components/schemas/MultiString"},"kind":{"type":["string","null"]},"lat":{"type":["number","null"]},"lng":{"type":["number","null"]},"precision":{"type":["string","null"],"enum":["exact","village","town","township","district","county","city","prefecture","province","region","country","approximate",null]}}},"PlaceLinksInput":{"type":"object","description":"Links to add from ONE place, all in one transaction (all-or-nothing: an unknown id or bad role rejects the whole request). Idempotent: a link that already exists is updated in place (role / weight / note) or reported `unchanged`. ≤1000 links per request.","properties":{"entries":{"type":"array","items":{"type":"object","required":["entry_id"],"properties":{"entry_id":{"type":"string"},"note":{"type":["string","null"]}}},"description":"Where the entry (this form) is attested or was recorded."},"texts":{"type":"array","items":{"type":"object","required":["text_id"],"properties":{"text_id":{"type":"string"},"role":{"type":["string","null"],"enum":["recorded","setting",null],"description":"`recorded` — told/recorded here; `setting` — the story is about this place."}}}},"dialects":{"type":"array","items":{"type":"object","required":["dialect_id"],"properties":{"dialect_id":{"type":"string"},"role":{"type":["string","null"],"enum":["documented","spoken","reported","diaspora",null],"description":"`documented` — recorded/surveyed here; `spoken` — reported as spoken here; `reported` — a weaker literature claim; `diaspora` — a community outside the homeland."},"weight":{"type":["number","null"],"minimum":0,"maximum":1,"description":"How strongly this place evidences the dialect (maps fade presence by weight)."}}}},"speakers":{"type":"array","items":{"type":"object","required":["speaker_id"],"properties":{"speaker_id":{"type":"string"}}},"description":"Sets each speaker's home place."},"dictionary":{"type":"object","properties":{"role":{"type":["string","null"],"enum":["documented","spoken","reported","diaspora",null]},"weight":{"type":["number","null"],"minimum":0,"maximum":1}},"description":"Marks this place as one of where the LANGUAGE is spoken (not tied to a dialect): the dictionary home map draws these, and the catalog location (`dictionaries.coordinates`, world map / directory) is derived from them. Same roles and weight as a dialect link. Send `{}` for a plain \"spoken here\"."}}},"PlaceUnlinkInput":{"type":"object","description":"Links to remove from ONE place. Ids that are not linked are ignored. Removing a speaker clears its home place.","properties":{"entry_ids":{"type":"array","items":{"type":"string"}},"text_ids":{"type":"array","items":{"type":"string"}},"dialect_ids":{"type":"array","items":{"type":"string"}},"speaker_ids":{"type":"array","items":{"type":"string"}},"dictionary":{"type":"boolean","description":"`true` removes the place from where the language is spoken."}}},"EtymologyInput":{"type":"object","required":["verdict"],"description":"One TYPED etymology record for an entry — the queryable companion to the free-prose `linguistic_history` MultiString (which stays as-is). Zero-to-many per entry; multiple records are COMPETING HYPOTHESES. `verdict` gates which side is meaningful but nothing else is hard-required: an `unknown` verdict with a note is a legitimate record (\"origin unclear, see discussion\"). A `loan` may still carry `reconstruction_level`/`reconstruction` when the word was borrowed FROM a reconstructed stage.","properties":{"id":{"type":"string","format":"uuid","description":"Optional client-generated UUID (any version — deterministic uuid5 ids work well). Supply it to know the id up front (for later edits) and to make writes idempotent — re-sending the same id is a safe no-op. Omit → the server mints one."},"verdict":{"type":"string","enum":["inherited","loan","unknown"]},"reconstruction_level":{"type":["string","null"],"description":"The reconstruction level, e.g. \"Proto-Riverine\". Free text; keep one consistent spelling per level within a dictionary."},"reconstruction":{"type":["string","null"],"description":"The ancestor form WITHOUT the leading asterisk (display adds it)."},"cognate_set_id":{"type":["string","null"],"description":"Point at the word family (`…/cognate-sets`) instead of / in addition to carrying a standalone reconstruction. Must be an existing set."},"donor_language":{"type":["string","null"],"description":"The donor language name (loan path). Free text."},"donor_form":{"type":["string","null"],"description":"The form in the donor language."},"stratum":{"type":["string","null"],"description":"Loan stratum / layer label. Free text; keep one consistent spelling per stratum within a dictionary."},"confidence":{"type":["string","null"],"enum":["certain","probable","possible",null]},"notes":{"oneOf":[{"type":"string"},{"$ref":"#/components/schemas/MultiString"}],"description":"Discussion as MARKDOWN, per analysis language."},"citations":{"type":"array","items":{"$ref":"#/components/schemas/SourceCitation"},"description":"Source refs — where this etymology is asserted: `{ slug }` for bare membership, add `locator` for the page / item number. Each slug must already exist (`POST …/sources` first)."}},"example":{"verdict":"loan","donor_language":"Lakeshore","donor_form":"kelu","stratum":"early trade layer","confidence":"probable","notes":"Borrowed with the introduction of net fishing."}},"EtymologyPatch":{"type":"object","description":"Field-merge edit of an etymology record: `notes` overlays language keys, `citations` UNION with what is already there (attribution accumulates), scalars replace. Send `null` to clear a field. `verdict` can change (a hypothesis gets settled) but never to null — the record's other fields are preserved, so a verdict flip keeps the evidence it supersedes.","properties":{"verdict":{"type":"string","enum":["inherited","loan","unknown"]},"reconstruction_level":{"type":["string","null"]},"reconstruction":{"type":["string","null"]},"cognate_set_id":{"type":["string","null"]},"donor_language":{"type":["string","null"]},"donor_form":{"type":["string","null"]},"stratum":{"type":["string","null"]},"confidence":{"type":["string","null"],"enum":["certain","probable","possible",null]},"notes":{"anyOf":[{"type":"string"},{"$ref":"#/components/schemas/MultiString"},{"type":"null"}]},"citations":{"type":["array","null"],"items":{"$ref":"#/components/schemas/SourceCitation"}}}},"EtymologyFull":{"type":"object","description":"A typed etymology record (read shape).","properties":{"id":{"type":"string"},"entry_id":{"type":"string"},"verdict":{"type":"string","enum":["inherited","loan","unknown"]},"reconstruction_level":{"type":["string","null"]},"reconstruction":{"type":["string","null"],"description":"WITHOUT the asterisk; render as *form."},"cognate_set_id":{"type":["string","null"]},"donor_language":{"type":["string","null"]},"donor_form":{"type":["string","null"]},"stratum":{"type":["string","null"]},"confidence":{"type":["string","null"],"enum":["certain","probable","possible",null]},"notes":{"type":["object","null"],"description":"A multilingual string: a map of locale code → text. Use `default` for the vernacular/headword writing system, and gloss-language codes (e.g. `en`, `es`) for glosses/translations. Most string inputs also accept a plain string, which is wrapped as `{ \"default\": \"…\" }`.","additionalProperties":{"type":"string"},"example":{"default":"mbwa","en":"dog"}},"citations":{"type":["array","null"],"items":{"$ref":"#/components/schemas/SourceCitation"}},"updated_at":{"type":"string","format":"date-time"}}}}}}