Configuration and operations9 of 9
Logs and diagnosis
On this page
Where the words go
There are three sinks, and which one you get depends on how you started Kotoba.
The backend, started directly (uvicorn kotoba.server:app …) logs to stderr. Nothing is written
to a file.
The kotoba command — any subcommand, including serve, setup, doctor and discord — redirects
its own logging to ~/.kotoba/cli.log before it dispatches, so her terminal stays hers and an httpx
INFO line cannot land inside her answer. The file is mode 0600, appended to, and started over
rather than rolled once it passes 2 MiB.
kotoba serve is both at once. The parent's logging goes to cli.log; the backend and the dev
server are separate processes whose stdout and stderr are pumped to your terminal, line by line, prefixed
[api] and [web].
The level is KOTOBA_LOG_LEVEL (default INFO), and it is read in two places — the kotoba logger at
server import, and the file handler the CLI installs.
The file format is fixed:
2026-01-01 12:00:00,000 INFO kotoba.demo: hello from the log
An MCP server is a separate process that writes to its own stderr, so its output follows whichever
process spawned it. Under the CLI that is cli.log — which is where a stranger finds out their stdio
server does not start. Under kotoba serve it goes to the backend's stderr and reaches you as [api]
lines.
What is redacted, and what is not
The query token is. The SSE stream, the file viewer and the voice WebSocket can only authenticate
through ?token=, and uvicorn logs the query verbatim — so the web password was being written in clear
text on every page load, into a file that gets pasted into bug reports. A filter is installed on
uvicorn.access, uvicorn.error, hypercorn.access and hypercorn.error. Measured:
"127.0.0.1 - GET /api/events/abc?token=supersecret"
→ "…/api/events/abc?token=<redacted>"
"connection open /api/voice/x?token=hunter2"
→ "connection open /api/voice/x?token=<redacted>"
The error loggers are covered too, not just access, because that is where the WebSocket handshake line is written.
API keys are. No path that handles one writes its value anywhere:
- the key-check paths —
kotoba setupand the web/API door — log a redacted exception and never the key. Where a provider could echo the key back inside its own error text, the key is replaced before the message is truncated: a cut landing inside it would otherwise leave a working prefix that no longer matches anything.POST /api/settings/llm-testfollows the same order, because itsdetailis the provider's own exception and is shown in the panel; - decrypted keys live in process memory only, and are never returned to a caller or put in the model's context;
- cache keys use a 12-character SHA-256 prefix, never the key;
- deleting a saved MCP credential stack-traces the name and nothing else.
Secrets typed into the masked input card are held in memory for one task, keyed by session, and never written to disk, never put in the model's context.
What is not redacted: ordinary paths, URLs, session ids, model names, and the arguments of a tool
call as recorded in the audit trail (see below). Treat cli.log as something you read before you paste
it anywhere.
The audit trail
Every tool call whose risk is write, exec or network is recorded in the local database. MCP tools
all count as network; among the built-in tools none carries network, so in practice it is the write
and exec ones plus everything an MCP server offers. A call that was refused before it ran is not filed
as run.
A gated call usually leaves two rows, told apart by detail:
detail | What it means |
|---|---|
decision | somebody (or something) said yes or no |
executed | it ran |
executed:failed | it ran and failed |
approver says who permitted it, and a view turns that into a sentence. authority and regime are
derived columns — regime exists because rows with detail IS NULL predate the current vocabulary and
their approved flag cannot be trusted.
No API exposes any of this. Read it yourself:
sqlite3 ~/.kotoba/kotoba.db \
"select created_at, action, risk_kind, approved, authority
from audit_log_read order by created_at desc limit 20"
Two practical notes. audit_log_read is a view, so it has no rowid — a query ordering by it fails
outright, which matters because created_at has one-second resolution and a busy turn writes several
rows in the same second. Order by rowid only against the audit_log table. And created_at is
naive UTC, not your local clock.
An example of what the view returns:
created_at 2026-01-01 12:00:00
action shell {"command": "npm install"}
risk_kind exec
approved 1
approver user
authority user
regime current
action write_file {"path": "notes.md"}
approved 0
approver agent-loop
authority none — the loop ran it, nobody was asked
The second row is not an alarm: agent-loop is how an ungated action is recorded, and whether the call
needed a card at all is decided by the sandbox and the approval rules, not by the trail. The trail's job
is to say afterwards which it was.
action carries the tool name plus its arguments, truncated to 180 characters of JSON. Assume it
contains whatever the model passed.
kotoba doctor
The one command whose job is to explain. It prints four sections, and within the first one the order is the order that decides whether she can run — anything downstream of a hard failure is reported as skipped rather than guessed at.
what she needs — machine name, terminal, Python (3.11 is the floor and it enforces the same
number), the personality file with the path it resolved, the database, cryptography, file
permissions, saved secrets, the model key, reasoning effort, the voice key, whether audio tags are
really in play, the sandbox, and the web UI.
The database row is a real open, which runs the migrations, so a stale schema surfaces here; it prints
the path and schema vN. The permissions row is asked by doing it, because the helper that applies
the grant logs its failures and raises nothing. The model-key row runs the real startup, because a key
saved in the panel lives encrypted in the database and reaches the client only along that path.
optional extras — server, voice, mcp, web, cli, each with what it costs you and the exact
pip install line.
on this machine — Node (for npx-launched MCP servers), a Chromium-family browser, ripgrep.
mcp servers — every saved server, and whether it actually connects.
It exits 0 when she can run and 1 when she cannot. A missing optional piece is a warning and never fails the run. Nothing it prints is a secret.
Reading a failure
She says she has no key, but you configured one. Either it was saved under the other provider, or
the master key changed. doctor's saved keys row lists every secret that no longer decrypts, by
name. There is no recovery; they are re-entered.
Every turn comes back empty, and the key tests fine. The model does not belong to the configured
provider. doctor reports this as a failure and names who does serve it.
Grep the log for the half the two phrases share. A key check that failed writes one of exactly two lines, depending on which door you came through:
first-run key check failed: … # kotoba setup, in the terminal
llm key check failed for <id>: … # the web panel and the API
so grep "key check failed" finds both. Neither line contains the key.
The call connects and she never speaks. Look for an ElevenLabs auth error. Also check
Settings → Personality: with tts_engine: fast the [audio tags] are stripped and she sounds flat,
which is a different complaint that arrives worded the same way.
Voice looks dead with no error on screen. The voice WebSocket is exempt from CORS and keeps its own
origin allowlist. A page served from a port that is not in it gets the handshake refused with a 403 the
UI does not show. kotoba serve adds the port it chose; anything else is CORS_ORIGINS, with the exact
scheme.
The login accepts the password and returns you to the login screen. The browser dropped a Secure
cookie on an http:// page. See Behind a reverse proxy.
Everything 401s after logging in. The backend and the frontend hold different values for
KOTOBA_WEB_PASSWORD — or one of them has KOTOBA_GATE_SECRET set and the other does not, which signs
sessions with two different keys. Set it in both, or in neither.
docker compose up fails immediately. api/.env does not exist. Compose reads it and refuses to
start; measured, the message names the exact path it looked for.
Something writes a warning about settings.yaml. Two shapes. "settings.yaml has an invalid <key>
… falling back" means a hand-edited value did not validate. "cannot be parsed … using defaults and
refusing to overwrite it" means the file is not valid YAML — Kotoba will keep running on defaults and
will not touch the file until you fix it.
