Skip to content

HTTP API

This is the endpoint reference for a running nidus serve. Every route maps one-to-one onto a library method; bodies and responses are JSON. To run the server, set a bind address, and configure auth, see the HTTP server guide.

Base URL is wherever the server is bound (the examples use localhost:7700). A server started with --namespaced (see Namespaced mode below) adds a /ns/{namespace} prefix in front of every path below except /health, /ready, /metrics, and /namespaces itself. Auth: when the server is started with a token, every request except the probe endpoints (GET /health, GET /ready, GET /metrics) must send Authorization: Bearer <token>; see Authentication. That token is process-wide even in namespaced mode; see single-credential. Errors return {"error": "<message>"} with a status code; see Errors. Correlation: every response carries X-Request-Id. Send your own and nidus echoes it, so the same id appears in your logs and the server’s.

Method & pathOperationLibrary method
GET /healthliveness check: 503 only when unrecoverably broken (always unauthenticated)–
GET /namespaces****namespaced mode only: every warm namespace’s name, byte size, and readiness–
GET /statsdimension, distance, the resolved open profile (ann, quantization, query_threads, mmap), collections, footprintdimension / footprint
GET /collectionslist collection namescollections
GET /aliaseslist alias to concrete collection mappingsaliases
PUT /aliases/{name}create or repoint an aliasset_alias
DELETE /aliases/{name}drop an aliasdrop_alias
POST /collections/{name}create a collectioncreate_collection
DELETE /collections/{name}drop a collection and its recordsdrop_collection
GET /collections/{name}/metaread collection metadataget_meta
PUT /collections/{name}/metareplace collection metadataset_meta
POST /collections/{name}/upsertinsert or overwrite recordsupsert
POST /collections/{name}/deletedelete by ids or by filterdelete / delete_where
GET /collections/{name}/recordsevery record in a collectionget_all
POST /collections/{name}/fts-schemadeclare full-text-indexed fieldsset_fts_schema
POST /collections/{name}/filter-indexdeclare filter-indexed fieldsset_filter_index
POST /collections/{name}/vector-namesdeclare a collection’s named-vector fieldsset_vector_names
POST /suggestranked term completions from the full-text vocabularysuggest
POST /searchnearest-neighbour searchsearch
POST /search/similar”more like this” using a stored record’s own vectorsearch_similar
POST /search/batchseveral queries in one round-trip, optionally RRF-fused–
POST /text-searchBM25 full-text searchtext_search
POST /hybrid-searchfused vector + BM25 (RRF)hybrid_search
POST /code-search***search a chunked code/docs corpus, grouped by file and symbolsearch / text_search
POST /listmetadata-only query (no vector)list
POST /aggregatecount + sum over a filter, no records materializedaggregate
POST /querySQL-shaped read syntax, compiled to the same routes abovequery / query_batch / compile
POST /flushflush buffered writes to diskflush
POST /compactreclaim dead rows and superseded log recordscompact
POST /refreshadopt another instance’s newer committed staterefresh
GET /readywhether this instance can serve (store open, not fenced, not stale)–
GET /clusterrole, writer-handle state, fencing token, commit counter, stalenesscluster_status
GET /versionsthe commit versions a pinned read can address, and this instance’s pinversions
GET /metricsPrometheus scrape: traffic, search path, lease counters (always unauthenticated)–
POST /collections/{name}/remember*text in, optionally summarize, embed, and upsert–
POST /collections/{name}/recall*text in, embed, and search with TTL filteringsearch
/mcp**the Model Context Protocol surface, nested inside this router–

* Present only in a memory-featured build (the default build; absent from a --no-default-features --features cli build). See Memory below. ** Needs the mcp feature on top of memory. See /mcp below. *** Needs the code feature on top of memory. See POST /code-search below and the code search guide. **** Only present when the server was started with --namespaced; a single-store server has no /namespaces route at all. See Namespaced mode below.

Started with --namespaced, nidus serve opens no single store: --dir/--persistence names a base location, and each request’s /ns/{namespace} prefix names which tenant’s store it addresses, opened lazily on first use. The prefix is stripped before the request reaches the handler, so every route documented on this page (its fields, its response shape, its error codes) is otherwise unchanged; only the path grows a prefix:

Terminal window
curl -s -X POST localhost:7700/ns/acme-corp/collections/docs/upsert \
-H 'content-type: application/json' \
-d '{"records": [{"id": "a", "vector": [1,0,0]}]}'

/health, /ready, /metrics, and /namespaces itself take no prefix: they answer at the process level, not per tenant (see GET /ready below for why). /mcp takes the prefix like any other route (/ns/acme-corp/mcp); see the MCP guide for the tool-argument alternative.

Omitting the prefix against a namespaced server, on any route that requires one, is a 400 naming the fix rather than a silent fall-through to some default tenant: there is no such thing as an unscoped store once --namespaced is on.

Every namespace currently held in the byte-bounded warm set: its name, its last-measured byte footprint, and its readiness. Opens nothing beyond what earlier requests already opened.

Terminal window
curl -s localhost:7700/namespaces
[
{"name": "acme-corp", "bytes": 40960, "role": "Writer", "fenced": false, "staleness_secs": 0},
{"name": "initech", "bytes": 8192, "role": "Writer", "fenced": false, "staleness_secs": 0}
]

role/fenced/staleness_secs are null for a namespace that has been admitted but has not yet finished opening; otherwise each means the same thing its GET /cluster namesake does for a single store, just reported per namespace rather than for the whole process. There is no cluster/holds_writer_handle/ lease_owner/commit_version here: those describe cluster-wide lease state, which this route does not report per tenant. A namespace evicted from the warm set (see the byte budget) simply does not appear here; its data is untouched on disk and the next request against it reopens it. A single-store server (no --namespaced) never registers this route at all, so requesting it there is a plain 404, not a mode-specific error.

See the multi-tenancy guide for the full model: isolation, the warm set, and the single-credential caveat.

Liveness probe. Returns 200 with the body ok. Always reachable without a token, so a load balancer or docker healthcheck needs no credential.

Says almost nothing about the store: only that the process is up, answering, and not unrecoverably broken. An instance waiting for the writer handle (see /ready) is alive, and killing it would be exactly wrong, so this keeps returning 200 throughout. So does an instance that is merely busy: a large upsert holds the store’s write guard for the length of the batch, which is normal work, not a fault.

It returns 503 in exactly one case: the store’s lock has been poisoned by a panic that unwound while the store was locked for writing. That leaves the in-RAM index possibly out of step with the durable bytes, and the condition never clears; every subsequent request would fail. The instance cannot recover on its own, so liveness fails and a supervisor restarts it; the durable data is intact and the fresh process rebuilds from it. In cluster mode that restart is also what releases the writer lease, letting a --wait-for-lease standby take over.

{
"status": "unhealthy",
"error": "store lock poisoned: a panic left this instance's in-RAM state untrustworthy — it must be restarted"
}

