Configuration and operations2 of 9
Environment variables
On this page
Every variable on this page has a reader in the code. It was compiled by finding the call sites, not by
reading the example files — an .env.example is documentation, and documentation drifts.
Variables come in three shapes, and the shape decides when a change takes effect.
Overrides of a stored setting. Seventeen names shadow the seventeen runtime settings. They are read
at every use, so a change takes effect on the next request — unless someone has saved that setting in
settings.yaml, in which case the saved value wins and the variable is ignored. See
The settings reference.
Environment-only, read at use. No setting behind them, but read fresh each time, so a restart is enough.
Environment-only, read at import. Read once when the module loads. Changing them on a running server does nothing; the process has to be restarted.
An empty value is usually "unset" — twice it is not
Most readers treat KOTOBA_X= exactly like an absent KOTOBA_X: the ten import-time limits read a
blank as "use the default" on purpose. A blank now short-circuits before the validator runs, so
KOTOBA_SANDBOX= or KOTOBA_MODEL= falls back to the default silently; an unusable value like
KOTOBA_SANDBOX=rocket is what fails validation and says so in the log.
One does not, and it means to. Measured, with the variable exported empty and nothing saved:
| Variable | Absent | Set to the empty string |
|---|---|---|
KOTOBA_REASONING_EFFORT | low | "" — a second spelling of off. No reasoning, and the canned English narration comes back |
That one is deliberate and pinned by a test. Everywhere else a blank is now read as unset and falls
back to the default, so blanking a line to comment it out no longer disables anything by accident —
KOTOBA_EXPRESSIVE= used to take the [audio tags] with it and no longer does. To turn reasoning
off on purpose, write off rather than leaving the value empty.
Keys
| Variable | Read by | What it is |
|---|---|---|
OPENAI_API_KEY | the OpenAI provider spec | Fallback key when no OpenAI key is saved in the app |
XAI_API_KEY | the xAI provider spec | Fallback key when no xAI key is saved in the app |
ELEVENLABS_API_KEY | the voice config | Fallback key for speech-to-text and text-to-speech |
KOTOBA_API_KEY | /v1/chat/completions | Bearer for that one endpoint. Needed only by voice_mode: agent |
KOTOBA_WEB_PASSWORD | the /api/* gate, both processes | The one password. Unset, /api/* is open |
KOTOBA_GATE_PASSWORD | the same two readers | Accepted alias. Used only when KOTOBA_WEB_PASSWORD is unset |
KOTOBA_GATE_SECRET | the session-cookie signer | Signing key. Defaults to the password. Not a password: on its own it does not turn the gate on |
KOTOBA_MASTER_KEY | the keystore | Encrypts saved secrets at rest. Any string; hashed to 32 bytes. Set, no key file is created at all |
GITHUB_PERSONAL_ACCESS_TOKEN | the GitHub MCP server entry | Also accepts GITHUB_MCP_TOKEN and GITHUB_TOKEN, in that order |
DISCORD_BOT_TOKEN | the Discord surface | The escape hatch; the keystore is read first |
An in-app saved key beats the environment for the three that have both paths (the two providers and ElevenLabs). Saved keys live encrypted in the database and are decrypted into memory at startup.
A value that looks like a template counts as no key at all: anything containing ..., …, <, >,
or that reduces to one of a short list of placeholder words once an sk-/xai-/el_ prefix is
stripped. This is why OPENAI_API_KEY=sk-... copied unedited from the example file reads as unset
rather than producing a 401 on every turn.
Where things live
Every one of these is environment-only and read at use, except where noted.
| Variable | Default |
|---|---|
KOTOBA_HOME | ~/.kotoba. Most readers ask it fresh; a few resolve it once at import, so set it before the process starts. An empty value is not "the current directory" — it falls back to ~/.kotoba |
KOTOBA_SETTINGS | <home>/settings.yaml |
DATABASE_URL | sqlite:///./kotoba.db — and a relative path resolves against the database directory, never the working directory |
SOUL_PATH | ./soul/default.md, resolved through a candidate list |
KOTOBA_KEYSTORE_KEY_FILE | <home>/.keystore_key |
KOTOBA_FILES_DIR | <home>/files |
KOTOBA_WORKSPACE_DIR | unset — she works in the files library |
KOTOBA_TMP_DIR | <home>/tmp |
KOTOBA_MEMORY_DIR | <home>/memory |
KOTOBA_VISUAL_MEMORY_DIR | <home>/visual-memory |
KOTOBA_MODELS_DIR | <home>/models. The home half is read at import |
KOTOBA_PLUGINS_PATH | <home>/plugins |
KOTOBA_SKILLS_DIR | the clone's soul/skills, else the copy inside the package |
KOTOBA_MCP_CONFIG | <home>/mcp.yaml |
KOTOBA_PENDING_MCP | <home>/pending_mcp.yaml |
KOTOBA_CLI_LOG | <home>/cli.log |
KOTOBA_CLI_HISTORY | <home>/cli_history |
KOTOBA_FRONTEND_DIR | unset — the packaged UI, else a clone's .next-export |
Network and access
| Variable | Default | What it does |
|---|---|---|
CORS_ORIGINS | empty | Comma-separated origins added to http://localhost:3000 and http://127.0.0.1:3000, never replacing them. The voice WebSocket reads the same list, so an entry must carry its exact scheme — https://x.example does not admit http://x.example |
KOTOBA_PUBLIC_URL | empty | Your externally reachable origin. Required only for OAuth MCP sign-in, which builds <KOTOBA_PUBLIC_URL>/api/mcp/oauth/callback and refuses with a 400 when it is unset |
KOTOBA_LOG_LEVEL | INFO | Level for the kotoba logger, and for the CLI's own file handler |
The brain
| Variable | Setting behind it? | Default |
|---|---|---|
KOTOBA_LLM_PROVIDER | yes — provider | openai |
KOTOBA_LLM_BASE_URL | yes — base_url | the provider's own |
KOTOBA_MODEL | yes — model | gpt-5.6-luna |
KOTOBA_WORK_MODEL KOTOBA_CODE_MODEL KOTOBA_RESEARCH_MODEL KOTOBA_UTILITY_MODEL | yes | empty (inherit) |
KOTOBA_REASONING_EFFORT | yes — reasoning_effort | low |
KOTOBA_WORK_REASONING_EFFORT | no — environment only | medium. off makes work inherit the companion effort |
KOTOBA_LLM_STORE | no — environment only | unset. 1/true/yes asks the provider to store reasoning state instead of carrying it as an encrypted blob |
KOTOBA_MAX_HISTORY | no | 24 recent turns sent to the model; floored at 2 |
Voice
| Variable | Setting behind it? | Default |
|---|---|---|
KOTOBA_VOICE_MODE | yes — voice_mode | local |
KOTOBA_TTS_ENGINE | yes — tts_engine | expressive |
KOTOBA_EXPRESSIVE | yes — expressive | on |
NEXT_PUBLIC_ELEVENLABS_AGENT_ID | yes — elevenlabs_agent_id. The one settable whose variable is not KOTOBA_* | empty |
KOTOBA_VOICE_ID | no | the built-in fallback voice. Her configured voice_id beats it |
KOTOBA_STT_LANGUAGE | no | empty. An ISO 639-1/639-3 code pins transcription; junk is refused with a warning, not sent |
KOTOBA_STT_FILTER_BACKGROUND | no | on. ElevenLabs' "ignore ambient noise" filter |
KOTOBA_TTS_STABILITY KOTOBA_TTS_SIMILARITY KOTOBA_TTS_STYLE | no | unset. Floats passed to the expressive engine only; a value that will not parse is dropped |
Execution, browser and MCP
| Variable | Setting behind it? | Default |
|---|---|---|
KOTOBA_SANDBOX | yes — sandbox | local |
KOTOBA_SANDBOX_IMAGE | no — read at import | python:3.12-slim |
KOTOBA_CHROMIUM_NO_SANDBOX | no | unset. 1/true/yes drops Chromium's own sandbox for the PDF render — only where the container is the sandbox |
KOTOBA_TRUST | no. Display-only | workspace. Nothing but the Settings panel and /settings reads it |
KOTOBA_BROWSER_CDP | no. Display-only in the panel, but really used | empty. Point it at a real browser's debugging endpoint |
KOTOBA_BROWSER_EXECUTABLE | no | auto-detected |
KOTOBA_BROWSER_PROFILE | no | <home>/browser-profile |
KOTOBA_BROWSER_OUTPUT_DIR | no | <home>/playwright-output |
KOTOBA_BROWSER_TOOL_DENY | no | empty. Fragments matched against tool names, never URLs — a domain here matches nothing |
KOTOBA_MCP_ALWAYS_ACTIVE | no | browser. Comma-separated server names whose tools are offered without being activated first |
KOTOBA_MCP_CONNECT_TIMEOUT | no — read at import | 30 seconds |
KOTOBA_MCP_DISCOVERY_FALLBACK | no | on. 0/false/no disables the fallback registry search |
KOTOBA_ALLOW_LOCAL_MCP | no | unset. 1/true/yes disables the guard that refuses a remote MCP URL pointing at loopback, link-local or a private range |
Work-mode limits
Live settings, changeable from the panel: work_timeout, work_max_iter, work_max_tool_calls,
work_fail_limit — variables KOTOBA_WORK_TIMEOUT, KOTOBA_WORK_MAX_ITER,
KOTOBA_WORK_MAX_TOOL_CALLS, KOTOBA_WORK_FAIL_LIMIT.
Two of those four also have an import-time twin in the loop's module constants. Ignore it: those constants are only a fallback for a runtime read that fails to parse, and the validator makes that unreachable. The runtime path is what decides.
The rest are read at import and cannot be changed on a running server:
| Variable | Default | Bounds |
|---|---|---|
KOTOBA_MAX_TOOL_CALLS | 8 | tool calls in one companion turn |
KOTOBA_FAIL_LIMIT | 2 | consecutive tool failures in a companion turn |
KOTOBA_PER_TOOL_LIMIT | 3 | calls to the same tool in one turn |
KOTOBA_WEB_SEARCH_LIMIT | 8 | web searches per run |
KOTOBA_DELEGATE_LIMIT | 3 | delegate calls per turn |
KOTOBA_DELEGATE_CONCURRENCY | 2 | helpers running at once |
KOTOBA_BROWSER_TOOL_LIMIT | 40 | browser steps per turn |
KOTOBA_TODO_TOOL_LIMIT | 10 | todo/cron operations per turn |
KOTOBA_URL_LOOKBACK | 3 | recent user messages scanned for a URL to open |
KOTOBA_RATELIMIT_RETRIES | 6 | rate-limit retries before the turn fails |
A blank value here is not an error and not a zero: it is read as "unset" and the default applies.
That matters because the shipped api/.env.example leaves six of these uncommented.
Rate limiting
Read at use, no setting: KOTOBA_TPM_FLOOR (35000 tokens — wait for the window to reset below this),
KOTOBA_TPM_MAX_PACE (75 seconds — the longest wait taken on Kotoba's own header arithmetic),
KOTOBA_MAX_RETRY_AFTER (120 seconds — the longest server-supplied Retry-After actually slept).
Odds and ends
| Variable | Default | What it does |
|---|---|---|
KOTOBA_TZ | unset | An IANA time zone for the clock in her prompt and in Discord history |
KOTOBA_KEY_DELETE_TRAP | on | Stack-traces every deletion of an mcp: / mcp_oauth: saved key, by name only. 0/false/no disables |
KOTOBA_DEFAULT_MODEL_URL | the Live2D sample's own URL | Where the "install the default avatar" route downloads from |
KOTOBA_DISCORD_OWNER_ID KOTOBA_DISCORD_GUILDS KOTOBA_DISCORD_HOME_CHANNELS | unset | The Discord surface |
The terminal reads a handful of its own presentation variables — NO_COLOR, KOTOBA_REDUCED_MOTION,
KOTOBA_NO_GRAPHICS, KOTOBA_FORCE_PORTRAIT, KOTOBA_FORCE_BACKGROUND, KOTOBA_MEASURE,
KOTOBA_CLI_FACES, KOTOBA_PRESS_MS, KOTOBA_HOLD_S, KOTOBA_CLIPBOARD, KOTOBA_CLIPBOARD_PNG —
which change nothing but how the screen is drawn.
Variables the browser bundle reads
These are compiled into the JavaScript by next build. Setting them on a running server does
nothing; see Running it for real.
| Variable | What it does |
|---|---|
NEXT_PUBLIC_API_URL | Empty (the default) = same-origin proxy mode. Set = the browser calls that origin directly |
KOTOBA_BACKEND_URL | Where the Next server's /api/* rewrite points, and what the voice socket's address is derived from. Default http://127.0.0.1:8000 |
NEXT_PUBLIC_VOICE_WS_URL | The ws:///wss:// origin the browser dials for voice |
NEXT_PUBLIC_ELEVENLABS_AGENT_ID | First-paint agent id |
NEXT_PUBLIC_KOTOBA_VOICE_MODE | First-paint voice mode |
NEXT_PUBLIC_LIVE2D_MODEL NEXT_PUBLIC_LIVE2D_ENTRY NEXT_PUBLIC_LIVE2D_SCALE NEXT_PUBLIC_LIVE2D_ANCHOR_Y | Legacy avatar framing, used only while no model is installed in the models directory |
KOTOBA_STATIC_EXPORT | Set to 1 by the packaging script; produces the static export the wheel carries |
DOCKER_BUILD | Set by Dockerfile.web; produces Next's standalone output |
The two NEXT_PUBLIC_* first-paint values are only a first paint. Once the app has a token it asks
/api/settings and the saved voice_mode and elevenlabs_agent_id replace them.
Only three variables are read by the Next server at run time: KOTOBA_WEB_PASSWORD,
KOTOBA_GATE_PASSWORD and KOTOBA_GATE_SECRET. If you run the frontend as its own process, those are
the three it needs in its own environment — and they must match the backend's.
