Skip to content

REST API

The Viewer exposes the SDK as REST. Every route is the natural projection of a Sheet method onto HTTP. CSRF tokens guard every mutating verb.

Base URL: http://127.0.0.1:<port>

CSRF flow

Terminal window
# 1. Get a token (also sets the folio_csrf cookie)
curl -s -c /tmp/folio.cookies http://127.0.0.1:3000/api/csrf
# {"csrf_token":"<value>"}
# 2. Use the token on writes
curl -s -b /tmp/folio.cookies \
-H "X-CSRF-Token: <value>" \
-H "Content-Type: application/json" \
-d '{"records":[{"id":"cust_999","company_name":"X","country":"FR"}]}' \
http://127.0.0.1:3000/api/records

Mutating verbs (POST /api/records, DELETE /api/records, POST /api/materialize) require:

  • X-CSRF-Token header equal to the cookie’s value.

If either is missing or doesn’t match, the response is 403 CSRF token missing or invalid.

Read endpoints don’t require CSRF, but they do set the cookie on the way out so a frontend page can issue mutations without a separate /api/csrf fetch.

Endpoints

GET /api/csrf

Issues / refreshes the CSRF cookie and returns the current token.

{"csrf_token": "<base64-url>"}

GET /api/contract

Returns the validated contract (same shape as Sheet.contract.model_dump).

GET /api/records

Query parameters:

ParamNotes
fieldsComma-separated list (e.g. id,country).
limitDefault 50.
cursorOpaque pagination cursor.
filterDuckDB WHERE clause.

Returns the list_records envelope:

{"records":[{"id":"cust_001",...}], "format":"json", "limit":50, "next_cursor":null}

GET /api/records/{id}

Returns one record or 404.

POST /api/records (CSRF)

Body:

{"records": [{"id":"cust_999","company_name":"X","country":"FR"}], "actor":"agent:human"}

Returns:

{"inserted":1, "updated":0, "total":11}

If actor is omitted, the server-default (--actor) is used. If both are absent, 400 actor is required.

DELETE /api/records?ids=cust_001,cust_002 (CSRF)

Header X-Folio-Actor carries the actor (since DELETE typically has no body). Falls back to the server-default.

{"deleted":2, "remaining":9}

POST /api/query

Body:

{"sql":"SELECT country, COUNT(*) AS n FROM records GROUP BY 1", "params":[]}

Returns:

{"rows":[{"country":"Japan","n":3}, ...], "count":2}

SELECT-only (ADR-0005). Anything else → 400 QueryError.

GET /api/status?targets=country_code,industry_tag

Returns the materialization_status shape (see folio status).

POST /api/materialize (CSRF)

Body:

{
"targets": ["country_code"], // optional
"record_ids": ["cust_001"], // optional
"force": false, // optional
"actor": "agent:demo" // optional; falls back to --actor
}

Returns the §10.6 envelope:

{"materialized":2, "skipped":5, "failures":[], "total_cost":0.0}

Publishes materialize.start, materialize.end (or materialize.error) frames on the SSE event bus.

GET /api/provenance

Query parameters:

ParamNotes
record_idrequired
fieldrequired
historytrue returns the full chain; default false returns the latest entry

Returns the matching provenance.jsonl lines as JSON.

GET /events

Server-Sent Events stream. See Events for the frame format.

Error envelope

FolioErrors are mapped to a typed JSON envelope:

{
"error": {
"type": "PermissionDeniedError",
"message": "agent:enrichment may not write company_name"
}
}
Error typeHTTP status
ContractError500 (sheet is broken)
SheetError404 (path not found)
RecordsError500
QueryError400
OperationError400
PermissionDeniedError403
LockTimeoutError409
ValidationError (Pydantic)400

Validation details are echoed under error.message.

CORS

The Viewer does not set CORS headers — same-origin only. The Vite dev server proxies /api/* to the backend so the dev experience stays same-origin.

Rate limiting / auth

Neither is built-in. The Viewer is local-only by design (Overview). Front it with a reverse proxy if you need either.