Readiness probe. 200 once this instance can actually serve; 503 otherwise. It fails for four distinct reasons, each of which should take an instance out of rotation:

  • no store yet: still starting, or a standby waiting for the writer handle;
  • fenced: this writer was superseded, so every write would fail and it must be replaced. A writer notices this on its own lease-renewal timer, so it stops reporting ready even if no write arrives to discover it;
  • stale: a reader has gone longer than --max-staleness without verifying it is current (only when that bound is set);
  • poisoned: a panic left this instance unrecoverable, so it leaves the load balancer as well as failing /health (see above).

What does not make an instance unready is being busy. A large upsert holds the store’s write guard for the whole batch, and readiness is answered without ever taking that lock, so a writer stays in rotation while it works. This matters most where there is only one writer to route to: dropping out mid-batch would take writes offline during exactly the operation the instance exists to perform.

Also always reachable without a token: an orchestrator would read a 401 as “not ready” and never route to a healthy instance.

Use this, not /health, to decide whether to send an instance traffic. The two differ whenever an instance is waiting: the server binds its port before opening the store, so /health answers immediately while /ready stays 503 until there is something to serve. That gap is the whole point for a standby writer, which may wait indefinitely for the active writer to release the handle.

Data routes answer 503 during that window too, with an error explaining that the instance is waiting or still starting up.

Namespaced mode (--namespaced) keeps this exact meaning: process-up, nothing more. There is no single store to be ready or not, so /ready answers 200 as soon as the listener is serving, before any namespace has ever been requested. Role, fencing, and staleness are answered per namespace instead, by GET /namespaces.

Who this instance is and how current it is: the introspection to reach for during an incident. Always unauthenticated-safe to scrape? No: unlike the probes, this one does require the token when one is configured.

{
"role": "ClusterWriter",
"cluster": true,
"holds_writer_handle": true,
"fenced": false,
"lease_owner": "4131-1784992862827886000",
"commit_version": 12,
"staleness_secs": 0,
"max_staleness_secs": null
}

role is one of Writer, Reader, ClusterWriter, ClusterReader, InMemory. lease_owner is this instance’s fencing token while it holds a cluster lease, and null otherwise; comparing it across instances answers “who is the writer right now”. commit_version is the manifest commit counter being served, so a reader behind the writer reports a lower number; the gap is replication lag. staleness_secs is 0 for a writer (it is the current state) and, for a reader, the age of its last successful refresh.

Every field is read from memory: no object-store round trip, so this is cheap to poll.

Prometheus text exposition (text/plain; version=0.0.4). Always reachable without a token: a scraper that got a 401 would report the target as down.

Terminal window
curl -s localhost:7700/metrics
# HELP nidus_search_queries_total Vector searches served
# TYPE nidus_search_queries_total counter
nidus_search_queries_total 1483
# TYPE nidus_http_requests_total counter
nidus_http_requests_total{route="/search",status="2xx"} 1483
nidus_http_request_duration_seconds_bucket{route="/search",le="0.01"} 1402
…

Route labels are templates (/collections/{name}/upsert), never the collection name: the scrape exposes traffic shape, not what is stored. The full metric list is in the server guide. Reading it takes no store lock, so a scrape answers instantly even during a long write.

Store-wide introspection, the network equivalent of nidus stats.

{
"dimension": 768,
"distance": "Cosine",
"ann": null,
"quantization": null,
"query_threads": 1,
"mmap": false,
"collections": ["docs", "notes"],
"footprint": {
"rows": 1240,
"dead_rows": 12,
"dimension": 768,
"vector_bytes": 3809280,
"doc_count": 1228
}
}

rows counts every vector slot on disk (including superseded ones); dead_rows is how many a compact would reclaim; doc_count is the live record count. ann is null for exact brute-force search (the default), or echoes the active ANN configuration when the server opened with one, whether from an explicit --ann hnsw/--ann ivf flag or a default recorded earlier with nidus configure (only the knobs that apply to the chosen index are reported):

"ann": { "kind": "Hnsw", "overscan": 4, "seed": 11400714819323198485,
"m": 16, "ef_construction": 200, "ef_search": 64 }

Returns the collection names as a JSON array: ["docs", "notes"]. Concrete names only; aliases are listed separately by GET /aliases.

Create a collection. The body is ignored. Upsert auto-creates a collection, so an explicit create is only needed to register an empty one (e.g. to attach metadata before any records land).

Terminal window
curl -s -X POST localhost:7700/collections/docs # → {"created": "docs"}

Drop a collection and its records. The body is ignored.

Terminal window
curl -s -X DELETE localhost:7700/collections/docs # → {"dropped": "docs"}

Read a collection’s free-form string→string metadata map.

Terminal window
curl -s localhost:7700/collections/docs/meta
# → {"model": "text-embedding-3-small", "owner": "search-team"}

Replace a collection’s metadata map wholesale.

Terminal window
curl -s -X PUT localhost:7700/collections/docs/meta \
-H 'content-type: application/json' \
-d '{"model": "text-embedding-3-small", "owner": "search-team"}'
# → {"ok": true}

Declare which attribute fields of a collection are full-text indexed for BM25. Run it once before (or after) upserting; see Full-text search for the ranking model.

Terminal window
curl -s -X POST localhost:7700/collections/docs/fts-schema \
-H 'content-type: application/json' \
-d '{"fields": ["body"]}'
# → {"ok": true}

A field entry may also be an object, tuning BM25 and the analyzer for that field alone. Every key but field is optional and defaults to what the bare-name form gets: k1 1.2, b 0.75, language "english", no ASCII folding, no token-length cap (details):

Terminal window
curl -s -X POST localhost:7700/collections/docs/fts-schema \
-H 'content-type: application/json' \
-d '{"fields": ["title", {"field": "body", "k1": 1.5, "b": 0.3, "ascii_folding": true}]}'
# → {"ok": true}

Declare which attribute fields are indexed for the text predicates (Fuzzy, ContainsAllTokens, ContainsAnyToken, ContainsTokenSequence, Regex). Documents already written are indexed as part of the declaration; see Indexing the text predicates.

This changes how fast those predicates run, never what they return.

Terminal window
curl -s -X POST localhost:7700/collections/docs/filter-index \
-H 'content-type: application/json' \
-d '{"fields": ["body"]}'
# → {"ok": true}

A field entry may also be an object, choosing which structures to build. Both tokens (the three token predicates) and trigrams (Fuzzy and Regex) default to true, and a field with both off is rejected as a 400. An empty fields list drops the declaration:

Terminal window
curl -s -X POST localhost:7700/collections/docs/filter-index \
-H 'content-type: application/json' \
-d '{"fields": ["title", {"field": "tag", "trigrams": false}]}'
# → {"ok": true}

Declare a collection’s additional named-vector fields: the names an upsert may set on Record::vectors and a search may score with names, beyond the reserved default vector that Record::vector always populates. Run it before the first upsert or search that uses a name; see Named vectors.

