Configuration - The-40-Thieves/obsidian-tc GitHub Wiki

Configuration

obsidian-tc reads one JSON config file, passed as the first CLI argument or via OBSIDIAN_TC_CONFIG. You can also pass a vault folder directly for zero-config startup (a single vault main with all defaults). Secrets stay off disk via environment variables.

The schema is ServerConfigSchema, exported from @the-40-thieves/obsidian-tc-shared (packages/shared/src/config.schema.ts). Inspect the effective config any time with obsidian-tc config show <path> (secrets redacted) or validate with obsidian-tc config validate <path>.

Minimal config

{
  "vaults": [
    { "id": "main", "path": "/Users/me/vault", "restApiKey": "<local-rest-api-key>" }
  ]
}

One vault, default none auth (loopback only), local Ollama embeddings. restApiKey is only needed for plugin-bridge tools โ€” pure filesystem/search use works without it.

Top-level fields

Field Type / default Purpose
cacheDir string, .obsidian-tc Where the shared SQLite cache (one cache.db + experiential.db, row-scoped by vault), traces, and spools live
vaults array, min 1 Vault registry (see below)
auth object none or jwt (HS256 or asymmetric RS256/ES256/EdDSA via JWKS)
acl object Root access-control block; each vault may override via vaults[].acl
embeddings object Provider, model, dimensions, chunkContext
retrieval { "rrfK": 10, "classRouter": false } RRF fusion constant (k=10 shipped after a measured sweep); the dark query-class router flag; and an experimental densify block โ€” derived graph edges (tag co-occurrence, vec0 kNN, and an LLM pass built by the obsidian-tc densify-llm CLI), all off by default and pending measurement
experiential object (see below) The quarantined work-memory tier's knobs
transports object stdio (default on) and http (default off, loopback)
governor { "maxResponseBytes": 1000000, "regexTimeoutMs": 2000 } Response size ceiling + regex worker-time budget (ReDoS guard)
writes { "requireCas": false, "maxAttachmentBytes": 25000000 } requireCas: when true, destructive note writes REQUIRE prev_hash (compare-and-swap) and fail closed without it. maxAttachmentBytes: decoded-byte cap on one write_attachment payload
snapshots { "enabled": true, "retention": 10 } Point-in-time snapshots of destructive writes so restore_note can roll back (on by default under trusted-local; retention is pruned inline)
bootstrap { "domains": [], "deepPaths": [], "maxPaths": 10 } Session-bootstrap routing table (signals โ†’ context notes; deep-mode phrases)
throttle object Per-class rate tiers (read 600/100 โ€ฆ admin 5/1) + max concurrent writes/vault (16)
observability object otel / prometheus / morgiana / retention (retention bounds event_log rows, trace files and the morgiana spool)
toolFacade { "mode": "triad" } Advertised tool surface โ€” triad (default) / domain / flat
toolVisibility object (optional) Hide/disable tools from the advertised surface
tools { "defaults": { "responseFormat": "detailed" } } Per-call output shape: tools.defaults.responseFormat is the response_format (concise / detailed) a call gets when it names neither response_format nor the legacy verbosity; it is also the only selector for MCP resources/read. See Tool Reference
plur object (optional) plur read-proxy endpoint
maintenance { "enabled": true, "intervalMinutes": 60 } Periodic cache.db sweep
plane { "enabled": false, "intervalMinutes": 240 } Sleep-time consolidation scheduler; opt-in โ€” set plane.enabled: true to run it; only does work with an inference gateway configured, and a gateway-configured deployment that never sets this key gets a boot-time notice
idempotencyTtlSeconds / idempotencyReclaimSeconds / elicitTtlSeconds 86400 / 60 / 300 TTLs

The memory-engine knobs

"experiential": {
  "logRetrievals": true,
  "captureEpisodes": true,
  "captureContent": true,
  "activationRerank": false
}
  • logRetrievals โ€” append serve-path retrieval events to the quarantined experiential store (local-only telemetry feeding activation recompute and citation inference).
  • captureEpisodes โ€” auto-capture agent tool-call outcomes as work episodes (the action axis).
  • captureContent โ€” additionally store secret-scanned call arguments (the content axis). On under trusted-local; securityProfile: "hardened" turns it off, as does setting it false yourself. Arguments are secret-scanned and size-capped before storage.
  • activationRerank โ€” apply cached ACT-R activation in the graph rerank (dark until its A/B wins).

Vault entry (vaults[])

{
  "id": "main",
  "path": "/Users/me/vault",
  "mode": "auto",
  "restApiUrl": "http://127.0.0.1:27124",
  "restApiKey": "<key from Local REST API plugin>",
  "memory": { "folder": "90-memory" },
  "workspace": { "traceFolder": ".obsidian-tc/traces" },
  "commands": { "enabled": false, "allowlist": [] },
  "bridges": { "timeoutMs": 5000, "probeTimeoutMs": 500, "ocrTimeoutMs": 30000, "templaterTimeoutMs": 30000 },
  "plugins": { "forceEnabled": ["dataview"], "probeSkip": false },
  "acl": { "readOnly": true }
}

mode is live | headless | auto (auto probes the Local REST API once at startup). memory.folder is where composite-context surfaces read/write memory notes (_next-session.md, reflections). commands is the deny-by-default command-execution gate (explicit enable + allowlist + HITL). The optional per-vault acl overrides the root ACL for this vault; omit it to inherit.

Auth

HS256 (shared secret):

"auth": { "mode": "jwt", "jwtSecret": "<>= 32 chars, prefer OBSIDIAN_TC_JWT_SECRET env>" }

Asymmetric (RS256 / ES256 / EdDSA) via a JWKS โ€” inline jwks or a jwksFile loaded at boot; algorithms is an allowlist, key rotation is kid-based:

"auth": { "mode": "jwt", "jwksFile": "/etc/obsidian-tc/jwks.json", "algorithms": ["RS256", "EdDSA"] }

mode is none | jwt; in jwt mode supply either jwtSecret or a JWKS. A fail-closed interlock refuses to start when transports.http.enabled && auth.mode === "none" and the host is non-loopback. Optional OAuth 2.0 Protected Resource Metadata (RFC 9728) via auth.resource + auth.authorizationServers. Full model in Security and ACL.

ACL (root + per-vault)

"acl": {
  "readOnly": false,
  "defaultScopes": ["read:vault"],
  "rules": [{ "glob": "02-projects/**", "scopes": ["read:vault", "write:vault"] }],
  "readPaths": ["**"],
  "writePaths": ["02-projects/**", "01-daily/**"],
  "deletePaths": [],
  "strictReadDefault": false
}

readOnly: true is the kill switch. rules are last-match-wins. Omitting readPaths / writePaths / deletePaths leaves that op kind unrestricted; strictReadDefault: true makes an undefined readPaths fail closed on reads.

Embeddings

"embeddings": {
  "provider": "ollama",
  "model": "nomic-embed-text",
  "dimensions": 768,
  "chunkContext": true
}

provider is a name resolved against the provider registry at startup, not a closed enum โ€” an unregistered name is a startup error listing every valid option the registry currently has registered, and the registry's contents grow over time. The built-in names today: ollama, openai, voyage, cohere, bge-m3, the generic openai-compatible, model-tier and the profile-gated module (bge-m3 targets a vLLM/TEI pooling server; model-tier splits dense and multi-vector retrieval across two services; openai-compatible serves anything OpenAI-shaped). None is privileged in code โ€” ollama is only the schema default, and that default is deprecated. chunkContext (default true) embeds each chunk with its note title + heading breadcrumb โ€” measured +0.223 nDCG; the first reconcile after enabling re-embeds in full. Further knobs (local-runner robustness + model-specific behavior): timeoutMs (120000), batchSize (512), maxBatchTokens (2048 โ€” keeps a batch inside a local runner's context), concurrency (4), truncate (false โ€” Matryoshka/MRL truncation for wider models), queryPrefix/documentPrefix ("" โ€” instruct prefixes for models that require them; a document-prefix change needs a fresh cacheDir).

Transports

"transports": {
  "stdio": true,
  "http": { "enabled": false, "host": "127.0.0.1", "port": 8765 }
}

A headless deploy with both transports on (docker run -d, a compose service without stdin_open: true, a systemd unit) has stdin backed by /dev/null, which hits stdio EOF immediately โ€” closing only the stdio transport, not the process, as long as http.enabled is true. Set stdio: false for a headless HTTP-only deploy to skip the notice and the stdio transport entirely.

Environment variables

Variable Purpose
OBSIDIAN_TC_CONFIG Path to the JSON config
OBSIDIAN_TC_DEFAULT_VAULT Default vault id when several are configured
OBSIDIAN_TC_JWT_SECRET JWT signing secret (keeps it off disk)
OBSIDIAN_TC_GATEWAY_URL Inference gateway base URL โ€” enables the generative tier (see below); unset degrades gracefully
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 + token
OBSIDIAN_TC_FORCE_JS_FALLBACK=1 Force the pure-JS native fallback
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 โ€” force the inspection-connection open (compact --dry-run/--into, doctor) onto its writable-descriptor fallback. Weakens the bytes-unchanged guarantee; never set it in production
OBSIDIAN_TC_FORCE_READONLY_OPEN_THROW Test-only โ€” make the NATIVE readonly open attempt fail inside the adapter (1 at the first statement, construct at construction), so the fallback path runs on a platform whose native open succeeds
OBSIDIAN_TC_FORCE_COMPACT_POST_OPTIMIZE_THROW=1 Test-only โ€” fail compact (in-place path) right after FTS5 'optimize' has committed its merge, so the report's partial ftsOptimized list is testable
OBSIDIAN_TC_FORCE_COMPACT_INTO_FAILURE Test-only โ€” interfere with compact --into after its VACUUM INTO: 1 throws, busy throws a SQLITE_BUSY, delete:<table> drops one row from the copy (a real row-count mismatch), count-error:<table> makes that table's count FAIL on the copy. Exists so the retained-copy and failed-verification paths are testable on every SQLite build

Inference gateway (generative tier)

reflect synthesis, knowledge_challenge, the sleep-time plane, the episode evaluator's judge layer, and preference extraction route through one optional OpenAI-compatible endpoint by role โ€” the engine requests model names extract, synthesize, judge. Wire it with the two env vars above; the recommended shape is a self-hosted LiteLLM container (digest-pinned, loopback-only, zero keys for an all-local Ollama-backed policy) whose config maps the three role aliases to real models. Swapping a role to a hosted model is a yaml edit + container restart โ€” obsidian-tc never changes, and every derived note records the resolved provider:model. Absence is a supported state: reflect degrades to recall with available: false and the plane idles. Full recipe: the docs-site page configuration/inference-gateway.md.

Multi-vault

Adding or removing a vault requires a restart. reload_vault re-reads and validates the on-disk config but the server keeps its startup config until restart โ€” config changes (including restApiUrl/restApiKey and live/headless mode, which is resolved once at boot) take effect on the next server start. Per-vault isolation (separate SQLite DBs, traces, embeddings, ACL) is detailed in Architecture.

Full configuration reference (generated)

Every key, type, default, and required flag โ€” generated from the Zod schema. Do not hand-edit between the markers.

acl

