Vocabulary API
The word list at /vocabulary. Mounted at /api/vocabulary. Two nouns: words and connections (typed edges between two words).
This module writes the same vocab_words table as the Knowledge API, so a word created either way is visible from both. The surface here is narrower and several response keys differ, so pick one path per client and stay on it rather than mixing them.
Model#
- A word reads back as
{id, word, language, category, definition, pronunciation, partOfSpeech, exampleSentence, labels, aiMetadata, source, memorizationLocations, longTermMemorized, createdBy, createdAt, updatedAt}. category,source,memorizationLocationsandlongTermMemorizedcome back on every read but cannot be set through this module: they belong to the knowledge ingest and practice paths. Through/api/vocabularythey stay at their defaults.languageis free text (spanish,korean,python), not a locale code, andlabelsis a flat string array. Both are open vocabularies: read the live sets fromGET /languagesandGET /labelsinstead of hardcoding.- Creating a word runs an LLM enrichment pass that fills definition, pronunciation, partOfSpeech, exampleSentence and labels. Fields you send explicitly always beat enriched ones, and
autoEnrich: falseskips the pass entirely. Enrichment failure never blocks the write, so the word is created unenriched and the error is only logged. - A connection is typed:
synonym antonym related translation root. Uniqueness is on (fromWordId, toWordId, connectionType), so the same pair can carry several differently-typed edges but not two of the same type.
Reads (public)#
| Method | Path | Notes |
|---|---|---|
| GET | /words | {words, total, limit, offset}, newest first. Offset pagination, not a cursor, unlike the journal and scoreboard |
| GET | /words/:id | {word, connections, connectedWords}; 404 Word not found. The key is connectedWords; the knowledge twin returns connectedEntries |
| GET | /connections | ?wordId=<uuid> is required (400 without it); {connections} covering edges in both directions |
| GET | /labels | {labels}, every distinct label across all words, sorted |
| GET | /languages | {languages}, every distinct language, sorted |
GET /words filters: language exact match, label exact match against the array (containment, not substring), search case-insensitive substring on the word column only (definitions and examples are not searched), limit 1..500 default 100, offset 0..1000000 default 0 (SHAN-529: an unbounded offset used to reach Postgres and 500). Oversized filter values are rejected with a 400 before touching the database. Send search raw: the term is matched literally, so % and _ are those characters and not wildcards (SHAN-545 -- before it, ?search=_ returned every row). See Text search.
Writes#
Every write needs scope knowledge:write. There is no vocabulary:write. Plain CRUD shares PAT bucket vocabulary-writes (30/min); enrichment gets its own tighter bucket vocabulary-enrich (10/min) because each call spends an LLM request. Browser JWT sessions bypass both.
Ownership: you may modify only words you created. Legacy rows with no creator (createdBy: null, predating the column) stay writable by any authed caller. A violation answers 403, not the 404 that owner-scoped modules like the scoreboard use, so this path does tell you the row exists.
Words#
| Method | Path | Body | Notes |
|---|---|---|---|
| POST | /words | {word, language, definition?, pronunciation?, partOfSpeech?, exampleSentence?, labels?, autoEnrich?} | 201 {word}. 409 {error, existingWord} when (word, language) already exists. A two-part key, unlike the knowledge module's (word, language, category). Caps: word 255, language 50, definition 20000, pronunciation 255, partOfSpeech 50, exampleSentence 2000, labels 50 entries of 100 |
| PUT | /words/:id | same fields, all optional, no autoEnrich | Despite the PUT verb this is a partial update: only the keys you send change; omitted fields are left alone. To clear definition, pronunciation, partOfSpeech or exampleSentence, send null: an empty or whitespace-only string is ignored like an omitted key, not stored as blank. word and language cannot be cleared (400 on null or blank). 404 unknown, 403 not yours |
| DELETE | /words/:id | none | {ok: true}. Cascades: the word's connections are deleted with it, with no 409 guard. 404/403 as above |
| POST | /words/:id/enrich | none | re-runs the LLM pass over the stored word and language. Same ownership rule as PUT: 403 on a word someone else created, checked before any LLM spend. Only the fields enrichment returns something for are written; the rest are left out of the update, so a blank result keeps whatever is stored when the write lands, including an edit saved while the pass ran. 502 (retryable) when every LLM provider is exhausted, 500 on anything else |
Connections#
| Method | Path | Body | Notes |
|---|---|---|---|
| POST | /connections | {fromWordId, toWordId, connectionType, note?} | 201 {connection}. 400 connecting a word to itself, 404 unknown word id (the message names which one), 403 unless you own both words, 409 that typed edge already exists. note <= 1000 chars |
| DELETE | /connections/:id | none | {ok: true}; 404 unknown, 403 unless you own both endpoint words |
The /api/knowledge/connections twins write the same table and enforce the same ownership rule, so switching paths does not let you link or unlink words you did not create.
Example#
API=https://shanebackend-production.up.railway.app/api/vocabulary
# see what is already in there before adding
curl "$API/languages"
curl "$API/words?language=spanish&limit=5"
# add a word and let the enricher fill in the rest
curl -X POST $API/words -H "Authorization: Bearer $PAT" -H "Content-Type: application/json" \
-d '{"word":"gracias","language":"spanish"}'
# -> 201 {"word":{"definition":"thank you","partOfSpeech":"interjection","labels":[...],...}}
# link it to its English translation (you must own both words)
curl -X POST $API/connections -H "Authorization: Bearer $PAT" -H "Content-Type: application/json" \
-d '{"fromWordId":"<ES_ID>","toWordId":"<EN_ID>","connectionType":"translation"}'
GET /words/<ES_ID> then returns that edge in connections with the English row inlined in connectedWords, which is the cheapest way to walk the graph one hop.