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

MethodPathPurpose
GET/v1/tenantsList
POST/v1/tenantsCreate
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

MethodPathPurpose
GET/v1/tenants/{t}/indexesList indexes
POST/v1/tenants/{t}/indexesCreate
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}/statusStatus
# 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

MethodPathPurpose
POST/v1/tenants/{t}/indexes/{i}/documentsAdd documents (live ingest)
POST/v1/tenants/{t}/indexes/{i}/importBulk import (queued)
GET/v1/tenants/{t}/indexes/{i}/pendingPending queue status
POST/v1/tenants/{t}/indexes/{i}/processProcess pending
DELETE/v1/tenants/{t}/indexes/{i}/pendingClear pending
POST/v1/tenants/{t}/indexes/{i}/compactMerge live → base
POST/v1/tenants/{t}/indexes/{i}/clearDrop all data, keep config
DELETE/v1/tenants/{t}/indexes/{i}/documentsBulk delete by id
DELETE/v1/tenants/{t}/indexes/{i}/documents/by-external-idBulk 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

MethodPathPurpose
POST/v1/tenants/{t}/indexes/{i}/searchHybrid (text or vector)
POST/v1/tenants/{t}/indexes/{i}/search/textText query only
POST/v1/tenants/{t}/indexes/{i}/search/vectorVector 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.

MethodPathPurpose
PATCH/v1/tenants/{t}/indexes/{i}/embedding-modelStart hot-swap
GET/v1/jobs/{jobID}Poll a job
GET/v1/tenants/{t}/jobsList 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"}