Knowhere

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 with GoError: Invalid module; require exists but resolves nothing worth having.
  • No network, no filesystem. fetch, console, and setTimeout are 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.entries all 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 propertiesid, 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().

  • maxSnapMeters defaults to 2000 on route(), time(), times(), and reach(), so a coordinate outside the area does not silently resolve onto whatever road is nearest inside it. Pass -1 for no limit. snap() keeps no default — reporting where a coordinate landed is its whole job, so read its offset_m.
  • Use times(), not a loop of time(). One call shares a single search setup across every destination. Results align by index, and an unreachable one is null — 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() and route() name the roads they landed on. Every Snapped carries road — way id, name, ref, highway class — because the router already resolved that edge to choose the junction. Do not go back to search to 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: true gives 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 sampling geometry and 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.