Skip to content

Configuration reference

obsidian-tc reads one JSON config file, passed as the first CLI argument or via OBSIDIAN_TC_CONFIG. Zero-config also works: pass a vault folder instead and the server boots a single vault with id main and every default below. Inspect any config with obsidian-tc config show <path> (secrets redacted) or obsidian-tc config validate <path>.

This page is the complete option surface, derived from ServerConfigSchema (packages/shared/src/config.schema.ts). Fields marked (optional) may be omitted entirely; every other block is fully defaulted, so any subset of this document is a valid config.

{
"cacheDir": ".obsidian-tc",
"db": {
"busyTimeoutMs": 5000
},
"vaults": [
{
"id": "main",
"name": "My Vault",
"path": "/absolute/path/to/vault",
"kind": "private"
}
],
"auth": {
"mode": "none",
"tokenTtlSeconds": 86400
},
"acl": {
"readOnly": false,
"defaultScopes": [],
"rules": [],
"strictReadDefault": false
},
"embeddings": {
"provider": "ollama",
"model": "nomic-embed-text",
"dimensions": 768,
"timeoutMs": 120000,
"batchSize": 512,
"maxBatchTokens": 2048,
"concurrency": 4,
"truncate": false,
"chunkContext": true,
"queryPrefix": "",
"documentPrefix": ""
},
"indexing": {
"writeConcurrency": 8,
"writeConcurrencyPerVault": 4,
"queueMax": 1000,
"streamingWalk": false,
"chunkTokens": 512
},
"retrieval": {
"rrfK": 10,
"classRouter": false,
"sparse": false,
"colbert": false,
"gatedRerank": false,
"gatedRerankHardness": {
"mode": "cosine",
"hardTop1": 0.55,
"hardZ": 1,
"pool": 20
},
"graphStream": {
"enabled": false,
"expansionSeeds": 8,
"perSeedCap": 3,
"hubDegreeCap": 40
},
"densify": {
"tagEdges": false,
"maxTagFanout": 25,
"knnEdges": false,
"knnK": 8,
"knnMinSim": 0,
"includeInWalk": false,
"derivedWeight": 0.5,
"llmEdges": false,
"confidenceFloor": 0.55
},
"adaptiveRrf": {
"enabled": false,
"gain": 0.5
},
"cache": {
"enabled": false,
"maxEntries": 64,
"ttlSeconds": 60
},
"summaries": {
"enabled": false,
"maxConcurrency": 12,
"clusters": {
"enabled": false,
"maxConcurrency": 12
}
}
},
"ranking": {
"metadataPrior": {
"enabled": false,
"rules": [],
"clampFraction": 0.5
}
},
"experiential": {
"logRetrievals": true,
"captureEpisodes": true,
"captureContent": true,
"captureRetentionDays": 30,
"activationDecay": 0.5,
"activationRerank": false,
"citationPreferences": false,
"derivedVerdictHold": false,
"citationInfer": {
"enabled": false,
"intervalHours": 6
},
"gapSweep": {
"enabled": false,
"intervalHours": 168,
"maxQueries": 50
},
"proactive": {
"enabled": false,
"minScore": 0.6,
"topK": 2,
"maxPerSession": 5,
"dismissalPenalty": 1
}
},
"transports": {
"stdio": true,
"http": {
"enabled": false,
"host": "127.0.0.1",
"port": 8765,
"enableDnsRebindingProtection": true,
"allowedHosts": [],
"allowedOrigins": []
}
},
"governor": {
"maxResponseBytes": 1000000,
"regexTimeoutMs": 2000
},
"writes": {
"requireCas": false
},
"toolFacade": {
"mode": "triad"
},
"bootstrap": {
"deepPaths": [],
"domains": [],
"maxPaths": 10,
"deepPhrases": [
"where did we leave off",
"what's open",
"whats open",
"catch me up",
"current state",
"where are we",
"what should i be working on",
"what should i work on"
]
},
"throttle": {
"enabled": true,
"tiers": {
"read": {
"perMinute": 600,
"burst": 100
},
"write": {
"perMinute": 60,
"burst": 20
},
"delete": {
"perMinute": 60,
"burst": 20
},
"bulk": {
"perMinute": 10,
"burst": 3
},
"execute": {
"perMinute": 5,
"burst": 1
},
"admin": {
"perMinute": 5,
"burst": 1
}
},
"maxConcurrentWritesPerVault": 16
},
"observability": {
"otel": {
"headers": {}
},
"prometheus": {
"enabled": false,
"port": 9464,
"bind": "127.0.0.1"
},
"morgiana": {
"spool": true,
"httpHeaders": {}
},
"retention": {
"eventLogDays": 30,
"tracesDays": 30
}
},
"maintenance": {
"enabled": true,
"intervalMinutes": 60,
"jobsCompleteRetentionDays": 7,
"episodesRetentionDays": 90,
"retrievalsRetentionDays": 365,
"jobsFailedRetentionDays": 30
},
"scheduler": {},
"watch": {
"enabled": true,
"debounceMs": 500
},
"snapshots": {
"enabled": true,
"retention": 10
},
"plane": {
"enabled": false,
"intervalMinutes": 240,
"gatewayMaxAttempts": 6,
"gatewayTimeoutMs": 300000,
"maxPromptChars": 45535
},
"egress": {
"excludePaths": []
},
"sessions": {
"autoOpen": false,
"traceContent": true,
"windowSeconds": 1800
},
"idempotencyTtlSeconds": 86400,
"idempotencyReclaimSeconds": 60,
"elicitTtlSeconds": 300
}