Terminal window
curl -s -X POST localhost:7700/collections/docs/vector-names \
-H 'content-type: application/json' \
-d '{"names": ["title", "summary"]}'
# → {"ok": true}

Never include "default" here: it needs no declaration and is refused with a 400 naming the reason. Re-running replaces the whole set rather than adding to it, mirroring fts-schema. An upsert naming an undeclared vector is a 400 naming the record and the missing declaration.

An alias is an indirect name resolving to one concrete collection, one hop only (an alias may not point at another alias). Data routes (upsert, delete, get/records, meta) accept an alias in place of {name} and resolve it; the structural routes above (create/drop a collection, fts-schema, filter-index) do not, and reject an alias with a 400. See the blue/green reindex guide for the end-to-end sequence.

Every alias and the concrete collection it currently points at:

Terminal window
curl -s localhost:7700/aliases # → {"docs": "docs_v2"}

Create or repoint an alias. The body names the target collection, which must already exist. Idempotent: repointing an alias that already points there is a no-op.

Terminal window
curl -s -X PUT localhost:7700/aliases/docs \
-H 'content-type: application/json' \
-d '{"target": "docs_v2"}'
# → {"alias": "docs", "target": "docs_v2"}

Drop an alias. The underlying collection is untouched.

Terminal window
curl -s -X DELETE localhost:7700/aliases/docs # → {"dropped": "docs"}

Insert or overwrite records by id. Each record is {id, vector, attrs}; vector length must match the store dimension, and may be omitted for a text-only document. A record may also carry vectors, a {name: [floats]} map of additional named vectors declared on the collection first (see vector-names above); every entry must match the store dimension, and "default" is refused there, since that name is what the bare vector field is for. attrs values are tagged: {"Str": …}, {"Int": …}, {"Bool": …}, {"List": […]}, {"Float": …}, {"DateTime": …} (epoch milliseconds), and the unit variant Null is the bare string "Null", not an object.

Float and Int are distinct types and never cross-compare in a filter, so a whole number sent as {"Float": 1.0} stays a Float on the way back out. Pick one spelling per attribute and keep to it.

Terminal window
curl -s localhost:7700/collections/docs/upsert \
-H 'content-type: application/json' \
-d '{"records": [
{
"id": "a",
"vector": [1, 0, 0],
"attrs": {"lang": {"Str": "rust"}, "ts": {"Int": 1781000000}}
}
]}'
# → {"upserted": 1}

The whole batch is all-or-nothing: a dimension mismatch or other fault rolls the store back, and the call returns 400 having changed nothing.

Delete by explicit ids, or by an attribute filter: supply ids or filter; filter wins if both are present.

Terminal window
# By id
curl -s localhost:7700/collections/docs/delete \
-H 'content-type: application/json' -d '{"ids": ["a", "b"]}'
# By filter (delete everything archived)
curl -s localhost:7700/collections/docs/delete \
-H 'content-type: application/json' \
-d '{"filter": [{"Eq": ["status", {"Str": "archived"}]}]}'
# → {"deleted": 7}

Every live record in the collection (id, vector, attrs) as a JSON array. Useful for export or for re-embedding against a new model. There is no pagination here; use POST /list when you want filtering or paging.

Nearest-neighbour search. query is the only required field. An empty or omitted scope searches every collection in one merged ranking (sound because all collections share one embedding space).

Terminal window
curl -s localhost:7700/search \
-H 'content-type: application/json' \
-d '{
"query": [1, 0, 0],
"scope": ["docs"],
"top_k": 5,
"min_score": 0.2,
"filter": [{"Eq": ["lang", {"Str": "rust"}]}]
}'
FieldDefaultMeaning
query– (required)query vector; length must equal the store dimension. A store with dimension 0 holds no vectors (see keyword-only ingest) and answers 400 here, naming that as the reason.
scopeall collectionscollection names to search
top_k10maximum hits to return
offset0top-ranked hits to skip, for pagination
min_scorenonedrop hits scoring below this similarity
filternoneAND of predicates applied before scoring
exactfalseforce the exact scan, bypassing any index and quantization
include_attributesall attrsreturn only these attrs
exclude_attributesall attrsreturn every attr but these
rank_bynonea ranking expression over the metric
limit_pernonecap hits per distinct value of an attribute
diversitynoneMMR lambda spreading hits apart in vector space (1.0 relevance, 0.0 variety)
expandnonewiden each hit with its document’s neighbouring chunks; see expand
reranknonere-score the candidate window with a hosted cross-encoder; see below
planfalsereport how the query ran alongside the hits; see Query plans
namesnonenamed vectors to score; see Scoring several named vectors
name_weightsnoneper-name weight, keyed by name; meaningless when names is empty
pool"Max"how several names scores fold into one; meaningless when names is empty

Omitting rerank leaves the response byte-identical to a nidus without the feature. rerank is compiled in under the rerank feature, part of the default build; requesting it against a build without a --rerank-provider configured is a 400 naming the flag, never a silent pass-through of the un-reranked order.

{"query": [1, 0, 0], "top_k": 5,
"rerank": {"query": "how do users sign in", "overscan": 10, "text_attr": "nidus.text"}}
rerank fieldDefaultMeaning
query– (required)the text the cross-encoder reads against each candidate; /search carries no text of its own, so this is required here
overscan10retrieve top_k * overscan candidates before reranking
text_attr"nidus.text"which attr holds each candidate’s text

A candidate missing text_attr (or holding a non-Str/empty value there) is passed through unranked: it keeps its original metric score and lands after every successfully reranked hit, in its original relative order. min_score and rank_by are cosine-scale and run before reranking; the reranked score afterward is on the provider’s own scale, not cosine. limit_per is re-applied after reranking. See the reranking guide for the full behaviour.

Returns hits ordered by (score desc, collection, id); the tie-break is a guarantee, which is what makes paging coherent:

[{"collection": "docs", "id": "a", "score": 1.0, "attrs": {"lang": {"Str": "rust"}}}]

offset pages one ranking: {"top_k": 20} then {"top_k": 20, "offset": 20} tiles it with no gap and no overlap. An offset past the last hit returns [] rather than an error. offset + top_k may not exceed 10 000: beyond that the request is a 400, never a silently shortened page. A page is stable only against an unchanging store; concurrent writes shift the ranking under a paged walk.

exact: true runs the exact brute-force scan for that one request, bypassing the ANN walk, the per-segment index, and the quantized first pass; the store keeps its index for every other query.

include_attributes and exclude_attributes choose which attrs the hits carry; omit both for every attr, exactly as before. Sending both in one request is a 400, not a precedence rule. The projection is applied where the hit is built, so an excluded attr is never serialized, which is the point on a collection of long text bodies.

rank_by layers a recency decay over the metric: an age penalty subtracted from each hit’s score, so it works for every distance metric and for BM25 alike.

