HTTP API
tennis serve exposes write, query, delete, and list over loopback — for every language that isn't Go.
tennis serve puts the same engine behind a small HTTP API, so Python, TypeScript, Ruby, or anything else can use it without a binding.
tennis serve # 127.0.0.1:8817
tennis serve --addr 127.0.0.1:9000
tennis serve --db /path/to/db.sqliteIt binds to loopback, and it has no auth
This is an interface to a local file, not a service. --addr will bind anywhere you tell it to; don't, unless you have put something in front of it.
Endpoints
| Method | Path | Does |
|---|---|---|
GET | /v1/namespaces | list namespaces |
POST | /v1/namespaces/{ns}/write | upsert documents |
POST | /v1/namespaces/{ns}/query | search |
POST | /v1/namespaces/{ns}/delete | delete by id |
GET | /health | liveness |
Write
curl -s localhost:8817/v1/namespaces/notes/write -d '{
"documents": [
{"id": "a1", "text": "make the login flow remember the user",
"attributes": {"status": "merged"}}
]
}'
# {"written":1,"skipped":0,"chunks":1}The namespace is created on first write, bound to the built-in model. To bind it to something else, say so on that first write — model names a built-in model, openai_model an OpenAI one — or create it first with tennis ns create:
curl -s localhost:8817/v1/namespaces/notes/write -d '{
"openai_model": "text-embedding-3-small",
"documents": [{"id": "a1", "text": "make the login flow remember the user"}]
}'Both fields are read only when the namespace does not exist yet. On a namespace that already exists they are ignored, because the binding was fixed at creation and cannot be changed by a later request — see Embedders. openai_model needs OPENAI_API_KEY in the environment of tennis serve, not of the client.
Query
curl -s localhost:8817/v1/namespaces/notes/query -d '{
"text": "keep me signed in", "top_k": 5, "where": "status=merged"
}'
# {"results":[{"id":"a1","score":0.0164,"text":"...","attributes":{"status":"merged"},"keyword_rank":0,"semantic_rank":1}]}where takes the same syntax as --where on the CLI: key=value, key!=value, key>value, comma-separated. keyword_rank and semantic_rank are 1-based, and 0 means that ranker did not surface the document at all.
List, delete, health
curl -s localhost:8817/v1/namespaces
# [{"name":"notes","embedder_id":"builtin:potion-retrieval-32M","dims":512,"created_at":"2026-10-06T00:00:21Z","documents":1,"chunks":1}]
curl -s localhost:8817/v1/namespaces/notes/delete -d '{"ids": ["a1"]}'
# {"deleted":1}
curl -s localhost:8817/health
# {"status":"ok","version":"v0.1.0 (ee10c3ed7123)","db":"/Users/you/.tennis/db.sqlite"}/health reports the same version string as tennis version and the database file the server has open, so a client can tell which binary and which data it is talking to.
Errors
Every error is a JSON body with one field:
{"error": "namespace \"notes\": namespace does not exist"}A namespace that does not exist is a 404; a body that will not decode, a where that will not parse, or an invalid name is a 400; everything else is a 500. Request bodies are capped at 32MB.
Python client, in full
import requests
BASE = "http://localhost:8817/v1"
def write(ns, docs):
return requests.post(f"{BASE}/namespaces/{ns}/write", json={"documents": docs}).json()
def match(ns, text, top_k=10, where=""):
r = requests.post(f"{BASE}/namespaces/{ns}/query",
json={"text": text, "top_k": top_k, "where": where})
r.raise_for_status()
return r.json()["results"]
write("notes", [{"id": "a1", "text": "make the login flow remember the user"}])
for hit in match("notes", "keep me signed in"):
print(f'{hit["score"]:.4f} {hit["id"]} {hit["text"][:60]}')TypeScript client, in full
const BASE = "http://localhost:8817/v1";
export async function write(ns: string, documents: unknown[]) {
const r = await fetch(`${BASE}/namespaces/${ns}/write`, {
method: "POST",
body: JSON.stringify({ documents }),
});
if (!r.ok) throw new Error(await r.text());
return r.json();
}
export async function match(ns: string, text: string, topK = 10, where = "") {
const r = await fetch(`${BASE}/namespaces/${ns}/query`, {
method: "POST",
body: JSON.stringify({ text, top_k: topK, where }),
});
if (!r.ok) throw new Error(await r.text());
return (await r.json()).results;
}
await write("notes", [{ id: "a1", text: "make the login flow remember the user" }]);
console.log(await match("notes", "keep me signed in"));