API reference¶
Two HTTP services, and every capability of this system is reachable through one of them. Nothing requires the browser UI: the console's web app is itself just a client of the management API.
| Service | Port | Module | Live contract |
|---|---|---|---|
| Query API | :8051 |
serve_api.py |
GET /schema |
| Management console API | :8052 |
manage_api.py |
GET /api/schema |
Both also expose FastAPI's own /docs (Swagger), /redoc and /openapi.json.
Read the schema, don't copy this page
Presets, branch limits, provider names and job kinds all come from your
config. GET /schema and GET /api/schema return the live values. This
page tells you what exists and why; the schema endpoints tell you what your
install currently accepts.
Security¶
Both services bind to 127.0.0.1 and assume a single trusted operator. Every
request passes three checks, in this order:
- Host, always on. The
Hostheader must name a loopback address (localhost,127.0.0.1,::1) or a name listed insecurity.allowed_hosts; anything else gets400. This is what stops DNS rebinding, which CORS does not. - Origin, always on, for
POST,PUT,PATCHandDELETE. A request that carries anOriginpasses only from the console's own origin (http://localhost:<webui.port>orhttp://127.0.0.1:<webui.port>), the service's own, or one listed insecurity.allowed_origins; anything else gets403. A request with noOrigin(curl, scripts, agents) passes. - Token, only when
RAG_API_TOKENis set (in.env; blank means unset). Every request then needsAuthorization: Bearer <token>, else401. Exempt:GET /health, CORS preflights (OPTIONS) and the console's page shellGET /./docsand/openapi.jsonneed the token like any other route.
Every refusal is JSON naming the setting to change, with guard set to
host, origin or token.
Reaching a service through a LAN address or a Kubernetes Service name needs
that name in security.allowed_hosts. kubectl port-forward arrives as
localhost and needs nothing. The console asks for the token once when a
request returns 401, and keeps it in the browser's local storage. Do not
expose either port to a network: the permission tiers (below) are still a
policy for callers to enforce, and the token says who may call, not what
they may do.
A known limit of the console's token. The console keeps the token in the browser's local storage, and it renders note content, imported pages and generated answers as Markdown without sanitising the HTML, so a crafted note could run script in the page and read the token. Use a token with a vault you trust. The MCP server and other non-browser callers keep the token in their own environment and are unaffected.
Query API — :8051¶
The pipeline is loaded once at startup and stays warm, so no call pays index or
model load cost. GET /schema returns the live endpoint map — treat that, not
this page, as the authoritative list for a running install.
Ask and search¶
| Endpoint | Purpose |
|---|---|
POST /search |
Retrieval only — chunks, labels, scores, text. No LLM, no tokens, no generation backend required. This is the primary call for agents. |
POST /query |
Full RAG: retrieve, then a grounded answer with [n] citations and a confidence line. |
POST /compare |
Run one question down a bounded tree of named branches (presets, rerank methods, generation backends) and report how they differ. |
GET /compare/options |
Start here for /compare. Ready-to-post branch sets per dimension, the real branch caps, and the reason any backend is unavailable. |
POST /graph/expand |
Graph mode — walk the hand-drawn edges of the graph lane. No LLM. Experimental, off by default. See Graph mode. |
POST /answer |
Generate over a caller-supplied document set. No retrieval runs. See Graph mode. |
POST /evidence_pack |
Evidence pack: one search per sub-question, plus an optional graph walk, merged into one evidence list with provenance, coverage and gaps. Writes no answer. See Evidence pack. |
Both /search and /query accept the same retrieval controls:
| Field | Effect |
|---|---|
q |
The question. Required. |
preset |
Apply a named bundle from retrieval.presets. |
auto_preset |
false suppresses implicit code-intent preset selection, for a config-only baseline. An explicit preset still wins. |
top_k |
How many reranked chunks reach the answer (1–50). |
dense_top_k / sparse_top_k |
Candidate-pool width per lane before fusion (1–200). The higher-leverage knobs. |
hyde / hype |
Toggle query expansion. |
omnisearch |
Add a live-vault lane (results are not stored index records). |
parent_context / neighbor_context |
Small-to-big expansion after reranking: swap a chunk for its full section, or append a PDF hit's adjacent pages. |
rerank |
Method for this call: cross_encoder, http, lexical, none, or laya — experimental: refused unless retrieval.laya.enabled, never swapped for another method. See Laya reranker. |
rerank_instruction |
Ranking criterion in plain language (max 2000 chars). See Rerank instructions. |
lane_weights |
Per-lane RRF fusion weights, e.g. {"sparse": 1.5}. See Lane weights. |
lanes |
Restrict the call to a subset of the lanes (names under Lane weights), e.g. ["sparse"]: a lane outside the list is not run at all. It only restricts — a conditional lane (code, scope, omnisearch, hype, and routed under generated_mode: route) still needs its own trigger. An empty list or an unknown name is an error (below); the echo's lanes_requested and lanes_run show what ran. |
metadata_boost |
true / false forces the course/domain/tag metadata boost on or off for this call; unset follows retrieval.metadata_boost. |
scope_lanes |
true / false forces the scope lanes (dense_scope / sparse_scope) on or off for this call. The scope is detected and echoed either way; unset follows retrieval.scope_lanes. |
max_per_file |
At most this many chunks of one source file in the final list (0–50, 0 = no cap), best first. When the cap leaves fewer than top_k, the skipped chunks fill the tail. Unset follows retrieval.max_per_file. |
decompose |
true / false forces query decomposition: one generation-model call (on /search too) splits a multi-part question into sub-queries, each retrieved and reranked against its own text, and the branches merge by reciprocal rank so every part keeps a seat. An atomic question runs unchanged. A failed or unparseable split is the call's error, never a quiet fallback to one search. Unset follows retrieval.decompose.enabled; the echo's decompose says what ran. |
gate / gate_threshold / gate_scorer |
The post-rerank relevance gate: drop chunks scoring below a cutoff before generation, and abstain when nothing is left. Off by default; a gate_threshold alone turns it on for the call. See Relevance gate. |
generated_mode |
"drop" keeps generated chunks out of this call's evidence. "route" does too, and lets generated hits bring the primary pages they cite into the pool, each such source carrying found_via (Route mode). off is config-only (retrieval.generated_mode), so a per-call "off" is a 422. Unset follows the configured mode. /compare branches and /evidence_pack take it too. |
source_file / pages |
Restrict every lane to one file's primary chunks, by any stored spelling of its path (GET /outline lists the sources), and with pages: [first, last] to the chunks whose pages overlap that range. The pages neighbor_context adds stay inside them too, and Omnisearch does not run. Under route, the routed lane takes only the cited spans inside them, clipped to them; a span outside counts in no route field. The echo's file_filter repeats them, with matched: false when the store holds no primary chunk there: an unknown file, pages outside it, or a map. pages needs source_file. Needs retrieval.sparse_backend: bm25s. /compare branches and /evidence_pack take them too; /evidence_pack refuses them with graph: true. |
include_text |
Characters of chunk text to return (0–6000). |
max_sources |
Cap the number of sources returned. |
/query additionally accepts provider and model (a configured backend
alias — endpoints and secrets can never be supplied per request),
max_tokens (64–8192), and retrieve_only.
max_tokens and citations
A very small max_tokens can truncate the citation footer and drop the
answer's confidence to UNKNOWN. Leave a few hundred tokens of room.
With generation.verify_citations on, /query and /answer also return the second
pass's result: verification (overall: PASS, FLAG or UNKNOWN, plus its
verdicts) and supported on each citation. Off (the shipped default), both are
null. The verdict is reported only: it never changes the answer or its confidence.
When a knob is wrong¶
A per-call retrieval knob with a bad value is never a silent no-op, and it
is not a 400 either: /search, /query and /compare answer HTTP 200
with the reason in the body, so a caller handles it where it handles any other
retrieval failure.
| Endpoint | How the error appears |
|---|---|
POST /search |
A top-level error string, with results: [] and retrieval: {}. |
POST /query (generated and retrieve_only alike) |
confidence: "ERROR" and the message in answer, with no citations or sources. Bad request: … for a bad value, Reranking failed: … for a reranker that failed or refused, Retrieval failed (<ErrorType>: …) for anything else. |
POST /compare |
That branch's error — Bad branch configuration: …, Reranking failed: … or Retrieval failed (…) — with retrieval_error: true. The other branches still run and report normally. |
A bad value is: an unknown preset; an empty or unknown entry in lanes; an
unknown lane or a negative weight in lane_weights; an unknown rerank mode,
or one that is refused (laya while retrieval.laya.enabled is off); a gate
that cannot work — no threshold anywhere, rerank: none under the
rerank_score scorer, an unknown gate_scorer, or a scorer other than the
configured one without its own gate_threshold; decompose together with
the gate, which would judge chunks scored against sub-queries;
generated_mode: "route" on a server with no retrieval.route block or with
the tantivy sparse backend; or pages without source_file, a pages range
that is not 1 <= first <= last, or source_file on the tantivy sparse
backend.
A wrong type is different: top_k: "ten", lanes: "sparse" (a string, not
a list), a non-numeric gate_threshold — or a number outside a field's
documented range — is rejected by request validation before retrieval runs, as
FastAPI's usual 422 with a detail list.
Two habits follow. Read error / confidence before reading results or
sources: an empty list that comes with an error is a rejected request, not
a gap in the corpus. And change the knob rather than resending the same body.
The rest keep their real status codes. /graph/expand and /answer
answer 400 for a bad seed or document list, and /compare answers 400 for
duplicate branch ids or too many query-mode branches. A route the server
cannot serve is a 409 (Route mode). A generation failure on
/query is also reported in the body (confidence: "ERROR"), but keeps the
sources and retrieval that were already retrieved.
Route mode¶
Under generated_mode: route, generated chunks are kept out of the evidence as
under drop, and they become routers. The routed lane searches the generated
rows (a dense pass and a sparse pass, plus the generated parents the hype
lane found), looks up the primary pages each hit cites, and
brings those pages into the pool, where they fuse and rerank like any other
candidate. Its fusion weight is the routed entry of retrieval.lane_weights
or a per-call lane_weights (Lane weights); in search, a lanes list
without routed runs route as drop (the routed lane does not run), but a graph walk
under route still swaps the walked maps for their routed primaries. The block
retrieval.route holds the sidecar
path and the lane's caps: router_k, per_target, max_targets and
max_new. Read them from config.yaml.
found_via. Each source the lane brought in carries[{id, label}]: the generated chunks whose cites found it. Every other source, and every source in another mode, has[]. Cite the source, never the map.- The sidecar.
retrieval.route.pointers_filemaps each generated chunk id to the spans it cites,{source_file, page_start, page_end}(null pages for a whole-file cite), and lists the unrouted ids with a reason. Build it withpython main.py route-pointersor the console'sroute_pointersjob. It reads the chunk JSONLs and the vault and writes no store.:8051loads it once at startup, in every mode, whenever theretrieval.routeblock is set and the file exists, so restart:8051after a build. Each vault has its own. - The
409. While the store held generated rows at startup and no sidecar is loaded, a per-call"route"is HTTP 409 with{"detail": {"error": "…"}}, and the error names the build. This is a real status, unlike a bad knob. It applies on/search,/query(with and withoutretrieve_only),/evidence_packand/compare. On/compare, oneroutebranch fails the whole request, after the earlier branches ran, so their results are lost too. With zero generated rows, route runs without a sidecar. Route also needsretrieval.sparse_backend: bm25s, and tantivy is refused first: on tantivy a per-call route is the knob error above, never the409, whose sidecar build could not make it route. - A configured route refuses to start under the same condition:
/healthisfailedwithRouteUnavailableError, and itshintnames the build. A configured route with noretrieval.routeblock, or with the tantivy sparse backend, fails to start too. - Coverage. A ready
GET /healthandGET /schema(underprovenance.generated_mode) carryroute:{generated_rows, with_pointers, sidecar}. These are the rows stamped generated, how many of them the sidecar routes, and the loaded sidecar's path (null when none is). Agent files and other rows the sidecar cannot read stay unrouted, sowith_pointersbelowgenerated_rowsis normal. The key's presence is the capability probe: a server without it predates route mode. It shows that the server knows route mode and whether a sidecar is loaded, not that a call can route: with noretrieval.routeblock, or on tantivy, a per-call route still fails. - What routed. Each call's echo carries
retrieval.route; see the retrieval echo. Generated rows indexed after the sidecar was built count asunrouteduntil it is rebuilt and:8051restarted.
Inspect and discover¶
| Endpoint | Purpose |
|---|---|
GET /health |
{ready, state, uptime_s, version, commit, generated_mode?, route?, error?}. version is the product version; commit the git commit the process started from (null outside a git checkout). generated_mode, present once ready, is retrieval.generated_mode as the pipeline resolved it. route, present once ready, is route coverage, {generated_rows, with_pointers, sidecar} (Route mode). state is ready, loading, or failed — see Readiness. |
GET /schema |
Machine-readable capability map: request fields, branch limits, the live preset registry, and the endpoint map. provenance.generated_mode describes each mode and carries the same route coverage. |
GET /config |
Live retrieval defaults, the preset registry, the active reranker, and generation provenance. |
POST /config |
Change retrieval defaults on the warm pipeline with no restart. persist: true also rewrites config.yaml and requires operator authorization. |
GET /providers |
Configured generation backends: protocol, endpoint, default model, the name of the environment variable holding each key, and readiness flags. Never returns a secret value. |
GET /chunks/{chunk_id} |
Fetch the current evidence record behind a stable id returned by /search, /query or /compare. |
GET /stats |
Corpus size with per-domain and per-file-type breakdown, plus latency: p50/p95 per endpoint, total and per stage, over the recent in-memory calls, so a caller can price a call before making it. |
GET /outline |
What the vault holds, for an agent that does not know it: groups of the primary sources by domain and course, each with file_type, chunks and pages, paged over sources (limit, offset; total counts the matches; domain filters exactly, course as a substring), and maps, the generated files, whole and apart. Built when :8051 starts. See Start from the outline. |
GET /outline/{source} |
One file's outline: a source's chapters (chapter, page_start, page_end, chunks), or a map's concepts, each with the primary spans it cites. 404 for a file the index does not hold and for an agent-instruction file; 503 while no bm25 index is built. |
GET /history |
The last /search and /query calls (newest first, in-memory): question, the knobs the caller set, the full retrieval echo, confidence, timing. |
GET /omnisearch |
Raw live-vault results, bypassing the index. |
Evidence ids¶
Every source carries a stable evidence id so results can be dereferenced:
{
"sources": [
{
"id": "<stable-evidence-id>",
"origin_id": "<indexed-chunk-id>",
"lookup_available": true,
"n": 1,
"label": "<source label>",
"cited": true,
"provenance": "source"
}
],
"retrieval": {"preset": "code", "rerank_top_k": 10, "hyde_used": false,
"reranker_model": "<resolved-reranker>"},
"generation": {"backend": "<provider-name>", "protocol": "<wire-protocol>",
"model": "<resolved-model>", "usage": {}}
}
lookup_available: true→GET /chunks/{id}resolves it.provenanceisauthored,sourceorgenerated, by the rule in Evidence pack.found_viais[{id, label}]for a source that the routed lane brought in: the generated chunks whose cites found it. It is[]for every other source.- Parent-expanded sections use
parent:<id>and report the indexed child asorigin_id, so overlap is computed from the text each branch actually received. - Live Omnisearch excerpts use a content-derived
live:<hash>id and setlookup_available: false, because they are not stored index records.
The retrieval echo¶
Every /search and /query response carries retrieval: what actually ran, as
opposed to what was asked for. Most keys restate an effective setting
(preset, rerank_mode, lane_weights, metadata_boost, …). These report how
the call went:
| Key | Meaning |
|---|---|
timings |
Milliseconds per stage of this call: preset, scope, hyde, embed, one lane.<name> per lane that ran, fuse, boost, rerank, gate (only on a call that gates), parent, neighbor and total. A stage that did not run is absent, not zero. On /query the generation stages (generate, parse, verify) are in generation.timings. |
lanes_requested |
The lanes list the caller sent, sorted; null when none was sent. |
lanes_run |
{lane: candidates it returned} for every lane that ran. A lane missing here did not run — it was not asked for, or its trigger did not fire. |
cold |
true when this call loaded the Chroma collection, the BM25 payload, the cross-encoder or the Laya checkpoint. When a BM25 index file exists, the service loads the collection and the payload at startup, before /health reports ready; the cross-encoder and the Laya checkpoint load on the first search that uses them, so that search's timings are a one-off spike, not steady state. |
hyde_cache |
Why the HyDE text is what it is: off (HyDE not used on this call), bypass (a code-intent signal skipped it), hit (draft replayed from the cache, no LLM call), miss (written fresh and stored), nocache (written fresh, no cache attached), error (the LLM failed, so the raw query was used). |
gate |
What the relevance gate did: {"enabled": false} when it did not run, otherwise {enabled, scorer, threshold, kept, dropped, abstained}. See Relevance gate. |
route |
Under generated_mode: route only, and absent otherwise: what the routed lane did, summed over every retrieval of the call (a decomposed search runs one for the question plus one per sub-query). router_hits counts the generated hits, routed those the sidecar has targets for, and unrouted those it has none for (or every hit, with no sidecar). targets counts the distinct cited spans taken (at most retrieval.route.max_targets per retrieval), targets_missing the cited spans not in the store: the file is not indexed, or no primary row lies in its pages (such a span takes no max_targets slot), and new the routed chunks no other lane had found (at most retrieval.route.max_new per retrieval). The caps hold per retrieval, so a decomposed call can exceed them, and a span two retrievals took counts in both. Each /compare branch's retrieval carries its own. On /evidence_pack it is retrieval[i].route, beside retrieval[i].graph_route for the walk. |
GET /history keeps each recent call's echo, with its timings repeated at the
top level of the entry.
Comparison trees¶
POST /compare takes a question, mode: search|query, and a bounded list of
named branches. Read the live /schema for current limits.
The response contains every branch plus comparison: common and branch-unique
source ids, per-branch ranks, rank spread, and pairwise overlap. It
intentionally does not compare raw scores, because cross-encoder logits,
lexical scores, HTTP reranker scores and fused RRF values are on different
scales and a shared axis would be meaningless.
Branches differing only by provider/model reuse one exact evidence set, which makes a backend comparison about answer behaviour rather than retrieval noise. A generation failure stays on its branch; a retrieval or reranker failure marks that branch as a retrieval error without discarding successful siblings.
curl -s -X POST http://127.0.0.1:8051/compare \
-H "Content-Type: application/json" \
-d '{
"q": "What does the retention policy say about backups?",
"mode": "search",
"branches": [
{"id": "baseline", "label": "Config baseline", "auto_preset": false},
{"id": "concept", "preset": "concept"},
{"id": "lexical", "rerank": "lexical"}
]
}'
Comparison options¶
Building a valid /compare call by hand means reading presets and
rerank_modes from /schema and the backends from /providers, joining the
three, and re-deriving the branch caps from a sentence of English.
GET /compare/options returns the joined result: per dimension, a list of
branch objects that can be posted to /compare unchanged.
{
"branch_limits": {"min": 2, "max": 6, "query": 3},
"dimensions": {
"preset": {"mode": "search", "branches": [{"id": "baseline", "auto_preset": false}, ...]},
"reranker": {"mode": "search", "branches": [{"id": "rerank_lexical", "rerank": "lexical"}, ...]},
"provider": {"mode": "query", "branches": [{"id": "provider_x", "provider": "x",
"available": false,
"unavailable_reason": "..."}]}
}
}
Unavailable backends are included and marked rather than dropped, so a
caller can explain why one is missing instead of silently omitting it. The
baseline branch (config defaults, auto_preset: false) is first in the preset
dimension because a comparison without it has nothing to be measured against.
The reranker dimension is built from the rerank modes themselves and, unlike the provider
dimension, carries no available flag. It lists the experimental laya mode whether or not that
is enabled; while it is off, that branch fails with its Reranking failed: error (the siblings are
unaffected), so leave it out unless the feature is on.
Rerank instructions¶
A cross-encoder answers "how relevant is this passage to this query" — but relevant for what is left implicit, and the model's answer comes from whatever it was trained on. Two passages can be equally on-topic while only one is the kind of thing you wanted: a worked procedure rather than a definition, a primary source rather than a summary, code rather than prose about code.
rerank_instruction states that criterion explicitly and applies it at
ranking time:
curl -s -X POST http://127.0.0.1:8051/search \
-H "Content-Type: application/json" \
-d '{"q": "how do I rotate credentials",
"rerank_instruction": "prefer worked procedures and runnable examples over definitions"}'
Two properties worth relying on:
- It reweights, it never filters. Retrieval already built the candidate pool against your real question. The instruction only changes the text the reranker scores against, so it reorders that pool and cannot remove anything from it.
- It is routed per mode, and the echo tells you which happened.
| Mode | Effect |
|---|---|
cross_encoder |
Joined to the question; the model reads the pair together. This is the lane it is for. |
http |
The same joined text goes to the external service — the only channel the /v1/rerank shape offers. |
lexical |
Ignored by design. Lexical scoring is query-term coverage; folding a sentence of instruction into the term set would dilute every real query term. |
none |
Nothing is scored at all. |
laya |
Ignored by design. The query goes into Laya's own question, the exact wording its checkpoint was fine-tuned on; a prepended instruction would change the input it was trained on. |
The retrieval echo reports rerank_instruction and rerank_instruction_applied,
so a no-op is visible rather than looking like a setting that took effect. Send
"" to disable a configured instruction for one call.
Config: retrieval.rerank_instruction (blank = off) and
retrieval.rerank_instruction_format — prefix (the instruction, a newline,
then the question; works with any cross-encoder because it simply reframes the
scored text) or instruct (the labelled <Instruct>/<Query> form, only for a
reranker documented to expect it).
Lane weights¶
Fusion is Reciprocal Rank Fusion over every lane that ran. The lanes:
| Lane | When it opens |
|---|---|
dense / sparse |
Always. Vector and BM25. |
dense_code / sparse_code |
On code-intent queries, filtered to the configured code file types. |
dense_scope / sparse_scope |
When retrieval.scope_lanes (or a call's scope_lanes) is on and the query names a domain or a content type, filtered to that scope. The lanes ship off; the scope is detected and echoed either way. |
omnisearch |
When the live-vault lane is enabled and reachable. |
hype |
When the hypothetical-question collection exists and the lane is on. |
routed |
Under generated_mode: route: the primary pages that generated hits cite. See Route mode. |
A document's fused score is the sum over lanes of
weight(lane) / (rrf_k + rank_in_that_lane). Every weight defaults to 1.0,
so an untouched config is plain unweighted RRF.
Set defaults in retrieval.lane_weights, per preset inside
retrieval.presets.*, or per call. All three merge lane by lane, so setting
one lane leaves every other lane where it was.
A weight of 0 is not an off switch: the lane still contributes candidates for
the reranker to consider, it just stops influencing the fused order. To drop a
lane, turn the lane off.
An unknown lane name or a negative weight is an error, never a setting that
silently does nothing: it comes back as HTTP 200 with an error field (see
When a knob is wrong), and a non-number is a 422. The
retrieval echo reports every lane's effective weight, so the ones you did not
override are visible too.
Tune these against an eval, not by feel
main.py bench run scores labelled questions per named configuration; a
configuration in eval/configs.yaml can set lane_weights like any other
search override. See Evaluation.
Relevance gate¶
Reranking orders the candidate pool, but it never says "none of these answers
the question": the top-k always reaches the generator, however weak. The
relevance gate is the decision step in between. After the rerank it scores each
chunk against the original question (never the HyDE text, which is a
hypothetical answer), drops the ones below a threshold, and, on /query,
abstains when nothing is left. It runs before parent/neighbor expansion, so
expansion never pads the context of a chunk the gate rejected.
It is off by default: configure it under retrieval.relevance_gate, or
drive it per call.
| Field | Effect |
|---|---|
gate |
true / false forces the gate on or off for this call; unset follows retrieval.relevance_gate.enabled. |
gate_threshold |
The cutoff: a chunk scoring below it is dropped. A finite number in the scorer's own units. It beats the configured threshold, and sent alone it turns the gate on for this call. |
gate_scorer |
rerank_score or laya; unset follows retrieval.relevance_gate.scorer. An unknown name is an error even while the gate is off. |
The scorers are not interchangeable:
| Scorer | Reads | Notes |
|---|---|---|
rerank_score |
The active reranker's own score, already on each chunk. | Free, and the baseline any learned gate has to beat. Its unit is that reranker's scale (a cross-encoder emits logits, lexical a coverage score), so a threshold means something for one reranker only. It needs a scoring rerank mode: none scores nothing, and a gate over it is an error, never a pass-through. |
laya |
P(relevant) in [0, 1] from the experimental Laya scorer. | Experimental. Needs retrieval.laya.enabled, the laya package and a checkpoint (see Laya reranker), and is never quietly replaced by rerank_score. |
A scorer other than the configured one must come with its own gate_threshold,
because the configured threshold is in the configured scorer's units.
When nothing passes, POST /query abstains: the fixed "nothing relevant"
answer at LOW confidence, no citations, no sources, and no LLM call.
POST /search returns whatever survived, possibly nothing. Either way the echo's
gate key says what happened: {enabled, scorer, threshold, kept, dropped,
abstained}, or {"enabled": false} when the gate did not run. abstained is
kept == 0, also when retrieval itself found nothing.
Misconfiguration is an error, never a pass-through. In config, an enabled gate
with no threshold, or an unknown scorer, fails the pipeline build, so
/health reports failed instead of the service
coming up half-configured. Per call, a gate with no threshold anywhere, a
rerank_score gate over rerank: none, an unknown gate_scorer, or a different
scorer without its own threshold is
answered with an error. gate_threshold must be
finite, since a non-finite cutoff would keep or drop everything.
/compare branches that differ only by the gate do not share an evidence set.
There is no console switch for the gate. Choose its threshold on the dev split of
the eval bench first;
the starting values in eval/configs.yaml are starting points, not tuned ones.
Graph mode¶
Experimental — off by default
Graph mode ships behind graph.enabled: false. It works and is covered by
tests, but it is experimental, so it is not part of the default workflow. While the flag is off,
POST /graph/expand answers 503 naming the key to set; /query and
/search are unaffected, and the graph lane still competes in ordinary
retrieval. Turn it on in the management console under Settings >
Experimental features (or set the key directly), then restart the API.
Some corpora carry a graph the author drew by hand — Obsidian canvases, where nodes are connected by labelled edges. The canvas lane flattens each node's edges into its chunk at ingest time, and graph mode walks them at query time.
GET /schema reports whether the endpoint is available and which indexed lane
holds the graph. Its top-level experimental.features block names the flagged
features that own an endpoint, with each one's config key and whether it is
currently on — so an agent can plan around a disabled mode instead of
discovering it through a 503. An experimental feature that is an option on an
ordinary request, like the Laya reranker, is
reported by the console API instead: GET :8052/api/settings, under
experimental.
POST /graph/expand — traversal, no LLM. Works with the generation backend
down.
| Field | Effect |
|---|---|
q |
Seed by searching the graph lane. |
seeds |
Seed from chunk ids of a previous result. Supplying both q and seeds, or neither, is a 400 — not a precedence rule. |
depth |
Hops from the seed set (0–5). 0 returns the seeds untouched. |
max_per_hop |
Neighbours followed per node (1–50). |
max_total |
Nodes in one traversal, seeds included (1–500). |
seed_top_k |
Seeds taken from a query-seeded run (1–50). |
rerank |
Orders query-seeded seeds. Ignored when seeds is given — those already carry the order the caller saw. |
include_text |
Characters of node text to return (0–6000). |
bridges |
true also attaches, to each canvas node, the chunks of the note or PDF page it cites (below). Null = graph.bridges. |
cross_canvas |
true also hops between canvases. Null = graph.cross_canvas. |
wikilinks |
true also follows the [[wikilinks]] of each note in the walk. Null = graph.wikilinks. |
direction |
both, down (along the arrowhead) or up (against it). Null = graph.direction. |
edge_types |
1–20 edge labels; only edges with these labels are followed. Labels are compared ignoring case, with spaces and underscores read as -, and an unlabelled edge never passes. same-as hops and bridges always pass. Null = graph.edge_types (every edge). |
The response carries nodes (each with its hop depth and graph metadata),
tree (parent → child with each edge's label and direction), cross_edges
(edges back onto nodes already reached), and stats. A direction is the
edge as drawn, seen from the parent: -> means an arrowhead points away from
it, <- one points at it, -- there are no arrowheads, and <> there are
two. A canvas chunk indexed before the loader read arrowheads stores only ->
or <-, taken from the end the edge starts at, until canvas_refresh.py
rewrites its metadata. A chunk indexed before edge labels were stored reports
--.
With a route sidecar loaded, in any generated_mode, each node
whose provenance is generated also carries routed_ids. These are the
first retrieval.route.per_target primary chunks of each span it cites, in
page order, for at most retrieval.route.max_targets spans. A cite with no
pages gives the file's first chunks, in document order. A map the sidecar does not route gets []. Send them to /answer in the map's place. Ticking a generated node in the
console's graph view does that. Such a node starts unticked, and one with no
routed_ids cannot be ticked.
Two details matter when reading it:
- Canvas graphs are cyclic. A knowledge map that loops A → B → C → A is
ordinary, not pathological. The walk tracks visited nodes keyed on
(source_file, canvas_node_id)— a node id is unique only inside its own canvas file — and reports the loops ascross_edgesinstead of dropping them, which would draw a cyclic graph as though it were a tree. - Caps are reported.
stats.truncated_atnames the first cap that bit andstats.caps_hitlists every one, so a truncated traversal never passes for a complete graph.stats.dangling_edgescounts edges pointing at nodes that never became chunks —fileandgroupnodes never do — which is normal, not an error.
A seed embedder of its own. By default (graph.embedding_provider: null)
a query-seeded walk searches the main collection with the main embedder. Set
the key to an embedding.providers name (or to local, when the main embedder
is not local) and query seeding searches a separate collection,
<paths.collection_name>__graph, instead. It holds exactly the main
collection's graph.file_type chunks: their ids and metadata, embedded by the
named model. Every seed found there is read back from the main collection, so
the walk and the node text are unchanged, and /query, /search and seeding by
chunk id never touch it.
- Building it.
python main.py graph-embed, or the console'sgraph_embedjob.--limit N(the job'slimit) makes a smoke build that is never served.POST /api/jobsrefuses the job with a400while the key is null, unknown, or names the main embedder. Every build drops the collection and rebuilds it from scratch, because a canvas chunk keeps its id when its text is edited. It never writes the main collection or a chunk file, andPOST /api/canvas/reindexqueues it after its append when the key is set. Restart:8051after a build. - The startup check.
:8051checks that the collection exists, was built by the configured embedder, is complete, and holds exactly the main collection's graph-lane ids. When a check fails,:8051still starts, and/query,/searchand seeding by id keep working; only a query-seededPOST /graph/expandfails, answering with anerror(Seeding failed (…)) that names the reason. - What the check cannot see is text edited under a kept id. So
GET :8052/api/canvas/embedderalso lists the chunk files written since the build, beside its verdict (off,missing,building,mismatch,stale,readyorerror) and the reason.
POST /answer — generation over a document set the caller chose. No
retrieval runs.
| Field | Effect |
|---|---|
q |
The question these documents should answer. |
docs |
1–100 chunk ids. An unknown id is a 400, never a quietly smaller context: an answer grounded on fewer documents than you chose is a different answer. |
provider / model / max_tokens / include_text / max_sources |
As on /query. |
Evidence ids that are not indexed records — live: excerpts and parent:
sections — cannot ground an answer and are rejected by name.
Under any generated_mode but off, ids whose provenance is generated are
a 400 and nothing is generated. detail is {"error": "generated chunks are
maps, not evidence: answer from the primary pages they cite", "ids": [...]},
and ids names only the generated ones. An unknown id is reported first.
With a route sidecar loaded, in any mode, detail also carries
routes_to: {id: [{source_file, page_start, page_end}, …]} for each
generated id the sidecar routes, leaving out any it does not. It lists only
the spans the store serves: a span whose file is not indexed, or with no
primary row in its pages (what targets_missing counts), is left out, and so
is an id left with none. These are the
full spans each one cites, as pages, not chunk ids. The ids to send in a map's
place are a /graph/expand node's routed_ids (above), which hold at most
retrieval.route.per_target chunks of each span. With no walk at hand, POST /graph/expand with
seeds: [<that id>] and depth: 0 returns the node with its routed_ids
while graph mode is on.
This shape is what makes the human-in-the-loop fork work without server session state: answer from the graph alone and merge with the previous result are the same call with a different id list, so "what counts as the previous stack" stays with the caller.
Widening and typing the walk¶
POST /graph/expand and the evidence pack take five walk
switches: bridges, cross_canvas, wikilinks, direction and edge_types.
Each falls back to its graph.* key, and those keys ship switched off
(false, both, null). With every switch at that default, the walk and its output are exactly the
canvas-edge walk described above. The caps named below
(graph.target_max_chunks, graph.bridge_max_total,
graph.same_as_min_chars, graph.same_as_max_canvases) are config keys only,
not request fields. Setting any switch off its default also extends the output:
- every node carries
kind(canvas,noteorbridge),file_type,heading,page_startandpage_end.nodeslists the walk's nodes first, then the bridges, andstats.nodescounts only the walk's nodes; - every
treeandcross_edgesentry carrieskind:canvasfor a drawn edge, otherwisebridge,same-as,shares-bridge,opens-canvasorlinks-to; pathssays how each node was reached, one entry per node:{to, nodes: [seed, …, to], edges: [{label, kind, direction}, …]}, along the first (shortest) route the walk found;statsaddsbridges(bridge chunks attached),filtered_edges(edges thatedge_typesordirectionrejected; such an edge is neither a hop nor a cross edge),virtual_edges(hops followed, per kind),virtual_edges_dropped, the miss counters named below,options(the switches in effect) andcaps.bridge_max_total.
Bridges (bridges) attach to a canvas node the chunks it cites:
- The targets are the node's file nodes (except those on a
.canvasfile, which are cross-canvas hops) and itsSource:lines. ASource:line is a text-node lineSource: <vault path>.pdfor.md, optionally followed byp. Norpp. a-b, c. At ingest, each line keeps its first two page groups and a node keeps six in all, and a page range bridges its first page and the page after it. - A PDF bridges only to a page: a file node with a
#page=Nsubpath, or aSource:line with pages. Any other PDF target bridges nothing, because a PDF's first pages are front matter, never evidence, and it is counted instats.bridge_pdf_without_page. - A note bridges its chunks, or with a
#Headingsubpath only that section's chunks. A heading the note does not have bridges nothing and is counted inbridge_heading_missing; the walk never widens to the whole note. A#^blocksubpath takes the whole note. - A target with no indexed chunk (an image, a missing or unindexed file, a path
that differs only in case) is counted in
bridge_targets_without_chunks. A path that differs only in Unicode form (NFC against NFD, which macOS can produce) still matches, and is counted inbridges_matched_after_normalised. - Bridges end the walk at that point. They are never walked further, never
counted in
max_total, and never filtered byedge_typesordirection.graph.target_max_chunkscaps the chunks per target andgraph.bridge_max_totalthe bridges per walk. A bridge's tree entry carries the edge's own label, orsourcefor aSource:line. An edge onto one of the node's file nodes is now its bridge, so it no longer counts indangling_edges. A cited chunk the walk already holds (a seed, a walk node) gets across_edgesentry instead, and a chunk that is both is listed once, as a walk node. - A canvas chunk indexed before
canvas_edge_filesexisted bridges nothing. The walk counts it instats.bridges_unavailableand never guesses from older metadata, and the response'snotenames the fix: runcanvas_refresh.py, thenrebuild_bm25.py, then restart this API.
Cross-canvas hops (cross_canvas) join a canvas node to nodes of other
canvases. They come after the node's drawn edges and share its max_per_hop
budget:
same-as: a node whose title (its first line of text, ignoring case and punctuation) is the same. A title shorter thangraph.same_as_min_charsjoins nothing, and neither does a generic title found in more thangraph.same_as_max_canvasescanvases.opens-canvas: a file node on a.canvasfile leads to that canvas's most connected nodes first.shares-bridge: a node of another canvas with a file node on the same file.Source:lines do not count here: a book cited on every node would join every canvas that cites it.same-aspasses everydirectionandedge_typesfilter.opens-canvasandshares-bridgehave no direction, so they are followed only underboth, and underedge_typesonly when the list names them. Both readcanvas_edge_files, so an older chunk takes onlysame-ashops (counted inbridges_unavailable).- A hop over the budget is counted in
virtual_edges_dropped, never as themax_per_hopcap. The canvases are indexed once per API process, so a canvas appended later joins at the restart that the append needs anyway.
Wikilinks (wikilinks) follow the outgoing [[links]] of each note in the
walk. Query seeding (q) starts from canvas chunks, so on this route a note
joins the walk only as a seed passed by id. An evidence pack
also seeds the notes its search returned.
- A link's name must equal a note's file name exactly, case included. Obsidian
ignores case here and this walk does not:
[[other]]does not reachOther.md, and is counted inwikilinks_unresolved. A name two notes share is counted inwikilinks_ambiguousand never guessed. Links to attachments (.png,.pdfand the like) are ignored. - A link with a folder (
[[A/Note]], which Obsidian writes when two notes share a name) reaches only the note under that folder. With none indexed there it is unresolved, never another folder's note. [[Note#Heading]]takes only that section, and a heading the note lacks is unresolved. A note is one node, taken as up tograph.target_max_chunkschunks per link: links to two sections of a note reach both, and a link into a note the walk already holds adds the section it names.- A link is followed along its arrow: under
downorboth, and underedge_typesonly when the list nameslinks-to; otherwise each link counts infiltered_edges. Backlinks are not followed. - Chroma stores a note's links joined by
,, which a name holding,itself breaks, so a joined list is read back from the sparse index. A note missing from it is dense/sparse drift: the walk refuses, and the error namesrebuild_bm25.py. - The parser stores a note's first
MAX_WIKILINKSlinks (src/ingestion/obsidian_parser.py);wikilinks_truncatedcounts the notes that reach that cap.
Evidence pack¶
POST /evidence_pack is one call for a multi-part question. The calling
agent splits the question into sub-questions, and the pack runs the /search
pipeline once for each, merges the results by evidence id, and reports which
sub-questions came back empty. It writes no answer. As on /search, the
generation model is called only for hyde or decompose. GET /schema lists
it under the experimental Graph RAG feature, but it works with graph mode off:
only graph: true needs graph mode. The agent guide
shows a full request and response.
With graph: true, each sub-question's search is followed by a walk. It is
seeded by that search's graph-lane (canvas) hits, its note hits when
wikilinks is on,
plus a graph-lane query (seed_top_k), and the
walk switches apply. The search hits and the
walked chunks are then reranked together down to the search's own top_k, so a
walk never wins by having more slots. A walk that adds nothing leaves the search
result as it was. parent_context and neighbor_context run after that
rerank, so it scores the child chunks on both sides. max_per_file and the
relevance gate are not applied again after it.
| Field | Effect |
|---|---|
q |
The question. It is searched alone when sub_questions is null. |
sub_questions |
The parts, each searched on its own. At most evidence_pack.max_sub_questions entries; an empty entry is a 400. |
top_k |
Evidence per sub-question (1–50). Null = evidence_pack.top_k. |
include_text |
Characters of text per evidence item (0–6000; 0 sends none). Null = evidence_pack.include_text. |
min_score |
A floor in the reranker's own units: items below it are dropped and counted. Null = evidence_pack.min_score, and when that is null too, everything retrieved counts as coverage. It needs scored items: a floor under rerank: none (which scores nothing), or with neighbor_context pages (added unscored) in the result, is a 400. |
graph |
true walks too, and needs graph mode: when it is off, a 503 names graph.enabled. Default false. |
depth / max_per_hop / max_total / seed_top_k / the five walk switches |
The walk, as on /graph/expand, read only with graph: true. Null = the graph.* key. |
every other /search field |
As on /search (preset, rerank, lanes, …). These are refused with a 400: omnisearch: true, because a live excerpt is not indexed evidence and GET /chunks/{id} could not check it; max_sources, because top_k is already per sub-question; and source_file or pages with graph: true, because a graph walk leaves the file. |
| Response key | Meaning |
|---|---|
evidence |
One item per distinct evidence id, merged across sub-questions: their sub_questions are joined and the best score is kept. Each item has id, origin_id, text, source (the label), source_file, file_type, pages ([start, end], or null), heading, course (null when unknown), provenance, score, via_path, found_via and sub_questions. |
paths |
The walk paths some evidence uses, in /graph/expand's paths shape. |
coverage |
{sub-question: [evidence ids]}. |
gaps |
The sub-questions with no evidence. |
generated_only |
The sub-questions whose every evidence item is generated. |
provenance_mix |
{authored, source, generated}: counts over evidence. |
dropped_below_min_score |
{sub-question: n}, 0 for every sub-question when no floor is set. |
graph |
Whether a walk ran. |
retrieval |
Per sub-question: q, rerank_mode, timings (the /search stages, plus graph_seed, graph_expand, and graph_rerank when the walk added chunks), graph_stats (the walk's stats, null without a walk), route (the search's route summary, null outside route) and graph_route (the same keys for the walk's own routing, where new counts the routed chunks neither the search nor the walk had found; null unless a graph: true walk under route reached generated nodes while a sidecar is loaded). The two are never summed. |
question / sub_questions / ms |
The question and sub-questions as searched (stripped), and the call's time in milliseconds. |
How to read an item:
idis whatGET /chunks/{id}serves:parent:<id>whenparent_contextswapped in the whole section, otherwise the chunk id, which is alsoorigin_id. Every pack holds indexed evidence only, so every id resolves.provenanceisauthored,sourceorgenerated, decided in this order. A storedprovenance: generatedisgenerated. Next, asource_filethe path rule names isgeneratedwhatever the chunk stores: one under aprovenance.generated_pathsprefix, an agent-instruction file (its name is inprovenance.agent_instruction_files, or a directory on its path is inprovenance.agent_instruction_dirs), or a.canvaspath holding one ofcanvas.generated_markers. Then any other storedprovenancevalue, then the file type's value fromprovenance.by_file_type(which mapspdftosourceas shipped), andauthoredotherwise. The note lane stores the value from the frontmatter: an explicitprovenance:, elsegeneratedforgenerated: true, elseauthored. The PDF lane storessource. An unknown stored value is an error, never a guess.scoreis the rerank score, or the fusion score under a rerank mode that scores nothing.via_pathis the node ids of the walk path that reached a chunk the walk added, and[]for a chunk the search itself returned. It is[]for a routed page too.found_viais a routed page's trail:[{id, label}], the maps whose cites brought it in, joined by id across sub-questions. It is[]for every other item. Cite the page, never the map.
A bad request is a 400 naming the problem. That covers a bad knob value too:
where /search answers 200 with an error (When a knob is wrong),
this route answers 400. A wrong type is the usual 422. A reranker that fails
gives 200 with error set to Reranking failed: …, and any other failure
gives 200 with Evidence pack failed (…). Both come with empty evidence and
retrieval.
Laya reranker (experimental)¶
Experimental — off by default
rerank: laya is built and covered by tests, but it has never been scored
on the eval sets, so whether it ranks better than the default is
unmeasured. It ships behind retrieval.laya.enabled: false.
One more rerank method. A Laya model, fine-tuned off-machine on the user's own
notes, gives every candidate passage a probability that it answers the question,
and that probability (rerank_score, in [0, 1]) orders the pool. Pick it per
call ("rerank": "laya") or as the default (retrieval.rerank_mode: laya). The
same scorer can also back the relevance gate.
- Refused while off. A call that asks for it comes back with an error naming
retrieval.laya.enabled(Reranking failed: …, see When a knob is wrong), never answered by another reranker. The same refusal covers a missinglayapackage or checkpoint.GET /schemalistslayaunderrerank_modes, andGET /compare/optionsoffers a branch for it, whether or not it is enabled. - It ignores
rerank_instruction, and the echo says so.reranker_modelandreranker_max_lengtharenullfor it: a checkpoint reads its own length from its config. - It needs the optional
layapackage, deliberately not inrequirements.txt, and a checkpoint underretrieval.laya.model_dir;docs/laya-finetune.mdis the manual for producing one. The checkpoint loads on the first call that uses it (acoldcall), and one loaded scorer serves both the reranker and the gate. - Switch it on from the console's Settings > Experimental features, or with
retrieval.laya.enabledin config, then restart:8051.GET :8052/api/settingsreports its state (experimental, idlaya_rerank). - Graduation: Evaluation says what it has to beat before it stops being experimental.
Readiness and failure¶
GET /health distinguishes three states, because a service that is still
loading and one that failed to build call for opposite responses:
state |
Meaning | What to do |
|---|---|---|
ready |
Serving. | — |
loading |
Indexes and models are still loading. | Wait. |
failed |
The pipeline could not be built from the current config. | Act — waiting cannot fix it. error names the cause. |
A failed service still answers: every retrieval endpoint returns 503 with
the same reason attached, rather than refusing the connection. The usual causes
are a model id that cannot be downloaded, an embedding model with a typo, or an
index path that moved — normally the setting that was changed most recently.
A configured generated_mode: route with generated rows and no route sidecar
fails as RouteUnavailableError, and its hint names the build
(Route mode).
GET /api/service/log on :8052 returns the startup log.
Reranker failures are retrieval failures
A response beginning Reranking failed: never reached generation. Preserve
the underlying model error and check GET /config for the active reranker.
Do not relabel it as a generation-provider outage.
Management console API — :8052¶
Endpoints covering the whole corpus lifecycle, plus the eval review queue.
GET /api/schema returns them with a permission tier on every operation; read
the live list rather than counting on this page.
Permission tiers¶
These tiers are a contract the caller honours; the optional token decides who may call, not which operation. Any agent toolkit driving this API should gate on them. The MCP server does: it maps each tier to its tools' annotations and prompts.
| Tier | Meaning |
|---|---|
read |
Safe. No confirmation needed. |
mutating |
Changes local state, or invokes a potentially billed external action. Confirm with the operator first. |
destructive |
Removes content. Always confirm, echo exactly what will be deleted, never run unprompted. |
Inspect the corpus — read¶
| Endpoint | Purpose |
|---|---|
GET /api/schema |
This capability map, with tiers. |
GET /api/overview |
Corpus summary: chunk/doc counts, per-domain and per-JSONL breakdown with each JSONL's last-modified time (jsonl_mtime, epoch seconds), vector count, disk use, query-API health, recent jobs. |
GET /api/documents |
Search / filter indexed documents. |
GET /api/facets |
Available domains, group labels, tags, file types. |
GET /api/documents/preview |
Preview a document's indexed chunks. |
GET /api/vault/tree |
Browse the vault tree, with in-index status per file. |
GET /api/vault/search |
Find files by name in the vault. |
GET /api/browse |
Filesystem folder picker (for choosing paths the container can see). |
GET /api/settings |
The editable config surface, provider registry status, reranker suggestions, device capabilities, taxonomy, and embedders: each known local model and embedding.providers entry with the finished chunk collections in the active Chroma dir that it built. |
GET /api/vaults |
Registered vaults and their per-vault settings (index paths, lane chunk files, figure/backup/staging folders, inbox and tree root, the embedder). |
GET /api/ocr/status |
What OCR this install can actually do right now, per engine. |
GET /api/service/log |
Tail the query API's startup log. |
GET /api/releases |
The version on disk (VERSION), each part of the install (this console, the query API, the installed agent skills, a packaged bundle) as current, behind, missing, not running or invalid with what to do, and the release notes from CHANGELOG.md. The console's header version chip opens it. |
GET /api/jobs, GET /api/jobs/{id}, GET /api/jobs/{id}/log |
Job queue state and output. A job record carries vault (the vault active when it was queued; blank for older records), elapsed_s, queue_position, progress (the current phase's {done, total, line}, read from the log) and an ETA (typical over past successful runs of that kind, and eta_s; both null without history). The log takes tail (last N lines) and wait (a long poll of up to 60 s that returns as soon as there is new output or a status change). |
GET /api/inbox, GET /api/import/converted, GET /api/import/file |
Staged-file listings and raw fetch. |
GET /api/import/ocr_scan |
Which pages of a staged PDF carry no extractable text — run this before OCRing anything. |
POST /api/rerank/check |
Preflight a cross-encoder: reachable? already cached (and where)? within its context limit? Downloads nothing. |
GET /api/engines |
Every engine per component, with availability and the cost of a switch. See Engines map. |
GET /api/fetch-engines |
Each web-fetch package (crawl4ai, scrapling, crawlee, playwright, patchright, markitdown): installed version, requirements.txt pin, status, post-install commands, and the environment an update lands in (Python, venv, container). No internet. |
POST /api/fetch-engines/{name}/check |
Latest release on PyPI plus a dry run of the upgrade (uv or pip) listing every package it would change. Installs nothing; a failure comes back readable in error. The upgrade itself is the engine_update job (mutating). |
GET /api/eval/progress, GET /api/eval/questions, GET /api/eval/questions/{qid} |
The eval review queue: per-suite progress, the filtered question list, and one question with its gold texts and validator findings. See Eval review queue. |
Change things — mutating¶
| Endpoint | Purpose |
|---|---|
POST /api/settings |
Persist whitelisted config values into config.yaml in place, comments preserved. Nothing hot-applies; the response says which service to restart. |
POST /api/engines/set |
Switch one component's engine persistently, after a preflight. See Engines map. |
POST /api/providers/key |
Set or clear one provider key in .env. Only variable names the registry already declares are accepted. Never returns the value; rejects a declared credential-prefix mismatch. |
POST /api/service/restart |
Restart the warm query API. Returns the relaunch generation counter; fails loudly if the supervisor does not actually relaunch. |
POST /api/jobs, .../retry, .../cancel |
Queue, retry and cancel jobs. |
POST /api/upload, POST /api/ingest_inbox, POST /api/ingest_custom |
Stage files and run ingest passes. |
POST /api/import/fetch, /convert, /promote |
Pull a URL as markdown or printed PDF, convert any upload to markdown, promote a staged result into ingest. |
POST /api/documents/retag |
Metadata-only update (domain / group label / tags). No re-embedding. |
POST /api/ocr/warm |
Load a vision-OCR model now rather than stalling the first ingest page. May bill an external endpoint. |
POST /api/vaults/switch, POST /api/vaults/forget |
Swap the whole per-vault path set atomically; drop a registration. |
POST /api/eval/questions/{qid} |
Verify, reject or edit one eval question and rewrite its sets file. See Eval review queue. |
Remove things — destructive¶
| Endpoint | Purpose |
|---|---|
POST /api/documents/delete |
Remove documents from the index: their vectors and their rows in the chunk files, and with rebuild: true a queued sparse rebuild. keep_jsonls names chunk files whose rows stay (the file a refused index --append will append again); a name that is no chunk file is a 400. Vault files are never touched. Restart :8051 afterwards. |
POST /api/inbox/delete |
Permanently deletes staged files (names, plain filenames) from the inbox or its _converted folder on disk: no .trash copy, and the index is untouched. folder (inbox or converted) says which folder to delete from. Without it, a name that both folders hold is refused with a 400 before anything is deleted. A name it cannot find comes back under missing. The console's two × buttons each send their own folder. |
POST /api/ocrstudio/remove |
Take one document out of the OCR queue by moving it into the vault's .trash; nothing is deleted. See OCR Studio. |
Engines map¶
GET /api/engines lists, for each component (generation, reranker, embeddings,
sparse, ocr, fetch, chunking), the active engine, the setting a switch writes,
and every option with:
| Field | Meaning |
|---|---|
available |
A fast probe says it can be used now. The probes load no model and call no internet; the local OCR sidecars get short health checks. |
swappable |
POST /api/engines/set accepts it: a probe can check it, the switch is checked before it is written, and it fails loudly instead of falling back. An option that is not is still listed. |
reason |
Why it is unavailable or not swappable; null when it is both. |
switch |
The cheapest way to use it: per_call, per_job, restart, build (a one-off job first) or reembed. |
POST /api/engines/set takes {component, engine, model?}. It refuses an
unknown or unswappable engine with its reason, then runs that component's
preflight: for the reranker, the local cache and then the model's hub entry
(with a timeout) and the context limit; for embeddings, only an already-built
collection whose fingerprint matches the configured embedder; for the sparse
lane, the tantivy index must exist; for OCR, the engine must answer now. It
writes through the same validator as POST /api/settings, and a refusal
writes nothing. It never restarts anything: the response is
{written, takes_effect, next_op, warnings}. next_op is the restart to run,
POST /api/service/restart, which restarts :8051 only; it is null when
nothing needs a restart and also when only :8052 does, so read
takes_effect. When the console must restart, warnings says so: restart it
by hand. warnings also carries the Settings writer's notes, such as a model
carried along with a provider. model applies to generation (the model id)
and to the reranker's cross_encoder.
Canvases (the Graph tab)¶
The Graph tab is a client of these routes. Of them, only POST /api/canvas/file
writes the canvas itself, and only on an explicit save.
| Endpoint | Tier | Behaviour |
|---|---|---|
GET /api/canvas/folders |
read |
Which vault trees hold .canvas files, with counts: the scope picker of the canvas ingest lane. One cached vault walk; refresh=1 rescans. |
POST /api/canvas/preview |
read |
Runs the canvas loader for real into a scratch file, then deletes it, and reports the chunks, nodes and edges, the share of chunks that carry edges, the text that context_depth duplicates, and one composed sample. Body: include_path, min_chunk_size, max_chunk_size, chunking, context_depth. Indexes nothing. |
GET /api/canvas/files |
read |
Every .canvas in the vault, vault-relative, from the same cached walk. |
GET /api/canvas/file |
read |
One canvas (path), parsed, with the sha256 of its bytes that a save must send back and its indexed chunks. |
POST /api/canvas/file |
mutating |
Save edits: {path, base_sha256, data}. Refused with 409 when the file changed on disk since it was loaded. The edit is merged onto the file as it is on disk, so only what the editor edits changes (node text, positions and sizes, edge labels, added and deleted text nodes and edges) and everything else stays as Obsidian wrote it. The old bytes are backed up to canvas.backup_dir, outside the vault, and the new ones replace the file atomically; a save with no edits writes nothing. |
POST /api/canvas/reindex |
mutating |
Re-index one canvas (path): removes its chunks from the index and the chunk files, then queues a scoped ingest_canvas into its own data/canvas_edit_*_chunks.jsonl and an index_append, and graph_embed third when graph.embedding_provider is set. A broken embedder setting is refused before anything is removed. Restart :8051 when the last job finishes. |
POST /api/canvas/suggest_label |
mutating |
A suggested label for one edge, from a language model (below). May bill; writes nothing. |
GET /api/canvas/suggest_options |
read |
The suggest-label picker: the default backend and model and which setting they come from, every providers: entry with its key status and vision flag, the relation vocabulary, the long-node settings, and whether label #tags are indexed. |
GET /api/canvas/embedder |
read |
Graph mode's seed embedder: the setting, the seed collection's fingerprint and freshness, a verdict with its reason, and the embedders it can be built with. See Graph mode. Reads only. |
To take a canvas out of the index, the Graph tab calls the ordinary
POST /api/documents/delete with its source_files and rebuild: true, then
queues graph_embed when a graph embedder is set, then offers the :8051
restart. The .canvas file stays in the vault.
Tags in labels. A label can carry Obsidian #tags (# and then a letter).
With canvas.label_tags: true (default false), the next ingest of that canvas
copies them into the tags of the chunks of both nodes the edge joins.
Metadata only: chunk ids and text do not change.
Suggest label¶
POST /api/canvas/suggest_label asks a language model for one edge's label.
Its body is path (an existing canvas), edge_id, data (the {nodes, edges}
the editor holds, unsaved edits included), and optionally provider, model
and guidance (free text appended to the prompt, up to 2000 characters).
What the model is shown is built from the posted canvas, not the file:
- Both endpoint nodes. File nodes are read from the vault, read-only: a note
without its frontmatter, plain text and code as they are, a sub-canvas as its
node titles, a group as its label and members, a link as its URL (never
fetched). A PDF or other binary, a missing file, or a path outside the vault
becomes a placeholder, never an error. An endpoint longer than
canvas.augment.node_charsis summarised by the same model first (one more call, reading at mostcanvas.augment.summarize_input_chars), or cut, withcanvas.augment.long_nodes: truncate. - The arrow as drawn (
→,←,↔or—), the canvas title, each endpoint's group and the canvas's groups. - The labels around it: the other edges at each endpoint, and the canvas's
labels as examples. A label with no letter once its
#tagsare removed (a step number, a bare#p) names no relation, so it is never an example and its edge shows only the neighbour's title. - Images, only for a
providers:entry markedvision: trueand up tocanvas.augment.max_image_mb. For any other entry the prompt names the image instead; the LLM client refuses to send images to an entry without the flag.
Who answers: the request's provider and model, else canvas.augment.provider
and canvas.augment.model, else the generation role's backend and model. A
backend that cannot be built is refused, never replaced by another. The output
cap is canvas.augment.max_tokens, sized for a model that reasons before it
answers.
The reply is read robustly: reasoning blocks are dropped and the last JSON
object with a non-empty label wins. The response carries label, relation
(a term of canvas.augment.vocabulary, read along the arrow, or null, with a
warning, when the model names another), tags, alternatives, confidence
(high, medium, low or null), why and warnings; the backend, protocol
and routed model that answered; usage over every call; prompt_version;
label_tags_indexed; and context, exactly what was sent, including how each
endpoint was fitted and which images went or why not. Nothing is written: the
label reaches the file only through a save.
Errors are typed by stage, and each 502 names the backend and model:
| Status | stage |
When |
|---|---|---|
400 |
— | The path is not a .canvas in the vault; data is not {nodes, edges}; the edge, or one of its endpoints, is not in data; canvas.augment.long_nodes is neither summarize nor truncate. A canvas that does not exist is a 404. |
400 |
client |
An unknown provider (the error names the field that set it), a missing or wrong-type key, or a vision value that is not a boolean. |
502 |
summary:from, summary:to |
A long endpoint's summary call failed or came back empty. |
502 |
label |
The label call failed: transport, HTTP or timeout. |
502 |
parse |
The reply holds no JSON object with a non-empty label; reply carries the start of the raw reply. |
OCR Studio¶
The OCR tab's routes. Studio work (regions, clean-up, masks, page status) is
kept in ocr_studio/ beside the chunk files, never in the vault.
| Endpoint | Tier | Behaviour |
|---|---|---|
GET /api/ocrstudio/files |
read |
The OCR queue: imports (ocr_studio.upload_dir), the inbox's top-level PDFs and images, other documents with studio work, and with q (two characters or more) a filename search of the whole vault. Every row carries its studio state and working page set. |
POST /api/ocrstudio/upload |
mutating |
Import one PDF or image (multipart file) into ocr_studio.upload_dir, whole and byte for byte. pages, a typed range, becomes its working page set, and every page keeps its real number. Never overwrites a name, never indexes; 413 above ocr_studio.upload_max_mb. |
POST /api/ocrstudio/remove |
destructive |
Take one document out of the queue (below). |
GET /api/ocrstudio/doc, /page, /page.png, /mask.png |
read |
One document's pages with the scanner's verdict per page, its progress and its indexed chunks; one page's regions and assembled markdown; the page rendered with its clean-up; its healing mask. |
POST /api/ocrstudio/page, /mask, /pageset |
mutating |
Save one page's regions, clean-up and status; store or clear its mask; set or clear the working pages (409 when saved work would fall outside them). |
POST /api/ocrstudio/propose |
read |
Region proposals for one page, from the page layout or the PaddleOCR sidecar's lines. Saves nothing. |
POST /api/ocrstudio/ocr |
mutating |
OCR one region with its own engine and language, and store the result. A failing engine is that region's error, never another engine's output. May call the sidecar or a vision model. |
GET /api/ocrstudio/engines, /quality |
read |
Each engine's availability and languages; the character error rate per engine over the pages marked done. |
POST /api/ocrstudio/ingest |
mutating |
Chunk the done (or every saved) page into the document's own data/ocrstudio_*_chunks.jsonl and queue its ingest and append. append is refused (409) for a document that already has chunks; replace first removes them from the index and every chunk file; dry_run only reports. |
POST /api/ocrstudio/export |
read |
A zip of the corrected markdown per document, real page numbers as <!-- page N --> markers, with its figure crops. Writes nothing. |
Taking a document out of the queue¶
POST /api/ocrstudio/remove {path} accepts exactly what the queue lists as an
import or an inbox document: a top-level PDF or image of ocr_studio.upload_dir
or of the inbox (webui.inbox_dir). Anything else is a 400, a missing file a
404.
- It moves the file into the vault's
.trashfolder, under its own vault-relative sub-path, with a(n)suffix when that name is taken. Nothing is deleted, and the reply namesmoved_to. - The studio work and figure crops stay. Moving the file back to its old path brings the document back with its work attached.
- It is refused with
409while the document has chunks in the index. Remove those first, withPOST /api/documents/deleteor the Documents tab.
The Ingest tab's inbox delete is a different operation. POST /api/inbox/delete
deletes a staged file from disk for good, with no trash copy; the queue's
remove only ever moves.
Eval review queue¶
The labelled questions behind the eval bench start life as
drafts that a person has to check. These endpoints are that review queue:
they read and rewrite the question-set files in the directory named by
eval.sets_dir (default eval/sets; local data, because it quotes the vault).
| Endpoint | Tier | Behaviour |
|---|---|---|
GET /api/eval/progress |
read |
Per suite: its quota, its total, and how many questions sit in each review status (draft, verified, edited, rejected), plus a totals row over the suites. The quotas are SUITES in eval/bench/questions.py; read them here instead of copying them. |
GET /api/eval/questions |
read |
The queue, one row per question: id, suite, tier, split, status, author, question, and n_errors / n_warnings (the validator's cached findings). Filters: suite, status, split; an empty filter matches everything. Rejected questions are listed. |
GET /api/eval/questions/{qid} |
read |
{record, gold_texts, findings}: the question as stored, the text of the chunks its gold locators resolve to, and the validator findings. The last two come from the review cache and are empty lists when it does not cover the question. 404 for an unknown id. |
POST /api/eval/questions/{qid} |
mutating |
Body: action (verify, reject or edit) and, for edit, the whole replacement record. Sets provenance.status (verified, rejected or edited) and reviewed_at, rewrites that question's whole sets file, and returns {ok: true, record}. |
Behaviour worth relying on:
- The cache is a snapshot.
findingsandgold_textscome from<sets_dir>/.review_cache.json, written bypython main.py bench validate --write-cache, and are as old as that run. Without a cache they are empty, which means "not validated", not "no findings". - An edit is validated like a load.
action: "edit"sends the whole replacementrecord, checked by the same schema the bench loads questions with. A record that breaks it is a400carrying the schema error, and nothing is written.recordis required witheditand refused with anything else. - Identity is locked. An edit cannot change
id,suiteorsplit(400). The split is assigned once bybench split, and moving a question between dev and test would leak the sealed test set. The server sets onlyprovenance.statusandreviewed_at; the record keeps whateverprovenance.authorit carries, so an edited draft is still a draft unless the editor says otherwise. - Writes are atomic and serialised. The sets file is replaced as a whole (a temp file, then a rename) under a lock, so a reader never sees half a file and two reviewers cannot overwrite each other with stale copies.
- Real status codes. Unlike the query API's retrieval knobs, failures here are
400/404, with{"ok": false, "error": …}on the write path. A sets file the loader rejects (a hand edit with a typo) is a500whose error names the file, the question and the problem.
Verifying is the human check that makes a drafted question trustworthy, so a client should confirm each verify, reject or edit with the operator rather than looping over the queue.
Which service do I call?¶
- Ask, search, compare, inspect evidence, or change warm retrieval defaults
→
:8051. - Manage the corpus, persistent settings, jobs, OCR, provider secrets, or the
eval review queue →
:8052.
Pair the two schema maps rather than assuming a copied capability list.
Worked example: an agent's first three calls¶
# 1. Is the query API up, and what does this install accept?
curl -s http://127.0.0.1:8051/health
curl -s http://127.0.0.1:8051/schema
# 2. Ground a question — retrieval only, no generation backend needed
curl -s -X POST http://127.0.0.1:8051/search \
-H "Content-Type: application/json" \
-d '{"q": "how do we rotate service credentials", "top_k": 6, "include_text": 1200}'
# 3. Pull the full text behind the best hit
curl -s "http://127.0.0.1:8051/chunks/<id-from-step-2>?include_text=4000"
Next: Agent integration for the patterns that make this token-efficient in a loop.