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
},
"resources": {
"boundVaultOnly": false
},
"vaults": [
{
"id": "main",
"name": "My Vault",
"path": "/absolute/path/to/vault",
"kind": "private"
}
],
"auth": {
"mode": "none",
"tokenTtlSeconds": 86400,
"rotationGraceSeconds": 0,
"requireJti": false
},
"acl": {
"readOnly": false,
"defaultScopes": [],
"rules": [],
"strictReadDefault": false
},
"embeddings": {
"provider": "local",
"model": "nomic-embed-text-v1.5",
"dimensions": 768,
"timeoutMs": 120000,
"batchSize": 512,
"maxBatchTokens": 2048,
"concurrency": 4,
"truncate": false,
"chunkContext": true,
"queryPrefix": "",
"documentPrefix": "",
"quantized": true,
"host": "in-process",
"onProviderChange": "keep"
},
"network": {
"plainHttpHosts": []
},
"indexing": {
"writeConcurrency": 8,
"writeConcurrencyPerVault": 4,
"queueMax": 1000,
"streamingWalk": false,
"chunkTokens": 512,
"backgroundEmbed": {
"mode": "idle",
"idleMs": 2000,
"maxDeferMs": 30000
}
},
"retrieval": {
"derivedDefaults": false,
"useSearchModePreference": false,
"searchAutoRoute": "text-first",
"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,
"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,
"allowFeedbackInReadOnly": false,
"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": [],
"cors": {
"allowedOrigins": []
},
"trustedProxies": [],
"forwardedHeader": "x-forwarded-for"
}
},
"governor": {
"maxResponseBytes": 1000000,
"regexTimeoutMs": 2000
},
"writes": {
"requireCas": false,
"maxAttachmentBytes": 25000000
},
"toolFacade": {
"mode": "triad",
"explainAutoMode": false,
"outputSchema": "full",
"advertise": "all",
"triadHotPath": false,
"profile": "full"
},
"tools": {
"defaults": {
"responseFormat": "detailed"
}
},
"uri": {
"allowOsLaunch": false
},
"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,
"backend": "memory",
"failurePolicy": "fail-open",
"redis": {
"urlEnv": "OBSIDIAN_TC_REDIS_URL",
"keyPrefix": "obsidian-tc:rl:"
}
},
"observability": {
"otel": {
"headers": {},
"detail": "root"
},
"prometheus": {
"enabled": false,
"port": 9464,
"bind": "127.0.0.1"
},
"morgiana": {
"spool": true,
"httpHeaders": {}
},
"retention": {
"eventLogDays": 30,
"tracesDays": 30,
"spoolRetentionDays": 30
}
},
"maintenance": {
"enabled": true,
"intervalMinutes": 60,
"jobsCompleteRetentionDays": 7,
"episodesRetentionDays": 90,
"retrievalsRetentionDays": 365,
"jobsFailedRetentionDays": 30,
"captureQueueRetentionDays": 30,
"memoryOrphans": {
"enabled": true,
"intervalMs": 86400000,
"batchSize": 500,
"dryRun": false
},
"wikiLint": {
"enabled": false,
"intervalHours": 24,
"maxNotes": 1500,
"judge": true,
"judgeMaxCalls": 20
},
"wikiPages": {
"enabled": false,
"intervalHours": 6
}
},
"scheduler": {},
"watch": {
"enabled": true,
"debounceMs": 500
},
"snapshots": {
"enabled": true,
"retention": 10
},
"provenance": {
"enabled": true,
"host": {
"mode": "hashed"
},
"query": {
"maxScanRows": 100000
},
"stamp": {
"gitTrailers": false,
"frontmatter": false,
"frontmatterKey": "obsidian_tc_provenance"
}
},
"plane": {
"enabled": false,
"intervalMinutes": 240,
"gatewayMaxAttempts": 6,
"gatewayTimeoutMs": 300000,
"maxPromptChars": 45535
},
"egress": {
"excludePaths": []
},
"wikiJudge": {
"enabled": false,
"lintEnabled": true,
"provider": "gateway",
"apiKeyEnv": "TYPESAFE_API_KEY",
"baseUrl": "https://api.typesafe.ai",
"plainHttpHosts": [],
"allowPlainHttp": false,
"maxCallsPerRequest": 3,
"maxCallsPerDay": 200,
"timeoutMs": 15000,
"maxNoteChars": 2400
},
"sessions": {
"autoOpen": false,
"traceContent": true,
"windowSeconds": 1800,
"maxExplicitLifetimeSeconds": 86400
},
"telemetry": {
"enabled": false,
"intervalMinutes": 1440
},
"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.
publicUrl https url (optional) Opt-in base url where this vault’s notes are published (Obsidian Publish, Quartz, …). When set, the standard search and fetch tools cite <publicUrl>/<note path> (each path segment percent-encoded, .md dropped) so ChatGPT deep research and company knowledge render clickable citations. Absent, they cite an obsidian://open?vault=…&file=… link. https only; credentials, a query or a fragment are rejected.
mode live | headless | auto (optional → auto) auto probes the Local REST API at startup and re-probes it every 60 s while it is unreachable or missing (a headless-pinned vault is not re-probed): reachable → live (bridge tools work), else headless (bridge tools return requires_live_obsidian; filesystem tools unaffected). refresh_plugin_capabilities re-probes on demand. 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 Capability-probe timeout, at startup and on each re-probe.
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.
memory.supersededInNote bool, true Whether a memory-entity note keeps its corrected or retired facts under ## Superseded. true keeps them, so search indexes the old text. false omits the section: the note holds current facts only, search stops returning the old text, and get_entity with as_of still returns the history. A note already on disk is rewritten the next time its entity is written.
workspace.traceFolder string, ".obsidian-tc/traces" Vault-relative JSONL session-trace folder (ACL-checked).
index.excludePaths string[], [] Extra Excluded files entries merged with the vault’s own Obsidian list. See Excluded files below.
conventions.protectSchemaFiles bool, false When true, creating, editing, deleting, moving or renaming any file named SCHEMA.md in the vault (the root one included) requires the admin:conventions scope (also granted by admin:*, * or admin). Without it the caller gets acl_denied, with the path and op in its details. Reads are unchanged: the caller still reads the layer, is still held to its rules, and get_conventions still lists it. The key binds only callers whose token lacks admin:conventions; a stdio caller or an auth.mode: none caller holds *, so it does nothing for them. Off by default. See Vault conventions.
wiki.folder string, unset Vault-relative folder of this vault’s LLM wiki, holding its SCHEMA.md. draft_wiki_page proposes pages there and commit_wiki_page only writes pages inside it (it refuses when this is unset), creating them without a confirmation. The value is a plain relative folder such as wiki or notes/wiki; ., /, "", an absolute path, .., and segments ending in a space or dot are rejected. A folder that is a symlink inside the vault (wiki -> pages) works only with the native module loaded: without it (Windows, OBSIDIAN_TC_FORCE_JS_FALLBACK=1, an addon-less install) every read, write, move and delete through it, or through the directory it points to by that directory’s own name, is refused with acl_denied. See Wiki workflow.
wiki.rawFolder string, raw beside wiki.folder Vault-relative folder of this vault’s raw sources, the inputs the wiki is built from (same rules as wiki.folder, including no segment ending in a space or dot). Default: raw next to the wiki folder (wiki gives raw, notes/wiki gives notes/raw); none when that would be the wiki folder itself. It must not be, contain or sit inside wiki.folder. Its files are immutable: no tool writes, creates, renames or deletes there, whatever the ACL whitelists say (reads follow the read ACL). draft_wiki_page source ingests one of its notes. Add sources outside the server. A raw folder that is a symlink follows the same native-module requirement as wiki.folder. See Ingesting a raw source.

