All posts
Engineering2 min read

API design choices for a desktop app

Tokori exposes a local HTTP API. Here's why we picked the conventions we did, and what we didn't borrow from REST orthodoxy.

The Tokori Team

The Tokori Team

Building a local-first language app

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. GET is safe. POST creates. PATCH is partial. DELETE deletes. 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=variants when 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=1 on 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.

The Tokori Team

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