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 operations9 of 9

Logs and diagnosis

About 7 minutes to read

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 setup and 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-test follows the same order, because its detail is 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:

detailWhat it means
decisionsomebody (or something) said yes or no
executedit ran
executed:failedit 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:

bash
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 extrasserver, 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.