obsidian-tc honors the Excluded files list Obsidian keeps for each vault (Settings → Files & links → Excluded files, stored as userIgnoreFilters in <vault>/.obsidian/app.json), and you can add entries for a vault with vaults[].index.excludePaths. The two lists are merged. Nothing needs configuring when the vault already has an Excluded files list.

Pattern dialect. The Obsidian help site does not state how an entry is matched, so this mirrors the Obsidian 1.13.7 app source (updateUserIgnoreFilters and isUserIgnored):

Entry Meaning
Archive/ Case-insensitive path prefix against the vault-relative path: the folder Archive. Not a glob: * is a literal character.
Notes/todo.md The same prefix rule, so it matches that one file (and any longer path that starts with it).
/\.draft\.md$/ An entry that starts and ends with / (and is longer than two characters) is a case-insensitive regular expression, tested unanchored against the path.
(empty or whitespace) Skipped. Entries are trimmed.
/(/ An invalid regular expression is skipped, as Obsidian does; doctor --probe lists it.

What an excluded note is. Obsidian treats an excluded file as hidden from Search and the Graph view but still a file in the vault: links to it resolve, and it still appears in backlinks and outgoing links. obsidian-tc does the same.

Excluded note
Chunks, embeddings, full-text (FTS) rows, note summaries, contradiction and cluster detection None. Never sent to an embedding provider either.
search_text, search_regex, search_semantic, search_jsonlogic, search_vault results Never returned, on any search leg.
Edges in the persisted link graph None (hidden from the graph, like Obsidian’s Graph view).
Wikilink resolution, find_unresolved_links, get_backlinks, get_outgoing_links, read_note Unchanged. A [[link]] to it resolves; the note still reads and lists.
Folder ACL (readPaths, scopes) Unchanged. Exclusion only removes notes from the index; it never grants access.
Eval golden-set contamination guard Honors the list: an excluded note cannot contaminate the text leg.

Dot-folders (.obsidian, .git, .trash) are a different mechanism: they are not loaded at all, so notes inside them are not even link targets. egress.excludePaths is also separate: it only withholds text from model providers and leaves the note searchable.

Live reload. The server watches .obsidian/app.json. When the effective list changes, the vault is reconciled: newly excluded notes are de-indexed (their open contradiction rows are dismissed with a reason), and notes no longer excluded are indexed again. A malformed app.json keeps the last good list. Without the file watcher (watch.enabled: false), the new list applies on the next index_vault or server start. doctor --probe prints the effective list, how many notes it excludes, and any invalid entry.

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 / jwksUri object / path / URL (optional) Asymmetric verification (RS256/ES256/EdDSA) from an inline JWKS, a file loaded once at boot, or an authorization server’s key set URL (pinned, https and public-only by default; see Remote key set). 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.
allowMissingAudience bool (optional) Opt-out for the deprecation on a JWKS key source that binds no audience (audience unset and no complete Protected Resource Metadata). That is a startup error from the next minor release; true stops the warning. It does not waive the load-time rule that a JWKS needs audience or resource.
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).
anonymousDiscovery none | list, none What a request with no token gets. none: 401 + challenge on every request, tools/list included (what grok.com and Claude need to start a sign-in). list: ChatGPT’s mixed mode, an anonymous initialize / tools/list is answered (tools carry securitySchemes) and an anonymous tools/call returns a tool error with _meta["mcp/www_authenticate"]. Needs jwt or oidc and resource; see what a request with no token gets.

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.

