docs/CONFIG.md
Rama main · réplica de lectura del repositorio original.
# Configuration Reference
All settings for SynaptoMind. Priority: **env vars > config.json > defaults**.
---
## Quick Start
```bash
cp config.json.example config.json
# edit config.json as needed
```
Or use environment variables:
```bash
cp .env.example .env
# edit .env
```
---
## Server
| Setting | Env Var | Default | Description |
|---------|---------|---------|-------------|
| `server.port` | `SYNAPTOMIND_PORT` | `3005` | HTTP ops port (health, health-check) |
| `server.host` | `SYNAPTOMIND_HOST` | `127.0.0.1` | Bind address. Use `0.0.0.0` to expose to network |
---
## MCP
| Setting | Env Var | Default | Description |
|---------|---------|---------|-------------|
| `mcp.httpPort` | `SYNAPTOMIND_MCP_HTTP_PORT` | `3006` | MCP HTTP transport port |
| `mcp.instructionsFile` | `SYNAPTOMIND_MCP_INSTRUCTIONS_FILE` | `""` | Path to custom MCP instructions file (markdown). Falls back to built-in instructions |
| `mcp.stdioStandalone` | `SYNAPTOMIND_MCP_STDIO_STANDALONE` | `false` | When `true`, a `--stdio` process also starts the embedder and background jobs. By default, the shared HTTP server owns scheduled jobs and stdio does not start an embedder at startup; embedding requests can still start a local child on demand. See [Stdio ownership](#stdio-ownership) |
| `mcp.corsOrigins` | `SYNAPTOMIND_MCP_CORS_ORIGINS` | `[]` | Comma-separated allowed CORS origins for the MCP HTTP transport. Empty = no CORS headers |
| `mcp.maxSessions` | `SYNAPTOMIND_MCP_MAX_SESSIONS` | `100` | Max concurrent **legacy** sessions per user (one user exhausting it cannot lock out the others). Legacy (2025-era) transport only: the 2026-07-28 era serves every request with a fresh instance and issues no `mcp-session-id`, so there is no session to cap |
| `mcp.sessionTtlMs` | `SYNAPTOMIND_MCP_SESSION_TTL_MS` | `3600000` | Idle timeout of a **legacy** session in ms (1h). Legacy-only: the 2026-07-28 era keeps no session between requests, so nothing idles |
| `mcp.keepAliveMs` | `SYNAPTOMIND_MCP_KEEPALIVE_MS` | `10000` | SSE keep-alive interval in ms, on both eras |
| `mcp.maxEventsPerSession` | `SYNAPTOMIND_MCP_MAX_EVENTS_PER_SESSION` | `1000` | Max events buffered per **legacy** session for `Last-Event-ID` replay. The 2026-07-28 era has no event store, so no replay to buffer |
| `mcp.maxSubscriptions` | `SYNAPTOMIND_MCP_MAX_SUBSCRIPTIONS` | `128` | Max concurrent `subscriptions/listen` streams on the 2026-07-28 era, process-wide (not per user). The stream past the cap is refused in-band with `-32603 "Subscription limit reached"` |
### HTTP transport and protocol revisions
`/mcp` serves two request eras on the same path. The era is decided per request
from the request itself; no setting chooses it.
- **2026-07-28 (modern, stateless).** Each request is served by a fresh server
instance and its own single-use transport. No `mcp-session-id` is issued, no
server or transport outlives the exchange, and there is nothing for a sweep or
a TTL to expire. A request enters this era when it carries the 2026-07-28
per-request `_meta` envelope claim: the `server/discover` probe, envelope
`tools/call`, and the protocol errors this path answers itself (an
unsupported-revision claim, a malformed envelope, a body that parses but is not
a JSON-RPC message — see below).
- **2025 era (legacy, sessionful).** The 2025-era revisions (latest
`2025-11-25`), served by the unchanged sessionful transport: `initialize`
issues an `mcp-session-id`, follow-up requests present it, `GET` opens an SSE
stream with keep-alive frames, and a dropped stream resumes from
`Last-Event-ID`. Every claim-less POST is served here — including the
`initialize` handshake — as are body-less `GET`/`DELETE` session operations
and a body that is empty or not valid JSON.
**A body that parses but is not a JSON-RPC message is served here.** `{}`,
`null`, `"x"` and `[1]` all parse as JSON and none is a JSON-RPC message, so the
modern path answers them itself with `400` `-32600` (invalid request) — `"Bad
Request: the request body is not a valid JSON-RPC message"`, or `"Bad Request:
JSON-RPC batch contains an invalid message"` for a bad batch — with or without an
`mcp-session-id`. Only an empty or unparseable body is routed to the legacy path
instead. For an existing client this changes one wire answer: a session request
with such a body used to reach the 2025 transport and get `400` `-32700` `"Parse
error: Invalid JSON-RPC message"`; it now gets the modern `400` `-32600`, and an
unknown session id no longer turns it into `404` `-32001`.
**Observable check order on `/mcp`.** A `POST` is checked in this order:
authentication (`401`), the declared-`Content-Length` bound (`413`, detailed
below), media type (`415` — a `Content-Type` whose type is not `application/json`;
parameters such as `charset` are ignored), era classification, then the legacy
session lookup (`404` `-32001` unknown id, `403` `-32002` a session created by
another token), and only once a session is found does the session transport
parse the body. So a `text/plain` POST is `415` even with an unknown session id,
while a valid JSON-RPC request carrying that same unknown id is still `404`
`-32001`, and an unparseable body with an unknown id is `404` rather than the
transport's `-32700`.
Negotiation is the client's decision, not a server setting. A client that does
not negotiate never sends the probe and opens a 2025 session, exactly as before
this change. A negotiating client probes with `server/discover`; the modern
answer selects the 2026-07-28 era for that connection, and the client then
attaches the envelope to its following requests. A client that falls back after
an unrecognised probe is served by the 2025 handshake; that silent fallback is
expected and needs no action on the server.
**Request body bound: 5 MB (5242880 bytes) on both eras.** A declared
`Content-Length` above it is answered `413` without parsing the body — the
guard decides from the declared length alone — and the body that follows is
drained off the connection and then discarded, never parsed or inspected
(`{"jsonrpc":"2.0","error":{"code":-32000,"message":"Request body too large (max 5MB)"},"id":null}`).
The drain is deliberate: Bun holds a request in flight until its body is
consumed, so a body left unread would make a graceful shutdown wait on the
rejected request. A chunked body declares no length, so it is read up to the
same limit and then answered `413`. The same drain applies to any `POST` the
chain answers with an error before consuming the body. Before the 2026-07-28
path existed, the 5 MB bound was enforced only by the middleware, and the
legacy session path inherited the transport's own 4 MB default, so a 4–5 MB
session request used to be rejected and is now served.
**Modern streams are not resumable.** The 2026-07-28 handler keeps no event
store, so `Last-Event-ID` replay does not exist there: a dropped
`subscriptions/listen` stream is re-opened by the client as a new subscription.
Open listen streams are the only long-lived resource on the modern path, and they
are bounded by `mcp.maxSubscriptions` (default `128`). The stream past the cap is
refused in-band with `-32603 "Subscription limit reached"` — HTTP 200, before
the subscription acknowledgement.
### Stdio ownership
A stdio MCP client talks to the same SQLite database as the HTTP server. By
default (`mcp.stdioStandalone=false`) the stdio process does **not** start an
embedder child or background jobs at startup; the shared HTTP server owns the
scheduled jobs. An embedding request can still start a local child on demand;
stdio does not reuse the HTTP server's embedder process.
To run stdio as a fully standalone node (no separate server), enable local
ownership with either:
- CLI flag: `--stdio-standalone` (e.g. `bun run src/index.ts --stdio --stdio-standalone`)
- Env var: `SYNAPTOMIND_MCP_STDIO_STANDALONE=true`
- `config.json`: `"mcp": { "stdioStandalone": true }`
---
## Database
| Setting | Env Var | Default | Description |
|---------|---------|---------|-------------|
| `db.path` | `SYNAPTOMIND_DB_PATH` | `./data/synaptomind.db` | Main SQLite database path |
| `db.busyTimeout` | `SYNAPTOMIND_DB_BUSY_TIMEOUT` | `5000` | SQLite busy timeout in ms. Increase if you see `SQLITE_BUSY` errors |
---
## Embedder
Local embedding model for semantic search. No API keys required.
| Setting | Env Var | Default | Description |
|---------|---------|---------|-------------|
| `embedder.enabled` | `SYNAPTOMIND_EMBEDDER_ENABLED` | `true` | Enable/disable local embeddings. Disabled = vector search unavailable |
| `embedder.model` | `SYNAPTOMIND_EMBEDDER_MODEL` | `Xenova/multilingual-e5-small` | HuggingFace model name |
| `embedder.dtype` | `SYNAPTOMIND_EMBEDDER_DTYPE` | `q8` | ONNX precision of the local model. `q8` downloads `onnx/model_quantized.onnx` (~113 MB), `fp32` downloads `onnx/model.onnx` (~449 MB). An unknown value fails at startup. Switching needs **no** corpus re-embed (measured fp32↔q8 cosine drift ≤0.0061); an offline host holding only an fp32 cache recovers with `SYNAPTOMIND_EMBEDDER_DTYPE=fp32` — see the note below |
| `embedder.dimensions` | `SYNAPTOMIND_EMBEDDER_DIMENSIONS` | `384` | Embedding vector dimensions |
| `embedder.pollIntervalMs` | `SYNAPTOMIND_EMBEDDER_POLL_INTERVAL` | `7000` | How often to check for pending embeddings (ms) |
| `embedder.cacheDir` | `SYNAPTOMIND_EMBEDDER_CACHE_DIR` | `./data/huggingface` | Model cache directory (~449 MB with `fp32`, ~113 MB with `q8`, after first load) |
| `embedder.idleTimeoutMs` | `SYNAPTOMIND_EMBEDDER_IDLE_TIMEOUT` | `600000` | Unload after this idle time (ms): the embedder process exits and the next request starts a new one. A queue poll that finds pending embeddings or an IPC request resets the countdown. `0` = never unload, the same effect as `embedder.precache` |
| `embedder.precache` | `SYNAPTOMIND_EMBEDDER_PRECACHE` | `false` | When `true`, keeps the embedder process resident instead of letting it unload after `embedder.idleTimeoutMs` (same effect as `idleTimeoutMs: 0`), so the model stays loaded and the first request after a quiet period pays no reload. It does not download or preload anything at startup: model files are fetched on the first process start either way, and the ONNX session still loads on the first embedding request |
| `embedder.resetDeadLetters` | `SYNAPTOMIND_RESET_DEAD_LETTER` | `false` | Re-queue dead-lettered embeddings on startup. Off by default: a poisonous thought would otherwise get fresh attempts on every restart. Enable after fixing an embedding bug to retry failed items |
| `embedder.batchSize` | `SYNAPTOMIND_EMBEDDER_BATCH_SIZE` | `8` | Embeddings per batch. Higher = faster but more RAM |
**Note:** The embedder child starts with the server, not with the first request:
`startEmbedderProcess()` runs at boot in every process that owns background jobs (see
[Stdio ownership](#stdio-ownership)), unless `embedder.enabled=false` or `--no-embedder`.
`embedder.precache` and `embedder.idleTimeoutMs` never change that; they only decide whether the
child survives between requests. On start the child validates the model files (downloading any
missing one) and then polls the embedding queue; the model itself is loaded into memory by the
first embedding it computes. The queue worker lives inside that child, so a thought queued after an
idle unload stays pending until the next embedding request starts a child — keep
`embedder.precache=true` or `embedder.idleTimeoutMs=0` on a host that must embed with no other
traffic.
**Note:** Switching `embedder.dtype` shifts embedding values only slightly — measured on the default
model with transformers.js 4.2.0, the fp32↔q8 cosine drift is ≤0.0061, far under the ~0.19 median distance between two different
thoughts. An existing corpus therefore does **not** need re-embedding after a switch.
An **offline host with an fp32-only cache** cannot become ready after the `q8` default: the quantized
file is not on disk and cannot be fetched, so the embedder process exits before it reports ready. Recover
by using the file already on disk (`SYNAPTOMIND_EMBEDDER_DTYPE=fp32`) or by pre-placing
`onnx/model_quantized.onnx` in the cache.
A switch leaves the previous dtype's ONNX file in the cache directory. Startup logs one warning naming
that file and its size — it is never deleted, because a switch back reuses it.
---
## Thoughts
| Setting | Env Var | Default | Description |
|---------|---------|---------|-------------|
| `thoughts.softLimit` | `SYNAPTOMIND_THOUGHT_SOFT_LIMIT` | `600` | Recommended content budget per thought. The only limit advertised to agents; content above it is accepted but logged as a warning |
| `thoughts.hardLimitBufferPercent` | `SYNAPTOMIND_THOUGHT_HARD_LIMIT_BUFFER_PERCENT` | `20` | Percent buffer added to the soft limit to derive the enforced hard ceiling: `hardLimit = round(softLimit * (1 + hardLimitBufferPercent / 100))` |
**Note:** The hard ceiling is derived, not configured. With the defaults it is `round(600 * 1.2) = 720`. Content above it is rejected; content between the soft limit and the hard ceiling is accepted with a warning log.
---
## Retention review
Archived and draft thoughts are not deleted by an age-based background cleanup. This
statement concerns thought deletion; the separate placement-proposal queue has its own
scheduled expiry and pruning job (see [Placement](#placement)). The
`retention_review` tool (`action=list`) lists eligible candidates; an operator must explicitly protect,
delete, or compact them. Candidate defaults are:
| Setting | Env Var | Default | Description |
|---------|---------|---------|-------------|
| `retentionReview.minAgeDays` | `SYNAPTOMIND_RETENTION_REVIEW_MIN_AGE_DAYS` | `30` | Minimum age, measured from `archived_at` or (when absent) `created_at` |
| `retentionReview.importanceFloor` | `SYNAPTOMIND_RETENTION_REVIEW_IMPORTANCE_FLOOR` | `0.3` | Candidate importance must be below this threshold |
| `retentionReview.pageSize` | `SYNAPTOMIND_RETENTION_REVIEW_PAGE_SIZE` | `20` | Default candidate page size (the `retention_review` `limit` argument accepts 1–100) |
Configuration loading normalizes `pageSize` to an integer in the range 1–100 and
clamps `importanceFloor` to 0–1. A non-positive or non-numeric `minAgeDays` uses
the 30-day default. These settings govern the manual retention-review workflow;
they do not enable age-based thought TTL cleanup.
`retention_review action=list` accepts `min_age_days` and `importance_floor` to
override these defaults per call, so candidate eligibility is set here and refined at
the request; the preview-first protect/delete/compact actions are documented in
[mcp-tools.md](./mcp-tools.md#retention_review--retention-candidates).
---
## Decay
Automatic importance decay for old thoughts.
| Setting | Env Var | Default | Description |
|---------|---------|---------|-------------|
| `decay.rate` | `SYNAPTOMIND_DECAY_RATE` | `0.95` | Daily decay multiplier. 0.95 = importance × 0.95 per day |
| `decay.intervalMs` | `SYNAPTOMIND_DECAY_INTERVAL_MS` | `86400000` | How often to run decay (ms). Default: 24h |
**Note:** Decay only lowers importance — it never archives, deletes or otherwise
changes a thought's status. Archiving is explicit (`memory_supersede action=archive`;
`memory_store action=update` rejects `status=archived`), and deletion of archived thoughts is
handled by the retention-review process (the sole deletion path).
---
## Primer
Compact project summary for quick context injection.
| Setting | Env Var | Default | Description |
|---------|---------|---------|-------------|
| `primer.promoteThreshold` | `SYNAPTOMIND_PRIMER_PROMOTE_THRESHOLD` | `5` | Min hits before a thought is primer-eligible |
| `primer.topN` | `SYNAPTOMIND_PRIMER_TOP_N` | `3` | Max thoughts promoted to primer per run |
---
## Retired settings (breaking 0.x change)
The standalone verification subsystem and its settings are retired, together with
its data: migration `v043` drops the table that held the per-thought results. Search
no longer excludes anything on that basis, so previously flagged thoughts may appear
in default results; `exclude_flagged` is an ordinary, ignored query parameter. A
configuration file that still carries the retired section loads unchanged and
ignores it. [CHANGELOG.md](../CHANGELOG.md) lists every removal per release.
---
## Auto-Cluster
Batch grouping by embedding proximity.
| Setting | Env Var | Default | Description |
|---------|---------|---------|-------------|
| `autoCluster.minAgeDays` | `SYNAPTOMIND_AUTO_CLUSTER_MIN_AGE_DAYS` | `3` | Don't cluster thoughts younger than this |
| `autoCluster.minSimilarity` | `SYNAPTOMIND_AUTO_CLUSTER_MIN_SIMILARITY` | `0.09` | Max cosine **distance** between neighbours (lower = tighter; 0.3 chains the whole graph into one mega-cluster) |
| `autoCluster.minMembers` | `SYNAPTOMIND_AUTO_CLUSTER_MIN_MEMBERS` | `3` | Min thoughts to form a cluster |
---
## Neighbour search (edge detection bounds)
These four keys bound the neighbour search behind edge proposals. The placement
engine (`memory_status action=propose`) reads all four:
`minSimilarity` sets the recall floor for embedding neighbours, `topK` the neighbour
width, `maxCandidates` the candidate pool loaded for one run, and `maxProposals`
the cap on emitted edge proposals. The draft health check reuses the candidate-pool
bound only.
No standalone detection route or `memory_status` action exists any more: a candidate
is only ever a proposal, produced by the read-only placement engine. An edge can
be created directly with `memory_store action=link`, or by applying a queued
proposal with `edge_review action=apply` or `apply_batch` and explicit confirmation.
| Setting | Env Var | Default | Description |
|---------|---------|---------|-------------|
| `edgeDetect.minSimilarity` | `SYNAPTOMIND_EDGE_DETECT_MIN_SIMILARITY` | `0.75` | Min embedding similarity for a neighbour pair to become a candidate |
| `edgeDetect.topK` | `SYNAPTOMIND_EDGE_DETECT_TOP_K` | `10` | Max nearest neighbours considered per thought |
| `edgeDetect.maxCandidates` | `SYNAPTOMIND_EDGE_DETECT_MAX_CANDIDATES` | `100` | Max candidate thoughts loaded for one run |
| `edgeDetect.maxProposals` | `SYNAPTOMIND_EDGE_DETECT_MAX_PROPOSALS` | `20` | Cap on emitted edge proposals; the review queue applies `placement.maxProposalsPerEnqueue` on top |
---
## Placement
Read-only cluster placement proposed by `memory_status action=propose` (see ADR 2026-09-28), plus the persisted review queue they feed (`edge_review` — see [mcp-tools.md](./mcp-tools.md#edge_review--placement-proposals)). The engine never writes the graph: `maxClusterSize` only suppresses a proposal, it never mutates; queued proposals are applied only on an explicit `confirm:true`. `proposalTtlDays` sets proposal expiry, controls the daily placement-retention job that expires/prunes queue rows, and bounds the rollback window. This job prunes proposal records, not thoughts.
| Setting | Env Var | Default | Description |
|---------|---------|---------|-------------|
| `placement.maxClusterSize` | `SYNAPTOMIND_PLACEMENT_MAX_CLUSTER_SIZE` | `50` | Skip a cluster placement proposal when the target cluster already holds this many members (`>=`). Re-checked against the live count at apply time. Prevents routing new thoughts into an oversized mega-cluster (lessons #934/#928) |
| `placement.proposalTtlDays` | `SYNAPTOMIND_PLACEMENT_PROPOSAL_TTL_DAYS` | `30` | Proposal expiry interval and rollback window. Newly enqueued/refreshed `pending` rows receive `expires_at`; apply rejects expired rows. The daily placement-retention job marks overdue pending rows expired and prunes terminal proposal rows after this interval; rollback also refuses accepted rows decided outside this window. This retention applies to proposal-queue rows, not thoughts. `0` expires immediately and the job prunes terminal rows at the current cutoff; a negative value disables proposal expiry, the job, and the rollback-window guard |
| `placement.maxPendingProposals` | `SYNAPTOMIND_PLACEMENT_MAX_PENDING_PROPOSALS` | `500` | Cap on live `pending` proposals. Enqueueing a *new* item at the cap throws a validation error; refreshing an existing live row is unaffected |
| `placement.maxProposalsPerEnqueue` | `SYNAPTOMIND_PLACEMENT_MAX_PROPOSALS_PER_ENQUEUE` | `5` | Cap on the number of proposals generated for one thought (enqueue/propose). A caller-supplied `max_proposals` overrides it |
**Note:** The review queue stores `edge` items only. A plan's `placement` and
`lifecycle` items are not queued; `edge_review action=enqueue` returns them
in `skipped` / `skipped_kinds`. Rows
written by retired subsystems may still exist in an upgraded install — they stay
listable and filterable, but neither apply nor rollback can act on them.
---
## Slots
Context windows for agent startup.
| Setting | Env Var | Default | Description |
|---------|---------|---------|-------------|
| `slots.defaultMaxChars` | `SYNAPTOMIND_SLOTS_MAX_CHARS` | `2000` | Default max characters per slot |
---
## Graph
| Setting | Env Var | Default | Description |
|---------|---------|---------|-------------|
| `graph.maxDegree` | `SYNAPTOMIND_GRAPH_MAX_DEGREE` | `50` | Max edges per thought in chain traversal |
---
## Search
| Setting | Env Var | Default | Description |
|---------|---------|---------|-------------|
| `search.confidence.vectorFloor` | `SYNAPTOMIND_SEARCH_CONFIDENCE_VECTOR_FLOOR` | `0.9` | Unitless cosine similarity (0–1) at or above which a hit is not marked `low_confidence` and reaches calibrated `relevance` 1 |
| `search.confidence.unrelatedFloor` | `SYNAPTOMIND_SEARCH_CONFIDENCE_UNRELATED_FLOOR` | `0.8` | Unitless cosine similarity (0–1) of an unrelated query/thought pair; the zero point of the calibrated `relevance` scale (`(similarity - unrelatedFloor) / (vectorFloor - unrelatedFloor)`). The default noise gate drops cosine-only rows below calibrated `relevance` 0.4 |
The calibrated scale maps `unrelatedFloor` to `relevance` 0 and `vectorFloor` to 1, clamped to
`[0, 1]` (`src/services/text-similarity.service.ts`). The measurement behind the defaults (200 sampled
thoughts, 20 unrelated queries, real e5-small embedder): unrelated pairs reach p50 0.7627,
p95 0.8002, max 0.8355.
**Noise gate.** Cosine-only hits are dropped below calibrated `relevance` **0.4** by default
(`DEFAULT_MIN_RELEVANCE` in `src/services/search-normalize.service.ts`, a raw similarity of 0.84 on the
default scale). A row with a lexical (BM25) anchor or an id is never gated. The threshold is not a
config file setting and has no environment variable: it is overridden per request —
`min_relevance` on the MCP `memory_recall` `search`/`clusters` actions — where `0`
disables the gate and keeps the pre-calibration fail-open result set.
---
## Rate Limit
| Setting | Env Var | Default | Description |
|---------|---------|---------|-------------|
| `rateLimit.max` | `SYNAPTOMIND_RATE_LIMIT` | `200` | Max requests per window |
| `rateLimit.windowMs` | `SYNAPTOMIND_RATE_LIMIT_WINDOW_MS` | `60000` | Window duration (ms). Default: 1 min |
| `rateLimit.trustProxy` | `SYNAPTOMIND_TRUST_PROXY` | `false` | Honor `X-Forwarded-For` / `X-Real-IP` for the client identity. The rightmost `X-Forwarded-For` hop (the one added by the trusted proxy) is used. Enable only behind a trusted reverse proxy; otherwise the socket peer address is used and proxy headers are ignored |
---
## Users (multi-user)
Optional list of per-user tenants. An empty list (`users: []`, the default)
preserves today's single-user behaviour (`config.db.path`).
Each entry: `{ id, dbPath, tokenSha256 }`.
| Field | Description |
|-------|-------------|
| `id` | Unique user identifier. Used to derive the env var name for token injection |
| `dbPath` | Absolute path to that user's SQLite database file |
| `tokenSha256` | SHA-256 hex digest of the user's bearer token |
Tokens are never stored in plaintext in `config.json`: provide `tokenSha256`
(the SHA-256 of the bearer token) or inject the plaintext token through the
environment as `SYNAPTOMIND_USER_TOKEN_<ID>` (the id is upper-cased and
non-alphanumeric characters become `_`). When the env var is set it overrides
any `tokenSha256` in the file.
Generate a token hash (the digest must be lowercase hex, 64 characters):
```bash
printf '%s' 'your-bearer-token' | sha256sum | awk '{print $1}'
# or, without sha256sum:
node -e "console.log(require('crypto').createHash('sha256').update(process.argv[1]).digest('hex'))" 'your-bearer-token'
```
Each user's `dbPath` is a separate SQLite database. Deployments back up every
configured database (the main `db.path` plus each `users[].dbPath`) before an
update; see [DEPLOY.md](DEPLOY.md#rollback).
Validation (applied at startup, aborts load on failure):
- `id` values must be unique
- `dbPath` values must be absolute and must identify distinct, non-overlapping
paths (one path must not be a directory-prefix of another)
- `tokenSha256` values must be unique
- plaintext `token`/`password`/`secret` keys in a user entry are rejected
For user database paths, startup validation resolves path components in
filesystem order, including symlinks and `..`. It compares the resolved paths
and, for existing files, their device and inode numbers, rejecting aliases
including hard links. A database file need not exist yet; missing path
components are allowed. Dangling symlinks and filesystem inspection errors
other than a missing component abort configuration loading. These comparisons
are between user database paths; the main `db.path` is not included.
**Filesystem trust:** This check is a startup snapshot, not a lock. A path or
file changed after validation, including between validation and opening the
database, can invalidate the result. Keep database files and their parent
directories trusted and non-writable by untrusted users, and restart after path
changes.
Example:
```json
{
"users": [
{ "id": "alice", "dbPath": "/var/lib/synaptomind/alice.db", "tokenSha256": "..." },
{ "id": "bob", "dbPath": "/var/lib/synaptomind/bob.db", "tokenSha256": "..." }
]
}
```
With env injection:
```bash
SYNAPTOMIND_USER_TOKEN_ALICE=plaintext-token-alice
SYNAPTOMIND_USER_TOKEN_BOB=plaintext-token-bob
```
### Adopting multi-user on an existing install
`users: []` is the default and keeps the current single-user behaviour, so
upgrading SynaptoMind does **not** change authentication, data location or the
database it opens until you opt in.
To switch an existing installation over:
1. Back up the current database (`<db>.backup/` after an update, or
`sqlite3 <db> ".backup '<copy>'"`).
2. Give the existing file to a user: set that user's `dbPath` to the absolute
path the service opens today (`db.path`).
3. Add the other users with their own absolute `dbPath` files; missing files are
created and migrated on boot.
4. Add each user's `tokenSha256` (or `SYNAPTOMIND_USER_TOKEN_<ID>`) and restart.
From then on every HTTP client (`/api/*`, `/mcp`) must send that user's own
token — `SYNAPTOMIND_SECRET` stops being accepted.
5. `db.path` is no longer opened by the HTTP server in multi-user mode. It is
still the database of the MCP **stdio** transport, which stays single-user
and unauthenticated; stdio refuses to start if that path aliases a user
database, so keep it separate (or unused) if you run `--stdio`.
### Adding, rotating and removing users
The user list is read once at startup. There is no user-management API, CLI or
hot reload: every change below is a `config.json` (or environment) edit plus a
service restart.
**Add a user**
1. Generate a token and its digest:
```bash
TOKEN="$(openssl rand -hex 24)"
printf '%s' "$TOKEN" | sha256sum | awk '{print $1}'
```
2. Append an entry to `users` with a unique `id`, an absolute `dbPath` that is
not shared with or a directory-prefix of another user's path, and the
`tokenSha256` from step 1. Keep the plaintext token for the client; it is not
stored in `config.json`.
3. Restart the service. A missing database file is created and migrated on
boot. If it cannot be opened, that user is marked unavailable — the other
users keep serving — and `/health` reports `degraded` with HTTP 503.
4. Verify with the read-only ops check, run with the service's config:
```bash
cd /opt/synaptomind && ./synaptomind ops health-check --user <id>
# or, from elsewhere:
SYNAPTOMIND_CONFIG=/var/lib/synaptomind/config.json /opt/synaptomind/synaptomind ops health-check --user <id>
```
It prints that user's health report (it never creates or migrates a
database). `/health` stays public and aggregate; `/api/health-check` with
the user's token reports the same data over HTTP.
**Rotate a token.** Replace that user's `tokenSha256` (or its
`SYNAPTOMIND_USER_TOKEN_<ID>`) and restart. The old token stops authenticating
at the first request after the restart. An MCP session created by that user
stays bound to the user id, so it works again as soon as the client presents
the new token.
**Remove a user.** Delete the entry and restart. The database file is left on
disk, and the backup step's discovery pass still covers it while it sits in
`DATA_DIR`; delete it yourself once its data is no longer needed.
---
## Authentication
Auth applies to the HTTP ops endpoints under `/api/*` and the MCP HTTP transport (`/mcp`); the MCP stdio transport is local and unauthenticated by design.
Authentication depends on the mode:
- **Single-user** (`users: []`, the default): the bearer token is
`SYNAPTOMIND_SECRET`. Without it the server **fails closed**: authenticated
requests are rejected with `401`. `SYNAPTOMIND_ALLOW_INSECURE=true` disables
auth for local development only.
- **Multi-user** (`users` non-empty): every request must present one of the
configured users' own tokens (see [Users](#users-multi-user)), and is served
that user's database. `SYNAPTOMIND_SECRET` and `SYNAPTOMIND_ALLOW_INSECURE`
are inert — the server logs a warning at startup if either is set.
`SYNAPTOMIND_SERVICE_TOKEN` has been removed: every token belongs to a user.
Use `SYNAPTOMIND_USER_TOKEN_<ID>` for a user's plaintext token.
| Env Var | Default | Description |
|---------|---------|-------------|
| `SYNAPTOMIND_SECRET` | (none) | Single-user bearer token. Ignored when `users` is configured |
| `SYNAPTOMIND_USER_TOKEN_<ID>` | (none) | Plaintext token for the configured user `<id>`; overrides that entry's `tokenSha256` |
| `SYNAPTOMIND_ALLOW_INSECURE` | `false` | **Local-development only.** Single-user only: when `true`, disables auth entirely and allows anonymous access. Never enable in production |
Unauthenticated probes: `GET /health` on both the API and MCP HTTP servers is unauthenticated and returns minimal liveness. The MCP HTTP `/health` does not disclose the server version or transport details.
---
## General
| Setting | Env Var | Default | Description |
|---------|---------|---------|-------------|
| `contentLanguage` | `SYNAPTOMIND_CONTENT_LANGUAGE` | `en` | Language for MCP instructions and content normalization |
### Debug logging
`SYNAPTOMIND_DEBUG=true` enables verbose debug logging (read directly from the
environment by the logging module, `src/logging/log.ts:112`). Off by default.
---
## Docker-Specific
| Env Var | Default | Description |
|---------|---------|-------------|
| `BIND_ADDR` | `127.0.0.1` | Docker port bind address. `0.0.0.0` for network access |
| `SYNAPTOMIND_SECRET` | (none) | Single-user auth token for the HTTP ops endpoints and MCP. **Required** — without it the server fails closed with `401` |
| `SYNAPTOMIND_USER_TOKEN_<ID>` | (none) | Per-user token when `config.json` has a `users` list; `SYNAPTOMIND_SECRET` is then inert |
---
## Full Example
```json
{
"contentLanguage": "en",
"server": { "port": 3005, "host": "127.0.0.1" },
"mcp": {
"httpPort": 3006, "instructionsFile": "", "stdioStandalone": false,
"corsOrigins": [], "maxSessions": 100, "sessionTtlMs": 3600000,
"keepAliveMs": 10000, "maxEventsPerSession": 1000,
"maxSubscriptions": 128
},
"db": { "path": "./data/synaptomind.db", "busyTimeout": 5000 },
"embedder": {
"enabled": true,
"model": "Xenova/multilingual-e5-small",
"dtype": "q8",
"dimensions": 384,
"pollIntervalMs": 7000,
"cacheDir": "./data/huggingface",
"idleTimeoutMs": 600000,
"precache": false,
"batchSize": 8,
"resetDeadLetters": false
},
"thoughts": { "softLimit": 600, "hardLimitBufferPercent": 20 },
"decay": { "rate": 0.95, "intervalMs": 86400000 },
"primer": { "promoteThreshold": 5, "topN": 3 },
"autoCluster": {
"minAgeDays": 3, "minSimilarity": 0.09,
"minMembers": 3
},
"edgeDetect": {
"minSimilarity": 0.75, "topK": 10,
"maxCandidates": 100, "maxProposals": 20
},
"placement": { "maxClusterSize": 50, "proposalTtlDays": 30, "maxPendingProposals": 500, "maxProposalsPerEnqueue": 5 },
"slots": { "defaultMaxChars": 2000 },
"graph": { "maxDegree": 50 },
"search": { "confidence": { "vectorFloor": 0.9, "unrelatedFloor": 0.8 } },
"rateLimit": { "max": 200, "windowMs": 60000, "trustProxy": false },
"users": []
}
```