Terminal window
curl -s localhost:7700/search \
-H 'content-type: application/json' \
-d '{
"query": [1, 0, 0],
"rank_by": {"Decay": {"field": "updated_at",
"origin": 1770000000000,
"scale": 604800000,
"lambda": 0.2}}
}'
Decay fieldDefaultMeaning
field– (required)timestamp attr: a DateTime or an Int, epoch milliseconds
origin– (required)“now” in epoch ms; ages are measured back from here
scale604800000 (7 days)the age at which the factor equals decay
decay0.5the factor at one scale of age (0.5 makes scale a half-life)
lambda1.0score a fully-decayed hit gives up
missing1.0factor for a record with no usable timestamp (no penalty)
count_fieldnoneinteger attr adding a second, subtracted reinforcement term; field may be empty when only this term is wanted
count_scale10.0saturation constant k in n / (n + k); must be positive when count_field is set
count_lambda1.0penalty an entirely un-reinforced record pays

The score is base − lambda × (1 − decay^(age / scale)). missing defaults to 1.0, so enabling decay never buries records written before the field existed. rank_by does not force an exact scan; over an ANN or quantized result set it reorders within an approximate candidate set. A malformed expression (a non-positive scale, a decay outside (0, 1), a negative lambda) is a 400.

count_field defaults to unset, so an existing rank_by with no count fields ranks exactly as it always has. Set it to read an integer count attribute, typically nidus.access_count from a reinforced recall, and subtract count_lambda * (1 - n / (n + count_scale)) from the score: a high count pays a small penalty, and a record with no count at all pays the full count_lambda, so memories nothing ever recalls sink.

Terminal window
curl -s localhost:7700/search \
-H 'content-type: application/json' \
-d '{"query": [1, 0, 0], "top_k": 20, "limit_per": {"field": "path", "max": 2}}'

Records missing the attribute share one group, and the value is read from the stored record, so exclude_attributes cannot lift the cap. The cap is exact only within an over-fetch window, so a capped page may come back shorter than top_k; what is guaranteed is that no page carries more than max hits for one value.

A collection with named vectors declared can be searched on more than the default one:

Terminal window
curl -s localhost:7700/search \
-H 'content-type: application/json' \
-d '{"query": [1, 0, 0], "names": ["title", "summary"], "pool": "Max"}'

names is empty by default, so an old request that never sends it searches only default, byte-identical to before this existed. A record is scored on whichever of names it actually carries, so a record missing one of them is not penalized for the gap. name_weights scales one name’s score before pooling; a name absent there weights 1.0. pool folds the (weighted) per-name scores into one record score: "Max" (the default) takes the best, "Sum" adds them all. Both fields are ignored, by the store, when names is empty.

“More like this”: search using the vector already stored at collection/id, instead of a caller-supplied query. Otherwise it takes the same fields as POST /search.

Terminal window
curl -s localhost:7700/search/similar \
-H 'content-type: application/json' \
-d '{"collection": "docs", "id": "a1", "top_k": 5}'
FieldDefaultMeaning
collection– (required)collection the source record lives in
id– (required)id of the record to search with
scopethe source’s own collectioncollection names to search
top_k10maximum hits to return
offset0top-ranked hits to skip, for pagination
min_scorenonedrop hits scoring below this similarity
filternoneAND of predicates applied before scoring
exactfalseforce the exact scan, bypassing any index and quantization
include_attributesall attrsreturn only these attrs
exclude_attributesall attrsreturn every attr but these
rank_bynonea ranking expression over the metric
limit_pernonecap hits per distinct value of an attribute
diversitynoneMMR lambda spreading hits apart in vector space (1.0 relevance, 0.0 variety)
expandnonewiden each hit with its document’s neighbouring chunks; see expand
planfalsereport how the query ran alongside the hits; see Query plans

The one difference from /search: an omitted or empty scope searches only the source’s own collection, not every collection in the store. /search/similar does not take names/name_weights/pool: it always searches the source record’s default vector. The nidus similar CLI and the library’s search_similar are not limited this way, since they call SearchOpts directly rather than going through this narrower wire shape.

The source record is always excluded from its own results, by (collection, id) identity rather than by score, so a genuine duplicate of the source (also scoring near 1.0) still comes back. collection/id naming no record, or a record with no stored vector to search with (a text-only entry), is a 400 naming the id and the reason, not an empty result.

BM25 full-text search of declared fields. Returns the same hit shape as /search. Takes scope, top_k, offset, filter, rank_by, limit_per, diversity, expand, min_score (here a raw BM25 floor, not cosine), the include_attributes/exclude_attributes projection, rerank, and the query itself in one of two spellings.

Terminal window
curl -s localhost:7700/text-search \
-H 'content-type: application/json' \
-d '{"field": "body", "query": "running quickly", "scope": ["docs"], "top_k": 5}'

Name several fields with clauses instead; each clause carries its own text:

Terminal window
curl -s localhost:7700/text-search \
-H 'content-type: application/json' \
-d '{
"clauses": [
{"field": "title", "query": "rust"},
{"field": "body", "query": "async runtime"}
],
"combine": "Sum",
"top_k": 5
}'
fielddefaultmeaning
field + query–the single-clause spelling
prefixfalseexpand the field+query shorthand’s final term as a prefix; ignored when clauses is sent
clauses–[{field, query, prefix}, …], one entry per field searched
combine"Sum""Sum" adds every matched clause’s score; "Max" takes the strongest
explainfalsereport each matched clause’s own BM25 score
highlightabsent{} for defaults, or {"max_fragments": 2, "fragment_chars": 120}

field+query and clauses are mutually exclusive, and an empty clauses list is a 400: an empty result set would otherwise read as “no matches” rather than “no query”.

Each clause’s prefix (default false, absent means false) matches only that clause’s final term as a prefix, for autocomplete/typeahead; earlier terms still match exactly:

Terminal window
curl -s localhost:7700/text-search \
-H 'content-type: application/json' \
-d '{"field": "title", "query": "quick br", "prefix": true, "top_k": 5}'

The expansion is capped at 256 terms; past the cap the match keeps the commonest completions rather than erroring. With explain: true, a hit’s clause score carries "expansion": {"matched": N, "scored": M}, matched > scored meaning the cap truncated it. See prefix matching for typeahead.

/text-search also takes the same rerank field as /search (overscan default 10, text_attr default "nidus.text"). query is optional when the query is named as one field plus its query: an omitted or empty rerank.query falls back to that text, so {"rerank": {}} is a valid minimal form. The clauses spelling has no single text to fall back to, so a rerank there must name rerank.query itself; omitting it is a 400. See the /search entry above and the reranking guide for the field shape and the passthrough/score-scale rules, which apply here unchanged.

/text-search has no plan field: it always runs the same BM25 postings walk, so there is no branch worth a plan. Query plans below cover only /search, /search/similar, and /hybrid-search. A prefix clause’s expansion cap is reported through explain instead, as expansion on that clause’s score.

