Skip to content

Configuration

All Meridian configuration is via environment variables. Set them in a .env file in the project root, via shell export, or via your deployment platform's secret store.

For a basic self-hosted install you only need SESSION_SECRET (and APP_URL if you're exposing it on a domain). Everything else is optional.


Database

Variable Description Default Required
MERIDIAN_DB_URL Postgres connection string. When set, Meridian uses Postgres instead of SQLite. Example: postgresql://user:pass@host/dbname No (SQLite if unset)
MERIDIAN_DB Path to the SQLite database file. Ignored when MERIDIAN_DB_URL is set. data/meridian.db No
MERIDIAN_DATA_DIR Directory for data files (SQLite DB, handoff files). data/ No

SQLite is the default and is fine for a single instance. Use Postgres if you want multiple instances or a managed backend (see Self-Hosting).


Authentication

Only needed if you want OAuth sign-in. A purely local single-user install can skip these.

Variable Description Default Required
GOOGLE_CLIENT_ID Google OAuth app client ID. Get from console.cloud.google.com. For Google login
GOOGLE_CLIENT_SECRET Google OAuth app client secret. For Google login
GITHUB_CLIENT_ID GitHub OAuth app client ID. Get from github.com/settings/developers. For GitHub login
GITHUB_CLIENT_SECRET GitHub OAuth app client secret. For GitHub login
SESSION_SECRET Secret key for signing session cookies. Use a long random string. dev-secret-change-me Yes for production
MERIDIAN_SESSION_SECRET Alias for SESSION_SECRET. Either name works.

Tip

Register your own OAuth apps and point their callback URLs at https://your-domain/auth/google/callback and https://your-domain/auth/github/callback.


App / General

Variable Description Default Required
APP_URL Public base URL of the deployment. Used in OAuth callbacks, emails, and MCP endpoint URLs. Example: https://meridian.example.com http://localhost:7878 Yes for production
MERIDIAN_BASE_URL Alias for APP_URL. Either name works.
MERIDIAN_PORT HTTP port for the dashboard/API server. 7878 No
MERIDIAN_HOST Host to bind the server to. 127.0.0.1 No
MERIDIAN_HUMAN_ID Default human identifier for task attribution. Falls back to $USER / $USERNAME / hostname. No
MERIDIAN_AFTER_LOGIN_URL Where to redirect after successful OAuth login. /dashboard No
MERIDIAN_AUTO_SUMMARY_INTERVAL Seconds between auto-summary cycles (background task). 600 No
SITE_PASSWORD When set, all routes (except /health) require entering this password in a gate page. Handy for locking down a staging/preview deployment. No

Optional: billing & email

These are only relevant if you run Meridian as a paid, hosted service for others. A normal self-hosted install does not need them.

Variable Description Required
STRIPE_SECRET_KEY Stripe secret key (sk_test_... / sk_live_...). For billing
STRIPE_WEBHOOK_SECRET Stripe webhook signing secret (whsec_...). For billing
STRIPE_PAYMENT_LINK URL of your Stripe Payment Link, shown as the upgrade button. No
RESEND_API_KEY Resend API key for transactional email. If unset, email is silently skipped. For email
MERIDIAN_FROM_EMAIL Sender address for outgoing email. No

Warning

Keep STRIPE_SECRET_KEY=sk_test_... during development. Never switch to a live key without thorough testing.


Optional: Redis push augmentation

send_message / receive_messages work via Postgres (durable, always-on). Setting MERIDIAN_REDIS_URL adds real-time push delivery on top — subscribers are woken instantly instead of polling.

Variable Description Required
MERIDIAN_REDIS_URL Redis connection URL (e.g. redis://... or rediss://...). When unset, push augmentation is skipped silently and all messaging falls back to Postgres polling. No

Per-tenant Redis command budget (hosted deployments)

To protect against runaway Upstash costs from a single tenant spamming send_message, Meridian enforces a three-tier budget based on each tenant's monthly Redis PUBLISH command count. Upstash pricing is approximately $0.20 / 100,000 commands ($1.00 = 500,000 commands).

Tier Threshold Action
Tier 1 — Warning 500,000 commands (~$1.00/mo) Dashboard notice + email to the tenant. Idempotent: sent at most once per calendar month per tenant.
Tier 2 — Disable 1,000,000 commands (~$2.00/mo) Real-time push is paused for that tenant; send_message falls back to Postgres polling. A separate "hard limit reached" email is sent. Messages are never lost — only delivery method changes. Resets at start of next month.
Tier 3 — Admin alert 2,000,000 commands (~$4.00/mo) Should be structurally unreachable if Tier 2 works. Crossing this threshold fires an admin alert via MERIDIAN_ADMIN_NTFY_URL / ADMIN_EMAIL — it signals a gate failure, not just a large tenant.

The thresholds above apply to paid tenants. Free-tier tenants receive no Redis push augmentation by default (they rely on Postgres polling only).

Runtime diagnostics

GET /tunnel/diagnostics/{tenant_id} (and the equivalent get_tunnel_diagnostics MCP tool) include a redis section reporting: whether Redis is configured (MERIDIAN_REDIS_URL presence only — never the value itself), an availability state (unconfigured / construction_failed / idle / connected_unverified / reachable / unreachable / degraded), a connection_generation counter, pub/sub publish attempt/success/failure and fallback counts with bounded latency samples, per-tenant budget-tier status, and a cache block distinguishing an (as of this writing, not yet implemented) Redis-backed read-through cache from the genuinely active process-local board-read cache in meridian/db/sprint_items.py — reported Neon-avoidance numbers only ever come from a cache that is actually live. This snapshot never performs a live Redis round-trip; it reads in-process counters only.

Two error pairs are reported, kept deliberately separate: last_error_class / last_error_age_seconds reflect the most recent publish-attempt failure, while last_construction_error_class / last_construction_error_age_seconds reflect the most recent client-construction failure (a bad MERIDIAN_REDIS_URL, a missing/incompatible redis-py, etc.). Keeping these distinct is what makes construction_failed a diagnosable state rather than indistinguishable from a genuine network outage.


Optional: AI-log OTel / self-hosted-Langfuse export

Meridian's own durable AI-log event stream (ai_log_events — every start_session/tool-call/handoff-correction event Meridian captures) is the canonical record of agent activity. This export is a strictly OPTIONAL, OFF-BY-DEFAULT adapter on top of it: an on-demand, bounded, batch push of that stream to a standard OTLP (OpenTelemetry Protocol) log-ingestion endpoint, or a self-hosted Langfuse instance's OTLP-compatible ingestion endpoint. Nothing outside Meridian's own database ever becomes authoritative — losing every byte this adapter has ever sent changes nothing about what Meridian itself knows.

Variable Description Default Required
MERIDIAN_AI_LOG_OTEL_ENABLED Master opt-in switch for the whole feature. Unset/false: export is completely inert — no dependency import, no network attempt. unset (off) No
MERIDIAN_AI_LOG_OTEL_ENDPOINT OTLP logs endpoint URL, e.g. https://collector.example.com/v1/logs or a self-hosted Langfuse instance's OTLP ingestion URL. Falls back to the standard OTEL_EXPORTER_OTLP_LOGS_ENDPOINT, then OTEL_EXPORTER_OTLP_ENDPOINT + /v1/logs, then a project's own otlp_endpoint override (set_ai_log_export_config). For export to actually send
MERIDIAN_AI_LOG_OTEL_HEADERS Extra HTTP headers for the endpoint (e.g. Authorization=Bearer%20xyz), comma-separated key=value pairs, percent-encoded values — same shape as the standard OTEL_EXPORTER_OTLP_HEADERS, which is honored as a fallback. This is the only place a bearer token/API key belongs — it is never accepted by set_ai_log_export_config and never stored in the database. For an authenticated endpoint
MERIDIAN_AI_LOG_OTEL_SERVICE_NAME OTel resource service.name for exported events. Falls back to the standard OTEL_SERVICE_NAME, then "meridian". meridian No
MERIDIAN_AI_LOG_OTEL_LANGFUSE_COMPAT Purely informational: adds a meridian.otel_sink_hint resource attribute so a receiving Langfuse instance (or a human) can tell the wire traffic is intentionally Langfuse-bound. Does not change the wire protocol — self-hosted Langfuse ingests standard OTLP. false No
MERIDIAN_AI_LOG_OTEL_BATCH_SIZE Max events fetched per export pass (clamped 1–1000). 200 No
MERIDIAN_AI_LOG_OTEL_CHUNK_SIZE Events per HTTP POST sub-batch (clamped 1–200, and never above the batch size). 50 No
MERIDIAN_AI_LOG_OTEL_MAX_RETRIES Bounded retry attempts per chunk before giving up (clamped 0–10). 3 No
MERIDIAN_AI_LOG_OTEL_BACKOFF_BASE_S Exponential backoff base (with jitter) between retries, seconds (clamped 0.05–10). 0.5 No
MERIDIAN_AI_LOG_OTEL_TIMEOUT_S Per-HTTP-attempt timeout, seconds (clamped 1–30). 5.0 No
MERIDIAN_AI_LOG_OTEL_TOTAL_DEADLINE_S Hard wall-clock ceiling for one whole export pass, seconds (clamped 1–60) — the pass returns a "degraded" result rather than running longer. 20.0 No

No new hard dependency. The real OTel client library (opentelemetry-sdk + opentelemetry-exporter-otlp-proto-http) is declared only under the otel extra: pip install 'meridian-server[otel]'. A normal install never pulls it in, and if it's ever missing (or a future release changes its API surface) export cleanly reports "unavailable" — every core AI-log capability (capture, storage, timeline, export_ai_log, purge_ai_log) continues to work identically either way.

Per-project overrideset_ai_log_export_config (MCP tool) lets one project point at a different endpoint/service name, or force itself off even while the feature is globally on (enabled: false). A project can never turn export ON when the operator has globally disabled it via MERIDIAN_AI_LOG_OTEL_ENABLED. get_ai_log_export_status reports the resolved, effective configuration plus the last export attempt's outcome without making any network call.

Triggering an export — there is no background scheduler; call the export_ai_log_otel MCP tool (or your own cron hitting the same code path) whenever you want a pass. Each pass resumes from a durable watermark, so calling it repeatedly (e.g. every few minutes from your own scheduler) only ever sends events that haven't gone out yet.


Example .env

# --- Minimal self-hosted setup ---
SESSION_SECRET=replace-with-a-long-random-string
APP_URL=https://meridian.example.com

# --- Optional: Postgres instead of SQLite ---
# MERIDIAN_DB_URL=postgresql://user:pass@host/dbname

# --- Optional: OAuth sign-in ---
# GOOGLE_CLIENT_ID=...
# GOOGLE_CLIENT_SECRET=...
# GITHUB_CLIENT_ID=...
# GITHUB_CLIENT_SECRET=...

# --- Optional: lock down a preview deployment ---
# SITE_PASSWORD=preview-password

Danger

Never commit .env to git. Meridian's .gitignore already excludes .env and secrets.env.


Context layers

Meridian injects project context into every AI session through three distinct layers, each with different lifetime and scope.

STATIC — CLAUDE.md / AGENTS.md

Repo conventions and project-level instructions committed to git. This is the document the AI reads at session start from the repository root. It never changes automatically between sessions — only a human (or update_md_section) edits it.

Use for: architecture decisions, coding standards, file conventions, "never do X" rules. Anything that should be true for every session forever.

DYNAMIC — additionalContext from Session Start hook

Live state injected at session start via POST /hooks/session-start. Meridian assembles this from get_context_block — includes the current goal, active sprint items (with their status), recent tasks, and pinned decisions.

Use for: what's in-flight right now. Updates every session automatically. The AI sees the latest goal and sprint queue without any manual copy-paste.

Source: GET /projects/{id}/context-block (called automatically by the hooks).

WORKSPACE — workspace notes and decisions

Tenant-global context injected at the top of every project's context block. Created via add_workspace_note / set_workspace_decision and visible across all projects owned by your account.

Use for: team-wide conventions, shared infrastructure notes, org-level decisions that apply to every project (e.g. "always use Neon for Postgres", "company style guide").

┌─────────────────────────────────────┐
│  WORKSPACE (tenant-global)          │  ← shared across all projects
│  workspace_notes + workspace_decisions
├─────────────────────────────────────┤
│  DYNAMIC (per-project, per-session) │  ← goal, sprint, tasks, decisions
│  get_context_block output           │
├─────────────────────────────────────┤
│  STATIC (per-repo)                  │  ← CLAUDE.md / AGENTS.md
│  committed to git                   │
└─────────────────────────────────────┘

Running parallel sessions safely

Meridian supports multiple AI sessions working on the same project simultaneously — for example, Claude Code and Codex running on separate machines, or two Claude Code windows on different features.

File conflict prevention

When you call start_session, Meridian checks the file_locks table for files claimed by other active sessions. If any overlap is found, the response includes a file_warnings array:

{
  "session_id": "...",
  "file_warnings": [
    "dashboard.js claimed by session pre-launch-final (last_seen 2026-06-11 22:45:00)"
  ]
}

What to do: Stop and coordinate with the other session before editing that file. Either wait for it to finish (and release the lock), or plan your changes so they don't conflict.

Claiming files with claim_file

Before editing any file, call claim_file(file_path, session_id). This registers a lock that: - Expires automatically after a configurable TTL (default: 4 hours) - Is visible to all concurrent sessions via start_session - Is released automatically when the session ends (via checkpoint)

claim_file(file_path="meridian/static/dashboard.js", session_id="<your-session-id>")

To release early (after you've committed), call release_file(file_path, session_id).

Sprint item touches_files

Each sprint item has a touches_files field — a JSON array of file paths the item is expected to modify. Meridian auto-populates this at generate_handoff time by running git diff --name-only HEAD~3 and matching filenames against sprint item titles.

This gives the next session a head-start on knowing which items conflict with which files, even before the files are actively claimed.

Cross-machine awareness

The hosted tier stores file locks in Neon Postgres, so awareness is global — a lock claimed by a Claude Code session on your laptop is visible to a Codex session on a CI runner or another machine. Self-hosted installs achieve the same if all instances share one MERIDIAN_DB_URL.


Profile layers (executor/tool configuration)

Meridian resolves executor-facing settings (HITL auto-answer, merge approval, tool priority, capability manifests, etc.) through a layered profile contract — five scopes, least to most specific:

hosted_default → workspace → user → project → session

A more-specific layer overrides a less-specific one field-by-field. This generalizes the plain per-project ProjectSettings you may already know (max_pinned_decisions, hitl_auto_answer, auto_worktrees, require_merge_approval, code_intel_enabled, execution_mode, and the executor_config.* sub-fields) — those 7 fields keep working exactly as before and simply become the project layer's contribution. Three fields are genuinely new to the profile system: tool_priority_map, capability_manifest_ref, and claim_verification_mode.

Layer scope_id Typical use
hosted_default "global" (or an admin-chosen id) Org-wide baseline. The one layer with a lifecycle: draft → active → deprecated → retired. Only active/deprecated are live at resolve time — a draft hosted_default is authored but not yet in effect.
workspace "singleton" (self-hosted default) Tenant-wide defaults across every project.
user a human/user id Per-person preferences.
project the project id Per-project overrides. The 7 legacy fields above still flow through update_project_settings; only the 3 new fields are stored as a profile_layers row here.
session the session id Per-run overrides — narrowest scope, highest precedence.

Managing a layer

Use the list_profile_layers / get_profile_layer / save_profile_layer / clone_profile_layer / activate_profile_layer / reset_profile_layer / get_profile_layer_revisions MCP tools (also exposed as REST under /profile-layers/... — see api-reference.md), or call get_effective_profile to see the fully merged result for a project.

save_profile_layer(scope_type="workspace", scope_id="singleton",
                    fields={"tool_priority_map": {"docs": "meridian-docs"}})
clone_profile_layer(source_scope_type="hosted_default", source_scope_id="global",
                     target_scope_type="hosted_default", target_scope_id="global-v2")
activate_profile_layer(scope_id="global-v2")   # hosted_default only: draft -> active
reset_profile_layer(scope_type="session", scope_id="session-uuid")  # delete a layer's row entirely
get_profile_layer_revisions(scope_id="global", limit=10)  # hosted_default audit trail (rollback visibility)

save_profile_layer wholesale-replaces a scope's stored fields (it is not a merge) and supports optimistic concurrency via expected_revision: a stale write is rejected with a structured STALE_REVISION error (or HTTP 409 over REST) instead of silently clobbering a concurrent change.

Safety rails

  • Prohibited values. No layer may ever store a secret-shaped string (API keys, tokens, passwords, connection strings with credentials) or a machine-local absolute path (outside the one field — executor_config.* at project/session — that's explicitly allowed to carry one). Rejected at write time, never silently dropped.
  • Narrow-only safety dials. A handful of fields (hitl_auto_answer, require_merge_approval, executor_config.test_min, claim_verification_mode) can only be tightened by a more-specific layer, never loosened, without an explicit override_reason. This is what stops a compromised or careless session-scoped override from silently re-enabling unattended HITL auto-answers that a hosted default or workspace policy deliberately turned off.
  • Restart/refresh signals. Every resolved profile reports restart_required and a per-component restart_report (tunnel/connector/capability/general) so a client knows when a changed field needs more than a hot-reload (e.g. changing executor_config.repo_path requires a restart; most fields don't).
  • Zero-impact rollout. A project that has never touched any profile_layers row resolves exactly as it did before this subsystem existed — get_effective_profile, start_session, generate_handoff, the tunnel/connector routes, and the batch_read profile adapter all degrade to sensible legacy defaults (executable: true, degraded: false) rather than erroring.

Optional: Redis read-through cache

When MERIDIAN_REDIS_URL is configured (see Optional: Redis push augmentation above), profile/effective-profile/capability-manifest/tool-manifest/handoff projections can be served through a content-addressed read-through cache (meridian/profile_cache.py) instead of re-resolving from Postgres on every read. The cache key embeds the resolution's generation_key, so a write is automatically a cache-miss-inducing new key — there is no separate invalidation ledger to keep in sync. Redis is never authoritative for anything in the profile system (Postgres/SQLite always is) and a Redis outage or per-tenant command-budget exhaustion falls back to a direct database read automatically, with no user-visible error.

What's measured vs. what isn't

This project's own test suite (tests/test_profile_contract_matrix.py) proves the cache's hit/miss/stale/outage behavior and measures exact authority-call counts against a real local database, using an in-memory fake Redis client — that is genuine, reproducible, local verification. It does not measure hit-rate or latency against a real production Redis or Neon deployment; treat any specific "X% fewer database calls" figure as scenario-specific measured evidence, not a general guarantee for your deployment's traffic pattern.