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

Environment variables

About 9 minutes to read

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:

VariableAbsentSet to the empty string
KOTOBA_REASONING_EFFORTlow"" — 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

VariableRead byWhat it is
OPENAI_API_KEYthe OpenAI provider specFallback key when no OpenAI key is saved in the app
XAI_API_KEYthe xAI provider specFallback key when no xAI key is saved in the app
ELEVENLABS_API_KEYthe voice configFallback key for speech-to-text and text-to-speech
KOTOBA_API_KEY/v1/chat/completionsBearer for that one endpoint. Needed only by voice_mode: agent
KOTOBA_WEB_PASSWORDthe /api/* gate, both processesThe one password. Unset, /api/* is open
KOTOBA_GATE_PASSWORDthe same two readersAccepted alias. Used only when KOTOBA_WEB_PASSWORD is unset
KOTOBA_GATE_SECRETthe session-cookie signerSigning key. Defaults to the password. Not a password: on its own it does not turn the gate on
KOTOBA_MASTER_KEYthe keystoreEncrypts saved secrets at rest. Any string; hashed to 32 bytes. Set, no key file is created at all
GITHUB_PERSONAL_ACCESS_TOKENthe GitHub MCP server entryAlso accepts GITHUB_MCP_TOKEN and GITHUB_TOKEN, in that order
DISCORD_BOT_TOKENthe Discord surfaceThe 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.

VariableDefault
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_URLsqlite:///./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_DIRunset — 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_DIRthe 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_DIRunset — the packaged UI, else a clone's .next-export

Network and access

VariableDefaultWhat it does
CORS_ORIGINSemptyComma-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_URLemptyYour 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_LEVELINFOLevel for the kotoba logger, and for the CLI's own file handler

The brain

VariableSetting behind it?Default
KOTOBA_LLM_PROVIDERyes — provideropenai
KOTOBA_LLM_BASE_URLyes — base_urlthe provider's own
KOTOBA_MODELyes — modelgpt-5.6-luna
KOTOBA_WORK_MODEL KOTOBA_CODE_MODEL KOTOBA_RESEARCH_MODEL KOTOBA_UTILITY_MODELyesempty (inherit)
KOTOBA_REASONING_EFFORTyes — reasoning_effortlow
KOTOBA_WORK_REASONING_EFFORTno — environment onlymedium. off makes work inherit the companion effort
KOTOBA_LLM_STOREno — environment onlyunset. 1/true/yes asks the provider to store reasoning state instead of carrying it as an encrypted blob
KOTOBA_MAX_HISTORYno24 recent turns sent to the model; floored at 2

Voice

VariableSetting behind it?Default
KOTOBA_VOICE_MODEyes — voice_modelocal
KOTOBA_TTS_ENGINEyes — tts_engineexpressive
KOTOBA_EXPRESSIVEyes — expressiveon
NEXT_PUBLIC_ELEVENLABS_AGENT_IDyes — elevenlabs_agent_id. The one settable whose variable is not KOTOBA_*empty
KOTOBA_VOICE_IDnothe built-in fallback voice. Her configured voice_id beats it
KOTOBA_STT_LANGUAGEnoempty. An ISO 639-1/639-3 code pins transcription; junk is refused with a warning, not sent
KOTOBA_STT_FILTER_BACKGROUNDnoon. ElevenLabs' "ignore ambient noise" filter
KOTOBA_TTS_STABILITY KOTOBA_TTS_SIMILARITY KOTOBA_TTS_STYLEnounset. Floats passed to the expressive engine only; a value that will not parse is dropped

Execution, browser and MCP

VariableSetting behind it?Default
KOTOBA_SANDBOXyes — sandboxlocal
KOTOBA_SANDBOX_IMAGEno — read at importpython:3.12-slim
KOTOBA_CHROMIUM_NO_SANDBOXnounset. 1/true/yes drops Chromium's own sandbox for the PDF render — only where the container is the sandbox
KOTOBA_TRUSTno. Display-onlyworkspace. Nothing but the Settings panel and /settings reads it
KOTOBA_BROWSER_CDPno. Display-only in the panel, but really usedempty. Point it at a real browser's debugging endpoint
KOTOBA_BROWSER_EXECUTABLEnoauto-detected
KOTOBA_BROWSER_PROFILEno<home>/browser-profile
KOTOBA_BROWSER_OUTPUT_DIRno<home>/playwright-output
KOTOBA_BROWSER_TOOL_DENYnoempty. Fragments matched against tool names, never URLs — a domain here matches nothing
KOTOBA_MCP_ALWAYS_ACTIVEnobrowser. Comma-separated server names whose tools are offered without being activated first
KOTOBA_MCP_CONNECT_TIMEOUTno — read at import30 seconds
KOTOBA_MCP_DISCOVERY_FALLBACKnoon. 0/false/no disables the fallback registry search
KOTOBA_ALLOW_LOCAL_MCPnounset. 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:

VariableDefaultBounds
KOTOBA_MAX_TOOL_CALLS8tool calls in one companion turn
KOTOBA_FAIL_LIMIT2consecutive tool failures in a companion turn
KOTOBA_PER_TOOL_LIMIT3calls to the same tool in one turn
KOTOBA_WEB_SEARCH_LIMIT8web searches per run
KOTOBA_DELEGATE_LIMIT3delegate calls per turn
KOTOBA_DELEGATE_CONCURRENCY2helpers running at once
KOTOBA_BROWSER_TOOL_LIMIT40browser steps per turn
KOTOBA_TODO_TOOL_LIMIT10todo/cron operations per turn
KOTOBA_URL_LOOKBACK3recent user messages scanned for a URL to open
KOTOBA_RATELIMIT_RETRIES6rate-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

VariableDefaultWhat it does
KOTOBA_TZunsetAn IANA time zone for the clock in her prompt and in Discord history
KOTOBA_KEY_DELETE_TRAPonStack-traces every deletion of an mcp: / mcp_oauth: saved key, by name only. 0/false/no disables
KOTOBA_DEFAULT_MODEL_URLthe Live2D sample's own URLWhere the "install the default avatar" route downloads from
KOTOBA_DISCORD_OWNER_ID KOTOBA_DISCORD_GUILDS KOTOBA_DISCORD_HOME_CHANNELSunsetThe 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.

VariableWhat it does
NEXT_PUBLIC_API_URLEmpty (the default) = same-origin proxy mode. Set = the browser calls that origin directly
KOTOBA_BACKEND_URLWhere 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_URLThe ws:///wss:// origin the browser dials for voice
NEXT_PUBLIC_ELEVENLABS_AGENT_IDFirst-paint agent id
NEXT_PUBLIC_KOTOBA_VOICE_MODEFirst-paint voice mode
NEXT_PUBLIC_LIVE2D_MODEL NEXT_PUBLIC_LIVE2D_ENTRY NEXT_PUBLIC_LIVE2D_SCALE NEXT_PUBLIC_LIVE2D_ANCHOR_YLegacy avatar framing, used only while no model is installed in the models directory
KOTOBA_STATIC_EXPORTSet to 1 by the packaging script; produces the static export the wheel carries
DOCKER_BUILDSet 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.