HTTP API (localdb serve)

EXPERIMENTAL — do not rely on this surface for production use.

The daemon opens the same unified database (<data_dir>/localdb.db) as the CLI, so CLI-indexed data IS visible via /v1/search, /v1/documents/{id}, /v1/stores/{name}/documents, and /v1/status. Ingestion via POST /v1/jobs runs the real pipeline through an async job queue (#187) — localdb index submits a job and attaches to its live progress (GET /v1/jobs/{id}/events, SSE) whenever a daemon is running, with identical output to embedded mode; you no longer need to stop the daemon first. It remains experimental as a surface: write concurrency across processes is SQLite WAL + busy_timeout=5000, not a dedicated lock.

For design rationale see specs/05-surfaces.md §3.


Starting the daemon

localdb serve

On startup the daemon prints a single announce line to stdout and then continues running:

daemon listening on http://127.0.0.1:7700

It binds the HTTP listener and also creates a Unix discovery socket at <data_dir>/daemon.sock so that CLI and MCP processes can detect it, plus a <data_dir>/daemon.url file recording the daemon’s actual client-reachable base URL (e.g. http://192.168.1.5:7700 for a LAN bind, or http://127.0.0.1:7700 when bound to 0.0.0.0/::, since the wildcard address itself isn’t connectable). CLI/MCP discovery reads this file, so it works for any configured bind address or port — not just the default 127.0.0.1:7700.

Bind address and port

The bind address and port are controlled by the server block in config.yaml:

version: 1
server:
  bind: 127.0.0.1 # default; any bind address is accepted (see Trust model below)
  port: 7700 # default; 0 = OS-assigned

Setting port: 0 asks the OS for an ephemeral port. The assigned port is shown in the announce line.

Trust model

The daemon binds 127.0.0.1 by default with no authentication. The documented trust boundary is: anything that can reach the bind address is as trusted as the files themselves. Any bind address is accepted — binding to a specific non-loopback address (e.g. a LAN or VPN IP) is treated as a deliberate trust decision and starts silently. Binding to 0.0.0.0 (all interfaces) logs a warning at startup, since that makes the unauthenticated daemon reachable from any network the machine is on. See specs/05-surfaces.md §3 for the binding and trust decision.


MCP over HTTP

Alongside /v1, the daemon also mounts /mcp — the same five read-only MCP tools (search, get_document, get_chunks, list_stores, list_documents) served over the MCP Streamable HTTP transport, for connecting a remote MCP client (e.g. Claude Code on another machine, over Tailscale/LAN). It inherits this daemon’s bind-address trust decision automatically — see docs/mcp.md for setup and specs/05-surfaces.md §4.2 for the transport/error-model details.


Endpoint reference

All endpoints are under the /v1 prefix. Request and response bodies are JSON; set Content-Type: application/json on requests that carry a body.

GET /v1/status

Returns a brief daemon health summary.

curl -s http://127.0.0.1:7700/v1/status
{
  "daemon": true,
  "store_count": 1,
  "source_count": 0,
  "job_count": 0,
  "stores": [
    {
      "name": "notes",
      "visibility": "private",
      "backend": "libsql",
      "document_count": 3,
      "chunk_count": 30
    }
  ],
  "database": {
    "path": "/path/to/data/localdb.db",
    "exists": true,
    "size_bytes": 90112,
    "wal_size_bytes": 0,
    "total_size_bytes": 90112,
    "bytes_per_chunk": 3003,
    "largest_tables": [{ "name": "chunks", "bytes": 65536 }]
  }
}
Field Type Description
daemon bool Always true when the daemon is responding
store_count int Number of stores known to this daemon instance
source_count int Total sources across all stores
job_count int Number of jobs ever created in this daemon session
stores[].document_count / stores[].chunk_count int|null Per-store RetrievalStore::stats() figures; null if that store’s stats call itself failed (a corrupt or mid-migration store must not blank out the report on the others)
database.path string Path to the shared localdb.db file — one physical file backs every store, so this is reported once, not per-store
database.exists bool Whether the file exists yet (false before the first store add/index)
database.size_bytes / database.wal_size_bytes int|null Bytes in the main file / -wal sidecar; null if a stat fails
database.total_size_bytes int size_bytes + wal_size_bytes (missing components treated as 0) — what the disk actually has allocated right now
database.bytes_per_chunk int|null total_size_bytes divided by the sum of every store’s chunk_count; null with no chunks
database.largest_tables array Up to 5 {name, bytes} rows, the largest on-disk tables via SQLite’s dbstat, descending; best-effort — empty if dbstat querying fails

This is the same shape the embedded CLI’s localdb status --json reports (see specs/05-surfaces.md §2.4) — daemon-routed and embedded status render identically.


GET /v1/stores

List all stores. Response is paginated (see Pagination).

curl -s http://127.0.0.1:7700/v1/stores
{
  "items": [
    {
      "name": "notes",
      "id": "01KTVGQ62TQN8X6XN9E5FDZN67",
      "visibility": "private",
      "backend": "libsql"
    }
  ],
  "next_cursor": null,
  "total": 1
}

POST /v1/stores

Create a runtime-owned store. The DB is the single source of truth for stores — there is no YAML store declaration (see GET /v1/config).

Request body:

Field Type Required Description
name string yes Store name; must be non-empty and not already exist
visibility string no "private" (default) or "shared"
curl -s -X POST http://127.0.0.1:7700/v1/stores \
  -H 'Content-Type: application/json' \
  -d '{"name":"notes"}'
{
  "name": "notes",
  "id": "01KTVGQ62TQN8X6XN9E5FDZN67",
  "visibility": "private",
  "backend": "libsql"
}

Returns 201 on success. invalid_request, 400, for an empty name, a name that already exists, or an unrecognized visibility value (see Error responses).


GET /v1/stores/{name}

Fetch a single store by name.

curl -s http://127.0.0.1:7700/v1/stores/notes
{
  "name": "notes",
  "id": "01KTVGQ62TQN8X6XN9E5FDZN67",
  "visibility": "private",
  "backend": "libsql"
}

Returns 404 with error code store_not_found if the store does not exist (see Error responses).


PATCH /v1/stores/{name}

Update a runtime-owned store. All fields are optional — only provided fields are updated. Currently the only mutable field is visibility.

Request body:

Field Type Required Description
visibility string no New visibility: "private" or "shared"
curl -s -X PATCH http://127.0.0.1:7700/v1/stores/notes \
  -H 'Content-Type: application/json' \
  -d '{"visibility":"shared"}'
{
  "name": "notes",
  "id": "01KTVGQ62TQN8X6XN9E5FDZN67",
  "visibility": "shared",
  "backend": "libsql"
}

Returns 404 with error code store_not_found if the store does not exist. invalid_request, 400, for an unrecognized visibility value.


DELETE /v1/stores/{name}

Delete a store, cascading to all its sources, documents, and chunks.

curl -s -X DELETE http://127.0.0.1:7700/v1/stores/notes

Returns 204 No Content (empty body) on success. Returns 404 with error code store_not_found if the store does not exist.


GET /v1/stores/{name}/sources

List sources attached to a store. Response is paginated.

curl -s http://127.0.0.1:7700/v1/stores/notes/sources
{
  "items": [],
  "next_cursor": null,
  "total": 0
}

POST /v1/stores/{name}/sources

Add a source to a store.

Request body:

Field Type Required Description
kind string yes "path", "url", or "feed"
spec object yes Kind-specific spec — e.g. {"root": "..."} for path (see specs/02-domain-model.md §2 for url/feed)
preset string no Chunking preset (default: "prose")
refresh string no Refresh interval (e.g. "24h"); persisted for url/feed sources; rejected with invalid_request (400) for any other kind
curl -s -X POST http://127.0.0.1:7700/v1/stores/notes/sources \
  -H 'Content-Type: application/json' \
  -d '{"kind":"path","spec":{"root":"/home/user/docs"}}'
{
  "id": "01KTVH6AY4DC84HWW7M2PP4F0X",
  "store_id": "01KTVGQ62TQN8X6XN9E5FDZN67",
  "kind": "path",
  "spec": { "root": "/home/user/docs", "include": [], "exclude": [] },
  "preset": "prose",
  "refresh": null
}

Returns 201 on success. 404 with error code store_not_found if the store does not exist. invalid_request, 400, for an unrecognized kind or a spec that fails kind-specific validation (e.g. a path source’s spec missing root, or a feed source’s max_entries: 0).


DELETE /v1/sources/{id}

Remove a source by id. Store-agnostic — the id alone identifies the source, so this route is not nested under /v1/stores/{name}.

curl -s -X DELETE http://127.0.0.1:7700/v1/sources/01KTVH6AY4DC84HWW7M2PP4F0X

Returns 204 No Content (empty body) on success. Returns 404 with error code source_not_found if the source does not exist.


GET /v1/stores/{name}/documents

List documents registered in a store. Response is paginated (see Pagination).

Query parameters:

Parameter Type Required Description
source string no Restrict to documents from this source id. An unknown id is a pure filter — it returns an empty page, not an error
cursor string no Pagination cursor from a previous response
limit int no Maximum items per page (must be ≥ 1; 0 is rejected as invalid_request)
curl -s http://127.0.0.1:7700/v1/stores/notes/documents
{
  "items": [
    {
      "store_id": "01KTVGQ62TQN8X6XN9E5FDZN67",
      "id": "a86bf252232bcec2a7da314d11e4c6005918f7930c7b9e1b081ef528034a34e8",
      "source_id": "01KTVH6AY4DC84HWW7M2PP4F0X",
      "ingestor_kind": "file",
      "uri": "file:///home/user/notes/meeting.txt",
      "title": null,
      "mime": "text/plain",
      "content_hash": "e3732cc41f646a4bc94bc3611b8b6fd9d7f31f1c192748d586f55b8e7e171fd2",
      "fetched_at": "2026-08-17T20:25:09Z",
      "origin_store": "01KTVGQ62TQN8X6XN9E5FDZN67",
      "policy_version": "a739e16768e0b8872b7220d37c37b9c9729d8eee52aa47575401035593411a69",
      "metadata": { "kind": "document", "format": "text/plain", "...": "..." }
    }
  ],
  "next_cursor": null,
  "total": 1
}

Returns 404 with error code store_not_found if the store does not exist (see Error responses).


GET /v1/documents/{id}

Fetch a single document’s identity, metadata, and reconstructed full text by id.

Query parameters:

Parameter Type Required Description
store string[] no Repeatable — scope the lookup to specific stores by name. Omitted: looks the id up across every store (invalid_request, 400, if more than one store holds a document with that id). Exactly one: scopes the lookup unambiguously. More than one: resolves unscoped, then checks the found document’s store against the given set. Same 0/1/many semantics as CLI document get -s (specs/05-surfaces.md §2.2), available in the daemon from day one
curl -s http://127.0.0.1:7700/v1/documents/a86bf252232bcec2a7da314d11e4c6005918f7930c7b9e1b081ef528034a34e8
{
  "id": "a86bf252232bcec2a7da314d11e4c6005918f7930c7b9e1b081ef528034a34e8",
  "uri": "file:///home/user/notes/meeting.txt",
  "title": null,
  "store_id": "01KTVGQ62TQN8X6XN9E5FDZN67",
  "source_id": "01KTVH6AY4DC84HWW7M2PP4F0X",
  "content_hash": "e3732cc41f646a4bc94bc3611b8b6fd9d7f31f1c192748d586f55b8e7e171fd2",
  "fetched_at": "2026-08-17T20:25:09Z",
  "normalized_text": "Meeting 2026-06-02: decided to adopt reciprocal rank fusion for combining dense and sparse retrieval results.",
  "metadata": { "kind": "document", "format": "text/plain", "...": "..." }
}

normalized_text is the document’s reconstructed full text — always present in the response; there is no query parameter that omits it (the CLI’s document get --text is purely a rendering choice on top of the same always-fetched text, specs/05-surfaces.md §2). metadata is the full Metadata enum, same shape as a search citation’s metadata field (specs/02-domain-model.md §7).

Returns 404 with error code resource_not_found if no document with that id exists in scope, or store_not_found if a named ?store= does not exist.

curl -s "http://127.0.0.1:7700/v1/documents/<id>?store=notes&store=books"

GET /v1/config

Returns the parsed configuration as localdb sees it, together with the effective store list (all runtime-created stores from the DB).

curl -s http://127.0.0.1:7700/v1/config
{
  "yaml_config": {
    "defaults": {
      "indexing": {
        "chunking": {
          "preset_overrides": {}
        },
        "embedding": {
          "model": "pplx-embed-context-v1-0.6b",
          "provider": "local-onnx"
        }
      }
    },
    "paths": {
      "data": "/path/to/data",
      "logs": "/path/to/logs",
      "models": "/path/to/models"
    },
    "providers": [],
    "server": {
      "bind": "127.0.0.1",
      "port": 7700
    },
    "stores": [],
    "version": 1
  },
  "effective_stores": [
    {
      "name": "notes",
      "visibility": "private",
      "backend": "libsql"
    }
  ]
}

effective_stores lists all stores registered via localdb store add (or POST /v1/stores). The DB is the single source of truth — there is no YAML store declaration. Config schema details are in specs/03-config.md.


POST /v1/search

Hybrid search across stores. Returns a ranked citation list over the same data the CLI indexes — the daemon and the CLI share <data_dir>/localdb.db.

Request body:

Field Type Required Description
query string yes Natural language search query
store_filter string[] no Store names to search; omit or pass [] to search all stores
limit int no Maximum results to return (default: 10; silently clamped to 100, SEARCH_MAX_LIMIT)
cursor string no Pagination cursor from a previous response
curl -s -X POST http://127.0.0.1:7700/v1/search \
  -H 'Content-Type: application/json' \
  -d '{"query":"hybrid search","limit":1}'
{
  "citations": [],
  "total_candidates": 0,
  "next_cursor": null
}

Each citation in citations follows the canonical Citation shape defined in specs/02-domain-model.md §6. For a fully-populated example see the localdb search --json output in the CLI reference.

A limit above 100 is not an error — it is silently clamped to 100 (localdb_core::SEARCH_MAX_LIMIT), matching the MCP search tool’s own cap.


GET /v1/jobs

List every job on the daemon’s queue, in any state, across every store.

curl -s http://127.0.0.1:7700/v1/jobs
[
  {
    "id": "01KTVM5XMA59N4WGHNZ80QX9B7",
    "store_id": "notes",
    "scope": { "type": "store" },
    "state": "running",
    "stats": {
      "docs_seen": 0,
      "docs_indexed": 0,
      "docs_skipped": 0,
      "docs_deleted": 0,
      "docs_prunable": 0,
      "chunks_written": 0,
      "unsupported_format_count": 0,
      "error_count": 0,
      "sources_count": 0
    },
    "error": null,
    "error_code": null,
    "created_at": "2026-06-11T15:17:59Z",
    "started_at": "2026-06-11T15:17:59Z",
    "completed_at": null
  }
]

Returns the raw IndexJob[] array directly — unlike /v1/stores and /v1/stores/{name}/sources, there is no pagination envelope: jobs are ephemeral operational records with bounded retention (the registry caps how many terminal jobs it keeps, evicting the oldest first), so the response never grows unbounded. Order is registry iteration order, not guaranteed stable.


POST /v1/jobs

Submit an index job for a store. This runs the real ingestion pipeline (server::job_exec::run_job) through an async job queue with a configurable worker pool (server.job_workers, default 1, issues #187/#208) — the daemon processes the job asynchronously, in the background; poll GET /v1/jobs/{id} or stream GET /v1/jobs/{id}/events for progress.

Request body:

Field Type Required Description
store_name string yes Name of the store to index
source_id string no Index only this source; omit to index the whole store
deletion_policy string no "retain" (default) — never removes documents; "delete" — prunes documents no longer present at their source (mirrors CLI index --delete). Any other value is invalid_request, 400
curl -s -X POST http://127.0.0.1:7700/v1/jobs \
  -H 'Content-Type: application/json' \
  -d '{"store_name":"notes"}'
{
  "id": "01KTVM5XMA59N4WGHNZ80QX9B7",
  "store_id": "notes",
  "scope": { "type": "store" },
  "state": "pending",
  "stats": {
    "docs_seen": 0,
    "docs_indexed": 0,
    "docs_skipped": 0,
    "docs_deleted": 0,
    "docs_prunable": 0,
    "chunks_written": 0,
    "unsupported_format_count": 0,
    "error_count": 0,
    "sources_count": 0
  },
  "error": null,
  "error_code": null,
  "created_at": "2026-06-11T15:17:59Z",
  "started_at": null,
  "completed_at": null
}

If you pass "store" instead of "store_name" the server returns a 422-style deserialisation error: Failed to deserialize the JSON body into the target type: missing field 'store_name' (followed by a line/column offset). Unknown keys are ignored rather than rejected — CreateJobRequest does not set deny_unknown_fields.

A second POST /v1/jobs for a store that already has a job queued or running is rejected with index_in_progress, 409 (see Error responses), regardless of worker-pool size — the in-flight guard is per-store, reserved atomically before the job is created, so two concurrent submissions for the same store can never both proceed. Jobs for different stores run concurrently, up to server.job_workers workers (default 1); same-store jobs are always serialized by the per-store guard.


GET /v1/jobs/{id}

Poll the status of a previously submitted job.

curl -s http://127.0.0.1:7700/v1/jobs/01KTVM5XMA59N4WGHNZ80QX9B7
{
  "id": "01KTVM5XMA59N4WGHNZ80QX9B7",
  "store_id": "notes",
  "scope": {
    "type": "store"
  },
  "state": "done",
  "stats": {
    "docs_seen": 3,
    "docs_indexed": 3,
    "docs_skipped": 0,
    "docs_deleted": 0,
    "docs_prunable": 0,
    "chunks_written": 12,
    "unsupported_format_count": 0,
    "error_count": 0,
    "sources_count": 1
  },
  "error": null,
  "error_code": null,
  "created_at": "2026-06-11T15:17:59Z",
  "started_at": "2026-06-11T15:17:59Z",
  "completed_at": "2026-06-11T15:17:59Z"
}

Job fields:

Field Type Description
id string ULID job identifier
store_id string Store name the job runs against
scope object {"type":"store"} for a full-store index, {"type":"source","source_id":"..."} for one source. {"type":"document","resource_id":"..."} also exists in the type but is currently unreachable — POST /v1/jobs has no resource_id field to construct it
state string "pending", "running", "done", or "failed"
stats object Running counters (see below)
error string|null Error message if the job failed
error_code string|null Stable error code (see Error responses) if the job failed with a typed error — e.g. "invalid_config" for an embedder-construction failure. null for a synthetic queue-level failure (the queue itself full/closed, or the job’s task panicking) that never had one, and always null on "done". Issue #187 review, finding 3: lets a daemon-attached CLI client reconstruct the original error and exit with the same code an equivalent embedded failure would, instead of every job failure collapsing to a generic internal error. #[serde(default)] on the Rust side, so a daemon predating this field omits the key entirely rather than sending null — treat a missing key the same as null
created_at string ISO 8601 timestamp
started_at string|null ISO 8601 timestamp; null while pending
completed_at string|null ISO 8601 timestamp; null while running

Stats fields:

Field Description
docs_seen Files/URLs examined
docs_indexed New or changed documents ingested
docs_skipped Documents skipped (unchanged content hash)
docs_deleted Documents removed because the source is gone (only ever non-zero with deletion_policy: "delete")
docs_prunable Documents that would have been deleted had deletion_policy: "delete" been requested — always 0 on a run that actually deleted (they were removed and counted in docs_deleted instead)
chunks_written Chunks written to the vector store
unsupported_format_count Files skipped due to unrecognised format
error_count Per-document errors
sources_count Number of sources the job’s scope resolved to, before any were processed — distinguishes “nothing to index” (0) from “sources existed but nothing needed indexing” (>0, other counters possibly still 0)

DELETE /v1/jobs/{id}

Request cancellation of a queued or running job (issue #218).

curl -s -X DELETE http://127.0.0.1:7700/v1/jobs/01KTVM5XMA59N4WGHNZ80QX9B7
{
  "id": "01KTVM5XMA59N4WGHNZ80QX9B7",
  "store_id": "notes",
  "scope": { "type": "store" },
  "state": "running",
  "stats": {
    "docs_seen": 0,
    "docs_indexed": 0,
    "docs_skipped": 0,
    "docs_deleted": 0,
    "docs_prunable": 0,
    "chunks_written": 0,
    "unsupported_format_count": 0,
    "error_count": 0,
    "sources_count": 0
  },
  "error": null,
  "error_code": null,
  "created_at": "2026-06-11T15:17:59Z",
  "started_at": "2026-06-11T15:17:59Z",
  "completed_at": null
}

Returns 202 and the job’s snapshot at the moment cancellation was requested — not a guarantee it has already stopped. Poll GET /v1/jobs/{id} or watch GET /v1/jobs/{id}/events for the eventual terminal state. Cancellation does not add a new state value — it reuses the existing "failed" terminal state with error_code: "job_cancelled":

{
  "id": "01KTVM5XMA59N4WGHNZ80QX9B7",
  "store_id": "notes",
  "scope": { "type": "store" },
  "state": "failed",
  "stats": { "...": "..." },
  "error": "job was cancelled",
  "error_code": "job_cancelled",
  "created_at": "2026-06-11T15:17:59Z",
  "started_at": "2026-06-11T15:17:59Z",
  "completed_at": "2026-06-11T15:17:59Z"
}

404 with error code job_not_found for an unknown job id. 409 with error code job_already_terminal for a job that already reached "done" or "failed" — a cancel landing after normal completion (or after a real failure) must never overwrite the recorded outcome:

{ "code": "job_already_terminal", "message": "job already reached a terminal state; cannot cancel" }

GET /v1/jobs/{id}/events

Stream a job’s live progress as Server-Sent Events (issue #83).

curl -N -H 'Accept: text/event-stream' http://127.0.0.1:7700/v1/jobs/01KTVM5XMA59N4WGHNZ80QX9B7/events

Each in-flight update is an event: progress frame, data: a JSON-serialized core::ProgressEvent (internally tagged on type):

event: progress
data: {"type":"source_started","source_id":"01K...","location":"/path/to/docs"}

event: progress
data: {"type":"discovered","total":3}

event: progress
data: {"type":"document_started","uri":"file:///path/to/docs/a.md","index":0,"total":3}

event: progress
data: {"type":"document_finished","uri":"file:///path/to/docs/a.md","outcome":{"outcome":"indexed","chunks":4}}

The stream always ends with exactly one event: job frame carrying the terminal IndexJob (the same shape GET /v1/jobs/{id} returns, state either "done" or "failed"), after which the connection closes:

event: job
data: {"id":"01KTVM5XMA59N4WGHNZ80QX9B7","store_id":"notes","scope":{"type":"store"},"state":"done","stats":{...},"error":null,"error_code":null,"created_at":"...","started_at":"...","completed_at":"..."}

A client that connects after the job has already reached a terminal state — or after its live progress channel has already been torn down — receives only that terminal job event, immediately; it never sees the progress events it missed. Progress delivery is lossy/best-effort by design (a lagging subscriber skips ahead rather than stalling the stream or buffering unboundedly), but the terminal job event is always guaranteed exactly once. Unknown job_idjob_not_found, 404, as an ordinary JSON error response (not an SSE frame — the 404 happens before the stream opens).


Pagination

List endpoints (/v1/stores, /v1/stores/{name}/sources, /v1/stores/{name}/documents) use cursor-based pagination. GET /v1/jobs is a list endpoint but is not paginated — see its own section above for why.

Query parameter Default Description
cursor Opaque cursor from a previous response’s next_cursor
limit server default Maximum items per page; must be ≥ 1 (0 is invalid_request)

A next_cursor of null means the last page has been reached.


Error responses

All errors use the same JSON envelope:

{ "code": "store_not_found", "message": "nope" }
Field Type Description
code string Machine-readable error code (stable API)
message string Error detail — see below for its exact shape

For store_not_found, source_not_found, resource_not_found, job_not_found, invalid_config, invalid_request, provider_unavailable, model_missing, and rate_limited, message is the bare field the error was built from (the id, or the validation/provider/rate-limit detail) — it does not carry the human-readable prefix a CLI-rendered version of the same error would (e.g. "store not found: "). This lets a daemon-attached client reconstruct the original typed error from code + message (core::Error::from_code) and render its own prefix without doubling it; a client that just wants display text should combine code and message itself (e.g. "store not found: nope"). Every other code’s message carries the full human-readable string as-is.

HTTP status codes follow the shared error taxonomy in specs/05-surfaces.md §5:

Code HTTP status Meaning
store_not_found / source_not_found / resource_not_found / job_not_found 404 Unknown entity
runtime_state_locked 409 Unified database locked by another process (SQLite busy_timeout exceeded)
daemon_running 409 A second daemon was started against the same data dir
daemon_unreachable 502 Daemon socket exists but is not responding
invalid_config 422 Config failed validation
invalid_request 400 Bad request body or arguments
unsupported_format 422 Extractor cannot handle the file
provider_unavailable 502 External embedding endpoint down
model_missing 503 Local model not yet downloaded
rate_limited 502 Retries against an upstream host exhausted; grouped with “upstream not currently servable” rather than 429, since it’s an upstream limit, not the daemon’s own
index_in_progress 409 Conflicting job already running for this scope
job_already_terminal 409 DELETE /v1/jobs/{id} requested for a job that already reached done/failed — cancellation must never overwrite a recorded outcome
job_cancelled n/a Never a live response’s code — appears only as a cancelled job’s error_code (GET /v1/jobs/{id}), reconstructed via core::Error::from_code
internal 500 Bug; response includes a correlation_id for log correlation

Troubleshooting

Diagnosing a rejected (4xx/5xx) request

localdb serve logs every response with status >= 400 at warn level, with the request’s method, path, status, and Host header — including responses from the nested /mcp mount (e.g. rmcp’s own DNS-rebinding Host-header check), not just /v1 routes. This surfaces on stderr by default: localdb’s default log filter (warn,pdf_oxide=off, set in localdb/src/main.rs) already passes warn-level events through, so no RUST_LOG is needed to see a rejected request logged. Set RUST_LOG=debug for more detail.

daemon_running (exit 4) when starting localdb serve

Only one daemon may run against a given data directory at a time. If localdb serve exits immediately with:

error: daemon is already running
exit: 4

there is already a daemon process running. Stop it before starting a new one.

Stale daemon.sock / daemon.url after an ungraceful shutdown

If the daemon process is killed (e.g. with kill <pid> or a crash), the Unix socket file at <data_dir>/daemon.sock and the discovery URL file at <data_dir>/daemon.url are not cleaned up. The CLI will then report the daemon as running and localdb search will exit with:

error: daemon is unreachable
exit: 5

Fix: remove the stale files manually, then CLI commands will fall back to embedded mode.

rm <data_dir>/daemon.sock <data_dir>/daemon.url

After removal localdb status will show daemon: not running (embedded mode).