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

Configuration and operations6 of 9

Running it for real

About 9 minutes to read

On this page

kotoba serve

One command, one Ctrl+C, two processes at most. Three arguments do anything:

kotoba serve [--port 8000] [--web-port 3000] [--open]

The four rendering flags (--plain, --ascii, --calm, --no-face) parse here and are then refused by name with exit 2 — "almost every line it prints belongs to the two servers it watches, not to her" — rather than accepted and thrown away.

It picks one of three shapes:

  1. A package.json, npm on your PATH, and node_modules present. It starts the backend and npm run dev, because a contributor's edits have to show. The dev server wins whenever it can run.
  2. Any of those missing, but the install carries a built UI. Only the backend starts, and it serves the UI itself at /app, on the backend's own port. A pip install needs no Node at all.
  3. Neither. It names the missing piece — no web app here, npm not installed, dependencies missing, or an install carrying no build — and says how to get one: python scripts/build_web.py in a clone, a reinstall otherwise.

It needs the server extra; without fastapi and uvicorn it prints the install line and exits 1.

Loopback only, and there is no flag

The backend command is built in the code and the address is not a parameter:

<the interpreter that has kotoba> -m uvicorn kotoba.server:app --host 127.0.0.1 --port <port>

The dev server is likewise started with --hostname 127.0.0.1. kotoba serve has no --host.

That is the intended shape rather than an omission. This backend runs shell commands and Python on your machine, and on a default install no password is set, so a LAN-reachable port would be a remote execution endpoint for anyone on the network. If you want it reachable from elsewhere, you put something in front of it — see Behind a reverse proxy — or you run uvicorn yourself and accept what that means.

Two more things serve does that are easy to miss:

  • It adds the web port it just chose to CORS_ORIGINS for the backend child. The voice WebSocket is exempt from CORS and keeps its own allowlist, which otherwise knows only port 3000 — so kotoba serve --web-port 3100 used to leave the handshake refused with a 403 the UI does not show, and voice simply looked dead.
  • It sets NEXT_TELEMETRY_DISABLED=1 on the dev server it starts. The packaged build already has telemetry off; a dev server started by Kotoba would otherwise be the one path where something leaves your machine unasked.

Each child gets its own process group, and the teardown covers SIGTERM and SIGHUP as well as Ctrl+C — so systemd, Docker or any supervisor stopping the parent does not leave a backend and a Next worker holding their ports.

Two topologies

One origin: the backend serves everything

This is what a pip install gives you. The FastAPI app answers the API, the voice WebSocket and the pages, all on the backend port — 8000 unless you moved it. / redirects to /app.

Nothing has to be routed. There is no CORS question, no cross-site cookie, and the voice socket dials the page's own origin because the backend is what served the page. Measured: the packaged bundle contains no absolute backend address at all.

The UI it serves is not a static mount. Pages are answered from the 404 handler, so they shadow no route, and each path is classified against the build on disk rather than against a list of prefixes:

PathAccess
/app, /setupgated — a signed session cookie, when a password is set
/login, /icon.svg, /404.html, /_next/**public
anything not in the build404, exactly as the API would have said

/gate, /gate/token, /health, /favicon.ico and /v1/* are public at the middleware, and /v1 carries its own bearer.

Two origins: Next in front

The Next app serves the pages; the backend serves the API. NEXT_PUBLIC_API_URL is the one knob:

  • Unset (the default) — same-origin proxy mode. The browser only ever talks to the Next origin, and Next rewrites /api/* to KOTOBA_BACKEND_URL server-side. No CORS, no cross-site cookie, one URL.
  • Set — direct mode. The browser calls that origin itself. Rewrites are switched off entirely and the backend's CORS_ORIGINS must name the page's origin.

The two routes that must reach the backend

Whatever you build, exactly two things have to arrive:

  1. /api/* over HTTP — settings, files, avatar, models, session control, and the SSE stream at /api/events/{session_id}.
  2. /api/voice/{session_id} over WebSocket — and this one cannot go through the Next rewrite, because a rewrite does not carry a WebSocket upgrade. Kotoba dials it directly, at whatever NEXT_PUBLIC_VOICE_WS_URL says, falling back to the page's own origin when that is empty.

Two paths are not rewritten and therefore do not reach the backend through the Next origin: /health and /v1/chat/completions. The compiled manifest has exactly one rule:

json
{"source": "/api/:path*", "destination": "http://127.0.0.1:8000/api/:path*"}

If you need /v1 reachable — which only voice_mode: agent does — expose the backend itself.

Two smaller details that bite:

  • The SSE stream must not be compressed. Next's gzip buffers a proxied stream until it closes, so events arrive only once the stream has died. Kotoba sets compress: false in the Next config, and /api/events/{session_id} answers with Cache-Control: no-cache, no-transform and X-Accel-Buffering: no. A proxy of your own has to respect those.
  • A proxied request body is size-limited. Next's own default truncated a rewritten body well below the size of a real Live2D model, so the config sets proxyClientMaxBodySize: "200mb" — wide enough that it is never the thing refusing the "install my own .zip" route.

What is frozen at build time

next build inlines every NEXT_PUBLIC_* into the JavaScript and writes the rewrite into .next/routes-manifest.json. Setting any of these on a running server does nothing.

  • NEXT_PUBLIC_API_URL
  • KOTOBA_BACKEND_URL (it is the rewrite destination)
  • NEXT_PUBLIC_VOICE_WS_URL
  • NEXT_PUBLIC_ELEVENLABS_AGENT_ID, NEXT_PUBLIC_KOTOBA_VOICE_MODE
  • the four NEXT_PUBLIC_LIVE2D_* values

Only three are read by the Next server at run time: KOTOBA_WEB_PASSWORD, KOTOBA_GATE_PASSWORD, KOTOBA_GATE_SECRET.

This is also why the packaging script refuses to build while a root dotenv file exists. It checks all six — .env, .env.local, .env.production, .env.production.local, .env.development, .env.development.local — because Next would read them and freeze your address into a bundle that ships to everybody. It also refuses if the finished build contains an address or a machine name, and if a Live2D model has been left under public/.

Mount at the root of an origin. The browser's API calls are root-relative (/api/...) and there is no basePath anywhere in the config, so serving Kotoba under a path prefix breaks them. The backend itself understands --root-path, and its redirects carry the prefix, but the compiled frontend does not.

Behind a reverse proxy

Two things are not recoverable by reading about them afterwards.

Set KOTOBA_WEB_PASSWORD before you expose anything. Without it every /api/* route is open to whoever finds the URL — settings, files, MCP connect, stored key names, and the gate that authorises execution on your machine. /v1/chat/completions is open too, and it is open while neither KOTOBA_API_KEY nor the web password is set; setting the web password closes both. If the Next frontend is its own process, it needs the same value in its own environment.

Put TLS in front of it. On plain HTTP the password and everything after it travel in the clear.

The login loop, and the one header to set

The session cookie is marked Secure only when the request came in over HTTPS, and a browser silently drops a Secure cookie on an http:// page. The symptom is the worst possible one: the password is accepted, the redirect happens, and you land back on the login screen with no error.

Kotoba reads X-Forwarded-Proto, then X-Forwarded-Scheme, then falls back to the request's own scheme. The rule is deliberately lopsided — any hop claiming https wins, and only a scheme that positively says http turns the flag off — because a needlessly-secure cookie loops forever while an insecure one on loopback is merely open.

Cloudflare, Vercel, nginx, Caddy and Traefik send it. If you run your own nginx you must write it out:

nginx
proxy_set_header X-Forwarded-Proto $scheme;

Origins

CORS_ORIGINS adds to http://localhost:3000 and http://127.0.0.1:3000; it never replaces them, so a fresh clone keeps working and a pinned production origin cannot silently break it. It is never * — this API can execute commands, so a random website must not be able to drive it from your browser.

The same list is what admits a voice WebSocket, since a handshake is exempt from CORS and would otherwise have no allowlist at all. Measured:

OriginHostAllowed
absentyes — not a browser: the CLI, a script, a test
http://localhost:3000anythingyes — a default entry
https://kotoba.example.comkotoba.example.comyes, when that exact string is in CORS_ORIGINS
http://kotoba.example.comkotoba.example.comno — the scheme is part of the match
https://evil.example127.0.0.1:8000no
http://127.0.0.1:8000127.0.0.1:8000yes — same origin, and only on loopback

The same-origin shortcut is restricted to loopback on purpose. Matching Origin against Host alone would accept a rebound domain: the attacker's page keeps its own origin, the name resolves to your machine, the two agree, and a default install has no password to ask for.

Brute force

Twenty failed /api/* credentials from one address buy a 60-second lockout. A valid token is checked before the lockout, so a loopback install cannot lock its own frontend out of its own backend, and a browser bounced to the login page does not spend an attempt — counting page reloads would lock you out of the very screen you are being sent to. POST /gate carries the same lockout plus a fixed 0.6-second delay, so a wrong password costs the same as a right one.

Docker Compose

bash
cp api/.env.example api/.env    # compose reads this file and will not start without it
docker compose up --build

What the shipped compose file actually does:

  • backend builds Dockerfile (Python 3.12), runs as a non-root user, and sets DATABASE_URL=sqlite:////app/data/kotoba.db and SOUL_PATH=/app/soul/default.md as absolute paths so the database lands on the volume rather than in the container layer.
  • Two named volumes: kotoba-db at /app/data and kotoba-home at /home/kotoba/.kotoba. Both mount points exist in the image and are owned by the runtime user, because Docker invents a root-owned directory for a mount point that is missing.
  • Ports are published on 127.0.0.1 by default. KOTOBA_BIND moves that; it is a compose variable only, nothing in the Python reads it. KOTOBA_BACKEND_PORT and KOTOBA_WEB_PORT move the ports.
  • CORS_ORIGINS is set from KOTOBA_WEB_PORT, for the WebSocket reason above.
  • frontend builds Dockerfile.web on node:22-slim with KOTOBA_BACKEND_URL=http://backend:8000 and NEXT_PUBLIC_VOICE_WS_URL=ws://localhost:${KOTOBA_BACKEND_PORT}. Those are build args: the browser cannot resolve backend, so the socket must point at the published host port, not at the compose network name.
  • The frontend reads .env.local at run time, optionally, and only for the gate password. Any KOTOBA_BACKEND_URL or NEXT_PUBLIC_* in that file is inert in the container.

Always pass --build when you change a port, because those two values are baked into the frontend image.

The backend image also sets KOTOBA_CHROMIUM_NO_SANDBOX=1 (an unprivileged uid cannot build a Chromium sandbox, and reports would render as nothing) and KOTOBA_MCP_CONFIG=/app/data/mcp.yaml so installed MCP servers survive a redeploy. It installs nodejs, npm and chromium; the browser MCP is deliberately absent, since it drives your browser over CDP.

Verifying

bash
curl http://127.0.0.1:8000/health
# → {"status":"ok"}

/health is public whatever else is configured, and it is not proxied through the Next origin — ask the backend directly.