Opt-in. Everything under auth.as is off while auth.as.enabled is false (the default), and the bearer paths do not change. When enabled it needs auth.mode: jwt, auth.resource and auth.as.issuer (it is refused under none and oidc). It serves the discovery document, the authorize, consent, token and revoke routes, client registration by metadata document and by RFC 7591 (dynamicRegistration, on by default) and the operator login; see the authorization server.

Field Type / default What it does
auth.as.enabled bool, false Serves RFC 8414 metadata at /.well-known/oauth-authorization-server (and the OpenID discovery alias) and generates the server’s own access-token signing key at boot.
auth.as.issuer origin (required when enabled) The issuer identifier, e.g. https://vault.example.com: an https origin with no path, trailing slash, query, port 443 or upper case (http only for a loopback host). Taken from here and never from the request’s Host, so the same string appears in the metadata, in Protected Resource Metadata and in every token’s iss.
auth.as.signingAlg ES256 | EdDSA, ES256 Algorithm of the generated as key. When auth.algorithms is set it must include it.
auth.as.accessTokenSeconds int 300 to 3600, 1800 Access-token lifetime. auth.tokenTtlSeconds must be at least this.
auth.as.refreshTokenDays int 1 to 90, 30 Absolute lifetime of a refresh-token family.
auth.as.refreshReuseGraceSeconds int 0 to 60, 30 Seconds after a refresh token’s successor is first used during which that token, presented again by its own client, is answered with the same successor and access token instead of revoking the family. Absorbs clients that refresh from several windows sharing one token (Okta’s grace period defaults to 30 s, range 0 to 60). Trade-off: inside the window rotation’s theft detection does not fire for the previous token; 0 restores strict rotation.
auth.as.dynamicRegistration bool, true RFC 7591 Dynamic Client Registration, on by default because many MCP surfaces (Cursor, Windsurf, grok.com, Le Chat, n8n and others) can only register this way. Claude, ChatGPT, Codex and Antigravity use metadata documents, which this server also supports. The unauthenticated client-creation surface is bounded by auth.as.dcr below. Set false to turn it off: /oauth/register is then a 404 and registration_endpoint is not advertised. A client that registers grant_types without refresh_token (or none, the RFC 7591 default) is issued no refresh token. One info line is logged at boot while it is on, and securityProfile: "hardened" forces it off even when set to true.
auth.as.dcr.maxClients int, 1000 Most dynamically registered clients kept at once. At the cap, registrations never used within a day are dropped first, then the server answers 503. Only used while dynamicRegistration is on.
auth.as.dcr.perIpPerHour int, 10 Registrations accepted per source address per hour; an IPv6 address counts as its /64. The eleventh from one source in an hour is 429.
auth.as.dcr.unusedDays int, 90 A dynamically registered client that has not completed a sign-in for this many days is deleted.
auth.as.cimd.allowedHosts hostname[], [] Hosts a Client ID Metadata Document may be served from, matched exactly against the client_id URL’s host. Empty admits any public https host; a document is never fetched from a non-public address either way.
auth.as.consent.loopback remember | prompt, remember Whether a sign-in that returns to this machine (127.0.0.0/8, ::1, localhost) is remembered once you approved the client. remember: reused across ports, so native CLIs (Claude Code, Codex) sign in without a click, but a malicious local process could then start its own sign-in without a prompt. prompt: every such sign-in shows the consent page. Redirects elsewhere are always remembered.
auth.as.setupTokenEnv env name, OBSIDIAN_TC_AS_SETUP_TOKEN Name of the variable holding the one-time token that claims the operator account on a host with no terminal. The token is never read from the config file.
auth.as.resetTokenEnv env name, OBSIDIAN_TC_AS_RESET_TOKEN Name of the variable holding the one-time token that lets the operator set a new password on a host with no shell. While it is set, a claimed server shows a reset form at /oauth/setup until the boot window closes; the token works once. Remove the variable afterwards. The token is never read from the config file.
auth.as.login.maxFailuresPerWindow int, 5 Wrong passwords tolerated per account within one window. The next one locks that name for 30 s, doubling with each further failure, never longer than the window.
auth.as.login.windowSeconds int, 900 Length of the window that maxFailuresPerWindow counts within. Counters are in memory and reset at restart.
auth.as.clients[] list, [] Pre-registered clients. Each entry is one client, with the keys below. Only these can be confidential.
auth.as.clients[].clientId string The client’s id. Must be unique across the list.
auth.as.clients[].name string The name the consent page shows for this client.
auth.as.clients[].redirectUris string list Redirect URIs this client may use. A loopback match ignores the port.
auth.as.clients[].secretEnv env name, optional Names the environment variable holding this client’s secret, which makes the client confidential.

