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,creturns only those properties.--no-geometrydrops 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_featuresandstac_searchfor agents - Webhooks: be told when new data lands, then query it