Skip to content
Get Started

Getting Started

Configuration

How the Mirobody engine resolves configuration: the three layers, automatic secret encryption, and every key in config.yaml grouped by what it does.

Every setting the engine has is a flat, upper-case key. Keys come from YAML files, from a remote config service, or from the process environment, and the loader merges them into one namespace at startup. There is no per-module config file and nothing is reloaded while the process runs — a config change means a restart.

./deploy.sh generates .env and config.{env}.yaml for you; what you fill in by hand is only this. Everything else has a working default — come back to the key reference when you actually need to change one.

What you setWhereWhat happens without it
ENV · CONFIG_ENCRYPTION_KEY.envIt won’t start: no override file found, or encrypted values can’t be opened
One LLM key (e.g. OPENROUTER_API_KEY)config.{env}.yamlLogin works, but a chat turn has no model
One embedding key (GOOGLE_API_KEY or DASHSCOPE_API_KEY)config.{env}.yamlChat works, but health indicators stay at 0 — failing silently in the worker log
JWT_KEYconfig.{env}.yamlThe auth layer is not installed and everyone is anonymous
Database and Redis connection keysalready in compose.yamlNothing to do under Compose; only needed for external instances
.env -> which environment, and the encryption key
config.{env}.yaml -> your overrides
config.yaml -> the committed template

Precedence runs from the top down — the topmost source that defines a key wins:

A lookup consults the process environment first: the key as given, then upper-cased. Only then does it fall back to the merged YAML map, which is a plain overwrite in load order — config.yaml, then remote config, then config.{env}.yaml. The last file to define a key is the one that survives.

FileTracked in git?Written byRole
config.yamlyesupstreamDefaults for every key. Its own header says do not edit this file.
config.{env}.yamlno (.gitignore matches *.*.yaml)you, or deploy.sh on first runEverything you change. With ENV=localdb that is config.localdb.yaml.
.envnoyou, or deploy.shENV picks the file above; CONFIG_ENCRYPTION_KEY unlocks encrypted values.

.env is loaded first and each line becomes an environment variable, set with setdefault, so a variable already exported in the shell beats the file. Worth remembering: anything you put in .env outranks both YAML layers, not just ENV and CONFIG_ENCRYPTION_KEY.

.env
# 'localdb', 'test', 'gray', 'prod', or a name you invent.
ENV=localdb
# Up to 32 characters. Encrypts sensitive values in config.{env}.yaml.
CONFIG_ENCRYPTION_KEY=Xk3pQ7mZ2vB9nR4tY6wL8sD1fG5hJ0aC

Set three environment variables and the engine fetches a resolved YAML document over HTTP before it reads your local override file:

Terminal window
CONFIG_SERVER=https://config.example.com
CONFIG_TOKEN=<sent as the X-Config-Token header>
ENV=prod

The request is GET {CONFIG_SERVER}/api/v1/config/environments/{ENV}/configs/resolved?is_yaml=true. If any of the three is empty, or the request fails, the engine logs the reason and carries on with local files only. Because remote config loads before config.{env}.yaml, a local override still wins, which is useful for pinning one key on one machine. compose.yaml passes CONFIG_SERVER and CONFIG_TOKEN through to the container, so putting them in .env is enough.

Secrets in your override file are encrypted in place, on the first load that sees them in plaintext. The rule is purely by key name: a top-level string value is encrypted when its name matches

_KEY _PASSWORD _PASS _PWD _SECRET _SK _TOKEN

with two exceptions: names ending in _URL are skipped (so GARMIN_TOKEN_URL stays readable), and the literal REPLACE_THIS_VALUE_IN_PRODUCTION is left alone. That’s why the template’s placeholders never turn into ciphertext.

So you write this:

config.localdb.yaml
JWT_KEY: 7f2Ka9LmQ4xRt6Zv1Bn8Cs3Wd5Yh0Pj2
OPENROUTER_API_KEY: sk-or-v1-abcdef
GARMIN_TOKEN_URL: https://connectapi.garmin.com/oauth-service/oauth/request_token

and after one start the file on disk reads:

config.localdb.yaml
JWT_KEY: gAAAAABm9x...truncated...Q3w==
OPENROUTER_API_KEY: gAAAAABm9x...truncated...7Yk=
GARMIN_TOKEN_URL: https://connectapi.garmin.com/oauth-service/oauth/request_token

