HTTP API
The read, write and database routes, their ETags, and the error bodies a client can switch on.
HTTP/1.1 and JSON, with one deliberate exception. The manifest goes out as raw bytes, because JSON-escaping a TSV adds bytes and parse cost to the payload transferred most often.
The same routes serve a unix socket and a TCP listener. The socket still speaks HTTP underneath.
Read endpoints
GET /v1/{tenant}/manifest[?bundle=sales] the manifest, ETag is the snapshot digest
GET /v1/{tenant}/snapshot {"snapshot": "…", "concepts": n}
POST /v1/{tenant}/get {"ids": [...], "section"?, "offset"?, "limit"?, "find"?}
POST /v1/{tenant}/search {"q": "...", "k"?: n, "expand"?: false, "bundle"?: "..."}The tenant segment is optional when a token identifies one, so /v1/manifest and
/v1/acme/manifest both work. It is required when the server has no tokens at all, which only
happens on a unix socket.
curl -H 'Authorization: Bearer read-only' http://127.0.0.1:7777/v1/manifest
curl -X POST -H 'Authorization: Bearer read-only' -H 'content-type: application/json' \
-d '{"ids":["orders"],"section":"schema"}' http://127.0.0.1:7777/v1/getConditional requests
The manifest carries an ETag set to the snapshot digest, or to digest:bundle when narrowed.
Send If-None-Match with the ETag you hold and an unchanged manifest answers 304 with no body.
curl -i -H 'Authorization: Bearer read-only' \
-H 'If-None-Match: "cd9078b9af0b1d912a9e6e0c8c12d409be3aac3394085d56dc618458b5e23ad8"' \
http://127.0.0.1:7777/v1/manifestHTTP/1.1 304 Not Modified
ETag: "cd9078b9af0b1d912a9e6e0c8c12d409be3aac3394085d56dc618458b5e23ad8"Between turns of a conversation that is the whole transfer skipped. snapshot is the cheaper check
when you only want to know whether anything moved.
The digest names compiled bytes, not a commit. Two revisions that compile to the same bytes share a
snapshot, so a commit that changes only what the read model does not carry leaves the ETag alone.
Use history when you need to know about commits.
Responses
| Endpoint | Content type | Shape |
|---|---|---|
manifest | text/tab-separated-values | Raw TSV, comment lines then a header row |
snapshot | application/json | {"snapshot": "…", "concepts": n} |
get | application/json | An object of slices keyed by id, missing ids absent |
search | text/tab-separated-values | Manifest rows plus a trailing pos column, with # query and # hits comments |
get returns an object rather than an array, so a missing id is simply not a key. Nothing errors
when one of a batch does not exist. Each value is a slice — {"text", "start", "end", "total"},
plus "matches" and "matchCount" when find was set — so a caller always knows how much text
exists beyond what it received:
{"orders":{"text":" | STRING | Primary key. |\n| `customer_id` | STRING | Joins to [customers](./customers.md). |","start":158,"end":278,"total":476,"matches":[212,462],"matchCount":2}}Get parameters
| Field | Default | Effect |
|---|---|---|
ids | required | Concept ids from the manifest |
section | none | One named section instead of the whole document |
offset | 0 | Character offset into the addressed text |
limit | none | Maximum characters returned per concept |
find | none | Literal case-insensitive phrase; the slice becomes a window around the first occurrence, 2,000 characters unless limit says otherwise, and offset is ignored |
The HTTP API never truncates unless asked. The 15,000-character default cap lives at the MCP boundary only, because that is the one caller that is a prompt rather than a program.
search returns manifest rows and never bodies. Each direct hit ends in a pos cell — the offset
where the query's words cluster densest in the body, - when they only match the row — so the
follow-up get can be {"offset": pos, "limit": 2000} instead of the whole document. Narrow with
search, then fetch what you chose with get.
Search parameters
| Field | Default | Effect |
|---|---|---|
q | required | The query. An empty string is a 400. |
k | 8 | Ranked matches before link expansion |
expand | true | false disables the one-hop expansion over the link graph |
bundle | none | Rank only within this bundle, and expand only into it |
The expansion is capped at k and ranked by how many hits point at a target, so a hub concept with
sixteen outgoing links cannot drag most of the manifest into the result.
Write and database routes
GET /v1/{tenant}/source list documents with sizes, hashes and ids
GET /v1/{tenant}/source/{bundle}/{path} read one, ETag is its content hash
PUT /v1/{tenant}/source/{bundle}/{path} create or replace one, as its own commit
DELETE /v1/{tenant}/source/{bundle}/{path} delete one, as its own commit
DELETE /v1/{tenant}/bundles/{bundle} delete a whole bundle, as one commit
POST /v1/{tenant}/transact commit a batch atomically
GET /v1/{tenant}/history committed revisions, newest first
POST /v1/{tenant}/restore publish an older revision as a new commit
POST /v1/{tenant}/sync the current commit and its diagnosticsSingle documents and their preconditions are covered in Editing over the network. Every change on a native tenant is a commit: the response comes back after the revision is durable, and the next read already sees it.
transact
curl -X POST -H "Authorization: Bearer $TOKEN" -H 'content-type: application/json' \
-d @batch.json http://127.0.0.1:7777/v1/transactThe body is a TransactionRequest, {"changes": [...], "expectedRevision"?: "…"}, where each
change is one of:
{"operation": "write", "bundle": "…", "path": "…", "content": "…", "replaces"?: "…"}
{"operation": "delete", "bundle": "…", "path": "…", "replaces": "…"}One to 1,000 changes and at most 16 MiB of source text. A body that names a tenant is refused, because the token already decides it. A success answers 200 with the new revision:
{"revision":"984e1dcb03ea2403acd84528949187909ef0e327504f03d92e00b199ba9dc9c9","snapshot":"0203add32834701b39b60d1b330ad88349a42c93e52bf29d0a660961372098ff","concepts":4,"bundles":["ops","sales"],"diagnostics":[]}A batch that fails any precondition publishes nothing and answers 409 with a code a client can switch on:
HTTP/1.1 409 Conflict
Content-Type: application/json;charset=utf-8
{"code":"CONFLICT","message":"concept already exists: re-read the concept and retry with its new hash"}Transactions covers what each conflict message means and what to re-read before retrying.
history and restore
GET /v1/{tenant}/history?limit=20&before=<cursor>limit runs from 1 to 100 and defaults to 20; anything else is a 400. A read-only token may page
history. The answer is {"revisions": [...], "next"?: "…"}, newest first, and next is the cursor to
pass as before for the page after.
POST /v1/{tenant}/restore {"revision": "…", "expectedRevision": "…"}Restore needs a write token, and expectedRevision must be the current head or the call answers
409 CONFLICT with database changed: re-read the revision and document hashes. It publishes the
older revision's complete source state as a new commit and rewrites nothing. See History and
restore.
sync
On a native tenant sync creates nothing. It answers with the current commit and its compiler
diagnostics, the same shape transact returns. On a tenant that has not been
migrated, it recompiles the registered source folder, which is why it
asks for a write token on both.
Status codes
| Code | When |
|---|---|
| 200 | Fine |
| 204 | A document write or delete committed, or a bundle was deleted |
| 304 | If-None-Match matched the current ETag |
| 400 | A malformed body or path, a hash that is not 64 hex characters, a history limit outside 1–100, text that is not valid UTF-8 |
| 401 | Missing or unknown bearer token, or no tenant in the path on a server with no tokens |
| 403 | The token does not grant that tenant, or may read but not write |
| 404 | No such route, no such document, no such bundle, and the catch-all below |
| 405 | Wrong method for the route |
| 409 | A database conflict, a tenant that is not migrated yet, or a tenant with no data |
| 412 | A stale precondition on a single-document write or delete |
| 413 | A document larger than 1,000,000 bytes |
| 428 | A document write or delete with no precondition header |
| 503 | The commit outcome is indeterminate |
Most errors carry a plain-text message. The database errors carry JSON with a code, so a client
can tell them apart without parsing prose:
| Status | Body | Meaning |
|---|---|---|
| 409 | {"code": "CONFLICT", "message": "…"} | A precondition or expectedRevision no longer holds |
| 409 | {"code": "LEGACY_TENANT", "message": "…"} | The tenant needs migration first |
| 503 | {"code": "INDETERMINATE_COMMIT", "message": "…", "revision": "…"} | The commit may or may not have landed |
A conflict on a single-document PUT or DELETE comes back as a plain-text 412 instead, since the
precondition there travels in a header.
503 means look before you retry
An indeterminate commit failed after the point where it may already be durable. The body names
the revision. Read history and decide from what is there; Durability
covers the contract.
404 is also the catch-all
Anything thrown that is not one of the errors above becomes a 404 carrying its message. That
includes database busy: another process holds the lock, which a writer gets after waiting 5
seconds for a lock another process holds, and the read of a tenant that has no data, whose message
names the file it could not open. A client should read the body instead of switching on the status
alone.