The Tokori HTTP API runs on localhost, behind a bearer token that lives at ~/.tokori/api-token. It looks REST-ish. Most of the conventions came from "what would a developer expect?" — a few came from "what would actively annoy them?" A short tour.
What we kept from REST
- Resource-oriented URLs.
/v1/workspaces/:id/vocab, not/v1/getVocabFor?ws=.... People read URLs out loud. - HTTP verbs do what they say.
GETis safe.POSTcreates.PATCHis partial.DELETEdeletes. We don't redefine them. - Versioned root. Everything lives under
/v1. When we break compatibility, it's a new prefix, not a silent shape change. - Standard status codes. 401 means "wrong key", 403 means "right key, wrong permission", 404 means "not here", 422 means "validation". One pattern across the whole surface.
What we ignored
- HATEOAS. Nobody writes clients that traverse hypermedia. The docs are the entry point.
- PUT for full-replace. We rarely needed it. PATCH does almost every update we have.
- Nested representations. By default, list endpoints don't embed children. You opt in with
?include=variantswhen you actually want them. That keeps the common case cheap.
Choices specific to "this lives on localhost"
- No OAuth. A single bearer token, generated locally, rotatable with one CLI command. There's no remote service to log into.
- CORS allows loopback by default. Browser extensions and local web tools should "just work". The token gates anything malicious.
- Read-only mode is a flag, not a separate token.
?readonly=1on any request. Useful for piping data into tools you don't fully trust. - SSE for streaming, not WebSockets. Same direction, fewer moving parts, works through every reverse proxy. Chat deltas come out as
text/event-stream.
Errors are an envelope, not a status code
Every error returns the same shape: { error: { code, message, field?, request_id } }. The status code communicates the kind, the code string is what your client matches on. Codes are namespaced (vocab.duplicate_word, chat.upstream_error) and stable across patch releases. That makes switch blocks in client code possible without grepping bodies.
Pagination is cursor-based, always
Lists return { data, next_cursor? }. Cursors are opaque base64 strings — clients should not try to parse them. We chose cursors over offsets because vocab lists grow and resort, and offset-pagination would visibly skip rows when something between pages got deleted mid-walk.
What we still don't know
We don't yet have a great answer for batch writes. Adding 5,000 vocab entries means 5,000 round-trips today. That's fine on localhost (microseconds each), but inelegant. A future POST /v1/workspaces/:id/vocab/batch might happen if anyone asks.
The full reference lives in the main Tokori repo on GitHub. Open an issue there if anything looks wrong, surprising, or missing.

About the author
The Tokori Team
Building a local-first language app
We're a small team building Tokori — a local-first, bring-your-own-AI workspace for language learners. We write here about the engineering and design calls behind the app.
More about us