Configuration and operations6 of 9
Running it for real
On this page
- kotoba serve
- Loopback only, and there is no flag
- Two topologies
- One origin: the backend serves everything
- Two origins: Next in front
- The two routes that must reach the backend
- What is frozen at build time
- Behind a reverse proxy
- The login loop, and the one header to set
- Origins
- Brute force
- Docker Compose
- Verifying
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:
- A
package.json, npm on yourPATH, andnode_modulespresent. It starts the backend andnpm run dev, because a contributor's edits have to show. The dev server wins whenever it can run. - 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. Apip installneeds no Node at all. - 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.pyin 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_ORIGINSfor the backend child. The voice WebSocket is exempt from CORS and keeps its own allowlist, which otherwise knows only port 3000 — sokotoba serve --web-port 3100used to leave the handshake refused with a 403 the UI does not show, and voice simply looked dead. - It sets
NEXT_TELEMETRY_DISABLED=1on 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:
| Path | Access |
|---|---|
/app, /setup | gated — a signed session cookie, when a password is set |
/login, /icon.svg, /404.html, /_next/** | public |
| anything not in the build | 404, 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/*toKOTOBA_BACKEND_URLserver-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_ORIGINSmust name the page's origin.
The two routes that must reach the backend
Whatever you build, exactly two things have to arrive:
/api/*over HTTP — settings, files, avatar, models, session control, and the SSE stream at/api/events/{session_id}./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 whateverNEXT_PUBLIC_VOICE_WS_URLsays, 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:
{"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: falsein the Next config, and/api/events/{session_id}answers withCache-Control: no-cache, no-transformandX-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_URLKOTOBA_BACKEND_URL(it is the rewrite destination)NEXT_PUBLIC_VOICE_WS_URLNEXT_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:
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:
Origin | Host | Allowed |
|---|---|---|
| absent | — | yes — not a browser: the CLI, a script, a test |
http://localhost:3000 | anything | yes — a default entry |
https://kotoba.example.com | kotoba.example.com | yes, when that exact string is in CORS_ORIGINS |
http://kotoba.example.com | kotoba.example.com | no — the scheme is part of the match |
https://evil.example | 127.0.0.1:8000 | no |
http://127.0.0.1:8000 | 127.0.0.1:8000 | yes — 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
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 setsDATABASE_URL=sqlite:////app/data/kotoba.dbandSOUL_PATH=/app/soul/default.mdas absolute paths so the database lands on the volume rather than in the container layer. - Two named volumes:
kotoba-dbat/app/dataandkotoba-homeat/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.1by default.KOTOBA_BINDmoves that; it is a compose variable only, nothing in the Python reads it.KOTOBA_BACKEND_PORTandKOTOBA_WEB_PORTmove the ports. CORS_ORIGINSis set fromKOTOBA_WEB_PORT, for the WebSocket reason above.- frontend builds
Dockerfile.webonnode:22-slimwithKOTOBA_BACKEND_URL=http://backend:8000andNEXT_PUBLIC_VOICE_WS_URL=ws://localhost:${KOTOBA_BACKEND_PORT}. Those are build args: the browser cannot resolvebackend, so the socket must point at the published host port, not at the compose network name. - The frontend reads
.env.localat run time, optionally, and only for the gate password. AnyKOTOBA_BACKEND_URLorNEXT_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
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.