The rewrite is a round-trip through ruamel.yaml, so your comments, key order and quoting style survive. On every later load, values beginning with gAAAA are recognised as Fernet tokens and decrypted into memory; the file is not touched again. In the startup summary, *_API_KEY values are printed masked as abc******xyz.

CONFIG_ENCRYPTION_KEY is a raw secret, not a Fernet key. The loader trims it, keeps at most the first 32 characters, right-pads shorter values with 0 to 32 bytes, and base64-encodes the result to derive the Fernet key. deploy.sh generates 32 random alphanumerics; any equivalent works:

Terminal window
openssl rand -hex 16 # 32 characters

Unless noted otherwise, every key below appears in config.yaml. Keys shown commented out there are inactive defaults you can copy into your own file.

KeyDefaultNotes
LOG_LEVELDEBUGCase-insensitive. An unrecognised name falls back to INFO.
LOG_NAME, LOG_DIRunsetBoth unset means console only. Files are named {date}_{name}_{time}.log.
DEFAULT_TIMEZONEAmerica/Los_AngelesUsed for users who have not chosen one.
config.localdb.yaml
HTTP_HOST: 0.0.0.0
HTTP_PORT: 18080
HTTP_ROOT: frontend
HTTP_HEADERS:
Access-Control-Allow-Origin: 'http://localhost:18080'
Access-Control-Allow-Credentials: 'true'
Access-Control-Allow-Methods: 'GET, POST, PUT, DELETE, OPTIONS'
Access-Control-Allow-Headers: 'Authorization, Content-Type'
Access-Control-Max-Age: '600'
REQUEST_RATE_LIMITER:
/api/chat: 6
/api/session: 6
KeyDefaultNotes
HTTP_SERVER_NAMEmirobodyAlso emitted as the Server: response header, with the version appended.
HTTP_HOST0.0.0.0
HTTP_PORT80Commented out in the template, so a source run listens on 80 unless you set it.
HTTP_URI_PREFIXemptyMounts every route under a sub-path; leading and trailing slashes are normalised.
HTTP_ROOTfrontendThe prebuilt web client, resolved next to the running process. It sits outside the Python package, so a wheel ships the engine rather than the JavaScript; a path that does not exist simply serves no client.
HTTP_HEADERSunsetVerbatim response headers — this is where CORS goes. The template’s own example pairs a fixed origin with Allow-Credentials, the combination browsers require; * and credentials together are rejected.
REQUEST_RATE_LIMITER/api/chat: 6, /api/session: 6A map of { "path": requests-per-minute }. Present and non-empty adds the rate-limit middleware, which counts per user in Redis; see Architecture Overview.
USER_INFO_UPDATERunsetA list of paths whose requests also refresh the caller’s profile.

MCP_PUBLIC_URL is your externally reachable base URL (an ngrok domain, for instance): remote MCP clients need it, files on the local filesystem are served from {MCP_PUBLIC_URL}/files, and the startup banner uses it as the address to open. See Mirobody MCP Server. The template also carries MCP_FRONTEND_URL, DATA_PUBLIC_URL and QR_LOGIN_URL as placeholders; no live code path reads them in this version.

Required — the cache, the task queues and the rate limiter all use it.

config.localdb.yaml
REDIS_HOST: 127.0.0.1
REDIS_PORT: 18089
REDIS_DB: 0
REDIS_PASSWORD: ''
REDIS_SSL: false
REDIS_SSL_CHECK_HOSTNAME: false
REDIS_SSL_CERT_REQS: none

The template ships REDIS_HOST: 10.108.0.9, the container’s address on the Compose bridge network; from the host use 127.0.0.1 with the published port. Appending a suffix to each name declares a second, independent connection: REDIS_HOST_LOG, REDIS_PORT_LOG and so on are picked up by the code that asks for the LOG connection.

config.localdb.yaml
PG_HOST: 127.0.0.1
PG_PORT: 18082
PG_USER: holistic_user
PG_PASSWORD: ''
PG_DBNAME: holistic_db
PG_SCHEMA: theta_ai
PG_ENCRYPTION_KEY: ''
PG_MIN_CONNECTION: 5
PG_MAX_CONNECTION: 20

Defaults are PG_HOST: 10.108.0.2, user holistic_user, database holistic_db, schema theta_ai, pool 5–20. PG_ENCRYPTION_KEY encrypts sensitive columns, so it has to be resolvable before the first query. The same _SUFFIX trick as Redis gives you a second connection, with one caveat: the encryption key is read without the suffix, so a suffixed connection reuses the primary PG_ENCRYPTION_KEY.

