Feature query and catalogue search

Tiles answer "draw this viewport". Feature query answers "which features match": server-side, returning GeoJSON, without downloading the file.

That distinction matters once data lives on Sarkoa rather than on your disk. A hosted parcel layer you can only render is a picture. A hosted parcel layer you can ask questions of is a data source your scripts, CI jobs, and AI agents can build on.

Querying vector features

sarkoa query parcels \
  --bbox -79.6,43.5,-79.1,43.9 \
  --where zone:eq:RM4 \
  --where assessed_value:gt:750000 \
  --fields address,zone,assessed_value

Or over HTTP:

POST /api/vector/{assetId}/features
{
  "bbox": [-79.6, 43.5, -79.1, 43.9],
  "filters": [
    { "field": "zone", "op": "eq", "value": "RM4" },
    { "field": "assessed_value", "op": "gt", "value": 750000 }
  ],
  "properties": ["address", "zone", "assessed_value"],
  "limit": 200
}

A GET form exists too, so a query is expressible in a URL you can paste into curl:

GET /api/vector/{assetId}/features?bbox=-79.6,43.5,-79.1,43.9&where=zone:eq:RM4

Run sarkoa inspect <file> first to see the available attribute names.

Operators

Operator Meaning
eq / neq Equal / not equal
gt / gte / lt / lte Ordered comparison
contains Case-insensitive substring
in Value is in a list

Filters are combined with AND. For OR, run two queries and merge the results.

Ordered comparisons are numeric when both sides are numbers and lexicographic otherwise, so ISO dates (2026-05-01) sort correctly with no extra configuration. Equality is tolerant of the string/number ambiguity common in GeoJSON: a CSV-derived layer carrying "5000" still matches a filter value of 5000.

The response

{
  "type": "FeatureCollection",
  "features": [ … ],
  "matched": 1284,
  "returned": 200,
  "limit": 200,
  "offset": 0,
  "hasMore": true,
  "assetId": "c47d…"
}

matched is the total number of features matching your query, not the page size. That is what tells you whether paging is worth doing.

The body is valid GeoJSON, and any reader will ignore the extra keys, so you can hand it straight to MapLibre, GDAL, or geopandas.

Paging

limit defaults to 100 and caps at 1000; a larger value is rejected with 400 rather than silently clamped, so a paging loop cannot quietly return less than it asked for.

sarkoa query parcels --where zone:eq:RM4 --limit 500 --offset 500

Keep going while hasMore is true:

let offset = 0;
for (;;) {
  const res = await fetch(`/api/vector/${assetId}/features`, {
    method: 'POST',
    headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
    body: JSON.stringify({ filters, limit: 500, offset }),
  });
  const page = await res.json();

  for (const feature of page.features) process(feature);

  if (!page.hasMore || page.features.length === 0) break;
  offset += page.features.length;
}

Break on an empty page as well as on hasMore, so a page that comes back empty cannot spin the loop forever.

Smaller responses

  • --fields a,b,c returns only those properties.
  • --no-geometry drops geometry entirely. For attribute analytics such as counts, sums, and joins, this is dramatically smaller.

Limits

Feature query reads the original upload, which must be GeoJSON. This is deliberate: it works today for every vector file already in your library, with no reprocessing.

Condition Response
Source is not GeoJSON (or is a raster) 415 with reason: source_format_not_queryable
Source exceeds 32 MB 413 with reason: source_too_large
Source is not a FeatureCollection 422 with reason: not_a_feature_collection

Each returns a machine-readable reason rather than an empty result, because a silently empty answer is indistinguishable from "nothing matched".

For layers above the size ceiling, filter upstream or split the layer.

Searching the raster catalogue

Rasters are indexed as STAC Items, searchable by space, time, and collection:

sarkoa stac search \
  --bbox -123.3,49.1,-122.9,49.4 \
  --datetime 2026-01-01/.. \
  --collection rgb
POST /api/stac/{orgId}/search
{ "bbox": [-123.3, 49.1, -122.9, 49.4], "datetime": "2026-01-01/..", "collections": ["rgb"] }

datetime accepts an instant (2026-05-01T00:00:00Z) or an interval (start/end), with .. for an open end in either direction.

Collections are assigned automatically during optimization: rgb, multispectral, singleband, elevation.

Each returned Item carries links to the data:

"assets": {
  "cog":   { "href": "…/source-download", "roles": ["data"] },
  "tiles": { "href": "…/tiles/{z}/{x}/{y}.webp", "roles": ["overview"] }
}

The catalogue is a conformant STAC API (core, collections, item-search), so standard tooling works against it:

Endpoint Purpose
/api/stac/{orgId}/catalog.json Catalog root
/api/stac/{orgId}/collections Collections with computed extents
/api/stac/{orgId}/collections/{id}/items Items in one collection
/api/stac/{orgId}/search Item search (GET and POST)

CQL2 (filter) is not implemented. Bounding box, time range, and collection cover how a raster archive is actually browsed.

From an AI agent

Both surfaces are MCP tools, query_features and stac_search, so an agent can answer questions about your data instead of only styling it.

See also

  • CLI: sarkoa query, sarkoa stac search
  • API reference: which host serves this, and the public tile API
  • MCP: query_features and stac_search for agents
  • Webhooks: be told when new data lands, then query it