Search a corpus ingested with nidus code ingest, grouped by file with each hit’s matching symbols. Needs the code feature on top of memory, both part of the default build; a build without it (--no-default-features) answers 404 here, not 400. Never returns a raw vector or the source body: read the file at the given lines for ground truth.

Terminal window
curl -s localhost:7700/code-search \
-H 'content-type: application/json' \
-d '{"collection": "code", "query": "where do we release commission payments", "limit": 10}'
FieldDefaultMeaning
collection– (required)collection to search
query– (required)the search text: a natural-language description for a vector search, or exact keywords for BM25, which matches the chunk body, the file path, the symbol name, and the doc comment (so a path or a symbol name works on its own)
limit10maximum files to return, ranked by their best-matching symbol
filternoneAND of predicates applied before scoring
vectornonetrue forces a vector search, false forces BM25; omit to let the store decide (a dimension-0 store, ingested with no embedder, answers BM25)
{
"files": [
{
"path": "internal/finance/commission/release.go",
"language": "go",
"symbols": [
{ "symbol": "ReleaseCommission", "kind": "function",
"start_line": 42, "end_line": 78, "score": 8.31 }
]
}
]
}

language is present only for a file that was AST-chunked (a language wdpkr-core recognises); a markdown or generically-chunked file carries language: null and its symbols carry symbol: null and kind: null, since a heading or a generic split is not a symbol. Files are ordered by their best-matching symbol’s score; symbols within a file keep that same descending order.

vector: true against a build with code but no memory embedder configured (a --no-default-features --features code build, or the default build with no --embed-provider at serve time) is a 400 naming the fix, rather than a silent BM25 fallback the caller did not ask for.

Ranked term completions from a field’s full-text vocabulary, for an autocomplete dropdown. Ranked by document frequency (commonest first), which is the opposite of how a prefix clause in /text-search ranks documents. scope and filter are spelled exactly as /text-search spells them: an omitted or empty scope means every collection. limit defaults to 10 (a dropdown, not a page) and is bounded by the same MAX_TOP_K ceiling as the other read routes. fuzzy is optional and defaults to true: send false to opt out.

Terminal window
curl -s localhost:7700/suggest \
-H 'content-type: application/json' \
-d '{"scope": ["docs"], "field": "body", "prefix": "nid", "limit": 10}'
{ "suggestions": [ { "term": "nidus", "df": 42 },
{ "term": "nidification", "df": 3 } ],
"matched": 2 }

matched counts every term matched before the 256-term cap; matched exceeding suggestions.length is the truncation signal. When the fuzzy fallback below answers, matched counts what it matched, so matched of 0 means neither leg found anything, not that the exact prefix missed. The prefix is folded (lowercased, optionally ASCII-folded) the same way a prefix clause folds it, and matched against the field’s surface forms, so completions are real words rather than stems: every keystroke of running completes to running. Two spellings of one stem are two completions sharing that stem’s df. A field with no full-text schema, or a collection that does not exist, contributes nothing, so a scope naming only those returns 200 with an empty suggestions list rather than an error.

Each df is a conditioned count, in two ways:

  • filter narrows it to the matching documents, so a dropdown can be scoped to what the caller is allowed to see. A completion whose only documents the filter excludes does not appear at all, rather than appearing with a corpus-wide count (the count itself would describe documents the caller cannot retrieve).
  • The words before the final token narrow it too. Only the final token is completed, but the earlier words are not discarded: a completion’s df counts only documents carrying all of them, so "quick br" ranks br* by the documents that also say “quick”, and a brown that never co-occurs with quick is not offered. Send the whole phrase typed so far.

A single-token prefix, or one whose earlier words are all stopwords ("the br"), has no head terms and so is unconditioned: it behaves exactly as "br" does.

If the exact prefix matches nothing at all, suggest retries the final token against the field’s vocabulary within a short edit budget and returns what it finds, ranked nearest first:

Terminal window
curl -s localhost:7700/suggest \
-H 'content-type: application/json' \
-d '{"scope": ["docs"], "field": "body", "prefix": "runing"}'
{ "suggestions": [ { "term": "running", "df": 12 } ], "matched": 1 }

The budget follows the fragment’s length: none below 4 characters, 1 at 4 to 7, 2 at 8 or more, and none again past 64, since a fragment that long is not a word being typed. This is a fallback, not a widening: a prefix that already completes costs exactly what it did before. Both df conditions above apply to it unchanged, so a completion the filter excludes is absent here too. Send "fuzzy": false to turn it off.

Terminal window
# only vocabulary from this tenant's documents, and only words that continue the phrase
curl -s localhost:7700/suggest \
-H 'content-type: application/json' \
-d '{"scope": ["docs"], "field": "body", "prefix": "quick br",
"filter": [{"Eq": ["tenant", {"Str": "acme"}]}]}'

Fuse a vector query and a BM25 text query with Reciprocal Rank Fusion. Takes vector plus the text leg (field + text, or the same clauses + combine + prefix as /text-search), plus top_k, offset (which pages the fused ranking, never a leg), filter, rrf_k (default 60), candidates (default 100), explain/highlight, and plan (report how the query ran; see Query plans). There is no min_score (a fused RRF score has no absolute scale). It also takes expand, applied after fusion so the RRF order is untouched. Returns the same hit shape as /search.

Terminal window
curl -s localhost:7700/hybrid-search \
-H 'content-type: application/json' \
-d '{"vector": [1,0,0], "field": "body", "text": "vector database", "top_k": 5}'

vector_weight and text_weight (both default 1.0) scale each leg’s contribution to the fused score. Leaving them out (or sending 1.0 for both) reproduces the unweighted fusion exactly. A non-finite or negative weight is a 400.

/hybrid-search also takes the same rerank field as /search (query required, overscan default 10, text_attr default "nidus.text"): the fused RRF ranking is the candidate window it reranks. See the /search entry above and the reranking guide for the field shape and the passthrough/score-scale rules, which apply here unchanged.

Terminal window
curl -s localhost:7700/hybrid-search \
-H 'content-type: application/json' \
-d '{"vector": [1,0,0], "field": "body", "text": "CVE-2026-1234", "text_weight": 3.0}'

limit_per and diversity cap and spread the fused ranking, the same as on /search (see Capping hits per attribute value and diversity); both run after fusion, on the one ranking that exists by then. /hybrid-search does not take names/name_weights/pool: a hybrid query always searches the default vector.

Terminal window
curl -s localhost:7700/hybrid-search \
-H 'content-type: application/json' \
-d '{"vector": [1,0,0], "field": "body", "text": "vector database", "limit_per": {"field": "path", "max": 2}}'

expand: widen a hit with its neighbouring chunks

Section titled “expand: widen a hit with its neighbouring chunks”

/search, /search/similar, /text-search and /hybrid-search all take expand, which adds a context string to each hit: the hit’s own chunk plus radius chunks either side of it, stitched back into the passage they came from.

