thetis.toml & overrides

Configuration

How Thetis reads its settings, what each section of thetis.toml controls, which environment variables override what, and where the API key should live.

Three layers

Thetis reads thetis.toml from the project root. Every setting is optional: delete the file and it still runs on built-in defaults. Three layers stack, each overriding the one before it.

  1. the defaults compiled in, so nothing is required to start
  2. thetis.toml, or whatever THETIS_CONFIG points at
  3. environment variables, for per-run overrides and secrets

The file is parsed strictly. A mistyped key fails at startup naming the key, rather than becoming a setting that silently does nothing. So does a default_mode that is not among the configured modes.

Relative paths resolve against the project root, meaning the directory holding the config file. Absolute paths are used as given.

The sections

The shipped file documents every section inline. These are the ones worth knowing, with the keys you are most likely to change.

SectionCoversKeys
[server]bind address, which gateway serves the UI, whether /admin is onbind = "127.0.0.1:7777", primary_gateway = "web", admin_enabled = true
[paths]where the agent, gateways, tools, skills, artifacts and data live, and the naming conventions for gateway and tool directoriesagent = "agents/agent-core", gateway_prefix = "gateway-"
[llm]base URL, default model, timeout, retriesbase_url = "https://openrouter.ai/api/v1", request_timeout_secs = 180, max_retries = 3
[agent]iteration ceiling, default mode, the system prompt (inline or from a file)max_iterations = 32, default_mode = "agent", system_prompt_file
[[models]]the model picker's contentsid, label, and optionally provider and wire_model
[[modes]]ways of working, each with read_onlyid, label, description, read_only, prompt
[budgets]the no-yield slice that catches infinite loops, tool and probe budgetswasm_slice_secs = 10, tool_secs = 30, probe_secs = 5
[limits]memory caps, spend ceiling, output and attachment sizesagent_memory_mb = 512, session_spend_limit_usd = 0.0, max_tool_output_bytes = 32768
[cache]prompt caching: TTL, anchor spacing, which vendors need explicit breakpointsttl = "5m", anchor_stride = 8, explicit_vendors = ["anthropic"]
[build]build command, target triple, profile, target directory, --locked, extra flagstarget = "wasm32-wasip2", profile = "release", target_dir = "target-wasm", locked = true
[watchdog]breaker window and threshold, probe interval, watch suppression, debouncefailure_window_secs = 120, failure_threshold = 3, debounce_ms = 500
[devkit]whether self-modification is offered, and which files guests may never editenabled = true, protected_files = [], protected_dirs = []
[sandbox]the Docker exec sandboxenabled = false

A few of these repay a second look.

[budgets]. wasm_slice_secs is the longest a guest may run without returning to a blocking host call. That is what catches an infinite loop, and it does not punish slow model calls, because time parked in a host import does not count against it. There is deliberately no ceiling on how long a whole turn may take: a turn that streams a long answer, runs a dozen tools and compiles something is doing its job, and a fixed number of seconds cannot tell that apart from a hang.

[cache]. anchor_stride is the spacing of the stable anchor breakpoints in the message list. A cache read looks back at most twenty blocks, so a turn that runs many tools can overshoot the previous mark; the anchors hold still and stay within reach. Smaller means more resilience and more writes. Only the vendors in explicit_vendors get explicit breakpoints, because marking a moving prefix on a provider that already caches would bill writes for nothing.

[build]. target_dir is a shared cargo target directory, so guests compile their dependencies once. timeout_secs = 900 caps a single cargo invocation; builds are serialized, so one that hangs would block every later build, and this kills it and reports a failed compile instead. allowed_crates lists the crates guests may depend on, and empty means anything on crates.io.

[devkit]. protected_files and protected_dirs are paths a component may not edit in its own source tree. Empty means it may edit anything, including its own Cargo.toml and build.rs, which is what adding a dependency or generating code requires.

[sandbox]. Off by default and not implemented yet. While it is off the agent is not offered code-execution tools at all, rather than being handed tools that fail.

Environment overrides

Anything in the file can be overridden per run. The common ones:

VariableOverrides
OPENROUTER_API_KEYllm.api_key
OPENROUTER_BASE_URLllm.base_url — point it at the mock for offline work
THETIS_CONFIGwhich config file to read
THETIS_ROOTthe project root
THETIS_BINDserver.bind
THETIS_MODELllm.model
THETIS_MODELSthe model picker, as id=Label pairs
THETIS_DEFAULT_MODEagent.default_mode
THETIS_SYSTEM_PROMPTthe system prompt
THETIS_SANDBOXsandbox.enabled
THETIS_DEVKITdevkit.enabled
THETIS_LOGtracing filter

Budget and limit values have THETIS_-prefixed overrides too, named after their keys: THETIS_WASM_SLICE_SECS, THETIS_TOOL_BUDGET_SECS, THETIS_MAX_ITERATIONS, THETIS_AGENT_MEM_MB, and so on.

Keys and secrets

The API key can live in [llm] api_key or in OPENROUTER_API_KEY, with the environment winning, so a key can be overridden for one run without editing anything.

Blank in either place counts as absent. An empty setting fails at startup rather than becoming an empty Authorization header.

A key in the file is a key on disk. .gitignore excludes thetis.local.toml and *.local.toml, so if this repo is ever version controlled, keep the real key in one of those and point THETIS_CONFIG at it. It does not exclude thetis.toml, so a key placed there would be committed.

cp thetis.toml thetis.local.toml
THETIS_CONFIG=thetis.local.toml cargo run --release

Thetis holds the key in a type whose Debug prints Secret(***), so it cannot reach a log through an incidental {:?} on the config.

Adding a mode

Adding a mode is configuration alone. read_only is carried through to the agent, which withholds every tool that changes something and refuses those tools at dispatch too. A new read-only mode needs no code in the agent, which never knows any mode by name.

Refusal at dispatch is the half that matters: a model that remembers a mutating tool from earlier in the conversation still cannot call it.

[[modes]]
id = "plan"
label = "Plan"
description = "Reads and reasons, but makes no changes. Tools that would modify anything are withheld."
read_only = true

A mode may also carry a prompt, appended to the system prompt while that mode is active. read_only decides which tools are offered; the prompt decides what the model does with them. Without one, the model finds the tools missing and works around the gap.

The tools panel reflects this without being told. It asks the agent what it is offered for this conversation rather than reconstructing the list, which is why the list shrinks in a read-only mode.