Key Type Default Required Description
acl.defaultScopes array<string> [] Scopes REQUIRED to operate on a path that matches no rule (P1.4). Empty (the default) adds no requirement.
acl.deletePaths array<string> โ€” Glob whitelist for deletes: a path must match at least one entry. Omitted leaves deletes unrestricted. Write separators as /: a backslash is read as a separator (notes\\** is notes/**), since a vault path can never contain one, and repeated separators collapse. Entries must be vault-relative: a leading separator, drive letter, UNC prefix, leading ./ or trailing separator is refused at load.
acl.readOnly boolean false Reject every mutating operation on this vault regardless of the scopes a caller holds.
acl.readPaths array<string> โ€” Glob whitelist for reads: a path must match at least one entry. Omitted leaves reads unrestricted (see strictReadDefault). Write separators as /: a backslash is read as a separator (notes\\** is notes/**), since a vault path can never contain one, and repeated separators collapse. Entries must be vault-relative: a leading separator, drive letter, UNC prefix, leading ./ or trailing separator is refused at load.
acl.rules array<object> โ€” Ordered glob-to-required-scope rules enforced at dispatch (P1.4). Later matches override earlier ones.
acl.rules[].glob string โ€” yes Glob matched against the vault-relative note path. Written with forward slashes; a backslash is read as a separator, so notes\\private\\** is notes/private/** (a vault path can never contain a backslash). Must be vault-relative: a leading separator, a drive letter (C:), a UNC prefix, a leading ./ or a trailing separator is refused at load, because such a rule can never match a note and its scopes would be silently bypassed.
acl.rules[].scopes array<string> [] Scopes REQUIRED to operate on paths matching this rule (P1.4): a caller must hold every listed scope, in addition to the tool's own required scopes, to read/write/delete a matching path. The LAST matching rule wins, replacing rather than merging the scopes of earlier matches. An empty list adds no requirement. Enforced at dispatch on tool operations, and applied to results too: a search, listing or graph result for a path whose scopes the caller lacks is dropped, exactly as a path outside readPaths is.
acl.strictReadDefault boolean false When true, an UNDEFINED readPaths whitelist fails CLOSED on the request path rather than only on bridge enumeration. Default false preserves allow-all back-compatibility.
acl.writePaths array<string> โ€” Glob whitelist for writes: a path must match at least one entry. Omitted leaves writes unrestricted. Write separators as /: a backslash is read as a separator (notes\\** is notes/**), since a vault path can never contain one, and repeated separators collapse. Entries must be vault-relative: a leading separator, drive letter, UNC prefix, leading ./ or trailing separator is refused at load.

auth

Key Type Default Required Description
auth.algorithms array<string> โ€” Explicit allowlist of accepted JWT algorithms, applied to every verify path: HS256 (the configured secret and registry keys), registry asymmetric keys, the JWKS and /metrics. Leaving HS256 out refuses HS256 tokens everywhere (["EdDSA"] is asymmetric-only). Absent, HS256 plus RS256/ES256/EdDSA are accepted. Algorithm confusion is structurally impossible regardless: HS256 verifies only against jwtSecret (or an HS256 registry key) and asymmetric algorithms only against the JWKS. Not used under mode: oidc, which has its own oidc.allowedAlgs.
auth.allowMissingAudience boolean โ€” DEPRECATION OPT-OUT. A JWKS key source (jwks, jwksFile, jwksUri) with no EFFECTIVE audience accepts a token its issuer minted for another service. audience is effective; resource is used as the audience only when Protected Resource Metadata is complete (authorizationServers set too), so a resource-only config passes validation and binds nothing. That keeps working for one release with a warning at startup, in obsidian-tc doctor and in server_health, and becomes a startup error in the next minor release. Set true only if you really mean to accept tokens regardless of aud; the warning then stops. It does NOT waive the rule above: a JWKS still needs audience or resource at config load.
auth.anonymousDiscovery enum(none|list) โ€” What an anonymous caller may do when OAuth is on. none (the default when unset) answers 401 with the WWW-Authenticate challenge to every request that carries no valid token, initialize and tools/list included: this is what makes grok.com and Claude start their OAuth flow. list is the ChatGPT mixed-auth mode: an anonymous initialize, ping, server/discover and tools/list are answered (the list is what a caller holding the default OAuth scopes sees, each tool carrying securitySchemes), and an anonymous tools/call returns a tool error whose _meta["mcp/www_authenticate"] holds the challenge that triggers ChatGPT's account-linking UI. Every other method, and any request with an invalid or expired token, still gets the 401. Do NOT use list for grok.com: it never starts OAuth when tools/list succeeds anonymously. Needs auth.mode jwt or oidc and auth.resource.
auth.as.accessTokenSeconds number 1800 Lifetime of an issued access token in seconds (300 to 3600, default 1800). auth.tokenTtlSeconds must be at least this, or the age cap would reject tokens before they expire.
auth.as.cimd.allowedHosts array<string> [] Hostnames a Client ID Metadata Document URL may be hosted on. Empty (the default) admits any public https host; set it to restrict which clients can ask the operator for consent.
auth.as.clients array<object> โ€” Pre-registered clients. They are the only confidential clients (secretEnv); every other client registers by metadata document or, when enabled, dynamically.
auth.as.clients[].clientId string โ€” yes Stable identifier the client presents as client_id. Any string that is not an https:// URL with a path (such strings are Client ID Metadata Document identifiers and are resolved by URL).
auth.as.clients[].name string โ€” yes Human-readable name shown on the consent page.
auth.as.clients[].redirectUris array<string> โ€” yes Exact redirect URIs this client may use. Loopback http URIs (127.0.0.1, [::1], localhost) match with the port ignored and the path exact; every other URI must match exactly.
auth.as.clients[].secretEnv string โ€” Optional. Name of the environment variable holding this client's secret, which makes it a confidential client authenticating with client_secret_basic. The secret itself is never written in the config file; static clients are the only confidential ones.
auth.as.consent.loopback enum(remember|prompt) "remember" How a sign-in whose redirect is on this machine (127.0.0.0/8, ::1 or localhost) is treated once the operator has approved the client. remember (the default): the approval is reused across ports, so Claude Code and Codex sign in without a click. prompt: every such sign-in shows the consent page, whichever port it uses, so a local process cannot start its own sign-in and obtain a token without the operator seeing it. A redirect that is not on this machine is always remembered.
auth.as.dcr.maxClients number 1000 Most dynamically registered clients kept at once (default 1000).
auth.as.dcr.perIpPerHour number 10 Registrations accepted per source address per hour (default 10).
auth.as.dcr.unusedDays number 90 A dynamically registered client unused for this many days is deleted (default 90).
auth.as.dynamicRegistration boolean true Default true. Serves RFC 7591 Dynamic Client Registration at /oauth/register and advertises registration_endpoint, 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 instead. The unauthenticated client-creation surface is bounded by dcr (a per-source budget, a client cap, deletion of unused registrations). Set false to turn it off: /oauth/register is then a 404 and registration_endpoint is not advertised. One info line is logged at startup while it is on, and securityProfile: "hardened" forces it off even when this is set to true.
auth.as.enabled boolean false Opt-in. Serves RFC 8414 authorization-server metadata and generates the server's own access-token signing key at boot. Requires auth.mode: jwt, auth.resource and issuer; refused under none and oidc. With false (the default) nothing about the bearer paths changes.
auth.as.issuer string โ€” The authorization server's issuer identifier, an origin such as https://vault.example.com. Required when enabled. Derived from here and never from the request's Host header; the same string appears byte for byte in the metadata, in Protected Resource Metadata and in every token's iss.
auth.as.login.maxFailuresPerWindow number 5 Wrong passwords tolerated per account within the window (default 5).
auth.as.login.windowSeconds number 900 Length of the failure window in seconds (default 900).
auth.as.refreshReuseGraceSeconds number 30 Seconds (0 to 60, default 30) after a refresh token's successor is first used during which presenting the token again, from the same client, is answered with the same successor and access token instead of revoking the family. It absorbs clients that refresh from several windows or processes sharing one token. Only the one token the family just left qualifies; an older token or a presentation after the window is a reuse (the family is revoked), and another client's presentation is refused without revoking. 0 disables the grace: a token is accepted again only until its successor is used.
auth.as.refreshTokenDays number 30 Absolute lifetime of a refresh-token family in days (1 to 90, default 30), counted from the code exchange that started the family; rotation never extends it. Refresh tokens rotate on every use.
auth.as.setupTokenEnv string "OBSIDIAN_TC_AS_SETUP_TOKEN" Name of the environment variable whose value is the one-time setup token that claims the operator account on a host with no terminal (default OBSIDIAN_TC_AS_SETUP_TOKEN). The token is never read from the config file.
auth.as.signingAlg enum(ES256|EdDSA) "ES256" Algorithm of the key that signs access tokens, ES256 or EdDSA. The key is generated at first boot with this enabled and kept in the auth registry under its own purpose, so rotating it never retires a hand-minted token's key. When auth.algorithms is set it must include this value.
auth.audience union โ€” Expected aud claim. Binding it rejects a token an issuer minted for a DIFFERENT service (confused deputy). Required with a JWKS or a non-loopback bind; defaults to resource when Protected Resource Metadata is configured.
auth.authorizationServers array<string> โ€” Authorization server issuer URLs advertised in the Protected Resource Metadata document. At least one is needed for PRM to be served.
auth.issuer string โ€” Expected iss claim. Setting it also requires an audience โ€” validating the issuer alone does not establish that the token was meant for this server.
auth.jwks record โ€” Inline JWKS document for asymmetric verification (RS256/ES256/EdDSA). Rotation is multiple keys in the set, selected by the token's kid.
auth.jwksFile string โ€” Path to a JWKS document, loaded once at transport boot. Adds no network dependency; prefer it over jwksUri when the keys are static.
auth.jwksUri string โ€” URL of an authorization server's JWKS (its jwks_uri), fetched and cached for asymmetric verification. Opt-in: it adds a network dependency to token verification, which jwks/jwksFile do not. Use it when an external AS rotates keys. The host is resolved once and the connection is pinned to the validated address (SNI and certificate on the hostname); redirects are refused and the body is capped. Default: https:// on a public host. A loopback host needs no entry; a host listed in network.plainHttpHosts may be http:// or on a private/tailnet address (the provider rules); link-local and cloud metadata addresses are refused even when listed. An unlisted host that resolves only to private addresses still works for one release with a deprecation (startup, doctor, server_health).
auth.jwtSecret string โ€” Shared secret for HS256 verification, minimum 32 characters. Secret. HS256 tokens verify ONLY against this (or an HS256 key in the auth registry), never against the JWKS. It is the initial signing key (kid config); once auth rotate-key has retired that key it is no longer used to verify or mint, and can be removed (obsidian-tc doctor says when).
auth.mode enum(none|jwt|oidc) "none" Authentication mode. none grants every request full wildcard scopes and is refused on a non-loopback HTTP bind; jwt needs a signing key: a jwtSecret, a JWKS, or a key in the auth registry (obsidian-tc auth rotate-key). A jwt server with none of them refuses to start. oidc verifies access tokens from an external OpenID Connect provider configured in auth.oidc; discovery runs at boot and a failure refuses to start.
auth.oidc.allowedAlgs array<enum> ["RS256","ES256","EdDSA"] Signature algorithms accepted, asymmetric only: HS256/384/512 and none are not valid values. Taken from this list, never from the token header alone.
auth.oidc.allowedJwksHosts array<string> โ€” Optional. Hostnames a DISCOVERED jwks_uri may name besides the issuer's own origin. Without it the discovered jwks_uri must be on the same origin as issuer (a discovery document cannot redirect key material to another host). Needed for IdPs that serve keys from another host (Google: www.googleapis.com). A listed host admits the default https port (443) only: a jwks_uri on another port is refused (set jwksUri for that). Ignored when jwksUri is set. It never lifts the private-network block.
auth.oidc.allowPrivateNetwork boolean false Default false: the issuer and the JWKS host are resolved before every fetch and refused when any address is loopback, link-local (including the cloud metadata address), private (RFC 1918, unique-local) or otherwise non-public. Set true only for a self-hosted identity provider on a LAN or loopback.
auth.oidc.audience union โ€” yes Required. The aud this server accepts: a token must name it (or one entry of a list). Use a dedicated API audience registered at the IdP, not a client id, so an ID token or another service's token is not accepted here.
auth.oidc.claimMapping.allowedPersonas array<string> โ€” Persona names the persona claim may name. Required when persona is set; a token naming any other persona (or a non-string) is refused, not ignored.
auth.oidc.claimMapping.allowedVaults array<string> โ€” Vault ids the vault claim may name. Required when vault is set; a token naming any other vault (or a non-string) is refused, not ignored.
auth.oidc.claimMapping.persona union โ€” Optional claim naming a configured persona, as the persona claim does in jwt mode (the persona's scopes replace the token's). Requires allowedPersonas. Unset: no persona claim is read.
auth.oidc.claimMapping.principal union โ€” Optional claim used as the caller label in audit and logs instead of the subject (for example email). It does not affect authentication.
auth.oidc.claimMapping.scopeMap record โ€” Optional. Maps a value of the scopes claim (an IdP role or group name) to the obsidian-tc scopes it grants, e.g. { vault_admin: ["admin:auth"], reader: ["read:notes", "read:search"] }. A mapped value grants exactly its list; an unmapped, non-qualified value grants nothing.
auth.oidc.claimMapping.scopes union "scope" Claim holding the granted scopes: a space-delimited string (scope, scp) or an array of strings (permissions, realm_access.roles). Only this claim grants scopes, and only FULLY-QUALIFIED values (read:notes, write:*) are taken from it: a bare value such as the role admin is dropped (and logged once) unless scopeMap maps it, because obsidian-tc reads a bare family name as a wildcard over every resource.
auth.oidc.claimMapping.subject union "sub" Claim naming the caller. Required on every token.
auth.oidc.claimMapping.vault union โ€” Optional claim binding the caller to one vault, as the vault claim does in jwt mode. Requires allowedVaults: a value outside it refuses the token. Unset: no vault claim is read.
auth.oidc.clientId string โ€” Optional. When set, the token's client_id (RFC 9068) or azp claim must equal it, restricting access to tokens minted for that one application. Unset accepts any client of the IdP that obtains a token for audience.
auth.oidc.clockToleranceSeconds number 30 Clock skew tolerated when checking exp, nbf and iat, in seconds. Default 30, maximum 300.
auth.oidc.discoveryCacheSeconds number 3600 How long the discovery document is trusted before it is fetched again, in seconds (60 to 86400, default 3600). A refresh that fails or returns a different issuer refuses tokens rather than serving a stale document.
auth.oidc.issuer string โ€” yes The identity provider's issuer identifier, an https URL. Compared EXACTLY (no normalisation, a trailing slash matters) against the iss claim of every token and the issuer member of the discovery document fetched from <issuer>/.well-known/openid-configuration.
auth.oidc.jwksUri string โ€” Optional override for the key set location. Discovery still runs (it validates the issuer); this replaces only the discovered jwks_uri. https only.
auth.oidc.requireAtJwtType boolean false Require the JOSE typ header to be at+jwt (RFC 9068), rejecting JWT and a missing typ. Default false, because several IdPs emit pre-RFC-9068 access tokens (Entra and Keycloak send JWT); with it off, at+jwt, JWT, Bearer and an absent typ are accepted and every other header typ (for example id_token+jwt) is refused. Independently of this flag, a token that names itself a non-access token is refused: a payload typ other than Bearer/Access (Keycloak ID and Refresh tokens), a nonce, at_hash or c_hash claim (ID-token markers), or a token_use other than access (Cognito). Turn the flag on when your IdP issues RFC 9068 tokens.
auth.oidc.requiredClaims union โ€” Extra claims a token must carry. An array of claim names requires a truthy, non-empty value (null, false, 0, "", [] and {} fail, so email_verified: false does not satisfy ["email_verified"]). An object maps a claim to the exact value it must have ({ email_verified: true, hd: "example.com" }; an array claim satisfies it when it contains the value). exp, iat, iss, aud and the subject claim are always required.
auth.requireJti boolean false Reject any bearer token that carries no jti claim, on every verify path (HS256, JWKS, /metrics). A jti-less token cannot be revoked individually โ€” only rotating its signing key kills it. Default false for compatibility with tokens minted before the registry, but securityProfile: "hardened" sets it true (an explicit value still wins); the default itself is planned to flip to true at the next major release. obsidian-tc doctor recommends true once auth rotate-key or token mint has initialised the registry (token mint always sets a jti).
auth.resource string โ€” This server's canonical resource URI (RFC 9728). Set together with authorizationServers to advertise Protected Resource Metadata; also serves as the default bound audience.
auth.resourceName string โ€” Human-readable resource name published in the Protected Resource Metadata document.
auth.rotationGraceSeconds number 0 Seconds the previous signing key keeps verifying after obsidian-tc auth rotate-key, used when --grace is not passed (an explicit --grace always wins, including --grace 0). 0, the default, retires the previous key immediately. Maximum 604800 (7 days). A retiring key stops verifying at its retire_after whether or not anything has rewritten its state, so the window is exact; obsidian-tc doctor lists each retiring key with its time remaining.
auth.scopesSupported array<string> โ€” Scopes advertised as supported in the Protected Resource Metadata document.
auth.tokenTtlSeconds number 86400 Maximum accepted token AGE in seconds, measured from the token's iat. This caps age INDEPENDENTLY of exp: a token with a one-year expiry is still rejected once it is older than this, so a long-lived credential needs this raised to match.

bootstrap

Key Type Default Required Description
bootstrap.deepPaths array<string> [] Context notes loaded additionally in deep mode.
bootstrap.deepPhrases array<string> ["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"] Catch-up phrases that force deep mode regardless of the triage result.
bootstrap.domains array<object> โ€” Signal-to-path routing table. Empty means the tool degrades to lightweight with nothing to load.
bootstrap.domains[].name string โ€” yes Label for this routing domain.
bootstrap.domains[].paths array<string> โ€” yes Context notes loaded when this domain matches.
bootstrap.domains[].signals array<string> โ€” yes Lowercased substrings; the domain matches when any one appears in the opening message.
bootstrap.maxPaths number 10 Ceiling on how many context notes one bootstrap may read.

cacheDir

Key Type Default Required Description
cacheDir string ".obsidian-tc" Directory holding the derived index and caches. cache.db, experiential.db and the other index files are regenerable โ€” deleting them forces a full reindex โ€” EXCEPT auth.db and auth-keys/, the auth registry: revocations, key retirements and signing-key files that CANNOT be regenerated and must be backed up, and server-secrets/, the per-server secret that seals generated wiki pages and keys the HITL confirmation codec (losing it regenerates the wiki pages and voids pending confirmations) (delete only cache.db*, never the whole directory, when resetting the index). This default only applies when embeddings.provider is not "local" โ€” a config file must set cacheDir explicitly when the embeddings provider is "local" (the default provider), or config load fails naming cacheDir; the bare obsidian-tc <vault> form (no config file) sets it for you.

db

Key Type Default Required Description
db.busyTimeoutMs number 5000 Milliseconds SQLite's busy handler retries a write before giving up, applied FIRST on every connection so it covers even the WAL-conversion window. Raising it is SYMPTOM TREATMENT, not a fix: a rising obsidian_tc_sql_lock_wait_seconds tail past this value is the direct evidence the writers genuinely overlap that long, and the fix is splitting the shared database per vault, not raising this further. Easy to miss: stdio MCP spawns ONE SERVER PROCESS PER CLIENT, and every process opens the SAME cache.db, so the concurrent client count is load-bearing for how much contention this has to absorb โ€” one operator measured server_health degraded (write_failures, last_write_error 'timed out') at 17 concurrent stdio processes on one cache.db, and ok again at 5 (GH #878).

egress

Key Type Default Required Description
egress.excludePaths array<string> [] Gitignore-style glob patterns (vault-relative), compiled with the same glob engine as auth.acl.readPaths, plus a NORMALISATION and a WIDENING that auth.acl.readPaths does not get, because a pattern here that matches nothing protects nothing and says so nowhere. Normalisation: a backslash is read as a separator (a vault path can never contain one, so Private\\** is Private/** rather than a pattern that silently matches nothing), a leading "/" or "./" is stripped and repeated separators collapse, so "/Private", "./Private", "Private" and "Private//" are one and the same root-anchored pattern. Widening: EVERY pattern also matches everything beneath it, so "Private", "Private/", "Private*/" and "Private/" each withhold that folder's whole subtree, not just its direct children; "**/Private" matches a Private folder at ANY depth (root included) and its subtree. Consequences worth stating, both in the safe direction (this is a security control, so an ambiguous pattern excludes too much, never too little): a folder-shaped pattern also matches a FILE of that exact name, so "Private/" or "Private/" additionally excludes a root note named "Private.md"; a literal pattern naming a specific file ("Private/a.md") still matches only that exact file, never a sibling like "Private/a.md.bak"; and matching stays case- and unicode-sensitive exactly as auth.acl.readPaths is. "**" is the EXCLUDE-ALL form: it withholds every note from every hosted provider, which is a supported fully-local deployment (the plane still runs; nothing vault-derived leaves the machine through it). One spelling is REFUSED at config load rather than compiled: a pattern that normalises to nothing ("", "/", "./", or whitespace only), which would sit in the config looking like a configured exclusion while protecting nothing. Enforced at the PORT, not merely by each caller: every GatewayClient this server constructs (gateway/client.ts's createGatewayClient) and every EmbeddingProvider it constructs (embeddings/index.ts's createEmbeddingProvider(Async)) refuses a request that does not declare which vault paths its text came from, or that names an excluded one โ€” so a call site that forgets to filter fails loudly instead of leaking, not only the ones that remember to check first. Covers every content-bearing egress leg in the tree: contradiction judging, synthesis, citation inference (both the scheduled job and the citation-infer CLI), index-time embedding (the batched reconcile AND the single-note write path โ€” write_note/append_note/patch_note and the filesystem watcher), reflect and knowledge_challenge's gateway calls, the note- and cluster-level summarizers (the obsidian-tc index/cluster CLI passes), densify-llm's semantic-edge extraction, the hosted /rerank passthrough, and the scheduled proactive-advisory sweep's candidate embedding. An excluded chunk is still chunked, stored, and text/regex-searchable, but gets no vector, so semantic_search will not surface it. This is the documented COST of exclusion: embeddings are themselves egress (a hosted embedding provider can, in principle, receive text it never sees in plaintext form), so an excluded folder is withheld from embedding too, not only from generative jobs. Best-effort, like every comparable exclusion file in this ecosystem (.gitignore, .cursorignore, .aiignore): a caller with direct filesystem or MCP-tool read access to the vault is unaffected โ€” this is an EGRESS control, not a read-visibility control (see auth.acl.readPaths for that). NOT covered: lexical/regex search over an excluded chunk stays local and searchable by design (the point is withholding it from a model, not hiding it from the vault owner). Paths are re-evaluated on every pass, not baked in once: renaming a folder into or out of the exclusion re-includes or re-excludes it on the very next consolidation pass, reconcile, or write, never retroactively.

elicitTtlSeconds

Key Type Default Required Description
elicitTtlSeconds number 300 Seconds a pending elicitation (human-in-the-loop prompt) stays valid before it expires.

embeddings

Key Type Default Required Description
embeddings.apiKey string โ€” Provider API key. Secret โ€” never logged or returned by a tool.
embeddings.apiKeyEnv string โ€” Name of the environment variable holding the provider API key. Needed for generic providers, which have no entry in the built-in per-vendor variable map. An inline apiKey takes precedence.
embeddings.baseUrl string โ€” Provider base URL. Required for self-hosted runners; hosted providers default to their public API.
embeddings.batchSize number 512 Maximum inputs per embed request.
embeddings.chunkContext boolean true Embed and BM25-index each chunk as "{title}{ โ€” heading breadcrumb}\n\n{content}" rather than bare section text, so title- and heading-only evidence is visible to both retrieval streams. Displayed content stays raw. The chunk hash covers the enriched text, so changing this re-embeds the vault on the next reconcile.
embeddings.concurrency number 4 How many embed requests run in flight at once.
embeddings.dimensions number 768 Stored vector width, and the width of the vec0 column. Changing it requires a fresh index โ€” existing vectors are not re-projected. Defaults to 768, nomic-embed-text-v1.5's native width (the "local" provider's default model) โ€” set this explicitly to match whatever model you configure (e.g. 384 for the smaller local catalog entries).
embeddings.documentPrefix string "" Instruct prefix prepended to document-side (indexing) embeds. Empty by default. Changing it re-embeds nothing on its own, since hashes cover chunk text and not the prefix โ€” pair a change with a fresh cacheDir.
embeddings.maxBatchTokens number 2048 Estimated-token ceiling per request (chars/4), splitting a dense sub-batch before it overruns a local runner's budget. Must stay UNDER the provider's loaded context: Ollama defaults to n_ctx 4096 and rejects an over-budget request, and the chars/4 estimate undercounts real tokenization on link-dense markdown.
embeddings.model string "nomic-embed-text-v1.5" Embedding model name as the provider names it. Defaults to nomic-embed-text-v1.5, the "local" provider's default catalog entry โ€” set this explicitly for every other provider. Under provider "local" this MUST be one of embedder-local's pinned catalog names (all-MiniLM-L6-v2, bge-small-en-v1.5, nomic-embed-text-v1.5) โ€” an unrecognized name is refused, since the local provider only ever loads checksum-verified weights it has a pinned manifest for.
embeddings.modelTier.dense.baseUrl string โ€” yes Base URL of the dense (Qwen3 via Rust TEI) embedding service.
embeddings.modelTier.dense.model string "Qwen/Qwen3-Embedding-0.6B" Dense model id. Its width is what embeddings.dimensions must match.
embeddings.modelTier.dense.pooling string "last-token" Pooling strategy for the dense model.
embeddings.modelTier.dense.revision string โ€” Pinned model revision for the dense service. PROVENANCE ONLY: it moves neither provider.id nor vec_index_fingerprint, so changing it does not rebuild the index. Use the top-level embeddings.revision to force a re-embed โ€” it applies to model-tier too.
embeddings.modelTier.full.authToken string โ€” Bearer token for the multi-vector service. Secret.
embeddings.modelTier.full.baseUrl string โ€” yes Base URL of the multi-vector (BGE-M3) service.
embeddings.modelTier.full.dimensions number 1024 Dense width of the multi-vector model, separate from embeddings.dimensions.
embeddings.modelTier.full.model string "BAAI/bge-m3" Multi-vector model id.
embeddings.modelTier.full.revision string โ€” Pinned model revision for the multi-vector service. PROVENANCE ONLY: it moves neither provider.id nor vec_index_fingerprint, so changing it does not rebuild the index. Use the top-level embeddings.revision to force a re-embed โ€” it applies to model-tier too.
embeddings.modulePath string โ€” Module exporting createEmbeddingProvider, for provider 'module'. Resolved against the config file's directory. Refused under the hardened security profile, and refused on CLI/eval entry points (module providers load only from the server's boot wiring). The factory may be sync or async (an async factory is awaited). It must return an object with a non-empty string id, provider, and model โ€” id is what chunk_embeddings.model and the vec fingerprint identify the provider by, so two module providers sharing (or omitting) id are indistinguishable to the index โ€” a positive integer dimensions, and embed(texts). Validated at load time, before first use.
embeddings.onProviderChange enum(keep|switch) "keep" When embeddings.provider is NOT set and this vault's cache already holds active embeddings from a different provider than the current default, "keep" (default) continues using that existing provider/model instead of silently switching and forcing a full re-embed (GH #995). "switch" adopts the current default ("local") outright, same as a fresh install. An explicit embeddings.provider always wins over this key โ€” setting embeddings.provider: "local" explicitly is itself the opt-in to switch.
embeddings.pooling string โ€” Pooling strategy the backend applies (e.g. 'mean', 'last-token'). Folded into the representation identity persisted as vec_index_fingerprint, so changing it rebuilds the vector index rather than serving vectors pooled a different way against queries pooled the new way. The rebuild reuses the stored embeddings โ€” it does not re-embed, and costs no provider calls.
embeddings.provider string "local" Embeddings backend name, resolved against the provider registry at startup. Built-ins: local (a bundled, fully offline dense embedder via the optional @the-40-thieves/obsidian-tc-embedder-local package; no network, no Ollama, no API key), ollama, openai, voyage, cohere, bge-m3, model-tier (splits dense and multi-vector across two services), the generic openai-compatible, and the profile-gated module. local is the DEFAULT when this whole block is absent โ€” semantic search works with zero configuration. An unregistered name is a startup error listing every valid option.
embeddings.quantized boolean true provider "local" only. true (default) loads the pinned q8 (int8) quantized ONNX export; false loads the pinned fp32 export instead โ€” larger, slower, marginally more precise. Both are separately checksum-verified; toggling this re-downloads the other variant on first use if it is not already cached.
embeddings.queryPrefix string "" Instruct prefix prepended to query-side embeds, for models whose cards require one. Empty by default โ€” such prefixes measured harmful on this corpus.
embeddings.revision string โ€” Model revision / commit / checkpoint id. Folded into vec_index_fingerprint, so declaring it makes a checkpoint upgrade at the SAME model name and width rebuild the index instead of silently serving the old checkpoint's vectors against queries embedded by the new one. Omitting it reproduces today's behaviour exactly.
embeddings.threads number โ€” provider "local" only. onnxruntime-node intra-/inter-op thread count override. GH #995: absent does NOT leave this to the runtime's own default โ€” it caps intraOpNumThreads to a quarter of the host's available CPU cores (minimum 1), sets interOpNumThreads to 1, and disables intra-/inter-op spinning. An explicit value overrides that default outright for both intra- and inter-op.
embeddings.timeoutMs number 120000 Timeout in ms for a single embed request. Defaults high because local runners are far slower than hosted APIs.
embeddings.truncate boolean false Matryoshka (MRL) truncation: accept a provider vector WIDER than dimensions by keeping the first dimensions components and renormalising. Off by default so a non-MRL width mismatch errors instead of silently storing a meaningless prefix.