A minimal example: the bundled server with Dynamic Client Registration turned off and one pre-registered client.

{
"cacheDir": "/var/lib/obsidian-tc/cache",
"vaults": [{ "id": "main", "path": "/absolute/path/to/vault" }],
"transports": { "http": { "allowedHosts": ["vault.example.com"] } },
"auth": {
"mode": "jwt",
"resource": "https://vault.example.com/mcp",
"audience": "https://vault.example.com/mcp",
"requireJti": true,
"as": {
"enabled": true,
"issuer": "https://vault.example.com",
"dynamicRegistration": false,
"clients": [
{
"clientId": "local-script",
"name": "Local script",
"redirectUris": ["http://127.0.0.1/callback"]
}
]
}
}
}

The config file is JSON. The loader reads no other format.

Cross-checks refused at load: auth.authorizationServers, when set, must list auth.as.issuer first (it defaults to [issuer]); auth.jwksUri may not point at this server’s own /.well-known/jwks.json; clientIds must be unique. Hand-minted tokens keep their rules: enabling the AS does not bind an aud to them (a hand-minted HS256 token without aud is still accepted); only auth.audience, or an explicit auth.authorizationServers list with auth.resource, does that. The tokens the AS issues are always checked against auth.resource.

A configured auth.jwks or auth.jwksFile that contains the public key of the server’s own as key stops the server at boot, naming the kid: a JWKS key is verified under the hand-minted-token rules, which would let a token signed by the as key skip the stricter as rules. A remote auth.jwksUri cannot be checked at boot, so keep the authorization server’s key out of any key set you publish. obsidian-tc doctor has an auth.as check. See the bundled authorization server.

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.

Path globs: write /, a backslash is a separator

Section titled “Path globs: write /, a backslash is a separator”