FieldDefaultMeaning
radius0chunks stitched either side. 0 reports the hit’s own text
parent_fieldnidus.parent_idattr grouping a document’s chunks
index_fieldnidus.chunk_indexattr ordering the chunks within a document
text_fieldnidus.textattr holding each chunk’s text

Those defaults are exactly what nidus ingest stamps, so {"radius": 1} is the whole object a chunked corpus needs.

Terminal window
curl -s localhost:7700/search -H 'content-type: application/json' -d '{"query": [1,0,0], "top_k": 5,
"limit_per": {"field": "nidus.parent_id", "max": 1},
"expand": {"radius": 1}}'

expand is payload only. It runs after every pass that can reorder or thin a ranking, so the ids, the scores and the order are identical to the same query without it, and context is the one key that differs. A hit whose record carries no chunk attrs has no context rather than failing the query. Omitting expand leaves the response byte-identical to a nidus without the feature.

On /collections/{name}/recall the same capability is spelled rollup, which pairs it with the per-document cap in one object:

FieldDefaultMeaning
per_parent1chunks kept per document
neighbours0chunks stitched either side of each survivor
Terminal window
curl -s localhost:7700/collections/docs/recall -H 'content-type: application/json' -d '{"query": "how does the writer lock work", "rollup": {"neighbours": 1}}'

This is the only spelling the /mcp recall tool offers: a model means “one result per document, widened”, not a set of attr names.

explain and highlight add an annotations object to each hit. Both are opt-in, and the key is absent otherwise: an unannotated response is byte-for-byte what it always was.

{
"collection": "docs", "id": "a", "score": 0.031, "attrs": {"title": {"Str": "vector search"}},
"annotations": {
"vector": {"rank": 0, "score": 0.98},
"text": {"rank": 1, "score": 1.10},
"clauses": [{"field": "title", "score": 0.49}, {"field": "body", "score": 0.61}],
"highlights": [
{"field": "body", "fragments": [
{"text": "the engineers were running", "spans": [[19, 26]]}
]}
]
}
}

vector/text are the fusion legs’ own rank and score, and appear only on /hybrid-search. clauses lists the clauses that actually matched, in query order. spans are [start, end) byte offsets into that fragment’s text, covering the word as the document spells it: a query for run marks running. Highlighting reads the stored text, so it still works on a field include_attributes/exclude_attributes dropped from the payload: that pairing (drop the long body, keep the snippet) is the point.

plan: true on /search, /search/similar, or /hybrid-search wraps the response in an object carrying both the hits and a plan describing how the query was answered. false (the default) keeps the response the bare hit array, byte-identical to a nidus without the feature. /text-search has no plan: it always runs the same BM25 postings walk, so there is no branch worth reporting.

Terminal window
curl -s localhost:7700/search \
-H 'content-type: application/json' \
-d '{"query": [1,0,0], "top_k": 5, "plan": true}'
{
"hits": [{"collection": "docs", "id": "a", "score": 0.98, "attrs": {}}],
"plan": {
"path": "quantized",
"rows_scanned": 12000,
"candidates": {
"surfaced": 400, "survived": 55,
"dropped_out_of_scope": 0, "dropped_stale": 0,
"dropped_filtered": 340, "dropped_min_score": 5
},
"narrowing": {"state": "inactive"},
"timings": {"first_pass_us": 120, "rescore_us": 340, "total_us": 610}
}
}

path names which branch of the search answered the query:

pathMeaning
annthe HNSW/IVF index was walked for an over-fetched candidate set
ann_prefilter_fallbacka selective filter made the index walk too thin, so an exact scan ran instead
segmenteda per-segment IVF index merged with an exhaustive scan of the active segment and any sealed segment below the indexing threshold
quantizedthe int8/binary first pass, then an exact f32 rerank
exactbrute-force cosine over every row in scope

Thin results paired with ann_prefilter_fallback is the operator story this section exists for: it means a filter narrow enough to starve the ANN walk, before the walk ever ran, not a broken index. Widening the filter, or raising --ann-overscan so the walk over-fetches a larger candidate set, are the two levers.

rows_scanned is the row count fed to a brute-force scan, and is absent, not 0, on the ann and segmented paths: no full scan happens on either, so a number there would claim precision the walk never had. candidates breaks an index walk’s surfaced set down by why each candidate did not survive: surfaced, survived, and dropped_out_of_scope / dropped_stale / dropped_filtered / dropped_min_score; it is absent on paths that never surface an index candidate set (exact, quantized).

narrowing reports whether the opt-in filter index narrowed the scan before it ran, one of three states:

stateMeaning
inactiveno collection in scope declares a filter index
declinedan index exists but could not answer this filter, so the full scan ran anyway
narrowedthe index cut the scan down to candidates rows

timings reports per-phase wall time in microseconds: narrow_us, gather_us, walk_us, resolve_us, first_pass_us, rescore_us, score_us, each present only for the phases the path taken actually runs, plus total_us, which always runs and covers the whole query.

NIDUS_SLOW_QUERY_MS (see Configuration) logs the short form of this same plan, path/total_us/rows scanned/candidates, for any query crossing that threshold, unconditionally: it needs no per-query plan: true, since an operator chasing a slow store cannot annotate every query in advance.

Metadata-only query: no vector, no scoring. Same scope and filter as search, plus offset/limit for pagination.

Terminal window
curl -s localhost:7700/list \
-H 'content-type: application/json' \
-d '{
"scope": ["docs"],
"filter": [{"Eq": ["lang", {"Str": "rust"}]}],
"offset": 0,
"limit": 100
}'

limit defaults to 100, offset to 0. /list takes the same include_attributes/exclude_attributes projection as /search, with the same 400 when both are sent. The response shape matches search (hits with a score of 0, since nothing is scored).

order_by sorts by an attribute instead of storage order: ORDER BY with no vector query. Sorting runs over the whole match set before the page is cut, so offset/limit walk the sorted order.

Terminal window
curl -s localhost:7700/list \
-H 'content-type: application/json' \
-d '{"order_by": {"field": "updated_at", "descending": true}, "limit": 20}'

descending defaults to false. Values that do not order against the first orderable one (a different Value variant, an unorderable Null/List, or a record missing the attribute) sort into one trailing bucket, which stays trailing when reversed.

Answer up to 16 vector queries in one round-trip. Each entry of queries takes exactly the fields /search does, with its own scope, filter and top_k.

Terminal window
curl -s localhost:7700/search/batch \
-H 'content-type: application/json' \
-d '{
"queries": [
{"query": [1, 0, 0], "top_k": 5},
{"query": [0, 1, 0], "top_k": 5, "filter": [{"Eq": ["lang", {"Str": "rust"}]}]}
]
}'
{"results": [[{"collection": "docs", "id": "a", "score": 0.98, "attrs": {}}], []]}