experiential

Key Type Default Required Description
experiential.activationDecay number 0.5 ACT-R decay exponent for the activation recompute. Weight falls as days ** -decay, so HIGHER means faster forgetting: ~0.3 for a reference vault where month-old notes still count, 0.5 (default, the ACT-R literature value), ~0.8 for a journal where last week is what matters.
experiential.activationRerank boolean false Apply the ACT-R cached-activation-score signal to graph search ranking: builds the lookup, threads it to every M7 graphSearch call, and enables the bounded bubble pass that composes it into the fused order (each item moves at most one position). Ships off; the A/B that would justify turning it on has not shipped.
experiential.allowFeedbackInReadOnly boolean false Exempt record_retrieval_feedback (only) from acl.readOnly and toolVisibility.requireReadOnly, because its writes are derived telemetry in experiential.db, never authored vault content. Default false. Needs logRetrievals: true as well โ€” otherwise there is nothing for it to update and the exemption is inert. The write:workspace scope requirement is unchanged.
experiential.captureContent boolean true Also persist each episode's raw parsed arguments, secret-scanned and size-capped, so work-memory carries what a call actually did rather than only that it happened. On under the trusted-local posture, paired with bounded retention (captureRetentionDays), a one-time boot notice, and a doctor check against vault-adjacent storage โ€” the mitigation set every accepted local-persistence precedent (VS Code/JetBrains Local History, Go local-mode telemetry) ships together. securityProfile: "hardened" turns it off, as does setting it false explicitly.
experiential.captureEpisodes boolean true Record every dispatch outcome as an agent_episodes row โ€” tool, status, duration, sizes, hashes, attribution. No payloads are stored.
experiential.captureRetentionDays number 30 Days a captured episode's raw args_json is kept before the maintenance sweep redacts it to NULL. The episode ROW is never deleted by this โ€” only the content axis ages out, matching agent_episodes' action-axis history staying complete. 0 disables the sweep (unlimited retention). Independent of maintenance.episodesRetentionDays, which governs row deletion for already-dead episodes only.
experiential.citationInfer.enabled boolean false Run the citation-inference pass on a schedule over a transcript index. Off by default: it needs transcriptIndex, an input no MCP surface can produce on its own.
experiential.citationInfer.intervalHours number 6 Hours between scheduled passes when enabled. Defaults to 6: the pass costs gateway judge calls, and a retrieval's citation status is not time-sensitive once stamped.
experiential.citationInfer.judge.allowPlainHttp boolean false DEPRECATED, removed at the next major release: use plainHttpHosts. Until then true means this judge.baseUrl's own host is listed in plainHttpHosts. It no longer waives anything else: the connect-time private-address check still applies, and a host that resolves to a public address is refused. obsidian-tc doctor and server_health warn while it is set.
experiential.citationInfer.judge.apiKey string โ€” TypeSafe API key. Secret โ€” never logged or returned by a tool. An inline apiKey wins over apiKeyEnv.
experiential.citationInfer.judge.apiKeyEnv string "TYPESAFE_API_KEY" Environment variable holding the TypeSafe API key, consulted when apiKey is not set inline.
experiential.citationInfer.judge.baseUrl string "https://api.typesafe.ai" TypeSafe API base URL. Must be https:// unless the host is loopback (a local test/dev endpoint) or its exact hostname is listed in plainHttpHosts โ€” this URL carries the bearer key and vault-derived text.
experiential.citationInfer.judge.model string โ€” Required when provider is "typesafe": a PINNED, versioned TypeSafe model id (e.g. "jev-1.13.0"). Rejected at config-load if it ends in "-latest" or "-preview" โ€” Noul thresholds are tuned per model version, and a floating alias would silently change the decision boundary under a fixed threshold.
experiential.citationInfer.judge.plainHttpHosts array<string> [] Exact hostnames a plain http:// judge.baseUrl may name (case-insensitive, IDNA/punycode-normalized; no wildcards, ports or paths). Without an entry a non-loopback http:// baseUrl is refused at config load; https:// and loopback need none. Listing a host is not enough on its own: when a request is sent the host is resolved once and refused unless EVERY address is loopback, RFC1918 (10/8, 172.16/12, 192.168/16) or IPv6 unique-local (fc00::/7). A LISTED host may also resolve to the Tailscale/CGNAT range 100.64.0.0/10 (list only a host that is a tailnet peer: Tailscale traffic is WireGuard-encrypted, ISP CGNAT space is not); an unlisted host in that range is refused. 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) are never allowed, listed or not, and the connection goes to that checked address with the original Host header, so a later DNS answer cannot redirect it. Redirects are refused (an https:// answer that redirects is refused too). Intended for a gateway on a host-local docker network or an encrypted overlay, e.g. ["litellm"] for the Cave LiteLLM pass-through at http://litellm:4000/typesafe. The bearer key and vault-derived text still travel in clear over that link.
experiential.citationInfer.judge.provider enum(gateway|typesafe) "gateway" Which service answers the citation stage-2 verdict. "gateway" (default) reuses the existing gateway judge role, unchanged. "typesafe" (EXPERIMENTAL) calls TypeSafe Jev's Noul question instead of the gateway โ€” a separate, opt-in judge provider for citation inference only.
experiential.citationInfer.judge.threshold number โ€” Required when provider is "typesafe": the Noul score (0..1) at or above which a chunk is judged cited. No default โ€” TypeSafe thresholds are tuned per model version and per deployment's tolerance for false positives, and TypeSafe's customer agreement bars publishing the benchmark numbers that would justify picking one here.
experiential.citationInfer.judge.timeoutMs number โ€” Per-attempt request timeout in ms for the TypeSafe client. Defaults to the client's own 60s.
experiential.citationInfer.transcriptIndex string โ€” Path to a JSONL transcript index โ€” one object per retrieval ({vault, surface_type, query, retrieved_at, transcript}), whose transcript is already filtered to text produced AFTER retrieved_at. Absent disables the scheduled pass entirely.
experiential.citationPreferences boolean false Fold chunk_retrievals citation verdicts (citation_state / cited_in_response) into the deterministic preferred.search_mode preference counter, alongside episode task_result evidence: a search-family tool whose retrievals are CONFIRMED cited strengthens the key, one whose retrievals are REJECTED weakens it. Off by default โ€” ranking-adjacent, needs a pre-registered eval before defaulting on. Needs citationInfer.enabled (or another citation_state producer) to have any effect.
experiential.derivedVerdictHold boolean false Let a DERIVED task verdict (-1, inferred from a closed session's tool-call log โ€” retries, a terminal error, no browse after search) hold an episode out of promotion the same way an OPERATOR work_result(-1) unconditionally does. Off by default: a derived -1 is still written and still feeds preferred.search_mode evidence, it just does not hold. The derivation itself always runs; this flag only governs whether its -1 verdicts gate retrieval. Dependency: the derivation acts only on sessions that exist and end (HTTP with sessions.autoOpen, or explicit start_session/end_session) โ€” on stdio with no session concept in play it is inert by design.
experiential.gapSweep.enabled boolean false Run the coverage-gap sweep on a schedule, persisting a gap_reports row the gap_report tool can read back. Off by default: each swept query costs an embedding call plus a search.
experiential.gapSweep.intervalHours number 168 Hours between sweeps when enabled. Defaults to weekly โ€” a coverage gap is a slow-moving property of the corpus, not something worth re-measuring hourly.
experiential.gapSweep.maxQueries number 50 Upper bound on queries per sweep. The sweep draws the most recent DISTINCT logged queries from chunk_retrievals, so this caps both gateway cost and how far back a single pass reaches.
experiential.logRetrievals boolean true Append serve-path retrieval events (chunk id, rank, score, query text, surface) to experiential.db. Local-only telemetry feeding activation recompute and usage stats; eval runs never log.
experiential.proactive.dismissalPenalty number 1 Budget removed per dismissal (a -1 stamped through record_retrieval_feedback on an advisory row). At 1, five dismissals exhaust a maxPerSession of five.
experiential.proactive.enabled boolean false Run the scheduled proactive-advisory sweep: score recent vault activity (changed notes, open contradictions, recent syntheses) against open goals and surface the top candidates to connected sessions. Off by default โ€” see the block-level comment for why this is a precision decision, not only a cost one.
experiential.proactive.maxPerSession number 5 Hard cap on advisories a single session may receive in total before dismissal decay reduces it further.
experiential.proactive.minScore number 0.6 Below this goal-similarity score, a candidate is not worth interrupting for. Precision over recall โ€” the ticket's phrase, not a paraphrase.
experiential.proactive.topK number 2 Hard cap on advisories surfaced per sweep, per session. The ticket: "Surface the top 1-2 items, never a digest."

gateway

Key Type Default Required Description
gateway.baseUrl string โ€” Inference gateway base URL. Takes precedence over the OBSIDIAN_TC_GATEWAY_URL environment variable when both are set โ€” set this when a host application rewrites its own env block on restart, which would otherwise silently drop the env var and degrade every generative seam with no error. Absent falls through to the env var, then to graceful degradation.
gateway.token string โ€” Bearer token (LiteLLM master/virtual key) for the gateway. Secret โ€” never logged, and takes precedence over the OBSIDIAN_TC_GATEWAY_TOKEN environment variable when both are set.

governor

Key Type Default Required Description
governor.maxResponseBytes number 1000000 Ceiling on a single tool or resource response in bytes, before it is refused (resources/read honors this too, not just tools).
governor.regexTimeoutMs number 2000 Worker-time budget in ms for one regex search. Only regex execution counts โ€” file I/O does not โ€” so a benign pattern over a large vault cannot false-positive the ReDoS guard.

idempotencyReclaimSeconds

Key Type Default Required Description
idempotencyReclaimSeconds number 60 Seconds after which a crashed in-flight idempotency row may be reclaimed at dispatch. Raise it for legitimately slow bulk tools: setting it below a live tool's runtime risks executing that tool twice.

idempotencyTtlSeconds

Key Type Default Required Description
idempotencyTtlSeconds number 86400 Seconds an idempotency record is retained, bounding how long a repeated request key is deduplicated.

indexing

Key Type Default Required Description
indexing.backgroundEmbed.idleMs number 2000 In "idle" mode, how many milliseconds the server must have had no dispatch activity (no tool call in flight, and none finished more recently than this) before the reconcile's next embed sub-batch is allowed to run.
indexing.backgroundEmbed.maxDeferMs number 30000 In "idle" mode, the ceiling on how long continuous dispatch activity may defer the reconcile's next embed sub-batch. Past this many milliseconds of continuous deferral, the next sub-batch is admitted as soon as no call is currently in flight (not waiting for a full quiet window); if no call ever clears, a second cap at 2x this value admits it unconditionally. Bounds indefinite starvation from ordinary polling traffic or a hung handler.
indexing.backgroundEmbed.mode enum(idle|immediate) "idle" "idle" pauses the boot/promotion/periodic reconcile's embed pass between sub-batches while a tool call is in flight or finished less than idleMs ago; "immediate" runs it at full speed with no pacing (the behavior before this key existed). Explicit index_vault calls and index-on-write are never paced, regardless of this setting.
indexing.chunkTokens number 512 Chunker token budget: a note section over this many estimated tokens is sub-split on paragraph boundaries. Participates in the representation fingerprint, and unlike the other axes a change here requires a full re-index โ€” different budget means different chunk boundaries, so stored vectors no longer describe any chunk that exists.
indexing.queueMax number 1000 Soft cap on distinct pending paths, surfaced as backpressure in server_health. Writes are never dropped when it is exceeded.
indexing.streamingWalk boolean false Walk the vault lazily per-directory (walkVaultStream) instead of materializing the full sorted file list before indexing starts. Lower peak memory on large vaults; index output is unchanged either way.
indexing.writeConcurrency number 8 Ceiling on concurrent index/embed calls across ALL vaults.
indexing.writeConcurrencyPerVault number 4 Ceiling on concurrent index/embed calls for a single vault.

maintenance

Key Type Default Required Description
maintenance.captureQueueRetentionDays number 30 Days a COMMITTED capture_queue row (committed_at IS NOT NULL) is retained before the maintenance sweep prunes it, measured from committed_at. A row still awaiting review (committed_at IS NULL) is never pruned by this sweep, at any age. An operator can also purge every committed row for one vault immediately (independent of age, matching that tool's other include flags) via reset_vault_cache's include.capture_committed. commit_capture's default (delete_from_queue: true) removes the row at commit time, before this window is ever reached; a caller that opts to KEEP a committed row (delete_from_queue: false) โ€” the only shape this sweep can still find and prune โ€” should keep this value above the longest highlight-import/ambient-import re-sync window (listCaptureTags reads a committed row's import-dedupe:/ambient-dedupe: tag as its dedup identity), or a purged row can be re-imported as a duplicate (see the highlight-import/ambient-import dedup tags noted on commit_capture).
maintenance.enabled boolean true Run the periodic cache.db maintenance sweep (expired idempotency and elicitation rows, event_log retention, PRAGMA optimize).
maintenance.episodesRetentionDays number 90 Days a DEAD agent_episodes row (a forget tombstone, or one whose valid_until has passed) is retained before the maintenance sweep prunes it. Live episodes are never pruned at any age. Keep this long enough to still answer 'was this forgotten?' after the fact.
maintenance.intervalMinutes number 60 Minutes between maintenance sweeps.
maintenance.jobsCompleteRetentionDays number 7 Days a COMPLETE job row is retained before the maintenance sweep prunes it. Must stay LONGER than the longest producer dedup window: enqueue() dedups against a terminal row unless replaceIfTerminal is set, so pruning one frees its idempotency key and lets that period run again (the weekly synthesis is the longest today).
maintenance.jobsFailedRetentionDays number 30 Days a FAILED (dead-lettered) job row is retained. Longer than the complete-row window because these exist to be read; bounded by age, so a burst of failures inside the window is still unbounded in count.
maintenance.memoryOrphans.batchSize number 500 Maximum rows deleted per write transaction, so a large backlog is drained in short lock holds rather than one long one. A class stops after 100 batches per run and resumes on the next run.
maintenance.memoryOrphans.dryRun boolean false Count what each class WOULD delete, log it, and delete nothing. The metric counter is not incremented for a dry run.
maintenance.memoryOrphans.enabled boolean true Run the memory orphan sweep. With only this on, it removes DANGLING rows: a memory_relations row whose source or target entity no longer exists, and a memory_observation_intervals row whose entity no longer exists. Both are unreachable by every reader. It never deletes an entity that merely has no relations. Set false to turn the whole sweep off, including the opt-in classes below. Also requires maintenance.enabled.
maintenance.memoryOrphans.intervalMs number 86400000 Milliseconds between memory orphan sweeps (default: daily).
maintenance.memoryOrphans.removedVaultRetentionDays number โ€” OPT-IN, off when absent. Days since an entity was last updated before it is deleted, together with its relations and intervals, when its vault_id is not in the live vault registry (the configured vaults plus any added with add_vault). Off by default because a vault missing from the registry can also be a config typo or an unmounted drive; never runs when the registry is empty. Keep it long.
maintenance.memoryOrphans.retiredRetentionDays number โ€” OPT-IN, off when absent. Days after retirement (measured from updated_at) before a status=retired entity is deleted, and only when it is empty: blank observations, no interval rows, no relation in either direction, and no materialized note (vault_path unset). A retired entity that still holds any fact, edge or note is never deleted.
maintenance.reconcileIntervalMinutes number โ€” Minutes between periodic full vault reconciles. ABSENT (the default) disables it: the boot reconcile and the filesystem watch already cover a healthy server. Set it when the watch cannot run โ€” a network mount with no change notification, or a vault large enough to exhaust the inotify limit โ€” and when derived graph edges should be refreshed, since single-note writes never densify. Costs a full vault walk per run; content-hash skip makes an unchanged vault cheap to re-walk but densification still runs.
maintenance.retrievalsRetentionDays number 365 Days a chunk_retrievals row is retained. NOT purely disk hygiene: chunk_access_stats is a VIEW over this table, so pruning REWRITES access_count / last_accessed_at / citations / observed, which feed activation and note quality. A short window makes a long-tail note that was genuinely useful look never-accessed. The default is deliberately a year, far above the other retention windows, so no signal any current consumer reads is affected.
maintenance.wikiLint.enabled boolean false Run the lint_wiki checks on a schedule and log a one-line summary per vault (proposal counts by kind). Read-only: it never edits a note and never blocks a write; call lint_wiki for the proposals themselves. Off by default. Also requires maintenance.enabled.
maintenance.wikiLint.folder string โ€” Lint only this vault-relative folder (applied to every vault). Absent lints the whole vault.
maintenance.wikiLint.intervalHours number 24 Hours between scheduled lint passes.
maintenance.wikiLint.judge boolean true Have the scheduled pass ask the wikiJudge LLM about each near-duplicate pair and log the verdict counts. On by default, but it only runs when a judge is configured (a gateway, or wikiJudge.provider typesafe) and wikiJudge.maxCallsPerDay is above 0; it spends judge calls, bounded by judgeMaxCalls and that daily cap. Set false to turn it off.
maintenance.wikiLint.judgeMaxCalls number 20 Per-run cap on judge calls made by the scheduled pass (cached verdicts are free and do not count). Pairs beyond it stay unjudged until a later run.
maintenance.wikiLint.maxNotes number 1500 Cap on notes compared pairwise by the near-duplicate check; its cost grows with the square of this.
maintenance.wikiPages.enabled boolean false Regenerate every wiki folder's generated index.md (the pages grouped by their SCHEMA.md type) and log.md (an append-only projection of the write provenance chain) on a schedule, as commit_wiki_page does after each write. Writes inside vaults[].wiki.folder only, never over a hand-edited or foreign index.md/log.md (lint_wiki reports those), never in a read-only vault, and lists only paths readable without any rule-scope. Off by default. Also requires maintenance.enabled.
maintenance.wikiPages.intervalHours number 6 Hours between scheduled regeneration passes.

network

Key Type Default Required Description
network.plainHttpHosts array<string> [] Exact hostnames a plain http:// provider URL may name: gateway.baseUrl / OBSIDIAN_TC_GATEWAY_URL, embeddings.baseUrl, embeddings.modelTier.*.baseUrl, reranker.baseUrl, plur.endpoint, a vault's restApiUrl, and JWT mode's auth.jwksUri. Case-insensitive, IDNA/punycode-normalized; no wildcards, ports or paths. One list for all of them (the two TypeSafe judge blocks keep their own plainHttpHosts, which config load enforces). https:// and loopback need no entry. A listed host is still refused when a request is sent unless EVERY address it resolves to is loopback, RFC1918 (10/8, 172.16/12, 192.168/16) or IPv6 unique-local (fc00::/7): a public address, link-local, 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) are never allowed, listed or not. Requests go straight to that checked address (never through HTTP_PROXY / ALL_PROXY) with the original Host header, and redirects are refused (an https:// answer that redirects is refused too, so a 307/308 can never replay the key and vault text elsewhere). DEPRECATED, removed at the next major release: until then a host that is NOT listed but resolves only to private addresses still works, with a warning that names it (obsidian-tc doctor and server_health report it); list it here to silence the warning. Intended for a gateway on a host-local docker network or an encrypted overlay, e.g. ["litellm"] for http://litellm:4000. The bearer key and vault text still travel in clear over that link. A LISTED host may also resolve to the Tailscale/CGNAT range 100.64.0.0/10 (list only a host that is a tailnet peer: Tailscale traffic is WireGuard-encrypted, ISP CGNAT space is not); an unlisted host in that range is refused, with no deprecation path.

observability

Key Type Default Required Description
observability.morgiana.httpEndpoint string โ€” Push CloudEvents to this URL. Absent means spool-only, with no network calls.
observability.morgiana.httpHeaders record {} Extra headers sent with event pushes. Values may be secret.
observability.morgiana.spool boolean true Write CloudEvents to a local JSONL spool file.
observability.otel.detail enum(root|children|verbose) "root" Span detail per tool call. "root": one span per request (default, no extra cost). "children": adds a span per pipeline stage (input_parse, auth_check, policy_eval, idempotency, rate_limit, hitl_check, acl_eval, tool_impl, output_serialize). "verbose": also one span per batch item and per SQLite transaction, capped at 64 child spans per request. Spans carry structured error codes only, never note content, paths or secrets.
observability.otel.endpoint string โ€” OTLP collector endpoint. OpenTelemetry export is a no-op until this is set.
observability.otel.headers record {} Extra headers sent with OTLP exports, e.g. an auth token. Values may be secret.
observability.prometheus.bind string "127.0.0.1" Bind address for the scrape endpoint. Loopback by default. /metrics is open only under auth.mode none on a loopback bind; under jwt or oidc it needs an admin:metrics bearer on every bind.
observability.prometheus.enabled boolean false Serve the Prometheus /metrics endpoint.
observability.prometheus.port number 9464 Port for the Prometheus scrape endpoint.
observability.retention.eventLogDays number 30 Days of event_log rows kept by the maintenance sweep. The morgiana event spool has its own bounds, spoolRetentionDays and spoolMaxBytes.
observability.retention.spoolMaxBytes number โ€” Optional size bound on the morgiana spool, in bytes, applied per vault directory: when a vault's spool files total more than this, the oldest day files are deleted until it fits. The current day's file is never deleted, so a single day larger than the bound is kept. Absent means no size bound.
observability.retention.spoolRetentionDays number 30 Days of morgiana CloudEvents spool files (//morgiana-events-.jsonl) kept by the maintenance sweep. Whole day files older than this are deleted; the file for the current UTC day, and any file modified in the last hour, is never touched. 0 keeps the spool forever (no age bound). Make sure MORGIANA has consumed a file before its day leaves this window.
observability.retention.tracesDays number 30 Days of workspace session trace files (//*.jsonl) kept by the maintenance sweep. Traces are per-vault and live INSIDE the vault, so they are also picked up by whatever syncs or backs it up. Orphans from a failed start_session are pruned by the same age rule (the trace is written before the session row, so a failed attempt leaves a file with no row referencing it).

pensieve

Key Type Default Required Description
pensieve.baseUrl string โ€” Base URL of a Pensieve instance (e.g. http://100.x.x.x:8839, its default port), queried at /api/search. Pensieve's API has no auth โ€” access control is expected at the network layer (tailscale). Absent means import-ambient no-ops with NO network call.

personas

Key Type Default Required Description
personas record โ€” Named persona bundles ({vaults, scopes, toolVisibility?}) a JWT's persona claim resolves to. Absent means no personas are configured โ€” any token carrying a persona claim is refused.

plane

Key Type Default Required Description
plane.enabled boolean false Run ambient sleep-time consolidation (synthesis and audit jobs). Only meaningful when the inference gateway roles are configured. Opt-in: a deployment with a gateway configured and this key unset gets a boot-time notice explaining how to turn it on.
plane.gatewayMaxAttempts number 6 Attempts a consolidation job's gateway call may make before failing, each with its own fresh timeout. Higher than the interactive default (3) because the models behind the gateway roles may be serverless and scale to zero: a cold start measured at over 180s exceeded 3 attempts x 60s, so every scheduled pass failed with a timeout while the same request against a warm endpoint took 4.8s. Separate from the interactive path on purpose: a multi-minute budget suits a background weekly pass and not a user-facing call. NOTE: attempts alone were not sufficient โ€” retries only help a TRANSIENT failure, and a request that deterministically exceeds the per-attempt timeout fails identically on every attempt. See planeGatewayTimeoutMs, which is the knob for that case.
plane.gatewayTimeoutMs number 300000 Per-ATTEMPT request timeout in ms for a consolidation job's gateway call, separate from the interactive 60s default. Raising ATTEMPTS (gatewayMaxAttempts) cannot rescue a call that is simply slow: a synthesis pass measured 370.4s and 370.4s on two runs 45 minutes apart โ€” 6 attempts x 60s plus backoff, identical to within 12ms, which is a client budget expiring rather than a varying cold start. The endpoint was warm throughout (a small completion through the same gateway answered in 360ms), so nothing was being waited out; one attempt simply could not finish inside 60s. A synthesis-sized request measured 28.9s for 9,422 prompt tokens and only 473 completion tokens, and generation dominates, so a pass emitting a few thousand tokens crosses 60s comfortably. The default is generous because this workload is latency-tolerant by construction โ€” a weekly consolidation pass has no user waiting on it โ€” while the interactive seam keeps 60s, where a multi-minute stall would be a hang. Lower it only if a stuck job holding a worker for minutes matters more than the pass completing.
plane.intervalMinutes number 240 Minutes between consolidation passes.
plane.maxPromptChars number 45535 Aggregate character cap on a consolidation job's WHOLE gateway request (system prompt + user message). Not a per-item cap: the synthesis job already truncated each chunk to 1000 chars and still built a 169,258-char prompt from 200 of them, which the serving window rejected as ContextWindowExceeded. Sized in characters, not tokens, because no tokenizer is available on this side. The default is conservative on purpose โ€” the model behind a gateway role is swappable at the gateway, and the server does not advertise its max_model_len through the LiteLLM /v1/models passthrough, so this side cannot discover the real ceiling. Sized so every vault shape keeps the SAME ~14,554-token output reserve inside a 32768-token window, not just prose-like ones โ€” derived from the worst-case density this job documents (code/CJK, ~2.5 chars/token): (32768 - 14554) x 2.5 = 45535 chars. A prose-dense vault (measured on a real vault at 3.294 chars/token) gets MORE reserve than this, never less. Raise it when the role points at a larger serving window.

plur

Key Type Default Required Description
plur.apiKey string โ€” Bearer token for the plur read API. Secret โ€” placed only in the Authorization header, never logged or included in an error or audit payload.
plur.apiPrefix string "" Path prefix prepended to plur API routes.
plur.command array<string> โ€” argv prefix for shelling out to a local plur CLI instead of the HTTP endpoint, e.g. ["plur"]. Takes precedence over endpoint.
plur.endpoint string โ€” Base URL of the plur read API. When absent (and no command is set) the plur tools degrade to plugin_missing with NO network call.
plur.timeoutMs number 5000 Timeout in ms for a plur read call.

provenance

Key Type Default Required Description
provenance.enabled boolean true Record one provenance row per committed mutating tool call: tool, vault, the paths it named with sha256 digests before and after (never content), timestamp, principal, session, client and claimed model/project/agent/machine, each field tagged verified or self_reported. Rows are hash-chained per vault and signed with the auth registry's EdDSA key when one is active. Verify with obsidian-tc provenance verify.
provenance.host.label string โ€” The host id recorded when mode is "label". Required in that mode.
provenance.host.mode enum(hashed|label) "hashed" "hashed" (default) records a stable digest of this machine's hostname, so rows correlate without naming the host. "label" records label verbatim.
provenance.query.maxScanRows number 100000 Most provenance rows one get_provenance call examines. A query is one newest-first pass over the vault's chain (the signed body is the only record of a path, so there is no index to seek by); each row examined costs one substring test over its ~0.5 KB body, so the default 100000 is a few hundred milliseconds at worst. A chain longer than this is read newest-first and the answer carries scan_truncated: older history was not examined. Raise it for a long-retention vault; obsidian-tc provenance verify is not bounded by it.
provenance.retentionDays number โ€” Days a provenance row is kept before the maintenance sweep prunes it. ABSENT (the default) keeps rows forever: this is an audit trail, and pruning it is an explicit decision. Pruning removes a contiguous prefix and moves a signed anchor up to the last row dropped, so the remaining chain still verifies.
provenance.stamp.frontmatter boolean false Write a compact provenance object under frontmatterKey into notes an agent CREATES (write_note creating, commit_capture, execute_template). Never touches an existing note, and never any other frontmatter key. The object holds session, principal (verified, else unverified), model_self_reported and seq; never the host id. Needs provenance.enabled.
provenance.stamp.frontmatterKey string "obsidian_tc_provenance" The frontmatter key the stamp is written under when frontmatter is on. A caller-supplied value under this key in a note being created is replaced by the real stamp.
provenance.stamp.gitTrailers boolean false Append Obsidian-TC-* trailers to a commit made through the git_commit tool, summarising the recorded writes whose current bytes are in that commit: session, principal (the verified principal, else unverified), model (always tagged (self-reported)) and the provenance seq range. A commit that includes no recorded write gets none. Any Obsidian-TC-* trailer the caller wrote itself is removed first, so a trailer in the log is always the server's. Needs provenance.enabled.

ranking

Key Type Default Required Description
ranking.metadataPrior.clampFraction number 0.5 Cap the absolute total boost per result at this fraction of the observed fused-score spread. Below 1 this guarantees the prior stays a tie-break: a fully boosted bottom result still cannot overtake the top base-scored one.
ranking.metadataPrior.enabled boolean false Apply the frontmatter authority-boost overlay after fusion.
ranking.metadataPrior.rules array<object> โ€” Field/value/boost rules summed for each result before the list is re-sorted.
ranking.metadataPrior.rules[].boost number โ€” yes Amount added to the fused score on a match. May be negative, which makes the rule an archive-style penalty.
ranking.metadataPrior.rules[].field string โ€” yes Frontmatter field name to test on a candidate note.
ranking.metadataPrior.rules[].value string โ€” yes Value that frontmatter[field] must equal for the boost to apply.

readwise

Key Type Default Required Description
readwise.token string โ€” Readwise access token (readwise.io/access_token), used as Authorization: Token <token> against the classic v2 export API. Secret โ€” never logged or placed in an error/audit payload. Absent means import-highlights no-ops with NO network call.

reranker

Key Type Default Required Description
reranker.apiKey string โ€” Provider API key. Secret โ€” never logged. Ignored โ€” and refused at boot if set โ€” by model-tier, which sources auth from embeddings.modelTier.full.authToken.
reranker.apiKeyEnv string โ€” Environment variable holding the API key. Inline apiKey wins. Ignored โ€” and refused at boot if set โ€” by model-tier (see apiKey).
reranker.baseUrl string โ€” Endpoint prefix preceding /rerank. Include the dialect version segment: Cohere rerank v2 replaced v1's max_chunks_per_doc with max_tokens_per_doc, and this prefix selects the dialect. Ignored โ€” and refused at boot if set โ€” by model-tier, which sources its endpoint from embeddings.modelTier.full.baseUrl.
reranker.localModelPath string โ€” Absolute path to the ROOT directory under which // lives (config.json, tokenizer.json, onnx/model_int8.onnx โ€” see @the-40-thieves/obsidian-tc-reranker-local's README) for provider 'local'. No longer required to be pre-populated โ€” fetched and sha256-verified automatically on the first rerank call if not already present. Defaults to that package's own models/ directory. bun run fetch-model still works, for offline/CI pre-population. Ignored โ€” and refused at boot if set โ€” by every other provider.
reranker.localModulePath string โ€” Explicit path to @the-40-thieves/obsidian-tc-reranker-local's BUILT module entry (its dist/index.js), for provider 'local'. Absolute, or resolved against the config file's directory (same convention as modulePath). Tried FIRST, before the bare package specifier and the source-checkout default โ€” see that package's README for when you need this. Ignored by every other provider.
reranker.model string โ€” Rerank model name as the provider names it. Required by cohere-compatible (refused at boot if absent). Ignored โ€” and refused at boot if set โ€” by model-tier, which sources its model from embeddings.modelTier.full.model. Optional for gateway: omitting it silently falls back to the model literal "rerank".
reranker.modulePath string โ€” Module exporting createReranker, for provider 'module'. Resolved against the config file's directory. Refused under the hardened security profile. The factory may be sync or async (an async factory is awaited). It must return a function: (query, documents, topN) => Promise<RerankHit[]>. Validated at load time, before first use.
reranker.passageFormat enum(chunk|title+chunk) โ€” What text each candidate is sent to the reranker as. "chunk" (the default when unset): the raw chunk text. "title+chunk": the note title (its file name without the .md extension), a blank line, then the chunk text, so a title-identified note is rankable on its title. Applies to every reranker backend, to gated rerank and to the rrf_rerank / score_merge fusion modes; a cluster-summary candidate is always sent as-is. Experimental: on the repository's two English corpora a reranker fed raw chunks lost on every shape while title+chunk won or tied for most rerankers, but two shapes are too few for a default (ADR 0007), so the default stays "chunk".
reranker.provider string โ€” yes Reranker backend name, resolved against the provider registry at startup. Built-ins: cohere-compatible (any Cohere-format /rerank endpoint), model-tier (the BGE cross-encoder, configured via embeddings.modelTier.full), gateway (the inference gateway passthrough), local (a bundled, fully offline cross-encoder โ€” no gateway or Python service required; requires the optional @the-40-thieves/obsidian-tc-reranker-local package, and is also tried AUTOMATICALLY โ€” no need to set provider: 'local' explicitly โ€” when this whole block is left unset, model-tier is not configured, and no gateway URL is configured either), and the profile-gated module.
reranker.timeoutMs number โ€” Timeout in ms for a single rerank request. Ignored โ€” and refused at boot if set โ€” by model-tier, which uses embeddings.timeoutMs instead.

resources

Key Type Default Required Description
resources.boundVaultOnly boolean false When true, a trusted (stdio) caller's note resources stay confined to the server's default vault, as before: resources/read refuses any other vault and resources/list lists only the default vault's notes. When false (the default), resources/read and resources/list serve every registered vault the caller may read, each under that vault's own folder ACL, and a refusal in another vault answers as a missing note. A vault-bound caller (an HTTP token) is confined to its own vault either way.

retrieval

Key Type Default Required Description
retrieval.adaptiveRrf.enabled boolean false Enable the adaptive per-stream RRF weighting tilt. Off by default.
retrieval.adaptiveRrf.gain number 0.5 Strength of the tilt, clamped to [0,1] so stream weights stay within [0,2] โ€” an over-unity gain would drive a weight negative and invert its ranking rather than just reweight it.
retrieval.cache.enabled boolean false Cache query encodings and graph-search results in process, keyed by vault generation + caller ACL fingerprint + query + retrieval config. A latency optimisation only; off by default.
retrieval.cache.maxEntries number 64 Maximum live entries per cache (results and query encodings are counted separately). Results carry chunk content, so this is the memory bound.
retrieval.cache.ttlSeconds number 60 Entry lifetime. Bounds staleness from inputs the key cannot see: wall-clock recency decay, and derived state (densified edges, activation scores) written by jobs that do not bump the vault generation.
retrieval.classRouter boolean false Enable the deterministic query-class router: a temporal auto-stream and a lexical short-circuit that skips the embedding round-trip. Ships dark pending an A/B on the golden set.
retrieval.colbert boolean false Rerank the fused top-K by bge-m3 ColBERT late-interaction maxSim. A no-op unless the provider emits the multi-vector heads.
retrieval.densify.confidenceFloor number 0.55 Minimum discrete-rubric confidence required to keep an LLM-inferred edge.
retrieval.densify.derivedWeight number 0.5 Down-weight factor applied to expansion reached via a derived edge. Annotates the score rather than gating the edge.
retrieval.densify.includeInWalk boolean false Let the graph walk traverse derived edges, down-weighted against authored links.
retrieval.densify.knnEdges boolean false Emit vec0 kNN semantic-neighbour edges (edge_type similar_to).
retrieval.densify.knnK number 8 Number of neighbours per note when knnEdges is enabled.
retrieval.densify.knnMinSim number โ€” Drop kNN edges below this cosine similarity. Unset means 0, which keeps every neighbour the kNN returns.
retrieval.densify.llmEdges boolean false Build LLM-inferred semantic edges (semantically_similar_to) via the configured gateway. Batch-only, and it sends note content to the model โ€” local by default.
retrieval.densify.maxTagFanout number 25 A tag applied to more notes than this is treated as a hub rather than a signal and emits no edges.
retrieval.densify.tagEdges boolean false Emit shared-frontmatter-tag co-occurrence edges (edge_type shared_tag).
retrieval.derivedDefaults boolean false Derive stat-conditional retrieval defaults (today: rrfK, from the vault's chunk count versus the seed-stream depth) from each vault's measured index statistics instead of one constant. An explicit rrfK always wins. Ships dark pending multi-shape evidence (ADR-0007); off is byte-identical to the shipped constants.
retrieval.gatedRerank boolean false Gate a cross-encoder rerank of the fused top-K onto hard queries only (weak top-1 seed, router silent). A no-op without a configured reranker (model-tier BGE or the gateway /rerank passthrough).
retrieval.gatedRerankHardness.hardTop1 number 0.55 Cosine mode: a query is hard when the top-1 seed cosine is below this.
retrieval.gatedRerankHardness.hardZ number 1 z-margin mode: a query is hard when the top-1 z-score over the seed-cosine pool is below this. 1.0 matches the eval harness's long-standing default.
retrieval.gatedRerankHardness.mode enum(cosine|zMargin) "cosine" Which hardness rule gates the rerank: absolute top-1 cosine, or the model-agnostic z-margin. Default cosine preserves the shipped behaviour; zMargin is a model-agnostic alternative that has never been the production default.
retrieval.gatedRerankHardness.pool number 20 How many fused candidates the reranker sees on a hard query.
retrieval.graphStream.enabled boolean false Enable the capped graph-expansion stream. Off by default: measured neutral on ranking quality (0 of 8 metrics significant at n=250) though non-inferior, so this is a cost lever rather than a quality one.
retrieval.graphStream.expansionSeeds number 8 Expand only from the top-N seeds by score.
retrieval.graphStream.hubDegreeCap number 40 Drop expansion candidates whose authored degree exceeds this, so index and dashboard pages cannot flood the fused ranking. Counts literal edges only โ€” counting derived edges would let densification inflate every degree and suppress the bridges it exists to surface.
retrieval.graphStream.perSeedCap number 3 Maximum expansion candidates any single seed may contribute.
retrieval.rrfK number โ€” Reciprocal-rank-fusion constant for graph_rrf. Keep BELOW the stream pool size (~30): a larger k lets overlapping low-rank noise outrank confident single-stream hits. Unset means 10, or the value derived from the vault's measured index stats when derivedDefaults is on; setting it always wins over both.
retrieval.searchAutoRoute enum(text-first|weak-text|hybrid) "text-first" How search_vault's auto mode (mode omitted or auto, string query) combines its literal text leg with its semantic leg. text-first (default, unchanged): run the text leg and fall back to the semantic leg only when text matched NO note, so any text hit, however irrelevant, blocks the fallback. weak-text: also run the semantic leg when the text leg matched exactly one note, and fuse the two by reciprocal rank. hybrid: always run both legs and fuse them by reciprocal rank over distinct notes (rrfK 10). A fused result reports mode_used hybrid; if the semantic leg fails on a query the text leg already answered, the text result stands. weak-text and hybrid embed the query (sending its text to the embeddings provider) on queries that text-first answers locally. Off by default: the ADR-0007 evidence bar is not met; text-first is byte-identical to before.
retrieval.sparse boolean false Fuse a bge-m3 learned-sparse stream into RRF at serve time. A no-op unless the embeddings provider emits the multi-vector heads (bge-m3 or model-tier).
retrieval.summaries.clusters.enabled boolean false Generate + retrieve cluster-level (tier-2/RAPTOR) summaries. Off ships the mechanism dark: zero gateway/embed calls at the offline cluster pass, no cluster_summary candidates at retrieval time. Gated on the SAME pre-registered global-query eval as the note-level tier.
retrieval.summaries.clusters.maxConcurrency number 12 In-flight extract() calls across clusters during a cluster-summary pass. Same 8-16 recommended range as the note-level tier.
retrieval.summaries.enabled boolean false Generate + retrieve note-level summaries. Off ships the mechanism dark: zero gateway calls at index time, no summary candidates at retrieval time. Gated on a pre-registered global-query eval, not built here.
retrieval.summaries.maxConcurrency number 12 In-flight extract() calls during a summarization pass, so a large first index does not fan out one request per note unbounded. 8-16 is the recommended range (research brief).
retrieval.summaries.model string โ€” Gateway model alias override for the extract-role summarization call. Omitted -> the gateway's configured extract-role model (see gateway.models.extract).
retrieval.useSearchModePreference boolean false Let search_vault use the caller's learned preferred.search_mode when the call names no mode: a stored search_text preference (profile weight >= 3.0, for this vault and this caller only) selects mode=text instead of auto. The weight counts evidence observed across extraction runs, not distinct windows: re-running obsidian-tc reflect re-counts unchanged evidence. An explicit mode, including an explicit auto, always wins; a stored value with no safe search_vault counterpart (search_regex, search_vault, vault_graph_search, search_omnisearch) and an object query are ignored. When on, search_vault reports mode_source (explicit, preference or default). The profile is written only by obsidian-tc reflect (extraction is deliberately not scheduled, because it is not idempotent), so it is as fresh as the last run. Needs the experiential store. Off by default โ€” ranking-adjacent, ADR-0007 bar not met; off is byte-identical to before.

scheduler

Key Type Default Required Description
scheduler.eventLoopDeferMs number โ€” Event-loop delay p99 (ms) above which a DUE background tick is deferred rather than run. Deferred is not skipped: the tick runs on a later pass once the loop recovers, and obsidian_tc_scheduler_deferred_total counts it. Absent (the default) disables deferral entirely and the event-loop monitor is never even created, so there is no cost when off. Set it when background work is observably competing with request latency; a value near your acceptable p99 tail is the starting point.

securityProfile

Key Type Default Required Description
securityProfile enum(hardened|trusted-local) โ€” Named security posture applied before validation. 'hardened' sets the least-privilege defaults (strictReadDefault, requireCas, snapshots on, HTTP off, auth.requireJti on); explicit fields override it. 'trusted-local' (the default) keeps the permissive single-user posture.

sessions

Key Type Default Required Description
sessions.autoOpen boolean false Open a workspace session automatically on a principal's first authenticated dispatch when it has none, instead of waiting for an explicit start_session. Off by default: session correlation changes what the server retains about who read what.
sessions.maxExplicitLifetimeSeconds number 86400 The absolute ceiling on how long an explicit start_session session may stay open, regardless of activity โ€” end_session still closes one early at any time. Once started_at + maxExplicitLifetimeSeconds has passed, the maintenance sweep closes it (recording ended_reason: "absolute_expired" in the session's existing metadata) on the same schedule closeStaleImplicitSessions runs on. Like that sweep, it never touches a caller IS NULL row โ€” that stays closeStaleImplicitSessions's job โ€” and never closes a session with a request in flight.
sessions.traceContent boolean true Also persist each dispatch's raw parsed arguments on the session trace, secret-scanned and size-capped, so a session can be replayed with obsidian-tc rerun. On under the trusted-local posture; securityProfile: "hardened" turns it off. A trace carrying arguments holds note bodies and search queries, so it lives in cacheDir (never the vault) and is not reachable through the note surface.
sessions.windowSeconds number 1800 How long a server-opened session keeps correlating before it becomes ELIGIBLE to be closed. This is a floor, not an exact lifetime: the closing is done by the maintenance sweep on ITS schedule (maintenance.intervalMinutes, default 60), so a session actually lives between windowSeconds and windowSeconds + that interval โ€” with both defaults, between 30 and 90 minutes. A session is a bounded activity window, not an idle timeout: it is closed on age, never on inactivity, and the next request opens a fresh one. Explicit start_session sessions are never closed by the sweep on this window alone โ€” only end_session closes those early โ€” but the resolver uses this SAME number as its bound: once an explicit session is older than windowSeconds, dispatch stops attaching new traffic to it (the caller gets a fresh implicit session instead), and sessions.maxExplicitLifetimeSeconds is the separate, longer bound on how long the stale row itself may stay open.

setupOrigin

Key Type Default Required Description
setupOrigin enum(first-run-fallback) โ€” Set only by obsidian-tc serve's own first-run fallback when it auto-wrote this config (exactly one vault found, no ambiguity) โ€” never set by an interactive obsidian-tc setup run. doctor surfaces this so an auto-generated config is visibly distinct from a reviewed one.

snapshots

Key Type Default Required Description
snapshots.enabled boolean true Capture the prior content-addressed state before a destructive note write, so restore_note can roll back. On by default under the trusted-local posture; retention is pruned inline, so growth is bounded.
snapshots.retention number 10 Maximum snapshot versions kept per note. Older versions are pruned.

telemetry

Key Type Default Required Description
telemetry.authTokenEnv string โ€” Name of an environment variable holding a bearer token, sent as "Authorization: Bearer " on the telemetry POST. Optional โ€” most reference collectors need no auth at all. Never put a secret in endpoint itself (refused below); this is the only supported way to authenticate to a collector.
telemetry.enabled boolean false Opt in to anonymous usage telemetry. Off by default. Turning this on with no endpoint set is a CONFIG ERROR at boot (refused below), never a silent no-op โ€” there is no default telemetry endpoint. When on, an aggregate document (never paths, note content, queries, vault ids, principals, tokens, hostnames or env) is POSTed to endpoint once every intervalMinutes, never at boot before the first interval elapses. obsidian-tc telemetry preview prints the exact document that would be sent right now.
telemetry.endpoint string โ€” Collector URL the telemetry document is POSTed to. Required when enabled is true โ€” refused below when absent. Must be https:// unless the host is loopback (localhost/127.0.0.1/[::1]), which is allowed ONLY for tests and a locally-run reference collector; unlike experiential.citationInfer.judge.baseUrl there is no allowPlainHttp widening here for a remote host โ€” a bearer token (authTokenEnv) and the document both travel over it, so a remote endpoint must be encrypted in transit. Must not name a literal private, link-local, carrier-grade-NAT, unspecified, or cloud-metadata IP address (loopback is the one such range that IS allowed) โ€” a hostname that happens to resolve to one is not checked here, by design.
telemetry.intervalMinutes number 1440 Minutes between telemetry sends. Minimum 60 โ€” this is aggregate, low-frequency telemetry, not a heartbeat. The first send happens no sooner than this many minutes after boot; there is never a send at boot itself.

throttle

Key Type Default Required Description
throttle.backend enum(memory|sqlite|redis) "memory" Where rate-limit buckets live. memory (default) is process-local: a restart resets buckets and every process limits on its own. sqlite shares buckets between every server process using the same cacheDir (ratelimit.db, one host). redis shares them across instances (needs the optional @redis/client package and throttle.redis).
throttle.enabled boolean true Enforce per-scope-class rate limits.
throttle.failurePolicy enum(fail-open|fail-closed) "fail-open" What a governed call does while the shared backend (sqlite or redis) cannot be reached. fail-open keeps serving and enforces the limits per process from local buckets, so an outage never takes the server down and the limit is not lifted, only un-shared. fail-closed refuses the call as throttled with reason backend_unavailable, for operators who need hard cluster-wide caps. Either way the outage is logged and counted once, not per request. Ignored by memory.
throttle.maxConcurrentWritesPerVault number 16 Ceiling on concurrent write operations against a single vault.
throttle.redis.keyPrefix string "obsidian-tc:rl:" Prefix for every bucket key, so several deployments can share one Redis without colliding.
throttle.redis.urlEnv string "OBSIDIAN_TC_REDIS_URL" NAME of the environment variable holding the Redis URL (redis://[user:password@]host:6379[/db], rediss:// for TLS). There is no inline url field: the URL carries the password and must not sit in config.json.
throttle.redis.urlFile string โ€” Path to a file whose content is the Redis URL (container/orchestrator secret mounts). Wins over urlEnv when set.
throttle.tiers.admin.burst number 1 Bucket depth for admin-scope calls: how many may fire back-to-back before the per-minute rate applies.
throttle.tiers.admin.perMinute number 5 Sustained admin-scope calls allowed per minute.
throttle.tiers.bulk.burst number 3 Bucket depth for bulk-scope calls: how many may fire back-to-back before the per-minute rate applies.
throttle.tiers.bulk.perMinute number 10 Sustained bulk-scope calls allowed per minute.
throttle.tiers.delete.burst number 20 Bucket depth for delete-scope calls: how many may fire back-to-back before the per-minute rate applies.
throttle.tiers.delete.perMinute number 60 Sustained delete-scope calls allowed per minute.
throttle.tiers.execute.burst number 1 Bucket depth for execute-scope calls: how many may fire back-to-back before the per-minute rate applies.
throttle.tiers.execute.perMinute number 5 Sustained execute-scope calls allowed per minute.
throttle.tiers.read.burst number 100 Bucket depth for read-scope calls: how many may fire back-to-back before the per-minute rate applies.
throttle.tiers.read.perMinute number 600 Sustained read-scope calls allowed per minute.
throttle.tiers.write.burst number 20 Bucket depth for write-scope calls: how many may fire back-to-back before the per-minute rate applies.
throttle.tiers.write.perMinute number 60 Sustained write-scope calls allowed per minute.

toolFacade

Key Type Default Required Description
toolFacade.advertise enum(all|core|essentials) "all" Tool-budget profile for clients that cap the tools they accept. "all" (the default) narrows nothing. "essentials" advertises a flat list of about 35 curated tools (about one per domain; fits 100-tool caps), "core" the flat core curation (101 tools; fits 128-tool caps). Anything other than "all" overrides mode with a flat list. On HTTP, a URL such as /mcp/essentials selects a profile per client and wins over this key. Advertisement only: tools outside the subset stay callable by name, subject to the same authorization.
toolFacade.autoClients record โ€” Deprecated, only used when mode is "auto". Maps a case-insensitive substring of the connecting client's clientInfo.name to a facade mode; checked in this object's own key order, before the server's built-in table, so an entry here overrides the same substring there. Absent clientInfo.name (most callers today) always falls back to "triad".
toolFacade.explainAutoMode boolean false Only meaningful when mode is "auto". Explain the auto-mode decision without changing it: one structured toolFacade.explain log line per resolution (client name, matched rule, chosen mode) and server_health's toolFacade.explanation for the calling client. Default false.
toolFacade.mode enum(triad|domain|flat|auto) "triad" Which surface tools/list advertises: triad exposes three meta-tools (find/describe/call_capability) plus the standard search and fetch tools, domain about a dozen domain meta-tools, flat the full tool surface, auto is deprecated: it resolves to triad for every client (unless autoClients overrides) and will be removed in the next major version, so set an explicit mode (recommended: triad). Every registered tool stays callable by name in every mode.
toolFacade.outputSchema enum(full|omit) "full" Whether tools/list advertises each tool's outputSchema. "full" (the default) is unchanged. "omit" drops it from every advertised tool for clients that mishandle the field (reported for claude.ai; Claude Desktop with older schema drafts; Cursor); tool results still carry structuredContent and a text block.
toolFacade.profile enum(full|core) "full" Which tools are visible/callable for this process โ€” registration itself is unaffected (every tool is always registered). "full" (the default) leaves every tool visible/callable, unchanged from today. "core" is an opt-in, smaller curated surface. Orthogonal to mode: mode picks what a session is ADVERTISED, profile picks what is CALLABLE. A tool core hides answers describe_capability/call_capability with a capability_hidden error rather than a silent failure.

tools

Key Type Default Required Description
tools.defaults.responseFormat enum(concise|detailed) "detailed" Default response_format for a tool call that sets neither response_format nor the legacy verbosity alias. Shipped default "detailed" (unchanged output); set "concise" once to cut the output of every tool that supports response_format, without each agent passing the parameter. It is also the only selector for MCP resources/read, which takes no parameters (concise returns the note body without its frontmatter).

toolVisibility

Key Type Default Required Description
toolVisibility.allowed array<string> โ€” Name allowlist: only these tools are listed. Absent lists all; an empty array lists none.
toolVisibility.disabled array<string> [] Tool names dropped from tools/list AND rejected at dispatch, so they behave as if unregistered.
toolVisibility.disabledTags array<string> [] Tags whose tools are hidden and rejected at dispatch. Same tag vocabulary as hiddenTags.
toolVisibility.hidden array<string> [] Tool names dropped from tools/list but still callable by name. A leaner default surface, NOT a security boundary.
toolVisibility.hiddenTags array<string> [] Tags whose tools are hidden from tools/list but remain callable. Tags come from the tool-tag vocabulary (for example destructive, bulk, external-network, domain:git); see Tool tags in the docs.
toolVisibility.requireReadOnly boolean false List only non-mutating tools. Mutation is derived from each tool's required scopes, so no per-tool annotation is needed. Hides rather than rejects.

transports

Key Type Default Required Description
transports.http.allowedHosts array<string> [] Additional Host header values accepted by the rebinding guard.
transports.http.allowedOrigins array<string> [] Additional Origin header values accepted by the rebinding guard.
transports.http.enabled boolean false Serve the MCP HTTP transport.
transports.http.enableDnsRebindingProtection boolean true Reject a request whose Host is neither loopback nor operator-allowed, or whose Origin is neither same-origin nor operator-allowed. Server-to-server clients send no Origin and are unaffected.
transports.http.forwardedHeader enum(x-forwarded-for|cf-connecting-ip) "x-forwarded-for" Which header carries the client address when the TCP peer is in trustedProxies. x-forwarded-for (default): the right-most address that is not itself a trusted proxy (a proxy appends the address it saw, so entries to the left of that are whatever the client wrote). cf-connecting-ip: Cloudflare's single-value header, set by Cloudflare at its edge and passed through cloudflared; use it behind a Cloudflare Tunnel. Ignored while trustedProxies is empty.
transports.http.host string "127.0.0.1" Bind address. A non-loopback host is refused while auth.mode is none, since every request would otherwise resolve to full wildcard scopes.
transports.http.port number 8765 TCP port for the HTTP transport.
transports.http.trustedProxies array<string> [] Reverse proxies or tunnels whose forwarded client address is believed: IP addresses and CIDR blocks of the TCP peer, for example ["127.0.0.1", "::1"] for cloudflared or nginx on the same host, or a docker bridge subnet. Empty (the default): no header is read, and every client behind a proxy or tunnel is counted as one unattributed source by the per-source limits (client-metadata lookups, passkey sign-in, authorize admission, password-login failures). When a request's TCP peer is listed, the client address is taken from the header named by forwardedHeader; from any other peer the header is ignored, so a client cannot choose its own address. List ONLY proxies you control and that overwrite or append to the header: a listed address that forwards a client-supplied header unchanged lets that client pick its own source.
transports.stdio boolean true Serve the MCP stdio transport.

uri

Key Type Default Required Description
uri.allowOsLaunch boolean false Let show_file_in_obsidian hand an obsidian:// URI to this machine's OS URI handler (xdg-open, open, rundll32) when no live Obsidian session answers through the companion plugin. Off by default and honoured ONLY for the local stdio transport: over HTTP the tool refuses regardless of this flag, because the launch happens on the server host, not the caller's machine. The URI is always built from a vault-relative path that passed the read ACL; no caller-supplied URI is ever launched.

vaults

Key Type Default Required Description
vaults array<object> โ€” yes Vaults this server serves. At least one is required.
vaults[].acl lazy โ€” Per-vault ACL override, same shape as the root acl block. Absent means the root ACL is inherited.
vaults[].bridges.ocrTimeoutMs number 30000 Timeout in ms for an OCR bridge call, which is far slower than a normal request.
vaults[].bridges.probeTimeoutMs number 500 Timeout in ms for the startup plugin/liveness probe. Deliberately short: it runs before the server is useful, so a dead Obsidian must not stall boot.
vaults[].bridges.templaterTimeoutMs number 30000 Timeout in ms for a Templater bridge call, which may run arbitrary user template logic.
vaults[].bridges.timeoutMs number 5000 Timeout in ms for a general plugin-bridge call to this vault's Local REST API.
vaults[].commands.allowlist array<string> [] Command ids that may be fired when enabled. Only ids listed here run, and only with a HITL token โ€” there is no wildcard.
vaults[].commands.enabled boolean false Allow execute_command on this vault at all. Deny-by-default: command execution stays off unless this is explicitly true.
vaults[].id string โ€” yes Stable identifier for this vault. Tools take it as their vault argument.
vaults[].index.excludePaths array<string> [] Extra entries merged with the Excluded files list Obsidian keeps in .obsidian/app.json (Settings -> Files & links -> Excluded files). Same dialect as Obsidian: an entry is a case-insensitive path prefix (Archive/ is a folder, Notes/todo.md a file), and an entry written /regex/ is a case-insensitive regular expression tested against the vault-relative path. In a path entry a backslash is read as a separator (Archive\\ is Archive/); a /regex/ entry is left exactly as written. A matching note is left out of the search index (no chunks, no embeddings, no search results, no graph edges) but stays a normal vault file: links to it resolve and read_note still works. This is NOT egress.excludePaths, which only withholds text from model providers.
vaults[].kind enum(private|docs|system) "private" Isolation kind, enforced one-directionally: the read:docs tools (knowledge_search/knowledge_get_critical) refuse any vault whose kind is not docs. private (default) = personal vault; docs = external docs corpus the read:docs surface is bound to; system = reserved internal vault. Not yet enforced: the private read:notes tools are NOT fenced out of a docs/system vault (a follow-up).
vaults[].memory.folder string "memory" Vault folder where create_entity(materialize) writes the regenerable .md projection. A normal folder so the link graph resolves in Obsidian; SQLite remains the source of truth.
vaults[].memory.supersededInNote boolean true Whether a materialized entity note keeps its superseded and retired observations under a Superseded heading. true (default) keeps them, so the indexer chunks and FTS-indexes the old text. false omits the section: the note then holds current facts only, and closed history stays in SQLite, still returned by get_entity with as_of.
vaults[].memoryDefense.mode enum(off|redact|block) "off" Memory write scan over create_entity/add_observation/link_entities/rename_entity/enqueue_capture/commit_capture/set_goal, before persistence. "off" (default) never scans. "redact" persists the argument with matches replaced by "[REDACTED]" and reports redactions in the tool result. "block" refuses the write with a secret_detected error naming the matched pattern ids and field names, never the value โ€” except a LOW-CONFIDENCE labeled_secret hit (an ordinary key: value line whose value does not itself look secret-shaped), which is redacted rather than refused even in "block" mode.
vaults[].memoryDefense.pii boolean false Also scan for PII: US SSN shape and Luhn-valid 13-19 digit numbers with a known card-issuer prefix. Emails and phone numbers are never flagged. Has no effect when mode is "off".
vaults[].mode enum(live|headless|auto) โ€” How this vault is reached. auto (the default when absent) probes the Local REST API once at startup: reachable means live, otherwise headless direct-filesystem access with Tier-3 action tools degrading to requires_live_obsidian. live/headless force the mode and skip the probe.
vaults[].name string โ€” Human-readable display name. Defaults to the id when absent.
vaults[].path string โ€” yes Absolute path to the vault directory on disk.
vaults[].plugins.forceDisabled array<string> [] Plugin ids to treat as missing regardless of what the probe finds.
vaults[].plugins.forceEnabled array<string> [] Plugin ids to treat as installed and enabled regardless of what the probe finds.
vaults[].plugins.probeSkip boolean false Skip the startup plugin probe entirely, making forceEnabled/forceDisabled the sole source of truth. The seam CI uses to assert tool behaviour without a live Obsidian.
vaults[].publicUrl string โ€” Opt-in https base url where this vault's notes are published (for example an Obsidian Publish or Quartz site). When set, the standard search and fetch tools cite <publicUrl>/<note path> (path segments percent-encoded, .md dropped) instead of an obsidian://open link, so ChatGPT deep research and company knowledge can render clickable citations. Absent means the obsidian:// deep link is used.
vaults[].reflect.citationStyle enum(numeric|wikilink) โ€” Default citation_style for reflect on this vault when the call omits it. "numeric" keeps the [n] markers; "wikilink" renders each [n] as path in the answer and in a persisted note. Unset means numeric.
vaults[].reflect.detail enum(concise|standard|thorough) โ€” Default detail for reflect on this vault when the call omits it: how much the synthesis says. Unset means concise.
vaults[].restApiKey string โ€” Bearer token for the Local REST API. Secret โ€” never logged or echoed in a tool result.
vaults[].restApiUrl string โ€” Base URL of this vault's Obsidian Local REST API, used for live-mode bridge calls.
vaults[].wiki.folder string โ€” yes Vault-relative folder that holds this vault's LLM wiki, written wiki or notes/wiki (no leading or trailing slash; ., /, an absolute path, .., and segments ending in a space or dot are rejected). A SCHEMA.md in it declares the page types, the frontmatter each type requires and the allowed property vocabulary; draft_wiki_page proposes pages in this folder and commit_wiki_page only writes new pages inside it. Creating a page there needs no confirmation (a new page has no snapshot: delete_note removes it; restore_note undoes patches and overwrites). Absent means the vault has no wiki folder: draft_wiki_page then applies no schema and commit_wiki_page refuses.
vaults[].wiki.rawFolder string โ€” Vault-relative folder of this vault's raw sources, the inputs the wiki is built from (same rules as folder). Default: a raw folder BESIDE the wiki folder (wiki gives raw, notes/wiki gives notes/raw); there is none when that would overlap the wiki folder. It must not be, contain or sit inside folder. Its files are IMMUTABLE: no tool writes, renames or deletes anything in it (add sources outside the server, for example with Obsidian or a web clipper); reads follow the normal read ACL. draft_wiki_page source ingests one of its notes.
vaults[].workspace.traceFolder string ".obsidian-tc/traces" Vault-relative folder for append-only JSONL session traces. Defaults to a dot-folder so traces stay out of Obsidian's graph view.

watch

Key Type Default Required Description
watch.debounceMs number 500 Quiet period before a burst of filesystem events is flushed. One editor save emits several events and a sync pass emits one per file; this coalesces both into a single reindex per path. Raising it delays pickup, lowering it costs redundant reindex passes during a large sync.
watch.enabled boolean true Watch each vault root and reindex notes changed outside the server (sync clients, git pull, an editor on the host). Active on ALL platforms including Windows: the watch root is resolved with realpathSync.native first, which is what the earlier Windows crash actually needed (libuv aborts when an 8.3 short path disagrees with the long-form filenames its events carry). Turn OFF for a vault on a filesystem with no usable change notification (some network mounts) or to cap inotify usage on a very large vault; index_vault then remains the only way changes are picked up.

wikiJudge

Key Type Default Required Description
wikiJudge.allowPlainHttp boolean false DEPRECATED, removed at the next major release: use plainHttpHosts. Until then true means this baseUrl's own host is listed in plainHttpHosts, with the connect-time private-address check still applied (a host that resolves to a public address is refused). obsidian-tc doctor and server_health warn while it is set.
wikiJudge.apiKeyEnv string "TYPESAFE_API_KEY" Environment variable holding the TypeSafe API key (provider typesafe).
wikiJudge.baseUrl string "https://api.typesafe.ai" TypeSafe API base URL (provider typesafe). Must be https:// unless the host is loopback or its exact hostname is listed in plainHttpHosts: this URL carries the bearer key and the opening text of two notes.
wikiJudge.enabled boolean false Whether find_existing_page runs the judge when its judge argument is omitted. A call can still pass judge=true or judge=false. Needs a configured judge (a gateway, or provider typesafe); without one the judge never runs. Off by default: the measured recall of topic-to-page judging is too low to turn on. The judge only ever resolves AMBIGUOUS candidates, never overrides exact name, alias or wikidata evidence, never blocks a write, and any failure leaves the verdict ambiguous.
wikiJudge.lintEnabled boolean true Whether lint_wiki's near-duplicate pass judges candidate pairs when its judge argument is omitted. On by default, but it only runs when a judge is configured and maxCallsPerDay is above 0; the judge only adds a verdict to a proposal, never drops one, never writes and never blocks. A call can still pass judge=true or judge=false. The scheduled sweep has its own switch, maintenance.wikiLint.judge.
wikiJudge.maxCallsPerDay number 200 Most gateway judge calls per UTC day across every caller (a failed call counts). 0 disables the judge. Cached verdicts do not count. Over the cap, verdicts stay ambiguous.
wikiJudge.maxCallsPerRequest number 3 Most candidates one find_existing_page call sends to the judge (top-ranked first). Cached verdicts do not count.
wikiJudge.maxNoteChars number 2400 Most characters of each page (opening text, after frontmatter) sent to the judge, so one call has a bounded size. A longer page is cut here; the topic string is cut to the same length.
wikiJudge.model string โ€” Required when provider is "typesafe": a PINNED, versioned TypeSafe model id (e.g. "jev-1.13.0"), rejected at config-load unless it ends in a dotted numeric version. A floating alias such as "jev-latest" would silently move the decision boundary under a fixed threshold.
wikiJudge.plainHttpHosts array<string> [] Exact hostnames a plain http:// baseUrl may name (case-insensitive, IDNA/punycode-normalized; no wildcards, ports or paths). https:// and loopback need none. A listed host is still refused when a request is sent unless EVERY address it resolves to is loopback, RFC1918 (10/8, 172.16/12, 192.168/16) or IPv6 unique-local (fc00::/7), or, for a LISTED host only, the Tailscale/CGNAT range 100.64.0.0/10 (list only a host that is a tailnet peer: Tailscale traffic is WireGuard-encrypted, ISP CGNAT space is not). 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) are never allowed, listed or not; the connection goes to that checked address with the original Host header, and redirects are refused (an https:// answer that redirects is refused too). Only for a host-local docker network or an encrypted overlay, e.g. ["litellm"] for a gateway pass-through at http://litellm:4000/typesafe; the key and note text still travel in clear over that link.
wikiJudge.provider enum(gateway|typesafe) "gateway" Which service answers. "gateway" (default) uses the gateway judge role. "typesafe" (EXPERIMENTAL) asks TypeSafe Jev a Choice question over {same_topic, overlapping, different} instead, using the model, threshold, baseUrl and apiKeyEnv below; it never falls back to the gateway. Jev is a ranking model, not a security control.
wikiJudge.threshold number โ€” Required when provider is "typesafe": the probability (0..1) Jev must give same_topic for the verdict to be same_topic. It is a ranking score, not a calibrated probability, and has no default: tune it on labelled pairs from your own vault.
wikiJudge.timeoutMs number 15000 Per-call wait for the judge. On timeout the request to the gateway is cancelled, the candidate stays ambiguous and the tool answers without it. The call still counts against maxCallsPerDay.

writes

Key Type Default Required Description
writes.maxAttachmentBytes number 25000000 Ceiling in decoded bytes on one write_attachment payload, enforced from the base64 length before anything is decoded. The payload travels inline as base64 (about 4/3 of this in the request), so lower it if an MCP client or reverse proxy in front of the server limits message size. The server enforces no request-body limit of its own beyond this cap.
writes.requireCas boolean false Require a prev_hash (compare-and-swap) on overwriting writes and on appends to an existing note, failing closed with invalid_input when absent so a stale hash cannot silently clobber.
โš ๏ธ **GitHub.com Fallback** โš ๏ธ