S3_KEY, S3_TOKEN, S3_REGION, S3_BUCKET, S3_PREFIX and S3_CDN all ship commented out, and that is itself a working configuration: the storage factory tries cloud backends in turn, the S3 backend refuses to construct without an access key, secret, region and bucket, and the factory then falls back to the local filesystem. Fill the four mandatory keys to switch to S3 or any S3-compatible store; S3_PREFIX namespaces the keys and S3_CDN is the public base URL used when building links. A name suffix selects a second bucket.

config.localdb.yaml
EMAIL_PREDEFINE_CODES:
exp1@mirobody.ai: '111111'
exp2@mirobody.ai: '111111'
exp3@mirobody.ai: '111111'

EMAIL_PREDEFINE_CODES maps an address to a fixed verification code: those accounts sign in with it and no mail is sent. The template enables three demo accounts and prints them as a table at startup, so a fresh checkout is usable immediately. To actually send codes, configure EMAIL_SMTP_HOST / EMAIL_SMTP_PORT / EMAIL_SMTP_USER / EMAIL_SMTP_PASS along with EMAIL_FROM and EMAIL_FROM_NAME.

Third-party sign-in is configured per vendor: GOOGLE_CLIENT_ID + FIREBASE_PROJECT_ID for Google, and APPLE_TEAM_ID / APPLE_KEY_ID / APPLE_PRIVATE_KEY / APPLE_CLIENT_ID for Apple (plus APPLE_CLIENT_ID_APP and APPLE_AUTH_CLIENT_ID when the app and web client ids differ). All commented out by default.

JWT_KEY is the HS256 secret, and deploy.sh writes a random value into your override file on first run. It is also the switch for the whole auth layer: the middleware that resolves a bearer token into a caller identity is only installed when JWT_KEY is non-empty. JWT_PRIVATE_KEY is the RS256 alternative. JWT_ISS, JWT_AUD, JWT_CLIENT_ID and JWT_SCOPE become the matching claims in the tokens the engine issues to MCP clients.

MIROBODY_WEB_CONFIG is a nested map handed to the bundled web client at runtime: feature toggles named __IS_*_ON__ plus the browser-side Firebase values. The whole block is commented out in the template.

config.localdb.yaml
MIROBODY_WEB_CONFIG:
__IS_API_CONFIG_ON__: true
__IS_QR_LOGIN_ON__: false
__IS_GOOGLE_LOGIN_ON__: true
__IS_APPLE_LOGIN_ON__: true
__IS_NEW_FEATURES_ON__:
- MCP
__IS_MOBILE_SOURCE_ON__: true
__FIREBASE_API_KEY__: ""
__FIREBASE_AUTH_DOMAIN__: ""
__FIREBASE_PROJECT_ID__: ""

Five keys, each a list of directories, tell the engine where to discover your own code at startup. Each ships with exactly one entry, the packaged one. Add your own directory and list it first; it is scanned ahead of the built-in one, which is what lets a deployment override a packaged tool or skill without editing the tree.

KeyDefaultWhat goes there
MCP_TOOL_DIRSmirobody/agent/toolsPython modules whose top-level functions and *Service classes become tools; see Tools & Agent Overview
MCP_RESOURCE_DIRSmirobody/agent/resourcesMCP UI resources
AGENT_DIRSmirobody/agentAgent implementations; see Agent Types
PROVIDER_DIRSmirobody/pulse/providersHealth-data providers; see Building a Provider
SKILL_DIRSmirobody/agent/skillsSkill directories, one SKILL.md each; see Agent Skills

Discovery happens at startup, so adding any of them means adding a file and restarting. Forward slashes are translated to the platform separator, and an empty list falls back to the default.

KeyWhere to get oneRead by
GOOGLE_API_KEYaistudio.google.com/apikeyGoogle GenAI clients, file analysis, embeddings
OPENAI_API_KEYplatform.openai.com/api-keysThe OpenAI-compatible client
OPENROUTER_API_KEYopenrouter.ai/keysOne key, many models: what the template’s providers use
DASHSCOPE_API_KEYdashscope.console.aliyun.com/apiKeyDashScope’s OpenAI-compatible endpoint; also audio transcription
ANTHROPIC_API_KEYAnthropic consoleDirect Claude access

