Skip to content

Configuration reference

FileWhat it isRead by
air.jsonThe AIR catalog wiring (dev/test)AirCatalogService via the AIR CLI
air.production.jsonSame, for the in-image catalog (prod/staging)ditto
roots.jsonAgent root definitionsAgentRootsConfig
mcp.jsonMCP server registryServersConfig
skills/skills.jsonSkill indexSkillsConfig
plugins/plugins.jsonPlugin indexPluginsConfig
hooks/hooks.jsonHook indexHooksConfig
references/references.jsonReference indexReferencesConfig
config/goals.jsonGoal / stop-condition catalogGoalsConfig

config/goals.json is the exception — it’s a plain static file that GoalsConfig reads directly, outside AIR.

VarPurposeSet by the shipped deploy?
SECRET_KEY_BASERails secret✅ (Kamal)
DATABASE_HOST / _PORT / _USERNAME / _PASSWORD / _SSLMODEPostgres✅ (Kamal)
REDIS_URLCache✅ (Kamal)
API_KEYSREST API and MCP auth. Each entry gets a named row the first time it is used; revoke one, or mint more, on /settings/api_keys✅ (Kamal)
APP_HOSTMCP OAuth redirect URI, and the mailer link host✅ (Kamal)
ZIMMER_PROD_BASE_URL / ZIMMER_STAGING_BASE_URLExternally-reachable base URL of this instance (e.g. https://zimmer.your-domain.com). AppUrl resolves it to build every absolute link Zimmer emits — session URLs in the orchestrator system prompt, “View trigger in Zimmer” alert links, MCP tool output, the MCP OAuth issuer, and the domain in the Host allow-list. Set this: when unset it falls back to a non-functional zimmer.example.com placeholder and generated links break. The shipped deploy sets it in config/deploy.{production,staging}.yml; a self-hosted instance must set it to its own host✅ (Kamal)
ZIMMER_TAILNET_HOSTNAMEThe droplet’s MagicDNS name, added to the Host allow-list bare and as <name>.<tailnet>.ts.net✅ (Kamal)
APNS_AUTH_KEY_P8 / APNS_KEY_ID / APNS_TEAM_IDThe APNs auth key (the .p8 file’s contents), its Key ID and the Apple team, for iOS push. Read through the secret chain on every send. Until all three are set, no iOS push is sentnot set (iOS push off)
APNS_TOPICThe iOS app’s bundle id, sent as apns-topiccom.tadasant.zimmer
ZIMMER_NATIVE_ACCESS_AUDThe iOS app host’s Cloudflare Access audience tag. The edge handoff requires an assertion’s aud to include it, and refuses every handoff while it is unset. Not a secretnot set (handoff refused)
ZIMMER_NATIVE_ACCESS_TEAM_DOMAINThe Cloudflare Access team domain whose JWKS and iss the edge handoff checkstadasant.cloudflareaccess.com
ZIMMER_ALLOWED_HOSTSExtra hostnames for the Host allow-list, comma-separated; .example.com also admits subdomains. * turns the check offnot set
RAILS_MASTER_KEYRails credentials✅ in a self-hosted production config; on staging it is optional, and degrades silently when absent
SLACK_BOT_TOKENSlack triggers and the channel pickervia mcp_secrets (encrypted credentials); ENV is the fallback
ENG_ALERTS_SLACK_CHANNEL_IDthe operational alert channel. Zimmer does not post there — the obs pipeline does, through its own webhook — it reads this id only to recognize the channel and keep passive listening from treating a page as a conversationvia mcp_secrets; ENV is the fallback
SLACK_BOT_MENTION_ALLOWED_USER_IDScomma-separated Slack user IDs allowed to fire bot_mention, dm_message and passive-listening triggers. Blank or unset means everyone — see the caveatvia mcp_secrets; ENV is the fallback
SLACK_TRIGGER_INGEST_MODEhow Slack triggers learn about messages: poll (default) or webhook_with_poll_fallback, which also fires them from Slack Events API deliveries to POST /webhooks/slack. See Slack Events API deliverynot set; Slack is polled
SLACK_SIGNING_SECRETthe Slack app’s signing secret, which every Events API delivery is verified against. Without it /webhooks/slack answers 404 whatever the modevia mcp_secrets; ENV is the fallback
GITHUB_TRIGGER_INGEST_MODEhow github_issue triggers learn about new issues: poll (default) or webhook_with_poll_fallback, which also fires them from GitHub webhook deliveries to POST /webhooks/github. See GitHub webhook deliverynot set; GitHub is polled
GITHUB_WEBHOOK_SECRETthe secret set on the GitHub webhook, which every delivery’s X-Hub-Signature-256 is verified against. Without it /webhooks/github answers 404 whatever the modevia mcp_secrets; ENV is the fallback
ZIMMER_GIT_USER_NAME / ZIMMER_GIT_USER_EMAILthe user.name / user.email every agent session commits with, written into the container’s ~/.gitconfig at boot. Set both or neither — Zimmer will not invent the missing half, and without them a session’s first git commit fails with Author identity unknown (why there is no default). Every commit a session makes is authored as this identity, so pick one you are content to see in git log. See the git identity an agent session commits withnot set; sessions cannot commit until it is
ZIMMER_ADMIN_USERthe users.key of the human responsible for web UI actions. Web sign-in, when it is on, does not change this: attribution does not read the signed-in identity. Unset falls back to tadasant; a value naming no row means web-UI human messages record nothing rather than guessing an authornot set; the default is the seeded admin
ZIMMER_WEB_AUTH_GOOGLE_CLIENT_ID and the other ZIMMER_WEB_AUTH_*the optional login wall in front of the web UI: Google Workspace sign-in limited to ZIMMER_WEB_AUTH_ALLOWED_DOMAINS, then an authenticator code. Setting the client ID turns it on. The full list, and the Google Cloud setup, are in Web sign-innot set; the web UI asks nobody to sign in

The env, secrets, and data-store wiring all live in config/deploy.*.yml and .kamal/secrets.*, not in Terraform — Terraform only provisions the host.

VarPurpose
ANTHROPIC_API_KEYClaude Code, when not using OAuth
ANTHROPIC_BASE_URLTest-only; triggers reading the OAuth token off disk and passing it as an API key
CODEX_HOMECodex config dir. Default ~/.codex
PI_CODING_AGENT_DIRPi config dir (auth.json, models.json, settings.json). Default ~/.pi/agent, and set explicitly in Dockerfile.base so every container agrees. PiRuntimeAdapter exports it to the spawned process
PI_EXTENSIONS_DIRWhere the Pi extensions are installed. Default /opt/pi-extensions; CI runners that cannot write there redirect it
OPENROUTER_API_KEYPi’s provider credential. Normally set in the Parameter Store, through the secrets console the Inference page’s Pi tab links, rather than here — see Runtimes
ZIMMER_STORE_CONSOLE_URLThe console that administers Zimmer’s own secret store. The Inference page’s Pi tab and /live’s key panel link it as the place to set OPENROUTER_API_KEY and LIVE_OPENAI_API_KEY. Unset, they show the parameter path with no link. Separate from the ZIMMER_SECRETS_CONSOLE_* triple the Connectors page uses — see Secrets Zimmer itself depends on
ZIMMER_STORE_CONSOLE_STORE_NAMEOptional. What that console calls Zimmer’s store, shown beside the link. Production sets Zimmer production
CLAUDE_CONFIG_DIRLogin isolation only (a scratch dir during the login flow)
AIR_CONFIGWhich air.json to resolve. Always wins over the per-environment default.
AIR_CATALOG_REFStaging-only catalog pinning: rewrites every github://tadasant/zimmer-catalog/… URI in the in-image air.production.json to pin that ref. It is read inside the AIR_CONFIG fallback, so setting AIR_CONFIG bypasses it entirely — and air.production.json declares no github:// URIs, so as shipped it pins nothing on any deployment and staging warns at boot and resolves the catalog unrewritten. See Catalog pinning is real code for a catalog this deployment does not run
ELICITATION_EXPIRATION_MINUTESHow long a new elicitation (an MCP server’s approval request) stays answerable, in minutes. Default 60. An MCP server that sends its own _meta["com.pulsemcp/expires-at"] keeps it; this sets the default for everything else. Blank is treated as unset; a non-numeric or zero/negative value is logged and ignored; anything above the 7-day ceiling is clamped
X_OAUTH_REDIRECT_URIWhere X sends the operator after the consent that /supervisor starts, on both the consent request and the token exchange. Default http://localhost:8080/callback, where nothing listens, so the operator pastes the redirect URL back. Set it to https://<APP_HOST>/supervisor/x_oauth/callback and the flow finishes on its own. Whatever you set must already be registered on the X app — that registration is a manual step on X’s developer portal. See X (Twitter) is minted from /supervisor
ZIMMER_CONTENT_SEARCH_BUDGET_SECONDSWall-clock ceiling for one transcript content search (search_contents), in seconds. Default 20, chosen to sit well under the proxies’ timeouts (How long a request may take) — raising it past them trades a structured “scan incomplete” answer for a 504. Zero means “stop before the first chunk”; a negative value is ignored. See Searching transcript contents
ZIMMER_CONTENT_SEARCH_CHUNK_SIZEHow many sessions one content-search statement reads at a time. Default 100. Smaller chunks make the budget finer-grained at the cost of more round trips
VarDefault
AGENT_CLONES_DIR~/.zimmer/clones
AGENT_TRANSCRIPT_ARCHIVE_DIR~/.zimmer/transcript_archives — where TranscriptArchiveJob writes latest.zip. A sibling of the clones dir, and on the same zimmer_data volume, because the job runs in the worker container and every reader of the archive is an HTTP route in web. Point it somewhere both roles mount, or the reader stops seeing the writer
AGENT_SCRATCH_DIRper-session durable scratch
REPO_BASE_PATHtmp/repos (bare repos)

WEB_CONCURRENCY, RAILS_MAX_THREADS, REDIS_POOL_SIZE, RAILS_LOG_LEVEL, PIDFILE, PROCESS_*.

Worker concurrency is per queue: GOOD_JOB_AGENTS_THREADS, GOOD_JOB_POLLERS_THREADS, GOOD_JOB_MAINTENANCE_THREADS, GOOD_JOB_TRIGGERS_THREADS, GOOD_JOB_AUTH_THREADS, GOOD_JOB_INFERENCE_THREADS, GOOD_JOB_DEFAULT_THREADS. Each of those threads can hold a database connection for the whole life of a job, so they size the ActiveRecord pool too — raising one raises the number of connections the database must be able to serve. DB_POOL and CABLE_DB_POOL override the derived pools directly, but read the connection budget before you do.

SENTRY_DSN_BACKEND, OTEL_SERVICE_NAME, OTEL_LOGS_EXPORTER_ENDPOINT, OTEL_LOGS_EXPORTER_BEARER_TOKEN.

Consumed as ${VAR} placeholders in mcp.json, resolved by SecretsLoader at prepare time: FLY_IO_API_TOKEN, OP_SERVICE_ACCOUNT_TOKEN, GITHUB_API_TOKEN, …

SecretsLoader resolves in this order: XOauthTokenVendor → Rails credentials (mcp_secrets) → ENV.

/settings writes to a single AppSetting row:

  • Default runtime (claude_code | codex | pi) and default model. See Runtimes.
  • Experimental toggles — MCP tool search and any experimental extension (the extension_states JSONB map). See Experimental settings and Extensions.
  • Catalog refresh controls.

Both values here are picked up by every session create that does not override them, with or without an agent_root: POST /api/v1/sessions, MCP start_session, the new-session form and Session.create_from_agent_root! all resolve through Sessions::ResolveSpawnDefaults, and with no root the chain falls straight through to this page. Pinned by sessions_controller_contract_test.rb (“create without agent_root honors the global default runtime and model”) and by start_session_test.rb (“a rootless spawn resolves runtime and model through the Settings-page defaults”).

The default runtime and model and the Experimental toggles are also reachable over MCP, through get_app_settings / action_app_settings in the opt-in settings tool group. See Zimmer’s MCP server.

LimitValue
Prompt max length500,000 chars
Session notes max50,000 chars
Search query max1,000 chars
MCP servers per session50
Skills / hooks per session100 each
Plugins per session50
API paginationdefault 25, max 100
MCP server startup timeout180,000 ms (3 min)
Elicitation expiry ceiling (ELICITATION_EXPIRATION_MINUTES)7 days
needs_input push debounce60 seconds
Trash retention (dirty clones)4 days
Large-prompt stream-json threshold100 KB