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.
Minimum configuration
Section titled “Minimum configuration”./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 set | Where | What happens without it |
|---|---|---|
ENV · CONFIG_ENCRYPTION_KEY | .env | It won’t start: no override file found, or encrypted values can’t be opened |
One LLM key (e.g. OPENROUTER_API_KEY) | config.{env}.yaml | Login works, but a chat turn has no model |
One embedding key (GOOGLE_API_KEY or DASHSCOPE_API_KEY) | config.{env}.yaml | Chat works, but health indicators stay at 0 — failing silently in the worker log |
JWT_KEY | config.{env}.yaml | The auth layer is not installed and everyone is anonymous |
| Database and Redis connection keys | already in compose.yaml | Nothing to do under Compose; only needed for external instances |
The three layers
Section titled “The three layers”.env -> which environment, and the encryption keyconfig.{env}.yaml -> your overridesconfig.yaml -> the committed templatePrecedence runs from the top down — the topmost source that defines a key wins:
environment: in compose.yamlevery key in .env config.{env}.yaml your overrides (git-ignored) JWT_KEYOPENROUTER_API_KEYMCP_PUBLIC_URL CONFIG_SERVER + CONFIG_TOKEN + ENV are all set config.yaml the shipped template (do not edit) 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.
| File | Tracked in git? | Written by | Role |
|---|---|---|---|
config.yaml | yes | upstream | Defaults for every key. Its own header says do not edit this file. |
config.{env}.yaml | no (.gitignore matches *.*.yaml) | you, or deploy.sh on first run | Everything you change. With ENV=localdb that is config.localdb.yaml. |
.env | no | you, or deploy.sh | ENV 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.
# '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=Xk3pQ7mZ2vB9nR4tY6wL8sD1fG5hJ0aCRemote configuration
Section titled “Remote configuration”Set three environment variables and the engine fetches a resolved YAML document over HTTP before it reads your local override file:
CONFIG_SERVER=https://config.example.comCONFIG_TOKEN=<sent as the X-Config-Token header>ENV=prodThe 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.
Automatic encryption
Section titled “Automatic encryption”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 _TOKENwith 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:
JWT_KEY: 7f2Ka9LmQ4xRt6Zv1Bn8Cs3Wd5Yh0Pj2OPENROUTER_API_KEY: sk-or-v1-abcdefGARMIN_TOKEN_URL: https://connectapi.garmin.com/oauth-service/oauth/request_tokenand after one start the file on disk reads:
JWT_KEY: gAAAAABm9x...truncated...Q3w==OPENROUTER_API_KEY: gAAAAABm9x...truncated...7Yk=GARMIN_TOKEN_URL: https://connectapi.garmin.com/oauth-service/oauth/request_tokenThe 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.
The encryption key
Section titled “The encryption key”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:
openssl rand -hex 16 # 32 charactersKey reference
Section titled “Key reference”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.
Logging and timezone
Section titled “Logging and timezone”| Key | Default | Notes |
|---|---|---|
LOG_LEVEL | DEBUG | Case-insensitive. An unrecognised name falls back to INFO. |
LOG_NAME, LOG_DIR | unset | Both unset means console only. Files are named {date}_{name}_{time}.log. |
DEFAULT_TIMEZONE | America/Los_Angeles | Used for users who have not chosen one. |
HTTP server
Section titled “HTTP server”HTTP_HOST: 0.0.0.0HTTP_PORT: 18080HTTP_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| Key | Default | Notes |
|---|---|---|
HTTP_SERVER_NAME | mirobody | Also emitted as the Server: response header, with the version appended. |
HTTP_HOST | 0.0.0.0 | |
HTTP_PORT | 80 | Commented out in the template, so a source run listens on 80 unless you set it. |
HTTP_URI_PREFIX | empty | Mounts every route under a sub-path; leading and trailing slashes are normalised. |
HTTP_ROOT | frontend | The 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_HEADERS | unset | Verbatim 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: 6 | A 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_UPDATER | unset | A list of paths whose requests also refresh the caller’s profile. |
Public URLs
Section titled “Public URLs”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.
REDIS_HOST: 127.0.0.1REDIS_PORT: 18089REDIS_DB: 0REDIS_PASSWORD: ''REDIS_SSL: falseREDIS_SSL_CHECK_HOSTNAME: falseREDIS_SSL_CERT_REQS: noneThe 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.
PostgreSQL
Section titled “PostgreSQL”PG_HOST: 127.0.0.1PG_PORT: 18082PG_USER: holistic_userPG_PASSWORD: ''PG_DBNAME: holistic_dbPG_SCHEMA: theta_aiPG_ENCRYPTION_KEY: ''PG_MIN_CONNECTION: 5PG_MAX_CONNECTION: 20Defaults 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.
Object storage
Section titled “Object storage”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.
Email and sign-in
Section titled “Email and sign-in”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 and OAuth
Section titled “JWT and OAuth”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.
Web client configuration
Section titled “Web client configuration”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.
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__: ""Extension directories
Section titled “Extension directories”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.
| Key | Default | What goes there |
|---|---|---|
MCP_TOOL_DIRS | mirobody/agent/tools | Python modules whose top-level functions and *Service classes become tools; see Tools & Agent Overview |
MCP_RESOURCE_DIRS | mirobody/agent/resources | MCP UI resources |
AGENT_DIRS | mirobody/agent | Agent implementations; see Agent Types |
PROVIDER_DIRS | mirobody/pulse/providers | Health-data providers; see Building a Provider |
SKILL_DIRS | mirobody/agent/skills | Skill 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.
LLM API keys
Section titled “LLM API keys”| Key | Where to get one | Read by |
|---|---|---|
GOOGLE_API_KEY | aistudio.google.com/apikey | Google GenAI clients, file analysis, embeddings |
OPENAI_API_KEY | platform.openai.com/api-keys | The OpenAI-compatible client |
OPENROUTER_API_KEY | openrouter.ai/keys | One key, many models: what the template’s providers use |
DASHSCOPE_API_KEY | dashscope.console.aliyun.com/apiKey | DashScope’s OpenAI-compatible endpoint; also audio transcription |
ANTHROPIC_API_KEY | Anthropic console | Direct Claude access |
Per-agent models and tools
Section titled “Per-agent models and tools”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:
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| Family | Shape | Notes |
|---|---|---|
PROVIDERS_{NAME} | map of display name to provider block | The 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 names | An allow-list. It takes precedence over the deny-list; both empty means every discovered tool is available. |
DISALLOWED_TOOLS_{NAME} | list of tool names | A deny-list. |
PROMPTS_{NAME} | list of .jinja paths | Resolved 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.
Third-party health platforms
Section titled “Third-party health platforms”GARMIN_CLIENT_ID: ''GARMIN_CLIENT_SECRET: ''GARMIN_REDIRECT_URL: ''
WHOOP_CLIENT_ID: ''WHOOP_CLIENT_SECRET: ''WHOOP_REDIRECT_URL: ''
OAUTH_TEMP_TTL_SECONDS: 900Garmin, 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.
File processing and indicators
Section titled “File processing and indicators”| Key | Default | Notes |
|---|---|---|
EMBEDDING_PROVIDER | gemini | gemini (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_MODEL | the provider’s own | Overrides 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.
Inspecting the resolved configuration
Section titled “Inspecting the resolved configuration”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.
docker compose logs mirobody | head -40 # under Composecurl http://localhost:18080/api/health # counts of tools, resources and agentsIf 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.
Next steps
Section titled “Next steps”Where these files come from, and the ports they describe
Running from source against Postgres and Redis in Docker
Which middleware and services each key switches on
Secrets, TLS and hardening beyond a local run