Knowledge API
Free-text note ingest with AI classification, plus structured entries, connections, and comments. Mounted at /api/knowledge. The vocabulary module (/api/vocabulary) writes the same underlying table with a narrower surface.
Note ingest (the interesting endpoint)#
POST /api/knowledge/notes with scope knowledge:write. Three accepted body shapes (strict: unknown keys are 400):
{"text": "today I learned gracias means thank you in Spanish"}
{"text": "...", "source": "Nibbler"}
{"notes": [{"text": "...", "source": {"book": "War and Peace", "location": "ch. 3"}}]}
texttrimmed 1..5000.sourceis a string (becomes{app}) or an object with app/book/author/location/rawContext.- Single: 201
{entries:[entry]}. Batch (1..50): 201 all-ok, 207{entries, failures:[{index, text, error}]}on partial failure, 502 when every note failed or the LLM chain is exhausted (retryable). - Caller-supplied source fields beat classifier-extracted ones. No cross-request dedup by design; within one batch, duplicate (word, language) pairs are silently dropped.
- Rate: 30/min single, 5/min batch (separate buckets).
Every other write here is rate limited per PAT as well (browser sessions bypass; 429 with Retry-After: 60): entry create, edit, delete and bulk-delete plus connection create and delete share one 30/min bucket, POST /entries/:id/enrich has its own 10/min bucket because each call runs the model, and comment create, edit and delete have their own 30/min bucket.
Entries#
| Method | Path | Auth | Notes |
|---|---|---|---|
| GET | /entries | public | ?language=&label=&search=&category=&app=&location=&excludeLocation=&limit=1..500&offset=0..1000000; returns {entries, total, limit, offset} (offset pagination, unlike most modules) |
| GET | /entries/:id | public | {entry, connections, connectedEntries} |
| POST | /entries | knowledge:write | word + language required; 409 {error, existingEntry} on (word, language, category) duplicate; auto-enriches vocabulary entries via LLM unless autoEnrich:false (enrich failure never blocks) |
| PUT | /entries/:id | knowledge:write | owner-only (legacy ownerless rows editable by anyone authed); partial update: send null to clear definition, pronunciation, partOfSpeech or exampleSentence (an empty or whitespace-only string is ignored like an omitted key); memorizationLocations replaces the whole list; addMemorizationLocations / removeMemorizationLocations change it in place (sending both shapes is a 400); either feeds the long-term-memorized derivation |
| DELETE | /entries/:id | knowledge:write | same ownership rule |
| POST | /entries/bulk-delete | knowledge:write | {ids: [1..100]}; always 200 with per-id {deleted, denied, notFound} |
| POST | /entries/:id/enrich | knowledge:write | re-run AI enrichment; 403 on an entry someone else created (same rule as PUT); writes only the fields enrichment returned, so blank fields keep their stored value; 502 on LLM exhaustion |
?location= matches cards whose memorizationLocations array contains that place, case-insensitively (max 120 chars, same bound as a single location on PUT). ?excludeLocation= is the inverse with the same bounds: cards NOT yet memorized at that place, including cards with no locations at all, so a place nothing has been memorized at returns every card. GET /api/knowledge/locations (public) returns {locations: string[]}: every distinct location across all cards, deduped case-insensitively and sorted. That list is what the browse filter's dropdown is built from. Its siblings GET /labels, GET /languages and GET /categories return the same shape for their dimensions.
To mark or unmark a place, send addMemorizationLocations or removeMemorizationLocations (each up to 50 names of up to 120 chars) rather than a full memorizationLocations list. The deltas are applied to the card as it is stored at that moment, under a row lock that practice mode also takes when it records a newly memorized place, so a place recorded after you read the card is kept. Removal is case-insensitive and adding a place the card already holds, in any casing, changes nothing. A full list overwrites whatever is stored, so only send it when you mean to replace the set.
Cards from notes#
Inline notes and Explain answers on journal entries, blog posts, knowledge cards and public courses also arrive here: each new note is classified into a card with the same pipeline as POST /notes, from the selected words and the note only, with source.app set to journal, blog, knowledge or course (since SHAN-653; a note on a private course never becomes a card). See AI and Reader Notes API.
Recite#
Recite your own cards from memory, one place at a time. Both routes need scope knowledge:write and work on the caller's cards: ones they created, plus legacy rows with no recorded creator (the same set PUT /entries/:id lets them write).
| Method | Path | Notes |
|---|---|---|
| POST | /recite/deck | {location: 1..120, categories?: string[] (max 50)}, strict. Saves the place to your practice locations (GET /api/practice/locations; a new name creates it, an existing one in any casing reuses it). Returns {location: {id, name}, cards, total, categories: [{category, total, remaining}]}: your cards that have a definition and are not yet memorized at that place, in random order (max 500), plus per-category counts of all your cards. categories filters cards only; the counts always cover every category |
| POST | /entries/:id/recite | {location, answer: 1..5000, attempt?: 1..50 (default 1)}, strict. Grades answer against the card's definition for meaning, not wording. Pass: 200 {correct: true, score, feedback, judge, memorizedAt, entry}, and location is added to the card's memorizationLocations under its saved name. Miss: 200 {correct: false, score, feedback, judge, hint, hintNumber, hintsTotal}, nothing written. 403 on someone else's card, 404 unknown, 422 when the card has no definition, 503 when no grader is reachable |
judgesays what decided:match(near-verbatim, no model call),ai(a small free model; Groqgpt-oss-20bfirst) orsimilarity(local embeddings, used only when every model provider is down).hintis{kind, label, text}; send the attempt number and attempt N gets hint N, gentlest first:example(ortopicswhen the card has no example, or its example contains the answer),opening(the first few words, skipped for answers under four words), thenshape(each word's first letter, the rest masked). Past the last hint, the last one repeats.- Miss
feedbacknever names what is missing. Feedback that would is replaced server-side; the hints are the only part of a miss that reveals the answer. - Rate (PATs): 30/min deck, 60/min attempts.
Connections (synonym, antonym, related, translation, root): GET/POST /connections, DELETE /connections/:id, scope knowledge:write. POST returns 201 {connection}; 400 connecting an entry to itself, 404 unknown entry id, 403 unless you own both entries, 409 if that typed edge already exists. DELETE returns {ok: true}; 404 unknown, 403 unless you own both endpoint entries. Entries with no recorded creator (legacy rows) count as yours for this check. Both modules enforce the same rule on the same table, so neither path can link or unlink someone else's entries.
Comments: same shape as journal comments (GET/POST /entries/:id/comments, PATCH/DELETE /comments/:id, scope comments:write, snake_case parent_comment_id, one reply level). The parent must be a top-level comment on the same entry, or the POST is a 400; deleting a comment keeps its replies as top-level comments.
Vocabulary module differences#
Full route table: Vocabulary API. The differences that bite most often:
GET /api/vocabulary/wordssearch matches the word column only; list key is{words}not{entries}. Both modules matchsearchliterally -- do not escape%or_yourself, see Text search.- Duplicate check is (word, language) with no category dimension.
- All writes use scope
knowledge:write(there is no vocabulary:write), bucketvocabulary-writes30/min, enrich 10/min.