Spunto CMS
The API

Querying content

Reading cards with a view definition — filters, sorts, cursors, grouped pages, counts and search.

Reading content means sending a view definition and getting a keyset page back.

It is a POST rather than a GET because a filter is a tree, and a tree does not survive a query string in any form worth reading. Despite the verb, these routes only read — which is exactly why the scope table that governs installed apps names them one by one instead of guessing from the method.

A page of cards

POST /workspaces/{workspaceId}/collections/{collectionId}/query
{
  "definition": {
    "filter": { "op": "eq", "field": { "fieldId": "sys:status" }, "value": "<statusId>" },
    "sort": [{ "field": { "fieldId": "sys:order" }, "direction": "asc" }],
    "includeSecondaryLists": true
  },
  "cursor": null,
  "limit": 50
}

limit defaults to 50 and caps at 200. The response is:

{ "rows": [ /* Item */ ], "nextCursor": "…" }

Loop until nextCursor is null, passing it back as cursor each time.

Items in a page have no body

rows are Item, and Item has no body and no blocks. Fetch a single card with GET /workspaces/{workspaceId}/items/{itemId} to get ItemDetail, which does. A page of 50 rows does not carry 50 documents.

Filters

A filter is either a predicate or a boolean node:

{ "and": [ <filter>, <filter> ] }
{ "or":  [ <filter>, <filter> ] }

A predicate names a field and an operator:

OperatorsShape
eq neq lt lte gt gte{ op, field, value }
contains starts_with{ op, field, value } — string value
in not_in{ op, field, values } — array
is_empty is_not_empty{ op, field }

A field is { "fieldId": "…" } — either a registry property's id, or a system field like sys:title, sys:status, sys:order or sys:created_at. The collection publishes both lists, so a client never has to hard-code them.

Three behaviours that are easy to get wrong from the outside, and are settled here:

  • neq keeps the empties. It compiles to is distinct from, so a card with no value for the property still matches "different from X". Anything else surprises everyone who tries it.
  • contains and starts_with are literal. % and _ in your value are escaped. A search term is data, not a pattern.
  • in with an empty list matches nothing; not_in with an empty list matches everything. Which is the reading that lets you build a filter from a selection without special-casing zero.

On an array property (multi_select, multi_person), eq and contains both mean containment, neq means "does not hold this one", and in / not_in are an or-chain of containments. Anything else is a 400 with a message that names the three that work.

Sorts

sort is an ordered list of { field, direction }, asc or desc.

Array properties cannot be sorted on, and asking returns a 400 rather than an arbitrary ordering. Sorting a select is alphabetical, not by option order — a known sharp edge.

Cursors

A cursor encodes the position and the sort it was issued for. Sending one back with a different sort fails loudly rather than returning a plausible wrong page.

Do not parse them. Do not build them. The one thing they promise is that handing one back gets you the next page of the same query.

Grouped reads

Two routes serve the grouped layouts, both compiling the same definition — which is what makes the columns add up to the view:

POST …/group-counts     — how many cards per group
POST …/grouped-query    — a page per group, each with its own cursor

Both need a groupBy in the definition, and both return null as an ordinary group key: the cards with no value.

grouped-query takes the groups you want rendered, in that order, even the empty ones — a GROUP BY only returns values that have rows, and an empty column still has to exist to be dropped into (see why). Omit it and you get the groups the data happens to contain.

limit there is per group, not per response, and defaults to 20. Each group comes back with its own nextCursor and with count — the group's full size, not the number of rows returned. Loading more of one column means sending that group's cursor in cursors, keyed by group.

Index hints

POST …/index-hints

Given a view definition, the API reports which indexes would serve it well, each with the reason it is wanted. It is the honest answer to "why is this view slow" — a filter on a JSON property with no expression index behind it reads very differently from one with.

Described, not created

This route reports. It does not create anything: the job that would apply the hints does not exist yet.

GET /workspaces/{workspaceId}/search?q=…&limit=…

Search crosses every list in the workspace and returns two arrays, never mixed: matching cards and matching collections.

What it covers is narrow, and stated rather than implied:

  • card titles and list names — never card bodies;
  • an exact readable key (DOC-12) brings that card to the front, even if the title does not match;
  • archived cards are excluded;
  • % and _ in your query are escaped.

It does not go through the view compiler: it carries no filter, no sort, and no cursor. Putting it there would have required a virtual field nothing else uses.

If you need body search, there is no index for it — see what it does not do.

On this page