Four key families are suffixed with the agent’s name in upper case. Two agents ship, so the suffixes that matter are DEEP and BASE; the *_RTC keys in the template are empty leftovers of a removed agent, and there is no *_MIX. A minimal PROVIDERS_DEEP looks like this:

config.localdb.yaml
PROVIDERS_DEEP:
gemini-3.5-flash:
llm_type: google-genai
api_key: GOOGLE_API_KEY
model: gemini-3.5-flash
temperature: 1.0
claude-sonnet:
llm_type: openai
api_key: OPENROUTER_API_KEY
base_url: https://openrouter.ai/api/v1
model: anthropic/claude-sonnet-4.6
temperature: 0.1
DISALLOWED_TOOLS_DEEP:
- task
FamilyShapeNotes
PROVIDERS_{NAME}map of display name to provider blockThe names users pick from. api_key holds the name of a config key, resolved at load time; one that resolves to nothing leaves a placeholder that reports the missing name instead of failing at import.
ALLOWED_TOOLS_{NAME}list of tool namesAn allow-list. It takes precedence over the deny-list; both empty means every discovered tool is available.
DISALLOWED_TOOLS_{NAME}list of tool namesA deny-list.
PROMPTS_{NAME}list of .jinja pathsResolved from the filesystem or from inside the installed package. A path@key suffix names the template explicitly, otherwise the filename is the name.

Which agent reads which suffix, and what the prompt names mean for that agent, is covered in Agent Types.

config.localdb.yaml
GARMIN_CLIENT_ID: ''
GARMIN_CLIENT_SECRET: ''
GARMIN_REDIRECT_URL: ''
WHOOP_CLIENT_ID: ''
WHOOP_CLIENT_SECRET: ''
WHOOP_REDIRECT_URL: ''
OAUTH_TEMP_TTL_SECONDS: 900

Garmin, Whoop and Oura are the shipped providers with OAuth credentials of their own. Note that the Oura keys (OURA_CLIENT_ID, OURA_CLIENT_SECRET, OURA_REDIRECT_URL) are read by its provider but are not in the template, so you add them yourself. Alongside the client ID, secret and redirect URL, the template pins their endpoints: GARMIN_TOKEN_URL, GARMIN_AUTH_URL, GARMIN_ACCESS_TOKEN_URL, GARMIN_API_BASE_URL, and WHOOP_TOKEN_URL, WHOOP_AUTH_URL, WHOOP_API_BASE_URL, WHOOP_SCOPES, so you normally only fill in the credentials. OAUTH_TEMP_TTL_SECONDS bounds how long a pending authorization stays valid. Connecting an account is described in Using Providers.

A provider with no credentials does not fail: it declines to start and logs declined to start (not configured), the honest state rather than an error. The one you can try immediately is the PostgreSQL provider: set ENABLE_PGSQL_DEVICE: 1 and the platform logs loaded 1 providers on the next boot.

The template also carries VITAL_API_KEY and VITAL_ENVIRONMENT, the RENPHO_* block, and commented FRONTIERX_CLIENT_ID / FRONTIERX_USER_POOL_ID. There is no provider package behind any of them in this release; treat them as reserved.

KeyDefaultNotes
EMBEDDING_PROVIDERgeminigemini (needs GOOGLE_API_KEY) or qwen (needs DASHSCOPE_API_KEY). Selects both the embedding model and the vector column used for indicator search; an unknown value raises. This is a different key from the chat model’s: without a working one, the worker’s indicator sync fails quietly and Health indicators stays 0. See Health Indicators.
<PROVIDER>_VISION_MODELthe provider’s ownOverrides the vision model for image and PDF parsing; the key name follows the provider.

DATABASE_DECRYPTION_KEY is marked deprecated in the template and may be removed: the live column-encryption key is PG_ENCRYPTION_KEY.

At startup the engine prints a summary of the resolved configuration: the files it read, the environment name, log and HTTP settings, the data stores, the discovered directories, and the API keys it found, masked. That summary is the fastest way to confirm a layer landed the way you expected.

Terminal window
docker compose logs mirobody | head -40 # under Compose
curl http://localhost:18080/api/health # counts of tools, resources and agents

If a value looks stale, check in this order: an environment variable shadowing it (including one from .env), then whether your file is really named config.{ENV}.yaml for the ENV you set, then whether the key is spelled exactly as the loader expects. Lookups are upper-cased, so case in the file does not matter, but nothing else about the name is forgiving.