Lang on Rock
Guides

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/get

Conditional 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/manifest
HTTP/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

EndpointContent typeShape
manifesttext/tab-separated-valuesRaw TSV, comment lines then a header row
snapshotapplication/json{"snapshot": "…", "concepts": n}
getapplication/jsonAn object of slices keyed by id, missing ids absent
searchtext/tab-separated-valuesManifest 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

FieldDefaultEffect
idsrequiredConcept ids from the manifest
sectionnoneOne named section instead of the whole document
offset0Character offset into the addressed text
limitnoneMaximum characters returned per concept
findnoneLiteral 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

FieldDefaultEffect
qrequiredThe query. An empty string is a 400.
k8Ranked matches before link expansion
expandtruefalse disables the one-hop expansion over the link graph
bundlenoneRank 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 diagnostics

Single 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/transact

The 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

CodeWhen
200Fine
204A document write or delete committed, or a bundle was deleted
304If-None-Match matched the current ETag
400A 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
401Missing or unknown bearer token, or no tenant in the path on a server with no tokens
403The token does not grant that tenant, or may read but not write
404No such route, no such document, no such bundle, and the catch-all below
405Wrong method for the route
409A database conflict, a tenant that is not migrated yet, or a tenant with no data
412A stale precondition on a single-document write or delete
413A document larger than 1,000,000 bytes
428A document write or delete with no precondition header
503The 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:

StatusBodyMeaning
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.

On this page