← All posts

April 12, 2026

API design choices for a desktop 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.