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 }, "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.
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. |
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. |
Excluded files
Section titled “Excluded files”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.
Bundled authorization server (auth.as)
Section titled “Bundled authorization server (auth.as)”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./: writePrivate/**,notes/**; - a trailing separator (
notes\private\): that is the exact pathnotes/private/, not the folder. Writenotes/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.
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 — 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. |
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. |
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.
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. |
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). |
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), 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). |
resources (note resources)
Section titled “resources (note resources)”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. |
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. 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. |
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. |
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. |
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.
Plain-http endpoints (plainHttpHosts)
Section titled “Plain-http endpoints (plainHttpHosts)”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 bridgegateway: baseUrl: http://litellm:4000embeddings: provider: openai-compatible baseUrl: http://litellm:4000/v1What 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.
Tailnet hosts (100.64.0.0/10)
Section titled “Tailnet hosts (100.64.0.0/10)”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.
JWT-mode key set (auth.jwksUri)
Section titled “JWT-mode key set (auth.jwksUri)”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.
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. 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.
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 (setup,serve,config show|validate,plugin install,cluster,activation-recompute,prefetch,reflect,metrics,gaps,forget,version); each command takes the same config path.setupis the odd one out — it takes no config path at all (it WRITES one); see First Run.