Configuration and operations1 of 9
The settings reference
On this page
Kotoba has exactly seventeen settings you can change while it is running. They are not a loose
collection of environment variables — they are one table in the code, _RUNTIME_SPEC in
api/src/kotoba/core/app_settings.py, and that table is simultaneously:
- the list of things the Settings panel and the terminal's
/setcan change, - the allowlist for the write endpoint (anything not in it is refused with a 400),
- the validator for both halves of a value (the saved one and the environment one),
- and the source of every default below.
Everything else that looks like configuration is an environment variable read directly by one module. Those are in Environment variables, and none of them can be changed from the Settings panel or the terminal.
How a value is decided
Every call site reads through runtime_value(key, env_var, default), which answers in this order:
- the override saved in
settings.yaml, if there is one and it validates; - the environment variable, if it is set and it validates;
- the default.
Two consequences are worth stating plainly.
A saved override beats the environment. If api/.env says KOTOBA_SANDBOX=local — and the shipped
example file does say exactly that, uncommented — and you then pick docker in Settings, docker wins,
now and after every restart. Nothing warns you that the two disagree, because the disagreement is
normal and the file is meant to lose.
An invalid value is discarded, not obeyed. Set KOTOBA_SANDBOX=rocket and the effective value is
local, with one line in the log saying so. The validator runs on the environment half too, which is
the whole point: an earlier version guarded only the file, so the panel drew local while the backend
picked rocket, found no backend for it, and silently ran nothing.
The single exception is reasoning_effort, whose environment half is passed through raw so that its
consumer can fold it down the ladder instead of treating it as unset (_CONSUMER_CLAMPS).
Changes apply on the next request, because the value is read at the moment it is used. There is no reload step and no restart.
The seventeen
Defaults are the ones the code produces on an install with no settings.yaml and no environment
(measured, not transcribed).
| Setting | What it does | Allowed values | Default |
|---|---|---|---|
provider | Which company answers | openai, xai | openai |
base_url | Override the provider's endpoint | empty, or an http:// / https:// URL | empty (the provider's own) |
model | The model of the companion turn, and the last fallback for every role | any non-empty text | gpt-5.6-luna |
work_model | Model for work (background) mode | any text; empty inherits | empty |
code_model | Model for the code role | any text; empty inherits | empty |
research_model | Model for the research role | any text; empty inherits | empty |
utility_model | Model for the two sidecars: emotion extraction and memory extraction | any text; empty inherits | empty |
reasoning_effort | How much the model thinks before it speaks | off, empty, minimal, low, medium, high, xhigh, max | low |
expressive | Whether [audio tags] are asked for and kept | on / off | on |
elevenlabs_agent_id | The agent id used by voice_mode: agent | any text | empty |
voice_mode | Voice transport | local, agent | local |
tts_engine | Which speech engine local voice uses | expressive, fast | expressive |
sandbox | Where her shell and code run | local, docker, none | local |
work_timeout | Wall-clock limit for one background task, in seconds | a finite number ≥ 30 | 3600 |
work_max_iter | Model round trips one background task may take | an integer ≥ 1 | 40 |
work_max_tool_calls | Tool calls one background task may make | an integer ≥ 1 | 40 |
work_fail_limit | Consecutive tool failures before work gives up | an integer ≥ 1 | 6 |
Notes that a table cannot carry:
-
base_urlempty is not the empty string on the wire. It falls back to the provider's own default, which ishttps://api.x.ai/v1for xAI and genuinely empty (the SDK's own endpoint) for OpenAI. -
reasoning_effortis clamped by the provider, not refused. OpenAI offerslow medium high xhigh max; xAI offerslow medium high xhigh. A value on the ladder but above what the provider sells is folded down —maxbecomesxhighon xAI — andminimalbecomeslowon OpenAI. A value that is not on the ladder at all falls to the provider's lowest, because guessing high would spend your money on a typo. -
reasoning_effortis also ignored by non-reasoning models. The gate is the model name, not this setting: OpenAIo1/o3/o4/gpt-5*and xAIgrok-4*/grok-build*take reasoning arguments; anything else gets none, whatever this says. -
Work mode does not use
reasoning_effort. It readswork_reasoning_effort(defaultmedium), fromKOTOBA_WORK_REASONING_EFFORTor a hand-writtenruntime.work_reasoning_effort:insettings.yaml— which wins. Neither/setnor the panel offers it. -
work_timeout's default is 3600 seconds, except for a job launched from an ElevenLabs call, where the built-in default drops to 1500. An explicit value you set is used in both cases. -
expressiveon is not enough for tags to be performed. The one authority isaudio_tags_enabled(), and it is measurable:expressivevoice_modetts_enginetags on localexpressiveyes on localfastno — the fast engine deletes them on agenteither yes — the performer is your ElevenLabs agent off anything anything no
Where you change each one
Three surfaces write the same file.
The browser — Settings:
| Section | Settings in it |
|---|---|
| Personality | voice_mode, tts_engine, elevenlabs_agent_id (plus her name, language, voice and avatar, which are not runtime settings) |
| Brain | provider, base_url, model, work_model, code_model, research_model, utility_model, reasoning_effort, expressive |
| Security | sandbox |
| Advanced (work) | work_timeout, work_max_iter, work_max_tool_calls, work_fail_limit |
The terminal — /settings lists them and /set <key> <value> writes one. Its sections are grouped
differently: BRAIN, VOICE (which is where expressive lives here), WORK, SECURITY.
The API — POST /api/settings/runtime with {"key": …, "value": …}. It takes any of the seventeen
and refuses everything else.
The two that are gated
sandbox and provider change the trust model — one moves where her commands run, the other sends
everything you say to a different company under a different privacy policy — so the terminal names them
GATED_KEYS and asks you to confirm, with the consequence spelled out, before writing either.
Be exact about what that gate is and is not:
- It exists in the terminal only. The browser's Sandbox control writes immediately; it shows a
warning line when the value is
none, and no confirmation dialog for any value. - It is not an authorisation check.
POST /api/settings/runtimeacceptssandboxandproviderwith no confirmation at all. Whoever can reach/api/*can change them — which is the real reasonKOTOBA_WEB_PASSWORDmatters on anything but a private machine. - The browser panel does not send
providerormodelto the runtime endpoint. It uses/api/setup/providerand/api/setup/model, which pin the pair together, because choosing xAI while the model still readsgpt-5.6-lunaproduces a 404 on the next turn and a perfectly good key reported as bad.
Two things the panel shows but cannot change
trust and browser_cdp are read straight from the environment (KOTOBA_TRUST, default workspace;
KOTOBA_BROWSER_CDP, default empty) and are display-only. They are deliberately absent from the
settable table: changing browser_cdp under an already-launched browser would leave the two out of
step, and trust has no enforcement behind it yet — nothing but the panel and /settings reads it.
The terminal refuses them by name: "it comes from the environment."
The file it writes
~/.kotoba/settings.yaml, or wherever KOTOBA_SETTINGS points. It holds two keys and nothing else:
disabled_toolsets:
- browser
runtime:
sandbox: docker
work_timeout: 45.0
runtime is the overrides above. disabled_toolsets is the tool families switched off in
Settings → Capabilities; the current family names are browser, code, core, cron, discord,
file, mcp, memory, report, skills, subagent, terminal, web, plus plugin:<name> for
each community plugin. That list is read before anything is imported, which is the only moment a
disabled plugin's module body can still be stopped from running.
Every write takes a cross-process lock and is published atomically, so two Kotoba processes — the terminal beside the server — cannot lose each other's changes.
If the file will not parse
Measured on a settings.yaml with a stray tab in it:
- Every read logs
… cannot be parsed (…) — using defaults and refusing to overwrite itand then falls back.sandbox: dockerin the file reads aslocal, and toolsets you had switched off come back on. - Every write raises
SettingsUnreadablerather than clobbering the file, and the file is left byte for byte as it was.POST /api/settings/runtimecatches onlyValueError, so from the panel this arrives as a 500 rather than a message.
So a broken file does not lose your settings, but it does mean Kotoba runs on defaults until you fix
it. The log line is the only warning, and it is easy to miss under kotoba serve, where it arrives as
an [api] line among the startup noise. Fix the YAML, or move the file aside and set what you need
again.