results holds one ranking per query, in request order. The whole batch is validated before any query runs, so a malformed leg answers 400 rather than returning a partial result that cannot be told apart from a complete one.

Add fuse to merge the legs into a single ranking with the same RRF /hybrid-search uses; the response then carries fused instead of results:

Terminal window
curl -s localhost:7700/search/batch \
-H 'content-type: application/json' \
-d '{
"queries": [{"query": [1, 0, 0]}, {"query": [0, 1, 0]}],
"fuse": {"rrf_k": 60, "weights": [1.0, 0.5], "top_k": 10}
}'
FieldDefaultMeaning
queries– (required)1–16 search bodies, each shaped exactly like /search
fusenonemerge the legs into one ranking instead of returning them side by side
fuse.rrf_k60RRF smoothing constant
fuse.weightsall 1.0per-leg weights; must be empty or exactly as long as queries
fuse.top_k10how many fused hits to return

A weights list of the wrong length is a 400 rather than a zero-filled short list, which would silently re-weight the wrong query.

plan is not supported here and is a 400, the same as rerank. A batch answers many queries into one response that has nowhere to carry a per-query plan, so accepting the field would mean dropping it in silence. Ask for the plan on a single-query /search.

Count the filter-matching records and total numeric attributes, without materializing any of them. Same scope and filter as /list.

Terminal window
curl -s localhost:7700/aggregate \
-H 'content-type: application/json' \
-d '{
"scope": ["docs"],
"filter": [{"Eq": ["lang", {"Str": "rust"}]}],
"sum": ["bytes"]
}'
{"count": 12, "sums": {"bytes": {"Int": 40960}}}

count is always reported; sum names attributes to total. Each total is a tagged value: Int while every addend was an Int, Float once any Float joined. A missing or non-numeric value is skipped rather than counted as zero. A filter matching nothing answers {"count": 0, ...} rather than erroring.

Add group_by to get the same figures per distinct value of an attribute, in the same pass and beside the unchanged totals:

Terminal window
curl -s localhost:7700/aggregate \
-H 'content-type: application/json' \
-d '{"sum": ["bytes"], "group_by": "lang"}'
{
"count": 12,
"sums": {"bytes": {"Int": 40960}},
"groups": [
{"value": {"Str": "rust"}, "count": 9, "sums": {"bytes": {"Int": 38000}}},
{"value": null, "count": 3, "sums": {"bytes": {"Int": 2960}}}
]
}

Groups come back largest first, with ties broken deterministically so repeating a query repeats the order. A null value is the group of records missing the attribute, not the same as records holding a Null. groups is omitted entirely when no group_by was asked for, so an existing client sees the response it always saw. If the distinct values exceed the server’s cap (10 000), later ones are dropped and groups_truncated is true.

SQL-shaped read syntax (see the SQL guide): a SELECT compiled to the same typed request the five routes above already run, then run through it. A single statement’s response is byte-identical to the equivalent typed route’s; a ;-separated script answers as a JSON array, in request order.

Terminal window
curl -s localhost:7700/query \
-H 'content-type: application/json' \
-d "{\"sql\": \"SELECT * FROM notes WHERE lang LIKE 'r*' ORDER BY knn([1,0,0,0]) LIMIT 3\"}"
FieldDefaultMeaning
sql– (required)one or more ;-separated SELECT statements
compile_onlyfalsecompile without running; the response is the compiled form (kind, collections, and the resolved options), never hits

A parse or compile failure names a byte offset, what went wrong, and the SPEC.md §7 section that owns the rule, and is a 400:

{"error": "sql parse error at byte 17: expected a value after '=' (§7.3 boolean composition)"}

Every route that takes a filter (/search, /text-search, /hybrid-search, /list, /aggregate, and /collections/{name}/delete) takes the same one: a JSON array of predicates, AND-combined. Each predicate is a single-key object naming the variant.

GroupPredicates
Equality & setsEq, Ne, In, NotIn
Ranges (same-type, orderable)Lt, Le, Gt, Ge
PatternsGlob, IGlob (ASCII-case-insensitive), Regex
List containmentContains, NotContains, ContainsAny
TextFuzzy, ContainsAllTokens, ContainsAnyToken, ContainsTokenSequence
Boolean groupsAll, Any, Not
[
{"Any": [{"Eq": ["lang", {"Str": "rust"}]}, {"Eq": ["lang", {"Str": "go"}]}]},
{"Not": {"Contains": ["tags", {"Str": "generated"}]}},
{"Ge": ["updated_at", {"DateTime": 1770000000000}]},
{"Fuzzy": ["name", "nidus", 2]},
{"Regex": ["path", "src/[a-z]+/mod\\.rs"]}
]

All/Any/Not take predicates rather than values, so any boolean shape nests inside the outer AND. Not and Ne differ on a missing attribute: Ne requires the key present, Not is a true complement. A filter is validated once per query, before any row is scanned, so an unparseable Regex or a Fuzzy budget above 8 is refused with an error rather than quietly matching nothing. See Search & filters for the full semantics.

Two text-native routes over the same store: send text, not vectors, and the server embeds (and optionally summarizes) it for you. Both are compiled in only under the memory feature, which is part of the default build and therefore present in cargo install nidus and the prebuilt cargo binstall binary. A --no-default-features --features cli build has neither route; hitting them there is a 404, not a 400. See the remember & recall guide for setup (an embedder, optionally a summarizer) and parity across the surfaces for how these two routes compare to the Rust API, the CLI, and the MCP tools below.

Store a piece of text: embed it (summarizing first if asked), then upsert it under the given id.

Terminal window
curl -s localhost:7700/collections/notes/remember \
-H 'content-type: application/json' \
-d '{"id": "note-1", "text": "the deploy window moved to Fridays",
"attrs": {"project": {"Str": "nidus"}}, "ttl_seconds": 604800}'
# → {"ok": true, "upserted": 1, "id": "note-1", "deduped": false}
FieldDefaultMeaning
id– (required)the record id to write. Unlike the MCP remember tool, nothing here derives one for you; omit it and the request is a 400
text– (required)the text to remember
mode"raw""raw" embeds the text as given; "summarize" summarizes first and embeds the summary (needs a summarizer)
attrs{}structured metadata stored alongside the text
ttl_secondsnone (never expires)seconds until this entry expires, counted from the moment it is written
dedupe_thresholdnone (dedup disabled)cosine-similarity floor above which this write updates the nearest existing entry instead of inserting a near-duplicate

The response is {"ok": true, "upserted": <n>, "id": "<id>", "deduped": <bool>}. id and deduped are not an echo of what you sent. When dedupe_threshold is set and an existing entry scores above it, the write updates that entry in place instead of inserting a competing near-duplicate, deduped comes back true, and id is the id of the entry that was actually written, which may differ from the id you sent. Read id out of the response rather than assuming it matches the request.

mode: "summarize" stamps the generated summary into nidus.summary in attrs, alongside the original text.

