http api
Everything the web UI does goes through this API. The probes and the metrics are at the root (/healthz, /readyz, /metrics, /mcp). Every other endpoint is under /v1.
A failure is {"error": "…"}, with a status that says what kind of failure it is. The access level of each endpoint is in its heading. See access for the tokens.
A server describes itself at /v1/openapi.json, with one typed path for the rows of each table. This site has the document of the retail example at /openapi.json.
tables
The data: writing rows into tables, and looking at what they hold. Tables are declared in tables/*.yaml; every table gets these endpoints.
POST /v1/tables/{table}/rows
Write rows into a table · access: ingest
The body is the rows, in one of three formats, chosen by Content-Type: application/json (an array of row objects), application/x-ndjson (one row object per line) or application/vnd.apache.arrow.stream (an Arrow IPC stream, matched to columns by field name).
Values are checked against the table's column types and never coerced. A batch is all or nothing: one bad value rejects it with every problem listed by row and column, and nothing is written. On an upsert table a row whose key exists replaces the old row, and a row equal to the one stored is skipped: it counts as neither written nor replaced, and a batch of such rows changes nothing and is visible at once.
Rows become visible to queries at the next epoch (within epoch_interval); ?wait=visible answers only once they are. An Idempotency-Key header makes retries safe: a key seen in the last 24 hours gets the first response again and writes nothing. A 503 means the server is busy; retry after Retry-After seconds.
| path parameter | description | example |
|---|---|---|
table | The table, by name. | orders |
| query parameter | description | example |
|---|---|---|
wait | visible: answer once the rows are visible to queries. | visible |
Request: rows: JSON, NDJSON or Arrow IPC. The rows.
| status | body | description |
|---|---|---|
200 | IngestResponse | Written. |
400 | IngestError | The body didn't decode, or a value doesn't fit its column. Nothing was written. |
404 | ApiError | No such table. |
409 | ApiError | A request with the same Idempotency-Key is still being processed, or the table was removed by a config change meanwhile. |
415 | ApiError | A Content-Type that isn't one of the three formats. |
503 | ApiError | Busy: the write queue is full. Retry after Retry-After seconds. |
DELETE /v1/tables/{table}/rows
Delete rows from an upsert table, by key · access: ingest
The body lists keys, in the same formats as writing: each row holds the table's primary key columns and nothing else. Keys that match no row are counted in missing, not treated as errors. Only upsert tables have keys, so only they can be deleted from this way.
| path parameter | description | example |
|---|---|---|
table | The table, by name. | customers |
| query parameter | description | example |
|---|---|---|
wait | visible: answer once the deletes are visible to queries. | visible |
Request: rows: JSON, NDJSON or Arrow IPC. The keys of the rows to delete.
| status | body | description |
|---|---|---|
200 | IngestResponse | Deleted. |
400 | IngestError | The body didn't decode, or the table isn't an upsert table. |
404 | ApiError | No such table. |
409 | ApiError | The table was removed by a config change meanwhile. |
503 | ApiError | Busy: retry after Retry-After seconds. |
PUT /v1/tables/{table}/rows
Replace an upsert table's contents · access: ingest
The body is the table's complete new contents, in the same formats as writing: its rows are written (a row whose key exists replaces the old one), and every row whose key isn't among them is deleted, in one batch that becomes visible at once. For keeping a dimension in step with a system of record that can only be read whole (an asset tree, a product catalogue): send all of it every time, and what disappeared there disappears here. Rows that are already as sent are skipped, so sending the same contents again changes nothing. replaced counts rows replaced and rows deleted. Only upsert tables have keys, so only they can be replaced.
| path parameter | description | example |
|---|---|---|
table | The table, by name. | customers |
| query parameter | description | example |
|---|---|---|
wait | visible: answer once the new contents are visible to queries. | visible |
Request: rows: JSON, NDJSON or Arrow IPC. Every row the table should hold.
| status | body | description |
|---|---|---|
200 | IngestResponse | Replaced. |
400 | IngestError | The body didn't decode, a value doesn't fit its column, or the table isn't an upsert table. Nothing was written. |
404 | ApiError | No such table. |
409 | ApiError | A request with the same Idempotency-Key is still being processed, or the table was removed by a config change meanwhile. |
415 | ApiError | A Content-Type that isn't one of the three formats. |
503 | ApiError | Busy: the write queue is full. Retry after Retry-After seconds. |
GET /v1/tables
Every table, with its columns and how much it holds · access: read
The tables declared in tables/*.yaml, with live row counts, write rates and memory, as of the latest epoch.
| status | body | description |
|---|---|---|
200 | list of TableInfo | The tables, in name order. |
GET /v1/tables/{table}
One table · access: read
The same as one entry of GET /v1/tables.
| path parameter | description | example |
|---|---|---|
table | The table, by name. | orders |
| status | body | description |
|---|---|---|
200 | TableInfo | The table. |
404 | ApiError | No such table. |
GET /v1/tables/{table}/preview
The newest rows of a table · access: read
Newest first, decoded: decimals, dates and timestamps as strings, so nothing is lost on the way through JSON.
| path parameter | description | example |
|---|---|---|
table | The table, by name. | orders |
| query parameter | description | example |
|---|---|---|
limit | How many rows, at most 1000. Default 100. | 10 |
| status | body | description |
|---|---|---|
200 | Preview | The rows. |
404 | ApiError | No such table. |
queries
Asking the model: metrics grouped by fields, under a selection. Names are the semantic layer's (model.ossie.yaml), not tables'.
POST /v1/query
Run a query · access: read
Metrics grouped by dimensions, under a selection, from the newest snapshot: the engine dashboards use, without a subscription. Filters are one-directional: a metric is filtered by the fields its table reaches through relationships, and the rest don't apply to it.
| query parameter | description | example |
|---|---|---|
format | csv for the answer as CSV (a header of column names, then the rows), for spreadsheets and exports. JSON otherwise. | json |
Request: QueryRequest. What to compute.
| status | body | description |
|---|---|---|
200 | QueryResult or CSV | The answer. |
400 | ApiError | The query names something the model doesn't have, or asks for something the engine can't do. |
POST /v1/rows
Read the newest rows · access: read
The newest rows of a table that pass the filters, newest first (the order they arrived in): a log tail. A contains filter searches a string field for a text, ignoring the case of ASCII letters, and is fast on encoding: plain columns of the table itself. Filters are one-directional, as in a query: the fields the table reaches through relationships filter its rows.
| query parameter | description | example |
|---|---|---|
format | csv for the rows as CSV, JSON otherwise. | json |
Request: RowsRequest. Which rows, and which of their fields.
| status | body | description |
|---|---|---|
200 | QueryResult or CSV | The rows, newest first, one column per field. |
400 | ApiError | A field the model doesn't have, or no table reaching all of them. |
GET /v1/ws
Live dashboards (WebSocket) · access: read
Upgrades to a WebSocket speaking the live protocol: JSON messages tagged by type (ClientMessage in, ServerMessage out). A session holds a selection with back/forward history and shows one dashboard; the server sends one frame per epoch with whatever changed on it, and answers selection changes right away. Browsers can't set headers on a WebSocket, so the read token may also come as ?token=.
| query parameter | description | example |
|---|---|---|
token | The read token, when one is configured. | s3cret |
| status | body | description |
|---|---|---|
101 | none | Switching to the WebSocket protocol. |
POST /v1/views
Make a view · access: read
A view is a dashboard that lives only in the server's memory: checked by the same rules as a dashboard file and shown live like one (open_dashboard with its id), but never written to the config, versioned or committed. For an answer an agent shows once; saving it as a dashboard is a config change like any other. The body is a dashboard file as JSON (the dashboard schema); a visual without at is placed for you. A view nobody opens for 24 hours is forgotten, and at most 1000 are kept.
Request: JSON. A dashboard file, as JSON.
| status | body | description |
|---|---|---|
200 | ViewInfo | The view: its id, and what it shows. |
422 | ViewProblems | The dashboard doesn't compile: its problems. |
GET /v1/views/{id}
A view · access: read
A view made with POST /v1/views: what it shows, and its dashboard file as YAML, ready to save under dashboards/.
| path parameter | description | example |
|---|---|---|
id | The view's id, as making it answered. | ~3f9c2a7d41b0e85c6d2f1a9b7e4c3d20 |
| status | body | description |
|---|---|---|
200 | ViewInfo | The view. |
404 | ApiError | No such view, or it was forgotten. |
GET /v1/sessions/{id}/selection
A live session's selection · access: read
What a person looking at a dashboard or view has selected, by the session id welcome gave their connection (an MCP App tells the host's model the id with every selection): an agent reads what the person is looking at.
| path parameter | description | example |
|---|---|---|
id | The session's id. | s3f9c2a7d41b0e85c6d2f1a9b7e4c3d20 |
| status | body | description |
|---|---|---|
200 | SessionSelection | The selection. |
404 | ApiError | No such session. |
PUT /v1/sessions/{id}/selection
Set a live session's selection · access: read
Replaces a session's selection, whole, with these conditions (written as a query's filters): an agent points the person at something. It is one step in the session's history, so Back undoes it, and the session's dashboard is refreshed at once.
| path parameter | description | example |
|---|---|---|
id | The session's id. | s3f9c2a7d41b0e85c6d2f1a9b7e4c3d20 |
Request: SelectionChange. The new selection.
| status | body | description |
|---|---|---|
200 | SessionSelection | The selection now. |
400 | ApiError | A field or value that isn't the model's. |
404 | ApiError | No such session. |
GET /v1/model
The semantic model · access: read
Datasets and their fields, relationships, and metrics with their SQL, anchor table and the dashboards that show them: what the model browser shows.
| status | body | description |
|---|---|---|
200 | ModelInfo | The model. |
POST /mcp
MCP for agents · access: read
The Model Context Protocol over Streamable HTTP: JSON-RPC 2.0 messages, each answered with one JSON body. tools/list lists the tools (the MCP reference has each one) and resources/list the config files' JSON Schemas. Each tool is the HTTP endpoint it wraps, called with the caller's credentials, so tools that change the config need the admin token and ingest_rows the ingest token. apply_change applies what agents.auto_apply allows and proposes the rest.
Request: JSON. A JSON-RPC message, or a batch of them.
| status | body | description |
|---|---|---|
200 | JSON | The JSON-RPC reply (or replies). |
202 | none | A notification: nothing to reply. |
config
Changing what the server runs while it runs: the config files, plans of what a change does, applying it, the versions so far and changes waiting for approval. Every change goes through the same pipeline, and is written back to the config directory.
GET /v1/config
The config · access: admin
Every config file of the current version, the version the server runs (behind while a change is rebuilt), and edits in the config directory that aren't applied.
| status | body | description |
|---|---|---|
200 | ConfigState | The config. |
POST /v1/config/plan
What a change would do · access: admin
Compiles the config with the change and compares it with the current one: every problem, located, and every change with its class (safe, rebuild, destructive). Changes nothing.
Request: ConfigChange. Files to set or remove, against a version.
| status | body | description |
|---|---|---|
200 | PlanInfo | The plan (also when the config has errors: valid says). |
409 | ConfigError | base_version isn't the current version. |
POST /v1/config/apply
Apply a change · access: admin
Plans the change and applies it: safe changes are live at the next epoch, rebuilds when their background rebuild is done (the old version serves until then). A destructive change needs approve. With propose it waits in the approval queue instead. Applied changes are written back to the config directory, and committed when it is a git repository.
Request: ConfigChange. Files to set or remove, against a version.
| status | body | description |
|---|---|---|
200 | ApplyResult | Applied, rebuilding, queued, or nothing to change. |
409 | ConfigError | base_version isn't current, the change destroys data and isn't approved, or a rebuild is still running. |
422 | ConfigError | The config doesn't compile with the change. |
GET /v1/config/versions
Versions so far · access: admin
Every applied version, newest first: who, when, from where, and what changed. Reverting is applying an old version's files.
| status | body | description |
|---|---|---|
200 | list of VersionInfo | The versions. |
GET /v1/config/versions/{version}
One version's files · access: admin
The files of an applied version, to compare or revert to.
| path parameter | description | example |
|---|---|---|
version | The version number. | 1 |
| status | body | description |
|---|---|---|
200 | ConfigVersion | The version. |
404 | ApiError | No such version. |
GET /v1/changes
Changes waiting for approval · access: admin
Proposed changes, and edits in the config directory that aren't safe to apply on their own, with their plans.
| status | body | description |
|---|---|---|
200 | list of PendingChange | The pending changes. |
POST /v1/changes/{id}/approve
Approve a pending change · access: admin
Applies it, destructive or not. It must still be against the current version.
| path parameter | description | example |
|---|---|---|
id | The pending change. | 1 |
| status | body | description |
|---|---|---|
200 | ApplyResult | Applied or rebuilding. |
404 | ApiError | No such pending change. |
409 | ConfigError | It was made against an older version, or a rebuild is running. |
POST /v1/changes/{id}/reject
Reject a pending change · access: admin
Drops it from the queue.
| path parameter | description | example |
|---|---|---|
id | The pending change. | 1 |
| status | body | description |
|---|---|---|
200 | PendingChange | The change, now dropped. |
404 | ApiError | No such pending change. |
server
The process itself: probes for orchestrators, and what server this is.
GET /v1/status
What the server is doing · access: read
Epoch, memory per table and for caches, live sessions, and the write-ahead log and checkpoints when data is kept on disk.
| status | body | description |
|---|---|---|
200 | StatusInfo | The status. |
POST /v1/auth/embed
A token for embedded views · access: read
A short-lived token that opens only the views named, over /v1/ws (as ?token=) and nowhere else: what a view shown inside another app (an MCP App in Claude or ChatGPT) connects with, so the read token never leaves the server. It ends after an hour unused, or when the server stops.
Request: EmbedRequest. The views it may open.
| status | body | description |
|---|---|---|
200 | EmbedToken | The token. |
404 | ApiError | One of the views isn't there. |
POST /v1/auth/session
Start a browser session · access: public
Trades the token in Authorization: Bearer <token> for a session cookie (HttpOnly, SameSite=Strict), good for the levels the token is: the UI does this so tokens stay out of URLs and logs. Sessions end after 12 idle hours, on sign-out, or when the server stops.
| status | body | description |
|---|---|---|
200 | SessionInfo | The session's levels; the cookie is set. |
400 | ApiError | No bearer token. |
401 | ApiError | The token is good for nothing here. |
GET /v1/auth/session
What this request may do · access: public
The levels open to this request: by its session cookie, its bearer token, or because they need no token.
| status | body | description |
|---|---|---|
200 | SessionInfo | The levels. |
DELETE /v1/auth/session
Sign out · access: public
Ends this browser's session and clears its cookie.
| status | body | description |
|---|---|---|
200 | SessionInfo | Signed out: what's left open to everyone. |
GET /metrics
Prometheus metrics · access: read
The server in the Prometheus text format: rows, memory and segments per table, ingest requests and rows, epoch publish and live refresh times, query times, sessions, the log and checkpoints, a running rebuild. At the root, where scrapers look.
| status | body | description |
|---|---|---|
200 | text | The metrics. |
GET /healthz
Liveness probe · access: public
Answers 200 whenever the process is serving HTTP at all. It says nothing about whether the data is ready to query; that is GET /readyz.
| status | body | description |
|---|---|---|
200 | ProbeStatus | The process is up. |
GET /readyz
Readiness probe · access: public
Answers 200 once the server can take traffic, and 503 while it is still starting. Later, starting includes applying the config and recovering data from the WAL, which is why this is separate from GET /healthz: a restart should not be killed for being slow to recover.
| status | body | description |
|---|---|---|
200 | ProbeStatus | Ready. |
503 | ProbeStatus | Up, but not ready yet. |
GET /v1/info
Which server this is · access: public
The server's name, version and API version.
| status | body | description |
|---|---|---|
200 | ServerInfo | The server's identity. |
reference
The API describing itself.
GET /v1/openapi.json
This API, as OpenAPI 3.1 · access: public
Generated from the same endpoint table the router is built from, so it can't describe a route that isn't served.
| status | body | description |
|---|---|---|
200 | JSON | The OpenAPI document. |
GET /v1/schemas/{kind}
The JSON Schema of a config file · access: public
For editors (yaml-language-server), the config editor and agents writing config. Kinds: server (tidepool.yaml), table (tables/.yaml), model (model.ossie.yaml), dashboard (dashboards/.yaml), and the JSON inside TIDEPOOL extensions: metric-extension, field-extension, dataset-extension, relationship-extension, model-extension.
| path parameter | description | example |
|---|---|---|
kind | Which kind of config file. | table |
| status | body | description |
|---|---|---|
200 | JSON | The schema. |
404 | ApiError | No such kind. |