Runtime API
The runtime tool runs sandboxed JavaScript/TypeScript with the geospatial
globals below. Use it when search cannot express what you need: joining
queries, clustering, nearest-neighbour lookups, distance or travel-time math.
The script must be synchronous (no await) and must export a payload:
export const payload = { hello: "world" };
payload is returned as JSON, so it must be JSON-serializable and fit under
the size cap — aggregate or slice inside the script rather than exporting a
raw result set.
Worked scripts are in knowhere://docs/recipes; the authoritative type
declarations are knowhere://docs/globals.
Sandbox limits
Each of these fails at run time, not at parse time.
- No modules, no npm.
import fs from "fs"fails withGoError: Invalid module;requireexists but resolves nothing worth having. - No network, no filesystem.
fetch,console, andsetTimeoutare undefined. The globals below are the entire outside world. - Modern syntax, not a browser. The engine is goja: arrow functions,
template literals, destructuring, spread,
for…of,flatMap,Object.entriesall work. - TypeScript is accepted but never type-checked. Annotations are stripped, not verified.
- A wall-clock deadline interrupts the VM, set per deployment (60s by
default). Never hard-code an elapsed-ms cutoff: read
budget.remainingMs()and return a partial result instead of being cut off with none.
query
query.execute(q: string): ResultArray // capped, see below
query.union(...qs: string[]): ResultArray // capped, see below
query.count(q: string): number // COUNT(*), no rows pulled
query.areas(): Area[]
query.fromAddress(fullAddress: string, area?: string): ResultArray
query.execute and query.union each stop at a row cap — 50,000 by default
— and raise TOO_MANY_RESULTS rather than returning a shortened array. A
truncated set would be indistinguishable from a complete one, and a script
computing "which of these has X but not Y" gets a wrong answer from it, not
a partial one. Narrow the area, add filters, split the work by bounding box,
or use query.count(), which materialises nothing and has no cap.
query.execute auto-detects syntax the way search does. Each Result has
snake_case properties — id, osm_id, osm_type, lat, lon (the
bbox centroid, same values the search tool reports), min_lat, max_lat,
min_lon, max_lon, tags — and methods: name(), asBound(),
asPoint(), asFeature(). Call them: r.name(), not r.name. Putting the
function in a payload fails with PAYLOAD_NOT_SERIALIZABLE.
Reading a property a Result does not have throws (UNKNOWN_PROPERTY,
with the full roster and a did-you-mean) rather than returning undefined —
a typo fails at the line that made it instead of surfacing as NaNs in the
payload. Feature-test with ("x" in r), which never throws; r.tags.* is
ordinary object access and stays probe-free. Results are read-only — copy
into your own object ({...r, extra: value}) to annotate one.
tags
tag_keys / tag_values in-runtime, both frequency-ranked:
tags.keys(area: string): { key: string; count: number }[]
tags.values(area: string, key: string): { value: string; count: number }[]
budget
budget.remainingMs(): number // ms before the interrupt (0 if past)
budget.elapsedMs(): number
geo
geo.asPoint(lat: number, lon: number): Point // throws if out of range
geo.point({ lat, lon }): Point // named keys — can't swap order
geo.bound({ minLat, minLon, maxLat, maxLon }): Bound
geo.asResults(...results: Result[]): ResultArray
geo.asBounds(...bounds: Locatable[]): BoundArray
geo.distance(a: Locatable, b: Locatable): number // metres, between centres
geo.asPoint takes (lat, lon); if you hold [lon, lat], use
geo.point({ lat, lon }). A Locatable is anything location-shaped: a
Result, a Point, a Bound, or a plain { lat, lon } object —
geo.distance measures between their centres and throws on anything else,
rather than silently treating it as (0, 0). It is straight-line, so confirm
walkability with walk_reach.
routing
The road network, for questions straight-line distance answers wrongly. The
behaviour and limits are in knowhere://docs/routing.
routing.profiles(area: string): string[] // e.g. ["car","foot"]; empty = no graph
routing.time(area, from, to, options?): number | null // seconds, null unreachable
routing.times(area, origin, destinations[], options?): (number|null)[]
routing.route(area, from, to, options?): Leg | null // + distance, snapped ends
routing.snap(area, at, options?): Snapped | null
routing.reach(area, origin, options?): Reached[] | null // walkable junctions
from / to / destinations accept a Point, a Bound (its centre), or a
plain { lat, lon }. Options: profile ("car" default, "foot"),
maxSnapMeters, maxWalkMeters (foot only, default 10000), and
geometry: true / steps: true on route().
maxSnapMetersdefaults to 2000 onroute(),time(),times(), andreach(), so a coordinate outside the area does not silently resolve onto whatever road is nearest inside it. Pass-1for no limit.snap()keeps no default — reporting where a coordinate landed is its whole job, so read itsoffset_m.- Use
times(), not a loop oftime(). One call shares a single search setup across every destination. Results align by index, and an unreachable one isnull— filter it, never treat it as zero. - Unreachable is an answer (
null). Only a broken call — unknown area, no graph, unknown profile — stops the script. snap()androute()name the roads they landed on. EverySnappedcarriesroad— way id, name,ref, highway class — because the router already resolved that edge to choose the junction. Do not go back tosearchto name a street you have snapped: a way is indexed by its bounding box, so ranking by it puts the middle of a kilometre of Main Street 500 m away while you are standing on it.steps: truegives the road log. The sequence of roads the trip runs along, run-length encoded, each with the distance it begins at and the motorway junction it was reached through. It is not turn-by-turn — no turn directions, no lanes. It replaces samplinggeometryand running a radius query per sample, which is dozens of queries for an answer the router had.
const leg = routing.route("colorado", denver, winterPark, { steps: true });
leg.steps.map((s) => `${s.road.ref || s.road.name} at ${s.atMeters / 1000} km`);
// ["US 6 at 2.2 km", "I 70 at 17.4 km", "US 40 at 63.6 km", ...]
reach() walks outward from a point and returns every junction it got to,
each with meters / miles / seconds / minutes:
const near = routing.reach("colorado", origin, { maxWalkMeters: 1250 });
// [{ point, seconds, minutes, meters, miles }, ...] — thousands of them
It is walking-only (profile defaults to "foot"; "car" stops the script),
capped at 5,000 m, and returns junctions, not places — for "cafes I can
walk to", use the walk_reach tool, which does the reach, the query, and the
annotation server-side. Budget roughly 90 ms for a 1,250 m reach in a city
centre, a few ms in a rural area: several per script, not hundreds.
assert
assert.eq(ok: boolean, message: string): void
assert.geoJSON(payload: unknown): void // throws if payload isn't valid GeoJSON
assert.stab(message: string): void // unconditional failure
There is no assert.equal(a, b) — compare yourself and call assert.eq.
Related
knowhere://docs/recipes— worked scripts.knowhere://docs/globals— full TypeScript type declarations.knowhere://docs/query— syntax passed toquery.execute.knowhere://docs/errors—PAYLOAD_TOO_LARGE,PAYLOAD_NOT_SERIALIZABLE.