Every config field that holds a vault path pattern is normalised once, when the config loads: each backslash becomes / and repeated separators collapse. So Private\** (the natural spelling on Windows, written "Private\\**" inside JSON) is exactly Private/**, and notes//drafts is notes/drafts. A vault path can never contain a backslash, so there is nothing to escape in operator input: a backslash is always a separator, and \* is not a way to match a literal *.

The fields this covers: acl.rules[].glob, acl.readPaths / writePaths / deletePaths (also under a vault’s own acl), egress.excludePaths, and vaults[].index.excludePaths (path-prefix entries only; an entry written /regex/ is a regular expression and is left exactly as written).

acl.rules[].glob and the three *Paths whitelists are stricter than the rest: an ACL pattern is matched against a path relative to the vault root (Private/x.md), so one that is still anchored at a root after normalising can never match. Config load refuses it, with the vault-relative spelling in the error, rather than guessing:

  • a leading separator (/Private/**, \Private\**), a drive letter (C:\notes\**), a UNC prefix (\\server\share), or a leading ./: write Private/**, notes/**;
  • a trailing separator (notes\private\): that is the exact path notes/private/, not the folder. Write notes/private/**.
  • a . or .. path segment anywhere (../Private/**, Private/./**, Private/../**): the server rejects .. and drops . in every path it checks, so a rule written that way never matches the folder it names. Spell the folder itself (Private/**). Names that merely contain dots (.obsidian/**, a..b/**, v1.2/**) are fine;
  • a control character (NUL, tab, newline, DEL and the other C0/C1 controls), which no ACL pattern may contain. NUL in particular is the matcher’s own internal marker for ** and would over-match.

Refusing instead of stripping is deliberate: an acl.rules entry that never matched left its extra scopes unenforced, and stripping the marker from a whitelist entry would turn one that granted nothing into a grant. Upgrade note: a config that carries such a pattern now fails to start (serve, doctor and the other commands print the field and the fix). Those rules never worked, so the fix is to rewrite them vault-relative. The egress and index fields keep stripping a leading separator and widening a trailing one to the folder.

Before this, a backslash glob compiled to a pattern no vault path could match. For a whitelist (readPaths and friends) that failed closed, which is safe. For a restriction (egress.excludePaths, acl.rules, index.excludePaths) it failed open: the exclusion silently excluded nothing.

obsidian-tc doctor runs config.path-globs, which warns about any configured glob that matches no file in its vault (a typo, the wrong case, a renamed folder). A dead restriction is reported as one that protects nothing; a dead whitelist entry as one that grants nothing.

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 — 512 under provider: local 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). The same ceiling sizes each in-process inference under provider: local, where resident memory scales with chunks per request (1 → ~0.5 GB, 4 → ~1.1 GB for nomic q8), so that provider resolves it to 512 unless you set it.
concurrency int, 4 — 1 under provider: local Embed requests in flight. Each is a separate in-process inference under provider: local, holding its own activation memory, so that provider resolves it to 1 unless you set it: a first index is slower, steady-state edits barely change.
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.
reranker.passageFormat chunk What text a configured reranker scores per candidate: chunk (the raw chunk text) or title+chunk (the note title, a blank line, then the chunk). Applies to every reranker backend and to gated rerank and the rrf_rerank / score_merge fusion modes. Experimental, default unchanged — a reranker fed raw chunks lost to dense-only retrieval on every measured corpus; title+chunk is the candidate fix, recorded in ADR 0007 and not yet validated on enough corpus shapes to become the default. Needs a reranker block (the auto-selected local reranker keeps chunk).
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.allowFeedbackInReadOnly false Let record_retrieval_feedback — and only it — update its derived-telemetry rows in experiential.db under acl.readOnly / toolVisibility.requireReadOnly; inert unless experiential.logRetrievals is also true; every authored-vault mutation stays blocked and the write:workspace scope requirement is unchanged.
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 — a prior virtual-hop attempt sat at an 80% bridge-recall ceiling, and the champion figure it was judged against has since been withdrawn as unreproducible (see docs/EVALUATION.md), 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.
http.cors.allowedOrigins [] Exact browser origins (https://app.example.com) allowed to call the MCP endpoint, the OAuth token, registration and revocation endpoints, and the /.well-known metadata from a web page. Echoed back exactly, never *. Empty: no CORS header. See From a web page.
http.trustedProxies [] IPs and CIDR blocks of the reverse proxies or tunnels whose forwarded client address is believed (for example ["127.0.0.1", "::1"]). Empty: no header is read. Never list a proxy you do not control; see Running behind a proxy or tunnel.
http.forwardedHeader x-forwarded-for Header a trusted proxy carries the client address in: x-forwarded-for or cf-connecting-ip (Cloudflare Tunnel).
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), append_note and patch_note (also patch_active_file) 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; 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 plus the standard search/fetch pair), domain (~a dozen domain meta-tools), flat (everything), auto (deprecated: resolves to triad for every client, removal planned for the next major; set an explicit mode). All tools stay callable by name in every mode.
toolFacade.triadHotPath false On the triad surface, also list read_note, search_vault, write_note and list_notes as top-level tools beside search and fetch, so an everyday read or search is one call. Ignored by the domain and flat modes and by a flat advertise list. Advertisement only: each tool stays callable through call_capability. Cost: the triad tools/list grows from about 4.5k to about 17.3k JSON characters (measured in memory, no vault registry), and Claude Code loads all of it every session because these tools carry alwaysLoad.
toolFacade.autoClients (optional) Deprecated with auto. Only used when mode is auto: substring-of-clientInfo.name -> mode overrides, checked before the built-in table.
toolFacade.outputSchema full Whether tools/list advertises each tool’s outputSchema: full (unchanged) or omit. Results carry structuredContent and a text block either way; omit only drops the advertisement, for clients that mishandle it (claude.ai is reported to fail tools that declare one, Claude Desktop with older schema drafts, Cursor when a root is not an object). Applies to the flat surface and to the search/fetch pair on triad (and the hot-path tools with triadHotPath); the triad’s three meta-tools and domain advertise no outputSchema. See MCP compatibility.
toolFacade.explainAutoMode false Deprecated with auto. Only used when mode is auto: log one structured explain line per resolution and return it as the explanation field of server_health’s toolFacade block. Observability only; the chosen mode is unchanged.
tools.defaults.responseFormat detailed The response_format a tool call gets when it sets neither response_format nor the legacy verbosity alias. detailed is the full payload (unchanged output); concise returns only the high-signal fields of every tool that supports response_format (see Response format, which lists the covered and exempt tools). It is also the only selector for MCP resources/read, which takes no parameters (concise returns the note body without its frontmatter). Errors and safety warnings are never trimmed.
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). Tags are the fixed vocabulary in Tool tags, e.g. destructive, bulk, external-network.
toolVisibility.disabled / disabledTags [] Drop from the list and reject at dispatch. Same tag vocabulary.
toolVisibility.requireReadOnly false Hide every mutating tool (derived from scopes — no per-tool annotation needed).
idempotencyTtlSeconds 86400 Idempotency-key replay window. A key is scoped to the calling principal, so another caller reusing it runs its own call.
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).

The obsidian-tc://<vault>/<path> note resources (resources/read, resources/list and the read_resources tool) read notes in the vault their URI names.

Key Default What it does
resources.boundVaultOnly false true confines a trusted (stdio) caller’s note resources to the server’s default vault, as before: a URI naming another vault is refused, and resources/list lists only the default vault. false serves every registered vault the caller may read, each under that vault’s own folder ACL. A refusal in another vault (an unknown vault, a denied folder, a link that escapes the vault or a hard link) is answered exactly as a missing note, so the URI cannot be used to learn which vaults or notes exist. A vault-bound caller, an HTTP token, is confined to its own vault either way.

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. otel.detail (root default, children, verbose) sets span depth; there is no sampling knob — every dispatch gets a root span 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 event_log rows older than this are pruned by the maintenance sweep.
retention.tracesDays 30 Workspace session trace files (<vault>/<traceFolder>/*.jsonl) older than this are deleted by the maintenance sweep. Traces live inside the vault, so a sync or backup picks them up too. Orphan traces from a failed start_session follow the same age rule.
retention.spoolRetentionDays 30 Morgiana spool day files (morgiana-events-<date>.jsonl) older than this are deleted by the maintenance sweep; 0 keeps them forever. The current UTC day’s file is never touched.
retention.spoolMaxBytes (optional) Per-vault size cap on the morgiana spool: the oldest day files are deleted until the vault’s spool fits. The current day’s file is never deleted.
Field Default What it does
maintenance enabled, every 60 min cache.db sweep: expired idempotency/elicit rows, event_log retention, PRAGMA optimize.
maintenance.wikiLint disabled (opt-in), every 24 h Scheduled wiki lint: runs the lint_wiki checks and logs one summary line per vault. Read-only. Keys: enabled, intervalHours, folder, maxNotes.
maintenance.wikiPages disabled (opt-in), every 6 h Scheduled regeneration of each wiki folder’s generated index.md and log.md, as commit_wiki_page does after a write. Writes inside wiki.folder only; never over a hand-edited page. Keys: enabled, intervalHours.
wikiJudge find_existing_page off, lint_wiki on when a judge is configured The LLM judge that resolves ambiguous wiki page matches. Needs a configured judge: the gateway judge role, or provider: typesafe. Keys: enabled (find_existing_page, default false), lintEnabled (lint_wiki, default true), provider (gateway or typesafe), maxCallsPerRequest (1 to 3, default 3), maxCallsPerDay (default 200, 0 disables), timeoutMs (default 15000, cancels the request), maxNoteChars (default 2400 per side). provider: typesafe (experimental) asks TypeSafe Jev instead and also needs model (a pinned, dotted version such as jev-1.13.0) and threshold (no default); apiKeyEnv, baseUrl and plainHttpHosts as in experiential.citationInfer.judge (see Plain-http endpoints; allowPlainHttp is deprecated). maintenance.wikiLint.judge (default true, but the sweep itself stays off until maintenance.wikiLint.enabled) / judgeMaxCalls let the scheduled lint judge too, unless toolVisibility.disabledTags has external-network. Candidate page text goes to the judge, never for egress-excluded or Excluded-files notes.
plane disabled (opt-in), 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.
network.plainHttpHosts [] Exact hostnames a plain http:// provider URL may name, for the gateway, embeddings, reranker, model tier, plur and each vault’s restApiUrl. See Plain-http provider endpoints.

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.

experiential.citationInfer.judge and wikiJudge (both with provider: typesafe) send a bearer key and vault-derived text to baseUrl. That URL must be https://, a loopback host, or an http:// URL whose exact hostname is listed in the block’s plainHttpHosts:

experiential:
citationInfer:
judge:
provider: typesafe
baseUrl: http://litellm:4000/typesafe # a gateway on a Docker bridge
plainHttpHosts: ["litellm"]

A listed host is still checked every time a request is sent. The host is resolved once, and the request is refused, with nothing sent, unless every address it resolves to is loopback, private (RFC 1918: 10/8, 172.16/12, 192.168/16) or IPv6 unique-local (fc00::/7); a listed host may also resolve to a Tailscale tailnet address (100.64.0.0/10, see Tailnet hosts below). Link-local addresses are never allowed, because 169.254.169.254 is the cloud metadata service. The connection then goes to the address that was checked, with the original Host header, so a second DNS answer cannot redirect it. A redirect from the host is refused rather than followed. Entries are exact hostnames (case-insensitive, internationalized names are converted to punycode); wildcards, ports and paths are config errors. A numeric form such as http://0x08080808/ or http://[::ffff:8.8.8.8]/ is the address it spells, so it needs to be private to be sent at all. obsidian-tc doctor lists each plain-http host with the addresses it resolves to and warns about any that are not private.

Every http:// request, loopback included, is sent directly: HTTP_PROXY, http_proxy and ALL_PROXY are not applied to it, so a proxy in the environment never receives the key or the vault text. A loopback name such as localhost must resolve only to loopback addresses. https:// requests do honour those variables; through a proxy that is a CONNECT tunnel, so the proxy sees the host and port but cannot read the body.

allowPlainHttp: true is deprecated and is removed in the next major release. Until then it means “this baseUrl’s own host is listed”, and it no longer waives the private-address check: a host that resolves to a public address is refused. doctor and server_health (deprecations) warn while it is set. Replace it with plainHttpHosts: ["<the host in baseUrl>"].

The two judge blocks keep their own plainHttpHosts because they refuse an unlisted host when the config loads. Every other provider URL uses the one root list below.

Plain-http provider endpoints (network.plainHttpHosts)

Section titled “Plain-http provider endpoints (network.plainHttpHosts)”

The same policy covers every other outbound provider client: gateway.baseUrl (or OBSIDIAN_TC_GATEWAY_URL), embeddings.baseUrl, embeddings.modelTier.dense.baseUrl / .full.baseUrl, reranker.baseUrl, plur.endpoint and each vault’s restApiUrl. They share one top-level list, so a host is named once however many providers use it (a per-provider list would only repeat the same name; the judge blocks are the one exception, above):

network:
plainHttpHosts: ["litellm"] # the LiteLLM container on a Docker bridge
gateway:
baseUrl: http://litellm:4000
embeddings:
provider: openai-compatible
baseUrl: http://litellm:4000/v1

What a plain http:// provider URL does:

URL host Result
https:// anything Goes through the ordinary fetch, which honours HTTPS_PROXY as a CONNECT tunnel. A redirect (any 3xx with a Location) is refused, never followed: following a 307/308 would replay the key and vault text to an unchecked destination.
Loopback (127.0.0.1, [::1], localhost) Works with no entry. Sent directly, never through HTTP_PROXY / ALL_PROXY.
Listed, and every address it resolves to is loopback, RFC 1918 or fc00::/7 Works, silently.
Not listed, and every address it resolves to is loopback, RFC 1918 or fc00::/7 Works for one more release with a deprecation warning that names the host and the config to add. Refused from the next major release.
Listed, and it resolves to a tailnet/CGNAT address (100.64.0.0/10), the rest private Works, silently. doctor shows allowed (listed tailnet/CGNAT).
Not listed, and it resolves to a tailnet/CGNAT address (100.64.0.0/10) Refused, nothing sent. There is no deprecation path for this range.
Resolves to any public address, listed or not Refused, nothing sent.
Link-local, including the 169.254.169.254 cloud metadata address, and the other cloud metadata addresses (100.100.100.200, fd00:ec2::254, fd00:64:64:64::254, fd20:ce::254) Refused, nothing sent, listed or not.

A refusal fails the request with the host and the offending address in the error (never the key, a path or a query) and is not retried. Every plain-http request, loopback included, connects straight to the address that was checked, with the original Host header; a redirect is refused. obsidian-tc doctor lists every plaintext provider host with its resolved address and a status (allowed, allowed (listed tailnet/CGNAT), deprecated-unlisted or refused). server_health (deprecations) gives the same advice, resolved once at startup: only a host that resolves solely to private addresses is told to be listed, a tailnet/CGNAT host is told to be listed only if it is a tailnet peer, and a public or metadata host is told it is refused and to use https://. The entries name hosts, addresses and vault ids, so only a caller that may read every vault sees them; anyone else gets one generic line with the count.

On Cave, embeddings.baseUrl and gateway.baseUrl are http://litellm:4000, a Docker bridge address in 172.16.0.0/12. They keep working unchanged; add network: { plainHttpHosts: ["litellm"] } to silence the warning before the next major release.

Tailscale gives every peer an address in 100.64.0.0/10. That range is not private in general: an ISP’s carrier-grade NAT space is shared and not encrypted. So it is admitted only for a host you list (network.plainHttpHosts, or a judge block’s own list), and every address the host resolves to must still be loopback, private or in that range. An unlisted host that resolves into it is refused with no deprecation path. Tailscale traffic is WireGuard-encrypted; listing a host is your explicit statement that it is a tailnet peer, so list only tailnet hosts. Link-local and the cloud metadata address stay refused.

auth.jwksUri follows the same list. It must be https:// on a public host (connected to the address that was validated, never resolved twice), unless the host is loopback (no entry) or listed in network.plainHttpHosts, which admits http:// and loopback, RFC 1918, fc00::/7 and (listed) tailnet addresses. An unlisted host that resolves only to private addresses still works for one more release with a deprecation; add it here to silence it. A public host over plain http://, and link-local or cloud metadata addresses, are refused even when listed. See Remote key set.

Not covered, by design: Pensieve (pensieve.baseUrl) is an inbound, key-less GET whose documented target is a tailnet address (100.64.0.0/10), the Readwise and OIDC clients are https-only, and the optional local embedder and reranker download their model files from fixed https hosts.

Variable Purpose
OBSIDIAN_TC_CONFIG Config path when no CLI argument is given.
OBSIDIAN_TC_DEFAULT_VAULT Default vault id when several are configured. It is also the vault an unbound HTTP/OAuth token (no vault claim, no grant vault) is bound to.
OBSIDIAN_TC_JWT_SECRET HS256 signing secret (keeps it off disk).
PORT HTTP port: transports.http.port, and turns the HTTP transport on unless the file sets it to false. Refused unless an integer from 1 to 65535. See Railway.
OBSIDIAN_TC_PUBLIC_URL Public https://host[:port]: sets auth.resource to its /mcp URL and adds its host to transports.http.allowedHosts.
OBSIDIAN_TC_ALLOWED_HOSTS Comma-separated Host values (host, host:port, IPv4 or [ipv6]:port) added to the DNS-rebinding allow-list, transports.http.allowedHosts. Exact entries only: no wildcards, scheme, path or whitespace. Matching is by host name: a port in an entry is accepted but not enforced, since the DNS-rebinding check is about the name. Merged with the file list and the OBSIDIAN_TC_PUBLIC_URL host.
OBSIDIAN_TC_JWKS_URI auth.jwksUri, the key set of an external authorization server.
OBSIDIAN_TC_AUDIENCE auth.audience: the aud claim JWT tokens must carry. Required for a non-loopback JWT server when OBSIDIAN_TC_PUBLIC_URL is not set.
OBSIDIAN_TC_VAULT_PATH Path of the one vault (vaults). Refused when the config names several vaults. When the folder is missing and its parent exists, the server creates it at boot with mode 0750 (or tighter under a stricter umask); a path set only in the config file is never created. A parent that is a symlink is refused. Only server boot creates the root: one-shot commands such as index do not.
OBSIDIAN_TC_CACHE_DIR cacheDir.
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 (setup, serve, config show|validate, plugin install, cluster, activation-recompute, prefetch, reflect, metrics, gaps, forget, version); each command takes the same config path. setup is the odd one out — it takes no config path at all (it WRITES one); see First Run.