Kotoba

Where to start

What Kotoba isWho she is and where she runs, in one page
InstallingOne package, two commands
First runA key, a model, her first words
The approval gateHow she asks before she acts
The two voice modesLocal voice, or the agent tunnel
The soul fileChange who she is
RoadmapWhat grows next, and what was cut on purpose

Or a section

↑↓ move openesc close124 pages
All pages

Architecture7 of 10

The packaged frontend

About 5 minutes to read

On this page

Kotoba's web UI is a Next.js app, and pip install kotoba-companion needs no Node at all. This page explains how that works and what it means when you are reasoning about a deployment.

The build becomes a folder inside the wheel

scripts/build_web.py runs next build with KOTOBA_STATIC_EXPORT=1, which turns on output: "export" into its own distDir (.next-export) — a static build alongside a running next dev was measured leaving the dev server serving. It then copies the export into api/src/kotoba/web/ and writes a stamp:

json
{ "version": "0.10.0",
  "files": 69,
  "web_sha256":     "…",   // the build's own contents
  "sources_sha256": "…" }  // the frontend sources it was built from

api/pyproject.toml declares web/**/* as package data, so that folder travels inside the wheel.

The build also refuses to carry the machine that made it. next build inlines any NEXT_PUBLIC_* it finds in the environment or in a .env.local, and minified into a chunk one address looks like any other, so the static build forces the lot to "" in next.config.ts rather than asking the builder to clean their shell. scripts/build_web.py then scans the output for hostnames, with an allowlist for the ones that legitimately appear and — checked first, so no allowlist entry can disable them — the shapes that can only be somebody's own machine: loopback names, private suffixes, tunnel domains.

The wheel build refuses a stale UI

api/pyproject.toml names an in-tree PEP 517 backend, api/kotoba_build.py. It never runs npm — an install must not need Node — it only verifies, through api/_web_check.py:

  • The four required pages are present (app.html, login.html, setup.html, 404.html) and there is JavaScript under web/_next/static.
  • The stamp's version matches the package's __version__.
  • The stamp's web_sha256 and file count still match what is on disk.
  • Where the frontend sources are present, the stamp's sources_sha256 still matches them — the other half of "stale", and the one that actually ships: the build is intact, but the frontend moved on since.

A wheel carrying an empty or stale build is worse than one carrying none, because the static layer denies every path by design: the person gets a blank page and no reason for it.

build_editable deliberately does not require the build — a contributor runs the dev server.

FastAPI serves it from the 404 handler

Not a Mount, not a catch-all route. kotoba/server.py:

python
@app.exception_handler(404)
async def serve_frontend(request, exc): ...

The static layer is reached only where routing found nothing, so it shadows no route and leaves every existing 404 and 405 exactly as it was. A catch-all route would not — it turns every unknown path into a partial match and answers 405 to anything but GET. A Mount is worse still: it also matches WebSocket scopes, and its html mode wants index.html where this build writes app.html.

Which build, and on what terms

core/frontend.py answers both, and it is deny by default, derived from the build output rather than from a list of URL prefixes. An allowlist of prefixes is a rule somebody has to keep in step with a route table forever, and that is the shape that has already failed open on this API twice.

root() picks the directory, in this order:

  1. KOTOBA_FRONTEND_DIR, if set — and if it holds no app.html, the answer is none, with no fallback. The env answer wins, so a typo there would otherwise disable the UI in silence even on an install carrying a perfectly good build.
  2. <package>/web — the packaged build.
  3. From a clone only, <repo>/.next-export.

Note the clone check on that third one is not cosmetic: from a wheel, REPO_ROOT is the home directory that also holds her file library, so an unguarded fallback would aim a public static server at the user's files.

classify(url_path) then returns one of three answers:

AccessPages
GATEDapp, setup — cost a signed session
PUBLIC_next, login, _not-found, 404, icon.svg, live2dcubismcore.min.js, worklets, subagents, scene, art
DENIEDeverything else, by falling off both lists

Everything a browser needs before it has a credential is public, because that is where a credential comes from.

classify runs two checks, and they do different jobs. The first is on the URL's own first segment and denies fast, before anything touches disk — that is what keeps every API path off the static layer entirely. The second is the one the module's docstring is emphatic about: the final PUBLIC / GATED answer is read off the resolved path, never the URL. So a file reached through a symlink from a public directory into app/ is still classified GATED, and a filesystem that resolves a different spelling to the same bytes cannot talk its way past by spelling.

Path resolution has two independent guards — the segments are checked before anything touches disk, and containment is re-checked after .resolve() collapses symlinks. The syntactic pass is segment-wise on purpose: a real Next chunk is named something like 0k90..qj97h~j.js, so a substring test for .. would refuse a file the app needs.

Verified live on a clone with the packaged build present:

/            -> public   (a redirect to /app, no body of its own)
/app         -> gated
/login       -> public
/icon.svg    -> public
/APP.HTML    -> denied
/api/files   -> denied   (keeps every API path off the disk entirely)
/health      -> denied   (it is a route, not a file — the API answers it)

What this means for a deployment

One port serves everything. kotoba serve on a wheel install starts the backend and the UI is at /app on the same port. There is no second process and no second address.

A clone with Node behaves differently, on purpose. kotoba serve starts next dev on port 3000 whenever it can, because a contributor's edits have to show. Only when the dev server cannot run does it ask whether this install carries a built UI the backend can serve by itself.

In dev, /api/* is a same-origin rewrite. With NEXT_PUBLIC_API_URL unset, next.config.ts rewrites /api/:path* to the backend, so there is no CORS and no cross-site cookie. Set it and the browser calls the backend directly instead.

The voice WebSocket never goes through that rewrite. Next does not forward upgrades, so in proxy mode the voice socket dials the backend directly. And a rewrite is frozen at build time — changing the backend URL on a running server does nothing. This is the one genuinely surprising thing in the Docker stack: the frontend image bakes ws://localhost:<backend port>, so opening the stack from the Docker host gives working voice, and opening it from another machine on the network gives a page that loads with voice silently absent. Text is unaffected.

Next's compression is off (compress: false). Its gzip buffers a proxied SSE stream until the stream closes, so events arrived only once the stream had died. The backend sends Cache-Control: no-cache, no-transform on the event stream for the same reason.

Two gates, one wire format. proxy.ts (Next 16's rename of the middleware convention) gates /app, /setup and /login at the edge; core/gate.py mints and verifies the same HMAC-signed cookie on the Python side. A session minted by either verifies on the other, and divergence would be invisible from outside — the login succeeds and every request after it refuses. Detail: The web gate.