Errors: no embedder configured at serve time is a 400 naming --embed-provider; mode: "summarize" with no summarizer configured is a separate 400 naming --summarize-provider.

Search by meaning: embed the query text, then rank the collection against it.

Terminal window
curl -s localhost:7700/collections/notes/recall \
-H 'content-type: application/json' \
-d '{"query": "when do we deploy", "top_k": 5,
"filter": [{"Eq": ["project", {"Str": "nidus"}]}]}'
FieldDefaultMeaning
query– (required)query text; embedded server-side
top_k10maximum hits to return
min_scorenonedrop hits scoring below this cosine similarity
filternoneAND of predicates applied before scoring
diversitynoneMMR lambda spreading hits apart in vector space (1.0 relevance, 0.0 variety)
rollupnoneread the collection as a chunked corpus; see expand
reranknonere-score the candidate window with a hosted cross-encoder; see below
reinforcefalsestamp nidus.access_count / nidus.last_accessed on every returned entry; see reinforcement
extend_ttl_secondsnonewith reinforce, push an existing nidus.expires_at forward to now plus this many seconds; never creates an expiry on an entry that had none
rank_bynoneranking expression over the metric, the same shape /search takes, so a recall can rank on nidus.access_count / nidus.last_accessed

Returns the same HitDto shape as /search: an array of {collection, id, score, attrs}, each gaining a context string when the query asked to expand or rollup.

Setting reinforce makes this call a write: it queues behind the server’s other writes to take the writer lock before stamping. On a server started with --read-only the request is refused, like any other write, rather than answered as though the stamp happened. It is also judged on --write-timeout rather than --read-timeout, since it waits in the same queue every other write does. Omit reinforce and the recall is a plain read that a read-only server serves normally, under the read deadline.

rerank takes the same overscan/text_attr fields as /search, but query is optional here: an omitted or empty rerank.query falls back to the request’s own query above, so {"rerank": {}} is a valid minimal form. This is the one route where reranking has nothing extra to ask for, since the recall query is already the text a cross-encoder needs.

TTL filtering applies here, and only here, of the routes on this page. /recall AND-s a not-expired predicate into your filter, so an entry past its ttl_seconds never comes back from this route. /search, /list, /text-search, and /hybrid-search apply no such predicate: an expired-but-unswept memory is still visible to those general-purpose routes unless you filter nidus.expires_at yourself. Do not read TTL as a store-wide property; it is a /recall-specific (and MCP-tool-specific) read filter, not a deletion.

Errors: no embedder configured is the same 400 as /remember. Recalling against a collection embedded by a different provider/model is also refused, since the vectors would not be comparable.

nidus serve also answers the Model Context Protocol at /mcp, behind the mcp feature (also folded into the serve umbrella). It is nest_service’d inside the same middleware stack as every route above, not layered separately, so it inherits the body limit, backpressure, bearer auth, and metrics rather than reimplementing any of them: a token required elsewhere on this server is required at /mcp too.

Eleven tools, all text-native: remember, recall, text_search, hybrid_search, list_collections, stats, forget, get, browse, related, suggest, plus a twelfth, code_search, behind the code feature (part of the default build). No tool takes a raw vector: every argument is natural language, which is deliberate, since a model cannot emit a raw float array as a tool call, and tests/e2e/mcp/ asserts the surface stays that way.

This page does not restate the tool schemas, the transport details, or protocol negotiation; see the MCP guide for those, including the stdio transport (nidus mcp) that does not go through this HTTP surface at all.

In namespaced mode, /mcp takes the same /ns/{namespace} prefix as every other route, and a tool call may also carry an explicit namespace argument in place of (or alongside) that; see Namespaced mode in the MCP guide.

Force buffered writes to disk (relevant under Fsync::OnFlush). Returns {"ok": true}.

Terminal window
curl -s -X POST localhost:7700/flush # → {"ok": true}

Rewrite the store to reclaim dead_rows and superseded log records. Returns {"ok": true}.

Terminal window
curl -s -X POST localhost:7700/compact # → {"ok": true}

An optional body sweeps expired entries first: {"expired": true} deletes every entry whose nidus.expires_at has passed, across every collection, then compacts to reclaim the rows it freed, all in one call. A bodyless POST /compact (or {"expired": false}) is still a plain compact with no sweep.

Terminal window
curl -s -X POST localhost:7700/compact \
-H 'content-type: application/json' \
-d '{"expired": true}' # → {"ok": true}

The commit-version landscape a point-in-time read can address: what this store is at now, how far back it can be read, and whether this instance is itself pinned. Requires the token when one is configured, like GET /cluster.

{
"commit_version": 42,
"oldest_readable": 31,
"pinned": null,
"readable": [31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42]
}

readable is empty and oldest_readable is null unless the store was written with history recording on (--history-versions N, off by default). pinned is the version this instance was started at with --at-version, or null for an ordinary instance.

A pinned instance is read-only end to end: every mutating route refuses, and POST /refresh answers {"adopted": false} rather than quietly advancing off the pin. See Point-in-time reads for what is addressable and how far back it survives.

Adopt newer state committed by another instance writing to the same shared store. A read-only instance loads a snapshot when it starts and keeps serving that snapshot, so this is how you advance it. adopted says whether there was anything new to take up, which lets a poller distinguish “no change” from “moved forward”.

Terminal window
curl -s -X POST localhost:7700/refresh # → {"adopted": true}

Reads are deliberately not made to refresh on their own: that would put a metadata fetch on every query, which is the opposite of what a read-heavy fan-out wants. Call this as often as your staleness tolerance requires. It is harmless anywhere else: an instance that does its own writing already has the freshest state, and answers {"adopted": false}.

Every error returns {"error": "<message>"}. The status code separates a client mistake from a server fault:

StatusWhen
400 Bad Requestmalformed JSON; a query/vector whose length ≠ store dimension; any vector query against a dimension-0 store, which holds no vectors (keyword-only ingest); or a POST /query SQL parse/compile error
401 Unauthorizedmissing or wrong bearer token (when a token is configured)
403 Forbiddena write against a --read-only server
409 Conflictthe store’s writer lock is held by another process
413 Payload Too Largerequest body exceeds --max-body-bytes
503 Service Unavailablethe store is not open yet (a standby waiting for promotion), or the request was shed at --max-concurrent-requests
504 Gateway Timeoutthe request exceeded --read-timeout / --write-timeout
507 Insufficient Storagean allocation guard (max_vector_bytes) or OOM tripped
500 Internal Server Erroranything else (an IO fault, a bug)

A shed 503 carries Retry-After: 1 and {"retryable": true} in the body. Nothing was attempted and the store is untouched, so retrying after a brief backoff is correct.

A 504 carries {"retryable": false}. The request was admitted and the work may still be running (a timeout frees the client, not the CPU), so an immediate retry piles a second copy onto an instance that is already behind. Back off substantially, or don’t retry.