Environment Variables
How Soothe consumes environment variables — the distinct mechanisms, what actually works today, and how to manage secrets safely.
Five Mechanisms
Soothe reads the environment in several ways. Mixing them up is the most common config mistake:
-
Zero-config provider keys — with no YAML and empty
providers, Soothe synthesizes a provider fromOPENAI_API_KEY/OPENAI_BASE_URLorANTHROPIC_API_KEY(seeSootheConfig._bootstrap_providers_from_env). -
Top-level
SOOTHE_*overrides — PydanticBaseSettingswithenv_prefix="SOOTHE_"maps env vars onto top-levelSootheConfigfields (debug,active_router_profile, etc.). -
JSON
SOOTHE_*blobs — list and nested-object fields (providers,router_profiles,persistence,vector_stores, …) must be passed as JSON strings. Dotted path env vars likeSOOTHE_ROUTER_DEFAULTorSOOTHE_PERSISTENCE_POSTGRES_BASE_DSNare not supported onSootheConfig(noenv_nested_delimiter). -
${VAR}interpolation in YAML — before Pydantic validation,_expand_env_in_configreplaces${VAR}tokens in YAML strings. Use this for secrets inside structured config files. -
SOOTHE_DAEMON_*(daemon server) — separate model (SootheDaemonConfig) with prefixSOOTHE_DAEMON_and nested delimiter__(e.g.SOOTHE_DAEMON_TRANSPORTS__WEBSOCKET__HOST).
They fail differently: an unset top-level SOOTHE_* var falls back to YAML/defaults; an unresolved ${VAR} in YAML stays literal and usually fails validation; a malformed JSON env var fails at startup with a Pydantic error.
Zero-Config (No YAML)
Minimum to run the agent core or daemon:
export OPENAI_API_KEY=sk-...
soothed start --foreground # or Docker — see [Quick Start](/soothe/wiki/getting-started/Quick-Start.html)
soothe "hello"
Bootstrap creates provider openai and applies the built-in default router profile (openai:gpt-4o-mini). Vector stores default to embedded sqlite_vec.
OpenAI-compatible endpoints (Qwen, OpenRouter, local vLLM):
export OPENAI_API_KEY=sk-...
export OPENAI_BASE_URL="https://your-provider-endpoint/v1"
export SOOTHE_ROUTER_PROFILES='[{"name":"default","router":{"default":"openai:qwen3.7-plus","fast":"openai:qwen3.7-plus","think":"openai:qwen3.7-plus"}}]'
export SOOTHE_EMBEDDING_PROFILE='[{"model_role":"openai:text-embedding-3-small","embedding_dims":1536}]'
Provider bootstrap names the provider openai; router targets must use the openai: prefix (e.g. openai:qwen3.7-plus), unless you define a named provider via SOOTHE_PROVIDERS JSON.
Anthropic:
export ANTHROPIC_API_KEY=sk-ant-...
To change the default model without YAML, set SOOTHE_ROUTER_PROFILES (JSON) — see Router overrides below. SOOTHE_ROUTER_DEFAULT does not work.
Top-Level SOOTHE_* (Agent Config)
These map directly and work as documented:
| YAML path | Env var | Notes |
|---|---|---|
debug |
SOOTHE_DEBUG |
|
active_router_profile |
SOOTHE_ACTIVE_ROUTER_PROFILE |
Also read inside _apply_active_router_profile |
tui_debug |
SOOTHE_TUI_DEBUG |
export SOOTHE_DEBUG=true
export SOOTHE_ACTIVE_ROUTER_PROFILE=production # profile must exist in YAML or SOOTHE_ROUTER_PROFILES
Does not work via flat env vars: nested paths such as router.default, agent.loop.max_iterations, persistence.postgres_base_dsn, observability.langfuse.enabled. Use JSON blobs, YAML, or CLI flags instead.
JSON SOOTHE_* (Nested / List Fields)
Pydantic Settings accepts JSON for complex fields. Prefix is still SOOTHE_.
| Field | Env var | Example |
|---|---|---|
providers |
SOOTHE_PROVIDERS |
'[{"name":"openai","api_key":"sk-..."}]' |
router_profiles |
SOOTHE_ROUTER_PROFILES |
See Router overrides |
embedding_profile |
SOOTHE_EMBEDDING_PROFILE |
'[{"model_role":"openai:text-embedding-3-small","embedding_dims":1536}]' |
persistence |
SOOTHE_PERSISTENCE |
'{"default_backend":"postgresql","postgres_base_dsn":"postgresql://..."}' |
vector_stores |
SOOTHE_VECTOR_STORES |
'[{"name":"pgvector","provider_type":"pgvector"}]' |
vector_store_router |
SOOTHE_VECTOR_STORE_ROUTER |
'{"default":"pgvector:soothe_default"}' |
export SOOTHE_PERSISTENCE='{"default_backend":"postgresql","postgres_base_dsn":"postgresql://postgres:postgres@localhost:5432"}'
export SOOTHE_VECTOR_STORES='[{"name":"pgvector","provider_type":"pgvector"}]'
export SOOTHE_VECTOR_STORE_ROUTER='{"default":"pgvector:soothe_default"}'
Quote carefully in shell and Compose — single quotes around the JSON are safest.
Router overrides (env-only)
router on SootheConfig is init=False: it is computed from router_profiles + active_router_profile. Individual SOOTHE_ROUTER_* role vars are not applied.
Replace the profile list (no YAML):
# OpenAI-compatible via OPENAI_* bootstrap (provider name "openai")
export SOOTHE_ROUTER_PROFILES='[{"name":"default","router":{"default":"openai:qwen3.7-plus","fast":"openai:qwen3.7-plus","think":"openai:qwen3.7-plus"}}]'
export SOOTHE_EMBEDDING_PROFILE='[{"model_role":"openai:text-embedding-3-small","embedding_dims":1536}]'
Generic OpenAI example:
export SOOTHE_ROUTER_PROFILES='[{"name":"default","router":{"default":"openai:gpt-4o","think":"openai:gpt-4o"}}]'
export SOOTHE_EMBEDDING_PROFILE='[{"model_role":"openai:text-embedding-3-small","embedding_dims":1536}]'
Switch a named profile (profiles defined in YAML or via SOOTHE_ROUTER_PROFILES):
export SOOTHE_ACTIVE_ROUTER_PROFILE=production
SOOTHE_DAEMON_* (Daemon Server)
Daemon transport, concurrency, and paths use prefix SOOTHE_DAEMON_ and nested delimiter __:
YAML path (daemon.yml) |
Env var |
|---|---|
transports.websocket.host |
SOOTHE_DAEMON_TRANSPORTS__WEBSOCKET__HOST |
transports.http_rest.enabled |
SOOTHE_DAEMON_TRANSPORTS__HTTP_REST__ENABLED |
thread_pool.request_timeout_seconds |
SOOTHE_DAEMON_THREAD_POOL__REQUEST_TIMEOUT_SECONDS |
worker_pool.request_timeout_seconds |
SOOTHE_DAEMON_WORKER_POOL__REQUEST_TIMEOUT_SECONDS |
soothe_config_path |
SOOTHE_DAEMON_SOOTHE_CONFIG_PATH |
memory_profiling.enabled |
SOOTHE_DAEMON_MEMORY_PROFILING__ENABLED |
Agent config (config.yml) nested autopilot deadline:
| YAML path | Env var |
|---|---|
agent.autopilot.goal_deadline_seconds |
SOOTHE_AGENT__AUTOPILOT__GOAL_DEADLINE_SECONDS |
The production Docker image sets SOOTHE_DAEMON_TRANSPORTS__WEBSOCKET__HOST=0.0.0.0 and enables HTTP REST by default.
If soothe_config_path points to a missing file, the daemon loads agent defaults (zero-config bootstrap). Do not mount a config file when running env-only.
${VAR} Interpolation (YAML)
Write placeholders in YAML; Soothe resolves them at load time:
providers:
- name: openai
api_key: ${OPENAI_API_KEY}
persistence:
postgres_base_dsn: ${POSTGRES_DSN}
Behaviors (config/env.py):
- Works anywhere in a string:
${HOME}/workspaces,${VAR1}/${VAR2}. - Unresolved vars stay literal — usually fails validation or skips the provider.
- Pattern:
\$\{(\w+)\}only (word characters; no$VARPOSIX form).
Production Compose often uses ${SOOTHE_POSTGRES_BASE_DSN} inside mounted YAML — that is interpolation into the file, not a SOOTHE_* field override.
Provider & Tool Keys (No Prefix)
Not SOOTHE_* — used by zero-config bootstrap, ${VAR} in YAML, or LangChain directly:
| Variable | Used for |
|---|---|
OPENAI_API_KEY, OPENAI_BASE_URL |
OpenAI / OpenAI-compatible APIs (Qwen/DashScope, OpenRouter, etc.) |
ANTHROPIC_API_KEY |
Anthropic |
OPENROUTER_API_KEY |
OpenRouter |
TAVILY_API_KEY, SERPER_API_KEY, JINA_API_KEY |
Web search |
DEEPXIV_API_KEY, DEEPXIV_TOKEN |
Academic search |
GITHUB_TOKEN, GITHUB_MCP_TOKEN, LINEAR_MCP_TOKEN |
MCP auth |
LANGFUSE_PUBLIC_KEY, LANGFUSE_SECRET_KEY, LANGFUSE_HOST |
Tracing |
POSTGRES_DSN, POSTGRES_VECTOR_DSN, … |
Storage (via ${...} in YAML) |
Path & Runtime Vars
| Variable | Purpose |
|---|---|
SOOTHE_HOME |
Base dir (default ~/.soothe; /var/lib/soothe in Docker) |
SOOTHE_CONFIG_FILE |
Override agent config path (CLI --config wins first) |
SOOTHE_JWT_KEY |
Identity JWT signing key |
SOOTHE_WORKSPACE |
Daemon fallback workspace (resolve_daemon_workspace); also used in tests |
CLI env vars (SOOTHE_CLI_*)
| Variable | Purpose |
|---|---|
SOOTHE_CLI_OMIT_WORKSPACE |
Optional 1/true: omit workspace on loop_new (default: send cwd) |
SOOTHE_CLI_WORKSPACE |
Override project path sent to daemon (default: cwd); none/-/omit omits workspace |
Agent config (JSON or YAML) for Docker path mapping:
| Variable / YAML | Purpose |
|---|---|
SOOTHE_WORKSPACE_MOUNT |
JSON: {"host_root":"/Users/you/Workspace","container_root":"/var/lib/soothe/workspaces"} |
SOOTHE_WORKSPACE_HOST_ROOT |
Host workspace bind-mount source for Compose (default: $HOME; optional override) |
workspace_mount.host_root |
Host prefix of CLI project paths (RFC-621) |
workspace_mount.container_root |
In-container mount point (must match Docker -v target) |
Priority & Override Chains
Resolution order, highest to lowest:
- CLI args (
--config,--debug, daemon--soothe-config) - Top-level / JSON
SOOTHE_*env vars - YAML file (with
${VAR}already interpolated) - Pydantic model defaults (+ zero-config provider bootstrap)
After load, _apply_active_router_profile copies the selected profile into derived router, while _apply_embedding_profile resolves embedding_model and embedding_dims from embedding_profile. Env cannot patch individual router roles afterward unless you replace SOOTHE_ROUTER_PROFILES or edit YAML.
Docker (Env-Only, No Config File)
OpenAI-compatible provider + qwen3.7-plus:
export OPENAI_API_KEY=sk-...
export OPENAI_BASE_URL=https://dashscope.aliyuncs.com/compatible-mode/v1
export SOOTHE_ROUTER_PROFILES='[{"name":"default","router":{"default":"openai:qwen3.7-plus","fast":"openai:qwen3.7-plus","think":"openai:qwen3.7-plus"}}]'
export SOOTHE_EMBEDDING_PROFILE='[{"model_role":"openai:text-embedding-3-small","embedding_dims":1536}]'
docker run --rm -d --name soothed \
-p 8765:8765 \
-e OPENAI_API_KEY \
-e OPENAI_BASE_URL \
-e SOOTHE_ROUTER_PROFILES \
-e SOOTHE_EMBEDDING_PROFILE \
-v soothe-data:/var/lib/soothe \
registry.cn-hangzhou.aliyuncs.com/lacogito/soothed:latest
Postgres sidecar + persistence without YAML:
-e SOOTHE_PERSISTENCE='{"default_backend":"postgresql","postgres_base_dsn":"postgresql://postgres:postgres@soothe-pgvector:5432"}' \
-e SOOTHE_VECTOR_STORES='[{"name":"pgvector","provider_type":"pgvector"}]' \
-e SOOTHE_VECTOR_STORE_ROUTER='{"default":"pgvector:soothe_default"}'
Image: registry.cn-hangzhou.aliyuncs.com/lacogito/soothed:latest (or a pinned tag). Health: curl http://127.0.0.1:8765/healthz.
Host CLI + Docker daemon: workspace mount (RFC-621)
Use this when the CLI runs on the host and the daemon runs in Docker and you want file/shell tools to touch your local repo.
Three pieces must align:
| Piece | Example |
|---|---|
| Docker bind mount | -v /Users/you/Workspace:/var/lib/soothe/workspaces |
workspace_mount.host_root |
/Users/you/Workspace (must be a prefix of CLI cwd) |
workspace_mount.container_root |
/var/lib/soothe/workspaces (same as mount target in container) |
Env-only (no config file):
export OPENAI_API_KEY=sk-...
export OPENAI_BASE_URL=https://dashscope.aliyuncs.com/compatible-mode/v1
export CONTAINER_WS=/var/lib/soothe/workspaces
export SOOTHE_ROUTER_PROFILES='[{"name":"default","router":{"default":"openai:qwen3.7-plus","fast":"openai:qwen3.7-plus","think":"openai:qwen3.7-plus"}}]'
export SOOTHE_EMBEDDING_PROFILE='[{"model_role":"openai:text-embedding-3-small","embedding_dims":1536}]'
docker run --rm -d --name soothed \
-p 8765:8765 \
-e OPENAI_API_KEY \
-e OPENAI_BASE_URL \
-e SOOTHE_ROUTER_PROFILES \
-e SOOTHE_EMBEDDING_PROFILE \
-e SOOTHE_WORKSPACE_MOUNT="{\"host_root\":\"$HOME\",\"container_root\":\"$CONTAINER_WS\"}" \
-v soothe-data:/var/lib/soothe \
-v "$HOME:$CONTAINER_WS" \
registry.cn-hangzhou.aliyuncs.com/lacogito/soothed:latest
cd ~/soothe && soothe
Path translation: CLI sends /Users/chenxm/Workspace/soothe → daemon uses /var/lib/soothe/workspaces/soothe.
With YAML (Compose / deploy/): same mapping under workspace_mount: in agent config; docker-compose mounts $HOME by default (${SOOTHE_WORKSPACE_HOST_ROOT:-${HOME}}:/var/lib/soothe/workspaces) — see deploy/config.prod.yml and root docker-compose.yml. Override SOOTHE_WORKSPACE_HOST_ROOT when projects live outside $HOME.
Rules:
host_rootmust be a parent directory of every project youcdinto before runningsoothe.- Do not set
SOOTHE_CLI_OMIT_WORKSPACEwhen using a mount — the CLI must send hostcwdfor RFC-621 mapping. - If you see
Client workspace does not exist, the bind mount orhost_rootdoes not match your paths (orworkspace_mountis unset).
Host CLI + Docker daemon: chat-only (no mount)
When the daemon has no workspace volume and no workspace_mount, the CLI may still send host cwd. The daemon detects that the path is absent on the container filesystem and uses $SOOTHE_HOME/data/workspaces/... automatically — SOOTHE_CLI_OMIT_WORKSPACE is not required.
Optionally set SOOTHE_CLI_OMIT_WORKSPACE=1 to omit workspace on the wire (slightly cleaner handshake; same runtime layout).
Per-Environment Setup
Keep structure in YAML (or JSON env for headless Docker); keep secrets in the environment.
Development:
export OPENAI_API_KEY=sk-dev-...
export SOOTHE_DEBUG=true
# optional: export SOOTHE_CONFIG_FILE=~/.soothe/config.dev.yml
Production / CI: provider keys via secret manager; Postgres via YAML + ${POSTGRES_DSN} or SOOTHE_PERSISTENCE JSON; Langfuse via ${LANGFUSE_*} in YAML.
Testing:
export SOOTHE_PERSISTENCE='{"default_backend":"sqlite"}'
# observability flags: set in YAML or use JSON SOOTHE_OBSERVABILITY='{"langfuse":{"enabled":false}}'
Secret Hygiene
- Never commit resolved secrets — use
${VAR}in YAML. - Rotate by updating env and restarting; no config edit needed for interpolated keys.
- Verify:
env | grep -E 'SOOTHE|OPENAI|ANTHROPIC'.
Troubleshooting
Variable not applied?
- Top-level field? Must match exactly (
SOOTHE_DEBUG, notSOOTHE_DEBUG_MODE). - Nested field? Use JSON (
SOOTHE_PERSISTENCE,SOOTHE_ROUTER_PROFILES,SOOTHE_EMBEDDING_PROFILE) or YAML — notSOOTHE_FOO_BAR_BAZdotted paths. - Router model unchanged?
SOOTHE_ROUTER_DEFAULTis ignored; useSOOTHE_ROUTER_PROFILESor YAMLrouter_profiles. - Daemon transport? Use
SOOTHE_DAEMON_*with__nesting. - Shell exported the var?
source ~/.zshrcor restart the daemon container.
Debug resolved config:
soothe --debug "test"
soothed doctor
See Also
- Common Patterns — zero-config and production recipes
- YAML Reference —
router_profilesschema - Daemon Setup — full stack deploy
- Source:
packages/soothe/src/soothe/config/settings.py,packages/soothe-daemon/src/soothe_daemon/config/settings.py