# Chroma API coverage

The client speaks Chroma's **v2 HTTP API**, rooted at
`{endpoint}/api/v2/`. Collection and record calls hang off
`tenants/{tenant}/databases/{database}/`; two endpoints (`pre-flight-checks`,
`auth/identity`) sit at the API root. This page lists, endpoint by endpoint,
what the client covers and what it deliberately does not.

Every method below exists in two flavours: `client.add(...)` is synchronous
(`Transport`), `client.add_async(...)` is asynchronous (`AsyncTransport`).
They are the same call over the same path, so the table lists them once.

The reference for the shape of each call is chroma-go's `pkg/api/v2`
(`Client` and `Collection`). The mapping from its methods to these is in the
[README](../README.mbt.md#alignment-with-chroma-go).

## Covered

| Chroma endpoint | chroma-go | Client method(s) |
| --- | --- | --- |
| `GET /heartbeat` | `Client.Heartbeat` | `heartbeat`, `heartbeat_async` |
| `GET /version` | `Client.GetVersion` | `version`, `version_async` |
| `GET /pre-flight-checks` | `Client.PreFlight` | `preflight`, `preflight_async` |
| `GET /auth/identity` | `Client.GetIdentity` | `identity`, `identity_async` |
| `GET /collections` (+ `?limit&offset`) | `Client.ListCollections` | `list_collections(limit~, offset~)` (+ `_async`) |
| `POST /tenants/{tenant}/databases` | `Client.CreateDatabase` | `create_database`, `create_database_async` (same path and body, built by one helper) |
| `POST /collections` | `Client.CreateCollection`, `Client.GetOrCreateCollection` | `create_collection`, `get_or_create_collection` (+ `_async`) |
| `GET /collections/{id}` | `Client.GetCollection` | `get_collection`, `get_collection_async` |
| `DELETE /collections/{id}` | `Client.DeleteCollection` | `delete_collection`, `delete_collection_async` |
| `PUT /collections/{id}` | `Collection.ModifyName`, `ModifyMetadata` | `modify_collection`, `modify_collection_async` |
| `GET /collections_count` | `Client.CountCollections` | `count_collections`, `count_collections_async` |
| `GET /collections/{id}/count` | `Collection.Count` | `count`, `count_async` |
| `POST /collections/{id}/get` | `Collection.Get` | `get`, `get_async`, and `peek` (limit clamped to 100) |
| `POST /collections/{id}/add` | `Collection.Add` | `add`, `add_async` |
| `POST /collections/{id}/update` | `Collection.Update` | `update`, `update_async` |
| `POST /collections/{id}/upsert` | `Collection.Upsert` | `upsert`, `upsert_async` |
| `POST /collections/{id}/delete` | `Collection.Delete` | `delete`, `delete_async` |
| `POST /collections/{id}/query` | `Collection.Query` | `query_embeddings`, `query_embeddings_async` |

Body shapes matter here, and they are not what the older 0.x API used:

- records are sent **columnar** (`ids` / `embeddings` / `documents` /
  `metadatas` as parallel arrays, missing values padded with `null`), matching
  Chroma's `AddEmbedding` model;
- `create_collection` always sends `get_or_create`, so the two entry points
  differ by that flag rather than by path;
- `count` and `collections_count` decode a **bare integer**; a `{"count": n}`
  envelope is accepted too;
- `heartbeat` returns `{"nanosecond heartbeat": <int>}` and is surfaced as an
  `Int64`;
- `modify_collection` sends `new_metadata`, not `metadata`;
- `PUT /collections/{id}` really wants a **UUID**, not a name — the `GET` and
  `DELETE` routes below the same path accept either. `modify_collection` takes
  a name like its neighbours, so it looks the id up first (a UUID argument
  skips that round trip). Sending a name straight to `PUT` fails with
  `400 InvalidArgumentError: Collection ID is not a valid UUIDv4`; only a live
  server catches this, because a scripted response never validates the path.

### Which filter fields each operation takes

chroma-go spells these as options (`WithIDs`, `WithWhere`, `WithWhereDocument`,
`WithInclude`, `WithLimit`, `WithOffset`, `WithNResults`); here they are the
fields of `Filter` plus the call's own parameters. The set that is actually
sent differs per endpoint, always because the server would reject the rest:

| Operation | `ids` | `where` | `where_document` | `limit` / `offset` | `include` |
| --- | --- | --- | --- | --- | --- |
| `get` | ✅ | ✅ | ✅ | ✅ (independent) | ✅ (`Include::get_default()`) |
| `query_embeddings` | ✅ | ✅ | ✅ | ❌ (use `n_results`) | ✅ (`Include::default()`) |
| `update` | — | ✅ | ✅ | — | — |
| `delete` | ✅ | ✅ | ✅ | `limit` only, and only with a clause | — |
| `peek` | ❌ | ❌ | ❌ | `limit`, clamped to `PEEK_LIMIT` | ✅ (`Include::get_default()`) |

`update` and `delete` send their clauses without an `include` array: unlike
`get`, they are writes, and `include` only decides which columns come back.
`delete`'s `limit` is the only paging field a write endpoint accepts.

## Not covered

The entries below are deliberate gaps, not oversights: each one is either
impossible to call correctly from this client or outside what a client for the
data plane should do. "Not implemented" entries are candidates for a follow-up
once the endpoint's wire shape settles; "out of scope" entries are not.

| Chroma endpoint | Why not |
| --- | --- |
| `POST /query` with `query_texts` | Chroma 1.x has **no server-side embedding**; the server rejects documents it would have to embed (422), so a `query_text` method could never succeed. Vectors come from your own embedding model — see `Record::with_embedding`. chroma-go hides this behind an embedding function; this client has no model to call. |
| Tenant endpoints (`GET`/`POST /tenants`) | Multi-tenant administration is out of scope; tenant and database are config, not client operations. |
| Database endpoints other than `POST` (`GET`, `DELETE /tenants/{t}/databases/{d}`) | Listing and dropping databases is administration; `create_database` exists only because a database is not created on demand, so writing into a fresh `Config::database` would otherwise fail with 400. |
| `POST /reset` | Out of scope. It wipes the whole server, not one collection, and is disabled unless `ALLOW_RESET` is set; an accident here is not recoverable. |
| `POST /collections/{id}/fork` and `GET /collections/{id}/fork_count` | Not implemented. Forking arrives with newer Chroma releases (and needs Chroma Cloud); it is tracked here until the request/response shape stabilises. |
| `POST /collections/{id}/search` | Not implemented. chroma-go's newer `Collection.Search` (ranking expressions, grouping, read levels) is a larger API than `query`; `query_embeddings` covers nearest-neighbour search today. |
| `GET /collections/{id}/indexing_status` | Not implemented. Useful after a bulk write, but requires a recent server; the same information is not needed to use the client. |
| `PUT /collections/{id}` with a new **configuration** | Not implemented. chroma-go has `ModifyConfiguration`; `modify_collection` covers name and metadata, which is what the collection lifecycle needs today. |

## Handled outside the endpoint list

Some behaviour is not an endpoint but still has to be right:

- **Authentication** — `Config::with_token` (Bearer) and `Config::with_basic_auth`,
  plus `with_header` / `with_headers` for anything else. These are request
  headers, so they cover every endpoint.
- **Timeouts** — `Config::timeout_ms` is stamped on every request as
  `x-chroma-timeout-ms` by the client, so a plain client has a deadline without
  a wrapper. `TimeoutTransport` (sync) and `TimeoutAsyncTransport` (async) stamp
  the same header around a transport. The bundled `AsyncHttpTransport` is the
  one that enforces it: it wraps the whole exchange in `@async.with_timeout` and
  raises `Transport` on expiry. The header is stripped before the request goes
  on the wire — Chroma does not implement it, the transport does.
- **Retries** — `RetryTransport` retries 429 and 5xx according to a
  `RetryPolicy`, announces each attempt through `on_retry`, and stamps the
  delay as `x-chroma-retry-delay-ms`. It never sleeps itself; the bundled
  `AsyncHttpTransport` reads that header and sleeps before sending, which is
  the only place a wait can happen without blocking a caller that has no
  runtime. A 4xx is never retried.
- **Errors** — non-2xx answers are decoded into `ApiError` (`status`,
  `error_code`, `message`, `details`, `body`); a body that is not a Chroma
  error envelope becomes `ChromaError::Status`.
- **Response size** — bodies are capped at `MAX_RESPONSE_BYTES` (32 MiB) and
  raise `Decode` past it, because truncating would turn a memory problem into
  silent data loss.
- **Local validation** — arguments Chroma would reject raise `InvalidArgument`
  before a request is built: empty `records` on `add` / `upsert` / `update`,
  a record with an empty id, a record on `add`/`upsert` that has no vector (a
  document alone is refused, because Chroma 1.x does not embed server-side), an
  empty `query_embeddings`, `n_results <= 0`, a negative `limit`/`offset`, and
  a `delete` with nothing to match on (chroma-go refuses that too — it would
  otherwise empty the collection). Batch-level rules — a repeated id on `add`,
  vectors of differing width — live in `validate_batch`.
- **Batch size** — a write larger than the server's `max_batch_size` is sent in
  chunks when the caller passes the size through `WriteOptions`,
  `WriteOptions::from_preflight` takes it from `GET /pre-flight-checks`, and
  `on_chunk` reports what has landed after each accepted chunk. A chunk that
  fails after an earlier one succeeded raises `ChromaError::PartialFailure`,
  whose `sent` is the stored prefix a retry resumes from. Off by default,
  which keeps the old one-request behaviour.
- **`where` spelling** — a plain key sitting next to `$and` / `$or` is folded
  into that operator's operand list (`normalize_where`) before it is sent, so
  `{"$and": [..], "b": ..}` cannot be silently read as `$and` alone.
- **Short query rows** — a query row that matched nothing comes back with
  fewer entries than `ids`; every column is padded to `ids.length()` so reading
  a result by index stays in bounds.

[`docs/protocol-constraints.md`](protocol-constraints.md) is the long version
of the last five bullets, with the request and response of each.

## Verifying coverage

Claiming an endpoint is implemented is only half the story; the claim is
backed by tests.

`client_test.mbt` drives every covered method through `MemoryTransport`, which
proves the request the client builds is the request the test expects. To see
what the client actually sends, point a `MemoryTransport` at `ChromaClient`
and inspect the recorded `Request`; that file is full of examples.

`chroma_wbtest.mbt` additionally talks to a real server when `CHROMA_ENDPOINT`
is set:

```bash
sh scripts/integration.sh
```

Its tests matter because scripted responses only prove the client agrees with
itself — the columnar record body, the bare integer the count endpoints return
and the `new_metadata` field on `modify_collection` were all found by a live
server, not by the unit suite.

Every covered method has a unit test, and the ones whose request shape the
server can silently reject — the columnar record body, the bare-integer count,
`new_metadata` on `modify_collection`, the `limit` on `peek`, `create_database`,
`include` on `get`, `limit`+`offset` paging, a `where` clause on `update`, a
`limit` on `delete`, and the `configuration` of a created collection — also have
a live test. Add a live test when a change touches the wire format; a scripted
response cannot catch a field the server ignores.

`chroma_wbtest.mbt` covers every endpoint above except `identity`, which is
asserted only to the extent a server without authentication allows (it answers
an empty object), and `preflight`, which a server without the endpoint answers
with 404 — both are checked for "no unexpected failure" rather than for a
value.

The published examples in [`README.mbt.md`](../README.mbt.md) are compiled by
`moon check` as well, and `readme_test.mbt` holds a copy of every code block, so
a README that drifts from the API fails CI instead of failing a reader. It is
backed by `scripts/ci.sh`'s `docs` step, which fails when the README stops
publishing MoonBit blocks, when a fence is hand-edited into something `moon fmt`
does not produce, or when `readme_test.mbt` stops naming the README it mirrors.
What no script can check is that every block *has* a copy — that is the review
step `docs/CONTRIBUTING.md` asks for, and the `docs` step prints the block count
so the two can be compared.
