API reference
All endpoints are HTTP/JSON under /v1. Tenant-scoped paths take a {tenantID} path parameter; index-scoped paths take both {tenantID} and {indexID}.
Health
GET /health
Liveness probe. Always 200 if the process is up.
curl -s http://localhost:38888/health
# {"status":"healthy"}
GET /ready
Readiness. 200 once the tenant manager has loaded; 503 otherwise. Use this for orchestrator readiness probes.
Tenants
| Method | Path | Purpose |
|---|---|---|
| GET | /v1/tenants | List |
| POST | /v1/tenants | Create |
| GET | /v1/tenants/{tenantID} | Get |
Create: pass id for an idempotent create (returns existing tenant if the id is taken). Otherwise id is generated.
# Body
# { "id": "t_acme", "name": "Acme Corp" }
curl -X POST http://localhost:38888/v1/tenants \
-H 'content-type: application/json' \
-d '{"id":"t_acme","name":"Acme Corp"}'
# 201 Created
# {"id":"t_acme","name":"Acme Corp","created_at":"..."}
Indexes
| Method | Path | Purpose |
|---|---|---|
| GET | /v1/tenants/{t}/indexes | List indexes |
| POST | /v1/tenants/{t}/indexes | Create |
| GET | /v1/tenants/{t}/indexes/{i} | Get |
| PATCH | /v1/tenants/{t}/indexes/{i} | Update (planned) |
| DELETE | /v1/tenants/{t}/indexes/{i} | Delete |
| GET | /v1/tenants/{t}/indexes/{i}/status | Status |
# Body
# { "id": "i_docs", "name": "product-docs", "description": "..." }
curl -X POST http://localhost:38888/v1/tenants/t_acme/indexes \
-H 'content-type: application/json' \
-d '{"id":"i_docs","name":"product-docs"}'
# 201 Created → IndexInfo (id, tenant_id, name, status, num_docs, ...)
Ingest
| Method | Path | Purpose |
|---|---|---|
| POST | /v1/tenants/{t}/indexes/{i}/documents | Add documents (live ingest) |
| POST | /v1/tenants/{t}/indexes/{i}/import | Bulk import (queued) |
| GET | /v1/tenants/{t}/indexes/{i}/pending | Pending queue status |
| POST | /v1/tenants/{t}/indexes/{i}/process | Process pending |
| DELETE | /v1/tenants/{t}/indexes/{i}/pending | Clear pending |
| POST | /v1/tenants/{t}/indexes/{i}/compact | Merge live → base |
| POST | /v1/tenants/{t}/indexes/{i}/clear | Drop all data, keep config |
| DELETE | /v1/tenants/{t}/indexes/{i}/documents | Bulk delete by id |
| DELETE | /v1/tenants/{t}/indexes/{i}/documents/by-external-id | Bulk delete by external id |
| DELETE | /v1/tenants/{t}/indexes/{i}/chunks/{chunkID} | Tombstone a chunk |
# Body
# {
# "documents": [
# {"id":"doc-1","text":"...","metadata":{"key":"val"},"repo_id":"..."}
# ]
# }
curl -X POST \
http://localhost:38888/v1/tenants/t_acme/indexes/i_docs/documents \
-H 'content-type: application/json' \
-d '{"documents":[{"id":"doc-1","text":"hello"}]}'
# 200 OK
# { "added": 1, "chunk_ids": ["..."] }
Search
| Method | Path | Purpose |
|---|---|---|
| POST | /v1/tenants/{t}/indexes/{i}/search | Hybrid (text or vector) |
| POST | /v1/tenants/{t}/indexes/{i}/search/text | Text query only |
| POST | /v1/tenants/{t}/indexes/{i}/search/vector | Vector only |
Body: query (string) or vector (float32[]), k (int, default 10), optional filter with repo_ids (array) and/or equals (string→string map) for AND-combined equality pre-filtering over typed metadata fields (author, source_type, content_type, file_path, external_id, etc.).
# Body
# {
# "query": "...",
# "k": 10,
# "filter": {
# "repo_ids": ["repo-a"],
# "equals": { "author": "alice", "content_type": "email" }
# }
# }
curl -X POST \
http://localhost:38888/v1/tenants/t_acme/indexes/i_docs/search \
-H 'content-type: application/json' \
-d '{"query":"how does search work","k":5}'
# 200 OK
# {
# "results": [
# {"id":"...","text":"...","score":0.82,"metadata":{...}}
# ],
# "total": 5
# }
Jobs (hot-model-switch)
Switching the embedding model on a populated index requires re-embedding every chunk. The endpoint kicks off an async job and returns a handle.
| Method | Path | Purpose |
|---|---|---|
| PATCH | /v1/tenants/{t}/indexes/{i}/embedding-model | Start hot-swap |
| GET | /v1/jobs/{jobID} | Poll a job |
| GET | /v1/tenants/{t}/jobs | List tenant jobs |
# Start a hot-swap
curl -X PATCH \
http://localhost:38888/v1/tenants/t_acme/indexes/i_docs/embedding-model \
-H 'content-type: application/json' \
-d '{"embedding_model":"nomic-embed-text:v1.5"}'
# 202 Accepted
# {"job_id":"job_...","status":"running","progress":0}
# Poll
curl http://localhost:38888/v1/jobs/job_...
# {"job_id":"job_...","status":"running","progress":0.42,
# "processed":420,"total":1000,"started_at":"..."}
Cluster
The HA cluster (Raft + gossip) is operational. Per-node sidecar exposes a basic membership endpoint on http-port + 1 (e.g. :38889 if HTTP is :38888).
# Per-node sidecar (port = http-port + 1)
curl http://localhost:38889/cluster/members
# Returns the gossip member list with node_id, zone, address, status.
Metrics
Prometheus scrape target: GET /metrics on the main HTTP port. Counters and histograms cover request latency, ingest throughput, ingest-buffer occupancy, compaction cycles, and per-tenant resource use. Full metric reference is shipped with each release.
curl http://localhost:38888/metrics | head -20
# graphann_search_requests_total{tenant="t_acme",index="i_docs"} 1234
# graphann_search_latency_seconds_bucket{le="0.05"} 980
# ...
Errors
All errors return JSON with error and a stable code field. Status codes follow standard semantics (400, 401, 403, 404, 409, 429, 500, 501, 503).
# 404 Not Found
# {"error":"Index not found","code":"not_found"}
# 400 Bad Request
# {"error":"Invalid tenant ID format","code":"bad_request"}
# 429 Too Many Requests
# {"error":"Documents per index quota exceeded","code":"quota_exceeded"}