Configuration - The-40-Thieves/obsidian-tc GitHub Wiki
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>.
{
"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.
| 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 |
"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 undertrusted-local;securityProfile: "hardened"turns it off, as does setting itfalseyourself. 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).
{
"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.
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": {
"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": {
"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": {
"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.
| 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 |
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.
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.
Every key, type, default, and required flag โ generated from the Zod schema. Do not hand-edit between the markers.
| 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. |
| 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. |
| 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. |
| 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. |
| 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). |
| 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. |
| Key | Type | Default | Required | Description |
|---|---|---|---|---|
elicitTtlSeconds |
number |
300 |
Seconds a pending elicitation (human-in-the-loop prompt) stays valid before it expires. |
| 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. |
| 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." |
| 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. |
| 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. |
| 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. |
| Key | Type | Default | Required | Description |
|---|---|---|---|---|
idempotencyTtlSeconds |
number |
86400 |
Seconds an idempotency record is retained, bounding how long a repeated request key is deduplicated. |
| 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. |
| 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. |
| 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. |
| 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). |
| 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. |
| 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. |
| 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. |
| 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. |
| 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. |
| 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. |
| 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. |
| 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. |
| 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. |
| 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. |
| 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. |
| 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. |
| 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. |
| 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. |
| 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. |
| 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. |
| 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. |
| 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. |
| 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). |
| 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. |
| 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. |
| 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. |
| 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. |
| 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. |
| 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. |
| 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. |