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 override — set_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)
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:
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.*atproject/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 explicitoverride_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_requiredand a per-componentrestart_report(tunnel/connector/capability/general) so a client knows when a changed field needs more than a hot-reload (e.g. changingexecutor_config.repo_pathrequires a restart; most fields don't). - Zero-impact rollout. A project that has never touched any
profile_layersrow resolves exactly as it did before this subsystem existed —get_effective_profile,start_session,generate_handoff, the tunnel/connector routes, and thebatch_readprofile 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.