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.
The full shape (all defaults shown)
Section titled “The full shape (all defaults shown)”{ "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.
Vault entries (vaults[], min 1)
Section titled “Vault entries (vaults[], min 1)”| 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. |
embeddings
Section titled “embeddings”| 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. |
retrieval and experiential
Section titled “retrieval and experiential”| 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.
transports
Section titled “transports”| 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. |
Governance, safety, and surface shaping
Section titled “Governance, safety, and surface shaping”| 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). |
bootstrap (session-bootstrap routing)
Section titled “bootstrap (session-bootstrap routing)”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). |
throttle
Section titled “throttle”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.
observability
Section titled “observability”| 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. |
Background schedulers
Section titled “Background schedulers”| 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. |
plur (optional)
Section titled “plur (optional)”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.
Environment variables (complete)
Section titled “Environment variables (complete)”| 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.
Applying config changes
Section titled “Applying config changes”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.
Hardened deployment profile
Section titled “Hardened deployment profile”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.
See also
Section titled “See also”- Inference gateway setup — the generative tier.
- Embedding model migration — changing models safely.
- The CLI:
obsidian-tc helplists 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.