Every value above is the schema’s own default, materialised by parsing a config containing nothing but the vaults entry — so this is literally what the server fills in for you. vaults[] is the only block with no default: it needs a real path.

Keys that are optional rather than defaulted — toolVisibility and the vaults[] sub-blocks (acl, bridges, plugins, commands, memory, workspace) — are absent here on purpose. They are documented in the sections below and in the Configuration Reference table.

Field Type / default What it does
id string, required Vault identifier every tool call names (vault: "main"). Lowercase [a-z0-9_-].
name string (optional) Display name.
path string, required Absolute vault directory.
mode live | headless | auto (optional → auto) auto probes the Local REST API once at startup: reachable → live (bridge tools work), else headless (bridge tools return requires_live_obsidian; filesystem tools unaffected). Resolved once — config changes take effect on the next server start.
restApiUrl url (optional) Local REST API base for live mode. Use the plugin’s non-encrypted loopback server (http://127.0.0.1:27123, enable it in LRA settings) — the bridge client does not trust LRA’s self-signed HTTPS certificate. Without this the vault is always headless.
restApiKey string (optional) The LRA API key. Treat as a full-vault admin credential.
acl object (optional) Per-vault ACL override, same shape as the root acl — “write vault A, read-only vault B” in one process.
bridges.timeoutMs int, 5000 Per-route bridge timeout.
bridges.probeTimeoutMs int, 500 Startup capability-probe timeout.
bridges.ocrTimeoutMs int, 30000 OCR route timeout.
bridges.templaterTimeoutMs int, 30000 Templater/execute route timeout.
plugins.forceEnabled string[], [] Treat these plugin ids as installed regardless of the probe (CI seam with probeSkip).
plugins.forceDisabled string[], [] Treat as missing — exercises plugin_missing or operationally disables a bridge.
plugins.probeSkip bool, false Skip the startup probe entirely; forceEnabled becomes the source of truth.
commands.enabled bool, false Deny-by-default gate for execute_command.
commands.allowlist string[], [] Command ids that may fire (still HITL-gated). Arbitrary command execution is never silent.
memory.folder string, "memory" Where memory-entity projections, _next-session.md, and reflections/ live.
workspace.traceFolder string, ".obsidian-tc/traces" Vault-relative JSONL session-trace folder (ACL-checked).
Field Type / default What it does
mode none | jwt, none none is loopback-only (see the interlock below).
jwtSecret string ≥32 (optional) HS256 shared secret; prefer OBSIDIAN_TC_JWT_SECRET.
tokenTtlSeconds int, 86400 Token lifetime.
jwks / jwksFile object / path (optional) Asymmetric verification (RS256/ES256/EdDSA) from an inline JWKS or a file loaded once at boot — never a URL fetch. Rotation is kid-based. HS256 verifies only against the secret and asymmetric algs only against the JWKS, so alg-confusion is structurally impossible.
algorithms string[] (optional) Asymmetric-algorithm allowlist.
resource, authorizationServers, resourceName, scopesSupported (optional) RFC 9728 Protected Resource Metadata: when resource + one authorizationServers entry are set, the HTTP transport advertises a PRM document + WWW-Authenticate challenge (OAuth 2.1 resource-server role).

mode: "jwt" requires jwtSecret or a JWKS — the config refuses to load otherwise.

Fail-closed interlock: the config is rejected when transports.http.enabled is true on a non-loopback host while auth.mode is none. An unauthenticated server never binds a routable address.

acl (root, inherited by every vault without its own)

Section titled “acl (root, inherited by every vault without its own)”
Field Type / default What it does
readOnly bool, false The kill switch — every write/delete short-circuits to read_only_mode.
defaultScopes string[], [] Scopes granted when the caller presents none.
rules [{glob, scopes}], [] Path-scoped scope grants; last match wins.
readPaths / writePaths / deletePaths glob[] (optional) Per-operation whitelists. Omitted = that operation unrestricted; present = a path must match at least one glob. .obsidian/, .git/, .trash/ are always denied (case-folded, so case variants can’t evade it).
strictReadDefault bool, false When true, an undefined readPaths fails closed on reads instead of allowing all.
Field Type / default What it does
provider ollama | openai | voyage | cohere | bge-m3 | openai-compatible | model-tier | module, ollama bge-m3 targets a vLLM pooling server (dense + learned-sparse + ColBERT heads); model-tier splits dense and multi-vector embedding across two separate services.
model string, nomic-embed-text
dimensions int, 768 The vec0 table is dimension-locked; changing it requires a fresh cacheDir (see migration).
baseUrl url (optional) Provider endpoint (e.g. http://127.0.0.1:11434 for Ollama).
apiKey string (optional) Cloud-provider key; config-then-env, never logged.
timeoutMs int, 120000 Per embed request.
batchSize int, 512 Max inputs per request.
maxBatchTokens int, 2048 Estimated-token cap per request (chars/4) — keeps a dense batch inside a local runner’s context (Ollama defaults to n_ctx 4096 and 400-rejects overruns; the indexer bisects and retries anyway).
concurrency int, 4 Embed requests in flight.
truncate bool, false Matryoshka (MRL) truncation: a provider returning vectors wider than dimensions is truncated + renormalized. Non-MRL width mismatches still error.
chunkContext bool, true Contextual enrichment: each chunk embeds + BM25-indexes as "{title} — {breadcrumb}\n\n{content}". Measured +0.223 nDCG@10 (p=0.0001). The content hash covers the enriched text, so flipping it re-embeds on the next reconcile.
queryPrefix / documentPrefix string, "" Asymmetric instruct prefixes for models that require them (e.g. Qwen3-Embedding’s query instruction). Changing documentPrefix does not re-embed by itself — pair it with a fresh cacheDir.
Field Default What it does
retrieval.rrfK 10 RRF fusion constant. Keep below the stream pool size (~30): k=10 beat k=60 on every metric — larger k lets overlapping low-rank noise outrank confident single-stream hits.
retrieval.classRouter false The deterministic query-class router (temporal auto-stream + lexical short-circuit). Dark by measurement — flips only if its A/B passes the ship rule.
retrieval.sparse false Serve-path bge-m3 learned-sparse RRF stream (needs a multi-vector embedFull provider). Dark — a no-op without one, measured on the golden set before any flip.
retrieval.colbert false Serve-path bge-m3 ColBERT late-interaction rerank of the fused top-K (needs a multi-vector provider). Dark — measured before any flip.
experiential.logRetrievals true Append serve-path retrieval events to the quarantined experiential.db (local-only telemetry feeding activation recompute + flywheel stats; eval runs never log).
experiential.captureEpisodes true Capture every dispatch outcome as a work-memory episode (action axis: tool, status, sizes, hashes — no payloads).
experiential.captureContent false Content axis: also persist secret-scanned, size-capped call args. Off by default — opt in deliberately.
experiential.activationRerank false ACT-R activation rerank pass on serve-path graph search. Dark pending its A/B.

retrieval.densify — graph densification (experimental)

Section titled “retrieval.densify — graph densification (experimental)”

Derived edges added to the vault_edges graph beyond authored wikilinks, so a multi-hop query can reach bridge notes that were never explicitly linked. All off/conservative by default and unmeasured — the prior THE-135 virtual-hop sat at an 80% bridge-recall ceiling, and the champion figure it was judged against has since been withdrawn as unreproducible (THE-748), so densification ships dark behind these flags pending a multi-hop golden-set A/B that establishes its own control, exactly like retrieval.sparse / retrieval.colbert. Derived edges are rebuildable cache and are never written back into notes as wikilinks; hub tags and hub nodes emit no edges.

Field Default What it does
tagEdges false Emit shared_tag edges between notes sharing a frontmatter tag (deterministic, no egress). Built during index_vault.
maxTagFanout 25 A tag on more than this many notes is a hub, not a signal — it emits no edges.
knnEdges false Emit similar_to edges from vec0 kNN semantic neighbours (no egress; needs a populated vector index). Built during index_vault.
knnK 8 Neighbours kept per note for knnEdges.
includeInWalk false Let the graph walk traverse derived edges, down-weighted vs authored links (annotate, never outrank an authored link at equal hop).
derivedWeight 0.5 Expansion down-weight applied when a hop is reached via a derived edge.
llmEdges false Build semantically_similar_to edges via LLM Pass-3 through the local inference gateway. Batch-only via the densify-llm CLI — not the inline index pass; sends note content to the model (local by default).
confidenceFloor 0.55 Minimum discrete-rubric confidence (0.55/0.65/0.75/0.85/0.95) to keep an LLM edge.

llmEdges is produced out-of-band by obsidian-tc densify-llm (below), not by indexing; tagEdges and knnEdges build inline during index_vault when set.

Field Default What it does
stdio true The trusted local transport.
http.enabled false Streamable HTTP for many-client / remote use.
http.host / http.port 127.0.0.1 / 8765 Non-loopback hosts require JWT (interlock above).
http.enableDnsRebindingProtection true Rejects requests whose Host isn’t loopback/allowed, or whose Origin isn’t same-origin/allowed. Server-to-server clients (no Origin) are unaffected.
http.allowedHosts / http.allowedOrigins [] Operator allowlists for the above.
Field Default What it does
governor.maxResponseBytes 1000000 Response size ceiling for both tool calls and resources/read — page with cursors rather than raising it.
governor.regexTimeoutMs 2000 Worker-time budget for one regex search (ReDoS guard; file I/O doesn’t count).
writes.requireCas false When true, write_note (overwrite) and append_note to an existing note require prev_hash and fail closed without it — no stale-hash clobbering.
snapshots.enabled true Point-in-time snapshots: destructive writes capture prior state (content-addressed) so restore_note can roll back. On by default (THE-648); retention is pruned inline so growth is bounded.
snapshots.retention 10 Versions kept per note (max 1000).
toolFacade.mode triad What tools/list advertises: triad (3 meta-tools), domain (~a dozen domain meta-tools), flat (everything). All tools stay callable by name in every mode.
toolVisibility.allowed (optional) Name allowlist for tools/list (absent = all; [] = none).
toolVisibility.hidden / hiddenTags [] Drop from tools/list but keep callable (lean surface, not a security boundary).
toolVisibility.disabled / disabledTags [] Drop from the list and reject at dispatch.
toolVisibility.requireReadOnly false Hide every mutating tool (derived from scopes — no per-tool annotation needed).
idempotencyTtlSeconds 86400 Idempotency-key replay window.
idempotencyReclaimSeconds 60 Window before a crashed in-flight idempotency row may be reclaimed — raise for slow bulk tools.
elicitTtlSeconds 300 HITL elicit-token lifetime (single-use, args-hash-bound).

Powers session_bootstrap: triages a session’s opening message to lightweight / standard / deep and preloads the matching context notes. The routing table is a judgment value you supply — it never ships baked in.

Field Default What it does
domains [] [{name, signals[], paths[]}] — a domain matches when any lowercased signal is a substring of the opening message; its paths load.
deepPaths [] Notes loaded in deep mode.
deepPhrases catch-up phrases (“where did we leave off”, “catch me up”, …) A hit forces deep mode.
maxPaths 10 Cap on loaded notes (max 50).

Per-scope-class token buckets: read 600/min (burst 100), write 60/20, delete 60/20, bulk 10/3, execute 5/1, admin 5/1 — each {perMinute, burst} — plus maxConcurrentWritesPerVault (16) and a master enabled (true). A trip returns rate_limit with retry_after_ms.

Field Default What it does
otel.endpoint (optional) OTLP export; unset = no-op. otel.headers for auth. There is no sampling knob and no traceDetail/detail-level switch — every dispatch gets one root span, unconditionally, when tracing is enabled.
prometheus disabled, 127.0.0.1:9464 /metrics scrape endpoint.
morgiana.spool true CloudEvents JSONL spool; httpEndpoint (+httpHeaders) enables push.
retention.eventLogDays 30 The only enforced retention: event_log rows older than this are pruned by the maintenance sweep. Trace files and the morgiana spool are not pruned by config — they grow without bound regardless of any value set here.
Field Default What it does
maintenance enabled, every 60 min cache.db sweep: expired idempotency/elicit rows, event_log retention, PRAGMA optimize.
plane disabled (opt-in, THE-825), every 240 min Ambient sleep-time consolidation (synthesis + audit jobs). Only does work when the inference gateway is configured — set plane.enabled: true to run it. A gateway-configured deployment that never sets this key gets a boot-time notice explaining how to turn it on.

Global (not per-vault) read-proxy for a plur engram store: endpoint + apiKey (or the env vars below) for HTTP, or command (argv prefix, e.g. ["plur"]) to shell the local plur CLI — command takes precedence. apiPrefix (""), timeoutMs (5000). Absent endpoint/command → the plur tools degrade to plugin_missing with no network call.

Variable Purpose
OBSIDIAN_TC_CONFIG Config path when no CLI argument is given.
OBSIDIAN_TC_DEFAULT_VAULT Default vault id when several are configured.
OBSIDIAN_TC_JWT_SECRET HS256 signing secret (keeps it off disk).
OBSIDIAN_TC_GATEWAY_URL Inference-gateway base URL — enables the generative tier (setup).
OBSIDIAN_TC_GATEWAY_TOKEN Optional gateway bearer (e.g. a LiteLLM key). Never logged.
OBSIDIAN_TC_PLUR_ENDPOINT / OBSIDIAN_TC_PLUR_TOKEN plur read-proxy endpoint + bearer.
OBSIDIAN_TC_FORCE_JS_FALLBACK=1 Skip the native module; use the numerically identical pure-JS implementations.
OBSIDIAN_TC_DISABLE_FTS=1 Disable the FTS5 index; lexical search uses the exhaustive fallback scanner (diagnostic).
OBSIDIAN_TC_PROFILE=1 Emit startup/dispatch profiling timings to stderr (diagnostic).
OBSIDIAN_TC_FORCE_READONLY_OPEN_FALLBACK=1 Test-only. Forces the inspection-connection open (compact --dry-run/--into, doctor) onto its writable-descriptor fallback, which no longer guarantees the file’s bytes are unchanged. Not for production use.
OBSIDIAN_TC_FORCE_READONLY_OPEN_THROW Test-only. Makes the NATIVE readonly open attempt fail inside the adapter — 1 at the first statement (where a deferred SQLite open failure lands), construct at construction — so the writable-fallback path is exercisable on a platform whose native open succeeds. Not for production use.
OBSIDIAN_TC_FORCE_COMPACT_POST_OPTIMIZE_THROW=1 Test-only. Fails compact’s in-place path immediately after FTS5 'optimize' commits, so the report’s partial ftsOptimized list is exercisable. Not for production use.
OBSIDIAN_TC_FORCE_COMPACT_INTO_FAILURE Test-only. Interferes with compact --into after its VACUUM INTO: 1 = a plain error, busy = SQLITE_BUSY, delete:<table> = drop one row from the copy so verification sees a real mismatch, count-error:<table> = make that table’s COUNT(*) fail on the copy. Not for production use.

Secrets (restApiKey, embedding API keys, the JWT secret, gateway/plur tokens) resolve config-then-env and never appear in logs, error details, or audit rows.

Config is read at server start; live/headless mode is also resolved once at start. reload_vault re-validates the on-disk file but the server keeps its startup config — restart the server (or your MCP client) to apply changes. Adding or removing a vault always requires a restart; destructive changes (path, embeddings provider/model/dimensions, cacheDir) additionally need reset_vault_cache or a fresh cacheDir.

The defaults target a trusted, single-user local process (stdio, auth.mode: "none", acl.readOnly: false, strictReadDefault: false, writes.requireCas: false). Governed by default is not the same as least-privilege by default: for a shared, multi-caller, or long-lived deployment, start from the hardened example and tighten from there. The server prints a one-line security posture summary at startup, plus a warning when the permissive local defaults are active, so the running profile is always visible.

A committed least-privilege starting point is examples/config.hardened.json:

{
"vaults": [{ "id": "main", "path": "/absolute/path/to/vault" }],
"acl": {
"readOnly": false,
"strictReadDefault": true,
"readPaths": ["02-projects/**", "09-reference/**"],
"writePaths": ["02-projects/**"],
"deletePaths": []
},
"writes": { "requireCas": true },
"snapshots": { "enabled": true, "retention": 20 },
"transports": { "stdio": true, "http": { "enabled": false } }
}

It fails reads closed on unlisted paths (strictReadDefault), whitelists read/write paths and forbids deletes (least privilege), requires prev_hash on overwrites (writes.requireCas, no stale-hash clobbering), and captures snapshots for restore_note.

For a shared or HTTP deployment, additionally set transports.http.enabled: true and auth.mode: "jwt", supplying the secret via OBSIDIAN_TC_JWT_SECRET (never commit it). A non-loopback host with auth.mode: "none" is refused by the fail-closed interlock. Consider acl.readOnly: true initially, then open specific write paths.

  • Inference gateway setup — the generative tier.
  • Embedding model migration — changing models safely.
  • The CLI: obsidian-tc help lists the full offline command family (serve, config show|validate, plugin install, cluster, activation-recompute, prefetch, reflect, metrics, gaps, forget, version); each command takes the same config path.