# What Knowhere Answers

Knowhere makes OpenStreetMap queryable **relative to other things**: not
"where is this address" but "what is near it, how long does it take to get
there, and can I walk it." It indexes `*.osm.pbf` extracts into a read-only
SQLite database and serves them over the Model Context Protocol, so the client
asking the questions is a model, not a map GUI. The data underneath is public
OpenStreetMap, and the index is one seekable-compressed file read in place —
a query touches the frames it needs and nothing else, rather than a copy of
the whole thing.

Point an MCP client at `/mcp` and ask in plain language. The model picks the
tool and writes the query; the rest of these pages are what it draws on.

## What you can ask

Every example is a real query against indexed data. Substitute your own area —
`list_areas` resolves a place name to the `snake_case` name a query needs.

| Ask | How it resolves |
| --- | --- |
| "Hot springs I could drive to" | `nwr[natural=hot_spring][name](area=colorado)` |
| "Ski areas" | `nwr[landuse=winter_sports][name](area=colorado)` |
| "Trailheads near town" | `n[highway=trailhead][name](area=colorado)` |
| "Somewhere with a patio for dinner" | `nw[amenity=restaurant][outdoor_seating=yes](area=colorado)` |
| "Rainy day with the kids" | `nwr[leisure=trampoline_park,bowling_alley,amusement_arcade,escape_game](area=colorado)` |
| "Where should we watch the sunset?" | `nwr[tourism=viewpoint][name](area=colorado)`, ranked by `drive_times` |

Trailheads are the case for asking rather than guessing: they are a `highway`
value, not `leisure` or `tourism`. The category cheat-sheet maps the everyday
concepts to the key that actually holds them.

Some questions are joins rather than queries. "Dinner and a movie within
walking distance of each other" is the `near` tool; "a park I can walk to from
this address" is `walk_reach`, which follows real sidewalks and so correctly
excludes the park across an uncrossable freeway; "three breweries within
walking distance of each other" is a runtime recipe.

## Where it lies to you

The data is volunteer-mapped, and the gaps are not random. These three
failures account for most wrong answers, and each looks like a correct query
returning an honest count.

**A count is not an answer.** `nwr[leisure=swimming_pool](area=colorado)`
matches thousands of pools. Fewer than one in twenty has a name and none carry
`access=public` — they are overwhelmingly backyards. "Public pool" is not a
tag. Filter to named results, or ask for `leisure=water_park`.

**A missing tag means a missing mapper, not a missing thing.**
`nw[amenity=bar][live_music=yes](area=colorado)` matches a single bar in the
whole state, and Colorado has rather more than one bar with live music. The
same trap sits under `fee=no`, `wheelchair=yes`, `dog=yes`, and every other
optional attribute. Filter on them to *rank* candidates, never to exclude.

**Opening hours, prices, and closures are not in here.** The database is a
dated extract. It does not know the museum shut last spring. Treat it as a
list of candidates to verify.


---

# Coverage

These are the areas this server is carrying right now, read from the database it is serving. Every query needs one of the `area=` names below, and routing never crosses between them.

| Place | `area=` |
| --- | --- |
| Alabama | `alabama` |
| Alaska | `alaska` |
| Alberta | `alberta` |
| Arizona | `arizona` |
| Arkansas | `arkansas` |
| British Columbia | `british_columbia` |
| California | `california` |
| Colorado | `colorado` |
| Connecticut | `connecticut` |
| Delaware | `delaware` |
| District Of Columbia | `district_of_columbia` |
| Florida | `florida` |
| Georgia | `georgia` |
| Hawaii | `hawaii` |
| Idaho | `idaho` |
| Illinois | `illinois` |
| Indiana | `indiana` |
| Iowa | `iowa` |
| Kansas | `kansas` |
| Kentucky | `kentucky` |
| Louisiana | `louisiana` |
| Maine | `maine` |
| Manitoba | `manitoba` |
| Maryland | `maryland` |
| Massachusetts | `massachusetts` |
| Michigan | `michigan` |
| Minnesota | `minnesota` |
| Mississippi | `mississippi` |
| Missouri | `missouri` |
| Montana | `montana` |
| Nebraska | `nebraska` |
| Nevada | `nevada` |
| New Brunswick | `new_brunswick` |
| New Hampshire | `new_hampshire` |
| New Jersey | `new_jersey` |
| New Mexico | `new_mexico` |
| New York | `new_york` |
| Newfoundland And Labrador | `newfoundland_and_labrador` |
| North Carolina | `north_carolina` |
| North Dakota | `north_dakota` |
| Northwest Territories | `northwest_territories` |
| Nova Scotia | `nova_scotia` |
| Nunavut | `nunavut` |
| Ohio | `ohio` |
| Oklahoma | `oklahoma` |
| Ontario | `ontario` |
| Oregon | `oregon` |
| Pennsylvania | `pennsylvania` |
| Prince Edward Island | `prince_edward_island` |
| Puerto Rico | `puerto_rico` |
| Quebec | `quebec` |
| Rhode Island | `rhode_island` |
| Saskatchewan | `saskatchewan` |
| South Carolina | `south_carolina` |
| South Dakota | `south_dakota` |
| Tennessee | `tennessee` |
| Texas | `texas` |
| Utah | `utah` |
| Vermont | `vermont` |
| Virginia | `virginia` |
| Washington | `washington` |
| West Virginia | `west_virginia` |
| Wisconsin | `wisconsin` |
| Wyoming | `wyoming` |
| Yukon | `yukon` |

65 area(s) indexed. `list_areas` answers the same question over MCP, and resolves a place name ("colorado springs") to the name a query needs.


---

# Query Language

Two syntaxes, same engine, same features. A query starting with `{` is parsed
as MongoDB-flavored JSON; anything else is bracket syntax (similar to Overpass
QL).

Bracket syntax is element types, then tag filters in `[…]`, then directives in
`(…)`:

```query
nw[amenity=restaurant][outdoor_seating=yes](area=california)
```

The JSON form is one object whose keys are directives (prefixed `$`) or tag
names. Multiple keys are an implicit AND:

```json
{ "$type": ["node", "way"], "$area": "california",
  "amenity": "restaurant", "outdoor_seating": "yes" }
```

An area is **required**. Without one the query returns `AREA_REQUIRED`. Names
are `snake_case` and lower-case — see `knowhere://docs/schema`.

## Element types

| Bracket | `$type`      | Meaning                          |
| ------- | ------------ | -------------------------------- |
| `n`     | `"node"`     | nodes                            |
| `w`     | `"way"`      | ways                             |
| `r`     | `"relation"` | relations                        |
| `nw`    | array        | nodes + ways                     |
| `nwr`   | array        | nodes + ways + relations         |
| `*`     | `"*"`        | everything                       |

Any combination of the single letters works (`wr`, `nwr`). `$type` defaults to
all types when omitted.

## Tag operators

| Bracket             | JSON                             | Meaning                           |
| ------------------- | -------------------------------- | --------------------------------- |
| `[amenity=cafe]`    | `"amenity": "cafe"` / `{"$eq":…}` | exact match                       |
| `[amenity=cafe,pub]`| `{ "$in": ["cafe","pub"] }`      | exact match, OR over values       |
| `[amenity!=cafe]`   | `{ "$ne": "cafe" }`              | does not equal                    |
| `[amenity!=cafe,pub]`| `{ "$nin": ["cafe","pub"] }`    | equals none of                    |
| `[name=~Starbucks]` | `{ "$regex": "Starbucks" }`      | case-insensitive **contains**     |
| `[name!~McDonald]`  | `{ "$not": { "$regex": … } }`    | case-insensitive does not contain |
| `[population>100000]`| `{ "$gt": 100000 }`             | numeric `>` (also `>=` `<` `<=` / `$gte` `$lt` `$lte`) |
| `[name]`            | `{ "$exists": true }`            | tag exists                        |
| `[!name]`           | `{ "$exists": false }`           | tag does not exist                |

- `=~` is a substring match, not a regex: no anchors, no wildcards. `$options`
  is accepted and ignored, and `$not` supports only `$regex`.
- The ordering operators (`>` `>=` `<` `<=` / `$gt` `$gte` `$lt` `$lte`) need
  numeric values (`1000`, not `"1000"`), and they read the number out of the
  stored text — a value with a unit compares on its leading number
  (`maxspeed="30 mph"` is `30`), and a value that does not start with a number
  matches no ordering comparison at all.
- `=` and `!=` are always an exact text match, even when the value looks like a
  number: `[bts:noise=114]` matches the value `tag_values` reports, and does
  not match `114.0`. Use `>=`/`<=` for a numeric range.
- Values with spaces or punctuation are double-quoted:
  `[name="The King's Head"]`. Each value in an OR quotes independently:
  `n[name="Starbucks","Peet's Coffee"](area=new_york)`.

## Directives

| Bracket                            | JSON       | Purpose                                        |
| ---------------------------------- | ---------- | ---------------------------------------------- |
| `(area=<snake_case>)`              | `$area`    | **Required.** Restrict to one area.            |
| `(bb=minLon,minLat,maxLon,maxLat)` | `$bb`      | Bounding box — note **lon, lat**.              |
| `(around=radiusMetres,lat,lon)`    | `$around`  | Centre within radius — note **lat, lon**.      |
| `(id=123,456)`                     | `$id`      | Restrict to specific OSM IDs.                  |
| —                                  | `$and`     | Explicit AND; implicit when several keys.      |

Multiple bounding boxes are allowed: pass 4N coordinates, grouped in fours.

## AND, OR, NOT

- **AND** — several tag filters in sequence: `nw[amenity=cafe][wifi=yes](area=seattle)`
- **OR** within one tag — comma-separated values: `nw[amenity=cafe,restaurant](area=seattle)`
- **NOT** — `!=`, `!~`, or `[!key]`

There is no top-level OR across different tag patterns. Issue two queries and
union them with `query.union` in `knowhere://docs/runtime`.

## Examples

```query
nw[amenity=restaurant](area=california)
n[name=~Starbucks][amenity=cafe](area=new_york)
wr[amenity=university](area=massachusetts)(bb=-71.2,42.3,-71.0,42.4)
nw[place=city][population>100000](area=texas)
*[tourism](area=paris)
```

```json
{ "$area": "san_francisco", "$bb": [-122.5, 37.7, -122.4, 37.8],
  "tourism": { "$exists": true } }
```

## Related

- `knowhere://docs/natural-language` — turning a request into one of these.
- `knowhere://docs/categories` — which tag key holds which concept.
- `knowhere://docs/schema` — coordinates, area names, result shape.
- `knowhere://docs/errors` — error codes and fixes.


---

# Natural Language to Queries

For translating user intent into Knowhere queries: the patterns that come up
most, and the rules for choosing among them.

## Rules

1. **Category words** ("restaurant", "museum", "park") map to a category key —
   `amenity`, `leisure`, `tourism`, `shop`. `knowhere://docs/categories` says
   which key holds which concept.
2. **Place words** map to `(area=…)`, `snake_case` and lower-case. Unsure, or
   ambiguous ("Denver" with no state)? Call `list_areas`. Never invent a name.
3. **Brand words** map to `name=~` (contains), or `brand=` when the user
   clearly means the chain. Chain vs. independent is `[brand]` vs. `[!brand]`,
   never a name regex — see `knowhere://docs/schema`.
4. **Unsure of a value?** `tag_values(area, key)` before filtering, not a
   guess. `tag_keys(area)` if unsure the key exists.
5. **"near a point"** with real coordinates is `(around=radiusMetres,lat,lon)`
   (5 mi ≈ `8047`). Without coordinates ("near downtown") it is too fuzzy for
   a filter — use `(area=…)` alone. For "A near a *set* of B", use the `near`
   tool.
6. **Anything about walking** goes to `walk_reach`, never `near` or
   `(around=…)`. See `knowhere://docs/routing`.
7. **Favour recall when the user is exploring.** If several values could
   satisfy the intent, prefer the OR form (`[amenity=cafe,restaurant]`) over
   one narrow guess.

## Mappings

| Ask | Query |
| --- | --- |
| "restaurants in California" | `nw[amenity=restaurant](area=california)` |
| "Starbucks in Seattle" | `nw[name=~Starbucks](area=seattle)` |
| "the Starbucks *chain*" | `nw[brand=Starbucks](area=seattle)` |
| "independent coffee shops in Bozeman" | `nw[amenity=cafe][!brand](area=bozeman)` |
| "cafes or restaurants in Denver" | `nw[amenity=cafe,restaurant](area=denver)` |
| "restaurants in LA that aren't McDonald's" | `nw[amenity=restaurant][name!~McDonald](area=los_angeles)` |
| "cities in Texas over 100,000 people" | `n[place=city][population>100000](area=texas)` |
| "universities in Massachusetts with a name" | `wr[amenity=university][name](area=massachusetts)` |
| "sushi restaurants in San Francisco" | `nw[amenity=restaurant][cuisine=~sushi](area=san_francisco)` |
| "parks without a name in Portland" | `nw[leisure=park][!name](area=portland)` |
| "museums between -122.5,37.7 and -122.4,37.8" | `nw[tourism=museum](area=san_francisco)(bb=-122.5,37.7,-122.4,37.8)` |
| "boundaries in Colorado named Denver" | `*[boundary=administrative][name=~Denver](area=colorado)` |

## Travel questions are not queries

**"What's within a 15-minute walk?", "is this house walkable to a coffee
shop?", "any parks I can walk to?"** — `walk_reach`, with `find` carrying the
query:

```query
walk_reach area=colorado from_lat=39.7392 from_lon=-104.9847
           max_walk_meters=1250
           find="nw[amenity=cafe](area=colorado)"
```

Report `no_walkable_network: true` as "the area has no pedestrian paths", not
as "nothing nearby" — one is a fact about sidewalks, the other a verdict on
the neighbourhood.

**"How far is the nearest Costco by car?"** — `search` for candidates, then
`drive_times` to rank them. There is no driving reachability tool; you must
name the destinations.

**"I want to walk two miles in a loop"** — `walk_loop`, with the length as
`target_meters` or the time as `target_minutes` ("walk for an hour" is
`target_minutes=60`). **"A mile loop that sees all the parks"** adds
`passing` with the query for what to see:

```query
walk_loop area=colorado from_lat=39.7392 from_lon=-104.9847
          target_meters=1609 passing="nw[leisure=park](area=colorado)"
```

Report the length each loop **actually came out at** (`meters`), never the
length that was asked for. A result with `none` set is a real answer —
quote its reason rather than retrying; a cul-de-sac subdivision genuinely
has no two-mile loop.

The limits behind all three — snapping, area boundaries, walking caps — are in
`knowhere://docs/routing`.

## Pitfalls

- **Don't forget `(area=…)`.** Every query needs one.
- **Don't map "near Seattle" to a bounding box** without real coordinates.
- **`=~` is contains, not regex** — no anchors, no wildcards.
- **Chain searches return more than the stores.** `name=~Costco` also matches
  its gas station, pharmacy, and per-aisle nodes. Filter by the primary
  feature tag as well.
- **Broad and unfiltered is slow.** A bare category over a large area can time
  out. Narrow with a `(bb=…)` — the cheapest, since it prunes on the spatial
  cell index — a second tag filter, or `count_only` first.
- **`=~` is only indexed on the name-ish tags** (`name`, `name:<lang>`,
  `alt_name`, `brand`, `operator`). Contains on any other tag is a scan:
  correct, but it reads every row the other filters left. Pair it with
  something selective — `nw[amenity=cafe][cuisine=~coffee]`, not
  `nw[cuisine=~coffee]` alone — and prefer `=` when the value is a known
  category.

## Related

- `knowhere://docs/query` — syntax reference.
- `knowhere://docs/categories` — concept to tag key.
- `knowhere://docs/errors` — codes when a translation misses.


---

# Category to Tag Cheat-Sheet

Everyday concepts, and the OSM key/value that indexes them. **Starting points,
not ground truth** — a value here may not exist in a given area. Confirm with
`tag_values(area, key)` before filtering on it, and `tag_keys(area)` when you
are not sure the key is there at all. Both return the real, frequency-ranked
vocabulary for that area, which beats guessing a spelling.

## Which key holds which concept

Pick the key first, then the value.

| Key         | Covers                                     | Frequent values                                                                 |
| ----------- | ------------------------------------------ | ------------------------------------------------------------------------------- |
| `amenity`   | Public / commercial point services         | `restaurant`, `cafe`, `fast_food`, `bar`, `pub`, `fuel`, `pharmacy`, `hospital`, `clinic`, `school`, `bank`, `atm`, `parking`, `toilets`, `place_of_worship`, `library`, `police`, `fire_station`, `post_office`, `cinema`, `theatre` |
| `shop`      | Retail                                     | `supermarket`, `convenience`, `wholesale`, `bakery`, `butcher`, `books`, `clothes`, `hardware`, `department_store`, `mall`, `alcohol`, `car`, `car_repair`, `hairdresser`, `optician` |
| `leisure`   | Recreation and sport grounds               | `park`, `playground`, `pitch`, `sports_centre`, `fitness_centre`, `stadium`, `golf_course`, `swimming_pool`, `dog_park`, `garden`, `nature_reserve` |
| `tourism`   | Travel, lodging, sights                    | `hotel`, `motel`, `guest_house`, `hostel`, `museum`, `attraction`, `viewpoint`, `information`, `artwork`, `camp_site` |
| `highway`   | Roads and paths, plus some point features  | `motorway`, `trunk`, `primary`, `secondary`, `residential`, `service`, `footway`, `path`, `cycleway`, `trailhead`, `bus_stop`, `crossing`, `traffic_signals` |
| `natural`   | Physical geography                         | `water`, `wood`, `tree`, `peak`, `beach`, `cliff`, `wetland`, `spring`, `hot_spring` |
| `building`  | Building footprints (the structure itself) | `yes`, `house`, `apartments`, `commercial`, `retail`, `industrial`, `school`, `hospital`, `church` |
| `office`    | White-collar / org premises                | `company`, `government`, `insurance`, `lawyer`, `estate_agent`, `it`, `coworking` |
| `healthcare`| Health services (complements `amenity`)    | `doctor`, `dentist`, `pharmacy`, `hospital`, `clinic`, `physiotherapist`         |
| `place`     | Populated places and admin units (nodes)   | `city`, `town`, `village`, `hamlet`, `suburb`, `neighbourhood`, `locality`       |
| `boundary`  | Administrative and other boundaries        | `administrative`, `national_park`, `protected_area`                              |
| `landuse`   | How land is used (areas)                   | `residential`, `commercial`, `industrial`, `retail`, `farmland`, `forest`, `grass`, `winter_sports` |
| `railway`   | Rail infrastructure                        | `station`, `halt`, `tram_stop`, `subway_entrance`, `rail`                        |
| `man_made`  | Built structures that are not buildings    | `tower`, `water_tower`, `pier`, `lighthouse`, `bridge`, `surveillance`           |

## Quick lookups

| You want…                       | Query with                                                       |
| ------------------------------- | ---------------------------------------------------------------- |
| Restaurants                     | `[amenity=restaurant]`                                            |
| Coffee shops                    | `[amenity=cafe]`                                                  |
| Gas / petrol stations           | `[amenity=fuel]` (**not** `shop=fuel`)                            |
| Grocery stores / supermarkets   | `[shop=supermarket]`                                              |
| Warehouse clubs (Costco, Sam's) | `[shop=wholesale]`                                                |
| Bookstores                      | `[shop=books]`                                                    |
| Colleges / universities         | `[amenity=university]` (**not** `amenity=college` for higher ed)  |
| Schools (K-12)                  | `[amenity=school]`                                                |
| Hospitals                       | `[amenity=hospital]`                                              |
| Pharmacies                      | `[amenity=pharmacy]`                                              |
| Parks                           | `[leisure=park]`                                                  |
| Playgrounds                     | `[leisure=playground]` (**not** `amenity`)                        |
| Dog parks                       | `[leisure=dog_park]` (**not** `amenity`)                          |
| Trailheads                      | `[highway=trailhead]` (**not** `leisure` or `tourism`)            |
| Hotels                          | `[tourism=hotel]` (`building=hotel` is only the structure)        |
| Museums                         | `[tourism=museum]`                                                |
| Cities / towns                  | `[place=city,town]`                                               |
| Parking                         | `[amenity=parking]`                                               |
| ATMs                            | `[amenity=atm]`                                                   |
| Places of worship               | `[amenity=place_of_worship]` (refine with `religion=*`)           |

## Gotchas

- **Feature vs. building.** The establishment lives on `amenity`/`tourism`/
  `shop`; `building=<same word>` is only the physical structure.
- **`amenity=college` ≠ university.** US higher ed is `amenity=university`;
  `amenity=college` is further education and vocational.
- **Values are singular.** "bookstores" → `shop=books`, "restaurants" →
  `amenity=restaurant`.
- **Chains carry sub-features.** A Costco is one `shop=wholesale` node, but the
  same name is *also* on its gas station, food court, pharmacy, tyre centre,
  and per-department nodes. Filter by the primary tag —
  `nw[shop=wholesale][name=~Costco](area=colorado)` — never `name=~` alone.

## Related

- `knowhere://docs/natural-language` — assembling these into full queries.
- `knowhere://docs/query` — syntax reference.
- `knowhere://docs/schema` — `brand` vs `name`, coordinates, result shape.


---

# Data Schema and Conventions

## Coordinates

All coordinates are **`[longitude, latitude]`** (GeoJSON RFC 7946): `(bb=…)`,
`"$bb"`, and every `Feature.geometry.coordinates` in search output. Longitude
is `[-180, 180]`, latitude `[-90, 90]`.

Geometry only. Tools that take a *point* take it as separate named fields
(`from_lat` / `from_lon`), so there is no order to get wrong. The two ordered
exceptions are `(around=radiusMetres,lat,lon)` and `zillow_region`'s boxes
(`[minLat, minLon, maxLat, maxLon]`) — both lat-first.

## Area names

An area is named in `snake_case`, lower-case, derived from its title:
`"New York"` → `new_york`, `"Colorado Springs"` → `colorado_springs`. Use
`list_areas` to discover them — it also returns `full_name` and the extract's
bounding box, and resolves a human place name to the name a query needs.

An area is a whole state or province, so **a city name never matches one**.
`list_areas(filter="denver")` therefore reports `match_kind: "none"` for the
name lookup and resolves it a second way, as a settlement: the answer carries
`resolved_place` with the containing area and the city's coordinates, which is
what `(around=…)` needs. `match_kind` distinguishes the cases — `exact`,
`substring`, `resolved_place`, `none`, `all` — and on `none` the `suggestions`
list holds the closest area *names*, which are frequently unrelated to what
was asked for. Never treat a suggestion as a match.

An area is **not** a bounding-box filter. Each is its own table built from that
region's source extract, and `(area=X)` selects from that table. Two
consequences:

- The bbox metadata from `list_areas` overlaps neighbours (Rhode Island's box
  reaches into Connecticut). Queries do not.
- Extracts carry a small border buffer, so features a few km across the line
  appear in both adjacent areas. Filter on `addr:state` if you need strict
  in-state results.

## Search result shape

`search` defaults to a compact shape — cheaper than GeoJSON when you don't
need geometry:

```jsonc
{
  "results": [
    {
      "id": 42,
      "osm_id": 123456789,
      "osm_type": "node",                        // node | way | relation
      "name": "Joe's Diner",
      "lat": 37.77, "lon": -122.41,              // bounding-box centroid
      "bbox": [-122.42, 37.76, -122.40, 37.78],  // omitted for point features
      "tags": { "amenity": "restaurant", "name": "Joe's Diner" }
    }
  ],
  "count": 1,
  "total_count": 1,
  "truncated": false
}
```

`"format": "geojson"` returns a real `FeatureCollection` with the feature's
actual geometry where the source has one. If `truncated` is true, more rows
exist beyond `limit` and `next_offset` is the offset to request next.

## Common tags

| Tag             | Meaning                                 | Example values                             |
| --------------- | --------------------------------------- | ------------------------------------------ |
| `amenity`       | Category of point of interest           | `restaurant`, `cafe`, `school`, `hospital` |
| `leisure`       | Recreational feature                    | `park`, `playground`, `stadium`            |
| `tourism`       | Tourism feature                         | `museum`, `hotel`, `attraction`            |
| `shop`          | Retail category                         | `supermarket`, `bakery`, `clothes`         |
| `name`          | Display name of this specific feature   | `"Joe's Diner"`                            |
| `brand`         | Chain or owner, when part of one        | `Starbucks`, `Costco`                      |
| `cuisine`       | Cuisine for food-serving amenities      | `italian`, `sushi`, `mexican`              |
| `population`    | Population count (on `place` features)  | `850000`                                   |
| `opening_hours` | OSM opening-hours expression            | `"Mo-Fr 09:00-17:00"`                      |
| `addr:*`        | Structured address components           | `addr:city`, `addr:street`, `addr:postcode` |

`knowhere://docs/categories` maps everyday concepts to the key that holds
them, and says what to confirm before filtering on a value.

### `brand` vs `name`

`brand` is the chain; `name` is this feature's display name. They are often
identical at a chain location, but only `brand` is reliable for "is this a
chain": chains carry `brand` / `brand:wikidata`, independents carry neither.
So "independent coffee shops" is `[amenity=cafe][!brand]`, never a name regex
that lets "Black Rock" or "Beans & Brews" through. Use `name=~` for words that
might appear anywhere in a name.

## Underlying SQL

Each area gets tables prefixed with its name. Callers never touch these —
`search`, `tag_keys`, and `tag_values` generate the SQL.

- `{area}_entries` — raw OSM data, tags as a JSON blob.
- `{area}_search` — FTS5 index over tags, pruning tag filters. Token
  membership only (`detail=none`, no positions): it narrows candidates, and
  exact filtering happens in SQL against `{area}_entries`.
- `{area}_cells` — the spatial index. `{area}_entries.id` is assigned in grid
  order, so every cell is a contiguous run of ids and this table records where
  each run starts and ends, at three grid sizes. That is what prunes `bb=`,
  `around=`, `near` and `reverse_geocode`.
- `{area}_trigram` — FTS5 trigram index over the name-ish values (`name`,
  `name:<lang>`, `alt_name`, `brand`, `operator`), so `=~` on those prunes by
  substring. Contains on any other tag returns the same rows but scans for
  them; trigram costs about a token per character, and category-like values
  are already served by `{area}_search`.
- `{area}_tag_keys` — precomputed key histogram behind `tag_keys`.

Areas built from a PBF also carry routing graphs, stored as contraction
hierarchies queried by indexed lookup rather than loaded into memory. Geometry
is shared by every profile (`{area}_routing_coords`, `_routing_geom`); each
profile has its own graph (`{area}_routing_<profile>_nodes`, `_up`, `_down`),
described by `{area}_routing_profiles`. A shortcut is a row in `_up`/`_down`
carrying a non-NULL `via_id` rather than a table of its own. Road names are
interned in `{area}_road_names` and referenced from `{area}_routing_edge_ways`.
Weights are whole deciseconds. Weights are baked in at contraction, so profiles
cannot share a hierarchy even though they share geometry. A symmetric profile —
a pedestrian one — stores no `_down`, because it would be a copy of `_up`.

## Related

- `knowhere://docs/query` — query syntax.
- `knowhere://docs/categories` — concept to tag key.
- `knowhere://docs/routing` — what the routing tables answer.
- `knowhere://docs/errors` — error codes.


---

# Routing

Knowhere answers travel questions over the real network — actual roads,
one-way streets, footpaths, per-road-class speeds — not straight-line
distance.

- `route` — one origin to one destination. Travel time, distance, and
  (opt-in) the polyline.
- `drive_times` — one origin to many destinations (max 100) in one call.
- `walk_reach` — what you can reach **on foot** from a point, without naming
  the destinations first. Matching places with real walking distances,
  nearest first.
- `walk_loop` — a walking circuit of roughly a requested length that starts
  and ends at a point without walking the same street twice, optionally
  routed through places a query names.

`route` and `drive_times` answer *how far away are these places I named*.
`walk_reach` answers *what is there*. `walk_loop` answers *take me for a
walk*.

## Which tool

`near` measures straight-line distance and is the cheapest way to filter
candidates. `drive_times` measures how quick they are to get to. The two
diverge sharply wherever the road network does not follow the crow — a
supermarket 1 km away across a freeway is a 15-minute drive. The usual
pattern combines them:

```
1. search  nw[shop=supermarket](area=wyoming)       -> candidates
2. drive_times from home to those                   -> ranked by minutes
```

**Walkable is never a straight line.** A cafe 200 m away across a freeway
with no crossing is a two-kilometre walk or no walk at all. Any question with
"walk" in it — "walking distance", "a 10-minute walk", "can I walk to" — goes
to `walk_reach`. Answering it with `near` or `(around=…)` produces a
confident number that is wrong exactly where people care.

## `walk_reach`

Walks outward over the pedestrian network until the distance budget runs out,
then returns the features matching `find` that it actually got to.

```query
walk_reach area=colorado from_lat=39.7392 from_lon=-104.9847
           max_walk_meters=1250
           find="nw[amenity=cafe](area=colorado)"
```

Budgets: 15 minutes ≈ 1,250 m, 30 minutes ≈ 2,500 m; "walkable" with no
number given is 1,250 m. Each result carries `walk_meters` (network distance,
**including** how far the place sits off the nearest path), `walk_minutes`,
and `snap_meters`.

Omit `find` for a summary instead of a list — reachable junction count,
bounding box, distance deciles. Raw junctions are never returned: a reach
settles thousands of them and they crowd out the answer. Scripts that want
them use `routing.reach()` (`knowhere://docs/runtime`).

**Foot only.** The car graph is a contraction hierarchy whose upward edges
are only part of the graph, so a one-sided search over it returns the search
space rather than the reachable set — a wrong answer that looks plausible.
`walk_reach` has no `profile` parameter, and asking for a driving version
returns `NO_ISOCHRONE`. "Within a 20-minute drive" needs `drive_times`
against destinations you name.

**Capped at 5,000 m**, about an hour's walk, independent of `route`'s 25 km
ceiling: a reach holds its whole neighbourhood in memory and that grows with
the *square* of the radius. Over the cap returns `REACH_TOO_LARGE` rather
than quietly answering a smaller question.

### Nowhere to walk

Some places have no pedestrian network. A mountain-valley address reaches
four junctions at 1,250 m where a suburb reaches ~2,100 and a city centre
~6,500. The result then sets `no_walkable_network: true`.

Read it and say so. An empty `results` there means *there is nowhere to
walk*, not *the area was searched and had no cafes* — reporting the latter
blames the neighbourhood for missing sidewalks. `origin_component_junctions`
tells the two apart: it counts everything connected to the starting junction
at any distance, so a rural road joins the whole state network and still
reaches nothing in fifteen minutes (a place with no destinations), while a
severed footway is a handful of junctions however far you ask (a place with
no paths).

Fragmented pedestrian data is normal in OSM, so snapping prefers a junction
with a real network behind it over a closer one that leads nowhere.

## `walk_loop`

"I want to walk a mile in a loop." A router alone cannot answer this: the
shortest path out and back is the same street twice — technically the
distance asked for, and not what anyone meant. `walk_loop` spreads
turnaround points around the origin (one per compass sector, from the
reachable network itself), builds circuits through them, and rejects any
circuit that retraces more than 25% of its own geometry. The thresholds
were tuned against real extracts in #62: genuine loops retrace 0.09–0.19,
a cul-de-sac subdivision scores 0.50 on every circuit it can build.

```query
walk_loop area=colorado from_lat=39.7328 from_lon=-104.9790
          target_meters=1600
```

Give the length as `target_meters` (default 1,609 — a mile) or the time as
`target_minutes` at 5 km/h; one or the other, not both. Cap is 10,000 m,
about a two-hour walk.

**Each loop reports the length it actually came out at.** A neighbourhood
may not contain a 2.0-mile loop, and 2.3 miles honestly labelled beats 2.0
miles claimed. Report `meters`/`minutes` from the result, never the target.

**`passing` makes it a themed loop.** Give it a Knowhere query and the
circuit routes through what it names — the parks, the coffee shops:

```query
walk_loop area=colorado from_lat=39.7328 from_lon=-104.9790
          target_meters=1600 passing="nw[leisure=park](area=colorado)"
```

Matches are bounded to the disc the loop can reach, snapped onto the path
network (a match with no path within 150 m is not somewhere a walk can
pass), and visited in a bearing sweep so the circuit circles rather than
zigzags. All of them when the full circuit holds up — the nearest 8 at
most — otherwise circuits through pairs, so one unreachable park costs
itself and not the answer.

**"None found" is a real answer.** A cul-de-sac subdivision with one way
in and out genuinely has no mile loop, and outside towns there is often no
pedestrian network to make a circuit from. The result then carries `none`
with the reason — quote it rather than retrying, and read
`no_walkable_network` the same way as `walk_reach`'s. Loops are foot-only,
like reachability, and for the same reason.

## Hard limits

**Routing does not cross area boundaries.** Each area's graph stops at its
extract's edge, so a route into a neighbouring state returns `NO_ROUTE` even
though both areas exist. Both endpoints must be in the same area.
Straight-line tools have no such limit.

**Coordinates snap to the nearest usable junction**, and the response reports
where they landed and how far that was (`offset_m`). "Usable" is per end: an
origin needs somewhere to go, a destination something that can arrive — a
coordinate metres from a one-way stub can land on a node you may drive out of
but never into.

**A far snap is refused.** `max_snap_meters` defaults to **2000**, and
anything further returns `NO_ROAD_NEARBY`. Real street addresses measure tens
of metres off, so this only catches points that are not in the area at all —
and the error names another indexed area containing the point when there is
one, which is usually the fix. Without it, a coordinate outside the area
silently lands on whatever junction is nearest inside it and the trip is
measured to somewhere you did not ask about.

Lower it (`max_snap_meters: 100`) to verify a coordinate really sits on a
street; raise it for a genuinely remote destination — a lake centroid, a
trailhead — or pass `-1` for no limit. Either way an endpoint resolving
further than 2 km comes back `far_from_road: true`, and any distance reported
for it is to the nearest road, not to the place. `drive_times` reports both
per row.

**Two profiles: `car` (default) and `foot`.** Separate networks, not one at a
different speed: walking uses footways, paths, and steps no vehicle can, and
ignores one-ways, so an urban walking route is often shorter than the drive.
Walking is a flat 5 km/h (1.5 km/h on steps) and capped by `max_walk_meters`
(default 10,000, max 25,000) because a pedestrian search is not hierarchical
and its cost grows with distance covered. Not every area has a foot graph;
one without returns `NO_ROUTING_DATA` for `profile: "foot"`.

No cycling profile, no live traffic, no time-of-day variation — two calls for
the same pair always return the same number. Turn restrictions are not
modelled, so a route may occasionally take a signposted-illegal turn; travel
times are unaffected at any meaningful scale.

## Geometry

`route` returns the polyline only when `include_geometry` is true, because a
long route is thousands of points. Points are `{lat, lon}` in travel order,
following real road shape, and `meters` / `miles` are measured along it — the
driving distance, not the straight line.

## Which roads

Every `route` reply names the road each endpoint snapped onto, in
`from.road` / `to.road`: OSM way id, `name`, `ref` (the route number, "I 70"),
and `highway` (the class — motorway, primary, residential). It costs nothing
extra, because the router resolved that edge in order to pick the junction.

**Do not reconstruct this from `search`.** A way is indexed by its bounding
box, so ranking ways by distance to their box centre puts the middle of a
kilometre of Main Street 500 m away while you are standing on it. Positional
questions about the network go through the graph, which measures to the road's
real shape.

`route` with `steps: true` returns the whole road log: the sequence of roads
the trip runs along, in order.

```json
"steps": [
  { "ref": "US 6", "name": "West 6th Avenue Freeway", "highway": "motorway",
    "at_km": 2.2, "km": 15.2 },
  { "ref": "I 70",  "highway": "motorway", "at_km": 17.4, "km": 46.2 },
  { "ref": "US 40", "highway": "primary",  "at_km": 63.6, "km": 42.1,
    "via": "exit 232" }
]
```

- **Run-length encoded.** Consecutive stretches of the same road are one
  entry. A road is "the same" when it shares a route number *or* a name —
  I-70 is tagged unnamed for most of its length and "Veterans Memorial Tunnel"
  for 230 m of it, and that is not a change of road.
- **Slip roads are folded in.** A `*_link` way belongs to the road it delivers
  you to, and its distance is credited there, so "US 40 at 63.6 km" is the
  point the interstate was left.
- **Steps tile the route.** The last `at_km` plus its `km` is the trip length;
  nothing is sampled and nothing is dropped.
- **`via` is the motorway junction**, where there is one — usually an exit
  number. Most roads change at an ordinary intersection and carry none.
- **This is not turn-by-turn.** No turn directions, no angles, no lane tags.
  It is a log of roads, not instructions for driving them.

`from.road` and the first step can disagree, and both are right: they answer
different questions. A coordinate at the corner of 14th and Bannock snaps to
the junction the two share, so `from.road` names whichever of them the
coordinate sits closest to, while step one names the road the trip drives
away along.

It costs roughly half a route again, so it is opt-in. It is still far cheaper
than the alternative it replaces: sampling the polyline every couple of
kilometres and running a radius query per sample is dozens of queries, and the
ones that hit a long way get the wrong label for the bounding-box reason above.

## Errors

`NO_ROUTING_DATA`, `NO_ROUTE`, `NO_ROAD_NEARBY`, `NO_ISOCHRONE`,
`REACH_TOO_LARGE`, `BEYOND_RANGE` — see `knowhere://docs/errors` for what
each means and how to correct it. In `drive_times`, an unreachable
destination is flagged on its own row (`reachable: false` with a `reason`)
rather than failing the call.

## Related

- `knowhere://docs/runtime` — `routing.*` in scripts.
- `knowhere://docs/schema` — the underlying routing tables.
- `knowhere://docs/errors` — the full error-code protocol.


---

# 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`:

```ts
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

```ts
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:

```ts
tags.keys(area: string): { key: string; count: number }[]
tags.values(area: string, key: string): { value: string; count: number }[]
```

## budget

```ts
budget.remainingMs(): number   // ms before the interrupt (0 if past)
budget.elapsedMs(): number
```

## geo

```ts
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`.

```ts
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.

```ts
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`:

```ts
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

```ts
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 to `query.execute`.
- `knowhere://docs/errors` — `PAYLOAD_TOO_LARGE`, `PAYLOAD_NOT_SERIALIZABLE`.


---

# Runtime Recipes

Worked scripts for the `runtime` tool. The API they use is
`knowhere://docs/runtime`.

## Query, projected down

```ts
const restaurants = query.execute("nw[amenity=restaurant](area=san_francisco)");
export const payload = restaurants.map((r) => ({
  name: r.name(),
  lat: r.asPoint().lat(),
  lon: r.asPoint().lon(),
}));
```

## Union of several queries

```ts
const all = query.union(
  "nw[amenity=cafe](area=seattle)",
  "nw[amenity=bakery](area=seattle)",
);
export const payload = { count: all.length };
```

## Distance from a set's centroid

```ts
const pois = query.execute("n[tourism=museum](area=paris)");
const center = pois.length > 0
  ? geo.asBounds(...pois.map((p) => p.asBound())).asBound().center()
  : null;

export const payload = center
  ? pois.map((p) => ({
    name: p.name(),
    distanceMeters: geo.distance(p.asBound(), center.asBound()),
  }))
  : [];
```

## Deduplicating nearby points

One representative per cluster of points within `radiusMeters` of each other:

```ts
function cluster(results, radiusMeters) {
  const kept = [];
  for (const entry of results) {
    const bound = entry.asBound();
    const isDupe = kept.some((k) => geo.distance(bound, k.asBound()) < radiusMeters);
    if (!isDupe) kept.push(entry);
  }
  return kept;
}

const costcos = cluster(query.execute("nwr[name=~Costco](area=colorado)"), 5000);
export const payload = costcos.map((r) => r.name());
```

## Nearest neighbour in another set

For plain "find A with a B within N metres", prefer the **`near` tool** — it
joins server-side and is far cheaper than pulling both sets into the runtime.
Reach for this form only when you need extra computation around it:

```ts
const schools = query.execute("nwr[amenity=school][name](area=colorado)");
const cafes = query.execute("nwr[amenity=cafe][name!~Starbucks](area=colorado)");

export const payload = schools.map((school) => {
  const sb = school.asBound();
  let best = null;
  for (const cafe of cafes) {
    const d = geo.distance(sb, cafe.asBound());
    if (best === null || d < best.meters) best = { cafe, meters: d };
  }
  return { school: school.name(), cafe: best ? best.cafe.name() : null };
});
```

## Where three things coincide

"Within a short drive of a Costco, and a walk of a school and a coffee shop" —
a join of three result sets at three different radii, which no tool expresses.
Anchor on the **rarest** set: it decides how many candidate centres there are,
and everything else is a distance check against it.

```ts
const wanted = [
  { radius: 5000, results: query.execute("nwr[name=~Costco](area=colorado)") },
  { radius: 1250, results: query.execute("nwr[amenity=school][name](area=colorado)") },
  { radius: 1250, results: query.execute("nwr[amenity=cafe][name][name!~Starbucks](area=colorado)") },
];

wanted.sort((a, b) => a.results.length - b.results.length);
const [anchor, ...rest] = wanted;

const found = [];
for (const centre of anchor.results) {
  const bound = centre.asBound();

  // Nearest member of each other set, bailing as soon as one is missing.
  const matches = [];
  for (const other of rest) {
    let best = null;
    for (const entry of other.results) {
      const meters = geo.distance(bound, entry.asBound());
      if (meters <= other.radius && (best === null || meters < best.meters)) {
        best = { name: entry.name(), meters: Math.round(meters) };
      }
    }
    if (best === null) break;
    matches.push(best);
  }

  if (matches.length === rest.length) {
    found.push({ centre: centre.name(), lat: centre.asPoint().lat(), lon: centre.asPoint().lon(), matches });
  }
}

export const payload = found;
```

Two deliberate limits: `geo.distance` is straight-line, so tighten the radius
or confirm survivors with `walk_reach`; and it returns names, because an
exhaustive GeoJSON of every match is the usual way to hit
`PAYLOAD_TOO_LARGE`.

## Ranking by travel time

The reason routing is in the runtime and not only in the tools — a query, a
join, and a sort in one place:

```ts
const home = geo.point({ lat: 41.14, lon: -104.8202 });
const stores = query.execute("nw[shop=supermarket](area=wyoming)");

// One call, not one per store.
const seconds = routing.times("wyoming", home, stores.map((s) => s.asPoint()));

export const payload = stores
  .map((store, i) => ({ name: store.name(), seconds: seconds[i] }))
  .filter((s) => s.seconds !== null) // unreachable, not "zero"
  .sort((a, b) => a.seconds - b.seconds)
  .slice(0, 10);
```

On wyoming this ranks a supermarket 2.1 mi away ahead of one 1.9 mi away —
3.1 minutes against 5.2. For walkability pass `{ profile: "foot" }`.

## Every area at once

```ts
const counties = query.areas().flatMap((area) =>
  query
    .execute(`nwr[admin_level=6][boundary=administrative][name](area=${area.name})`)
    .map((county) => {
      const centre = county.asBound().center();
      return { name: county.name(), area: area.name, lat: centre.lat(), lon: centre.lon() };
    })
);

export const payload = counties;
```

One query per area, so guard the loop with `budget.remainingMs()` and return
what you have.

## A walking loop of a given length

**The `walk_loop` tool does this now** — including themed loops via its
`passing` parameter ("a mile loop past the parks") and time targets via
`target_minutes`. Reach for this script only to build a variant the tool
does not offer: different thresholds, custom turnaround scoring, more than
two turnarounds per circuit.

A router finds the *shortest* path, so "walk out a mile and come back" returns
the same street twice. A real loop needs turnarounds spread around you and a
test that rejects circuits which double back.

```ts
const area = "colorado";
const origin = geo.point({ lat: 39.7328, lon: -104.9790 });
const target = 3200; // metres; 2 miles

function retrace(legs) {
  // Hash consecutive point pairs and intersect. Undirected, so the same
  // street walked back still collides — this is what separates a loop from
  // an out-and-back.
  const seen = {};
  let shared = 0, total = 0;
  for (const leg of legs) {
    for (let i = 1; i < leg.geometry.length; i++) {
      const a = leg.geometry[i - 1], b = leg.geometry[i];
      const key = [
        Math.min(a.lat(), b.lat()).toFixed(6), Math.min(a.lon(), b.lon()).toFixed(6),
        Math.max(a.lat(), b.lat()).toFixed(6), Math.max(a.lon(), b.lon()).toFixed(6),
      ].join(",");
      total++;
      if (seen[key]) shared++; else seen[key] = true;
    }
  }
  return total === 0 ? 1 : shared / total;
}

function loops(radius) {
  const reached = routing.reach(area, origin, { maxWalkMeters: radius });
  if (!reached) return [];

  // One turnaround per compass sector, so they spread around the origin
  // instead of clustering down whichever street is longest.
  const sectors = {};
  for (const n of reached) {
    if (n.meters < radius * 0.7) continue; // too near: a stunted loop
    const deg = (Math.atan2(n.point.lon() - origin.lon(),
                            n.point.lat() - origin.lat()) * 180) / Math.PI;
    const s = Math.floor((((deg % 360) + 360) % 360) / 45);
    if (!sectors[s] || n.meters > sectors[s].meters) sectors[s] = n;
  }

  const spread = Object.keys(sectors).map((k) => sectors[k]);
  const out = [];
  const opts = { profile: "foot", geometry: true, maxWalkMeters: target };

  for (let i = 0; i < spread.length; i++) {
    for (let j = i + 1; j < spread.length; j++) {
      const a = routing.route(area, origin, spread[i].point, opts);
      const b = routing.route(area, spread[i].point, spread[j].point, opts);
      const c = routing.route(area, spread[j].point, origin, opts);
      if (!a || !b || !c) continue;

      const ratio = retrace([a, b, c]);
      if (ratio > 0.25) continue; // an out-and-back wearing a loop's clothes

      out.push({
        meters: Math.round(a.meters + b.meters + c.meters),
        minutes: Math.round((a.minutes + b.minutes + c.minutes) * 10) / 10,
        retrace: Math.round(ratio * 100) / 100,
      });
    }
  }
  return out;
}

// A circuit through two turnarounds has THREE legs, so the reach radius is a
// third of the target, not a half. Widen only if nothing survives.
let found = loops(target / 3);
if (found.length === 0) found = loops(target / 2.4);

found.sort((x, y) => Math.abs(x.meters - target) - Math.abs(y.meters - target));
export const payload = found.length ? found.slice(0, 5) : { none: "no loop of that length here" };
```

The two thresholds were tuned against real extracts: `target/3` lands within
2 % where `target/2` overshoots by 10–20 %, and genuine loops retrace
0.09–0.19 where a cul-de-sac subdivision scores 0.50 on every circuit it can
build. **"None found" is the right answer** in a subdivision with one way in
and out, and outside towns there is often no pedestrian network to make a
circuit from at all. Cost is about 1 s at a 1,600 m target, 6 s at 3,200 m.

**Report the length it actually came out at, never the length that was asked
for.** 2.3 miles honestly labelled beats 2.0 miles claimed.

To route the loop through query results instead of bare turnarounds (what
`walk_loop`'s `passing` does), remember Result fields are **methods** in
the runtime — `r.name()`, `r.asPoint()` — and snap each match with
`routing.snap()` first: a big park's centroid sits deep inside the
polygon, and the snapped point is the entrance the path network actually
reaches.

## Tag vocabulary in an area

Prefer the `tag_values` tool — it is cheaper. This form is for combining it
with other computation:

`nwr[name](area=colorado)` matches every named feature in the state, which is
well past the 50,000-row cap and returns `TOO_MANY_RESULTS`. Scope it to the
part you are actually asking about:

```ts
const entries = query.execute(
  "nwr[name](area=colorado)(around=5000,39.7392,-104.9903)",
);
export const payload = Object.entries(entries.tagCount())
  .sort(([, a], [, b]) => b - a)
  .slice(0, 20);
```

## Related

- `knowhere://docs/runtime` — the API these use.
- `knowhere://docs/globals` — TypeScript type declarations.
- `knowhere://docs/routing` — what `routing.*` measures, and its limits.


---

# Runtime Type Declarations

Knowhere runtime globals, as actually registered by the sandboxed VM
(see runtime/pool.go). This is the authoritative reference for the
'runtime' MCP tool — knowhere://docs/runtime is the prose API guide and
knowhere://docs/recipes has worked scripts (both also served by the
'docs' tool, slugs 'runtime' and 'recipes').

Note: this is hand-maintained; the shape test suite
(services/runtime_shape_test.go) and `deno check examples/` are what keep
it honest against the Go source.

## Globals

```ts
declare const assert: Assert;
declare const colors: Colors;
declare const geo: Geo;
declare const query: Query;
declare const tags: Tags;
declare const budget: Budget;
declare const address: Address;
declare const routing: Routing;
```

## Properties

```ts
interface Properties {
  [key: string]: string | number | boolean;
}
```

## Feature

```ts
// The live object behind every asFeature() is paulmach/orb's
// geojson.Feature crossing the Go/JS boundary, so it carries more than the
// three GeoJSON keys.
interface Feature {
  type: string; // "Feature"
  properties: FeatureProperties;
  geometry: Geometry;
  id: unknown; // null unless the source feature carried an id
  bbox: number[] | null; // [minLon, minLat, maxLon, maxLat] when present
  point(): OrbPoint; // center of the geometry's bounding box
  // marshalJSON / marshalBSON / unmarshalJSON / unmarshalBSON also cross
  // over (Go (de)serialisers trafficking in raw byte arrays). Of no use
  // from JS — return the feature itself and let the server serialise it.
}
```

## FeatureProperties

```ts
// A feature's properties: the map handed to asFeature(), plus the result's
// tags when appendTags was true. Read keys directly.
interface FeatureProperties {
  [key: string]: unknown;
  // Go helper methods that also cross over. AVOID THEM: each panics when
  // the key is absent and no default is given, and the numeric ones panic
  // on any number that entered through JS (boxed as a type they refuse).
  // The panic is NOT a JS exception — try/catch cannot intercept it, the
  // script just dies. properties["key"] answers the same question safely.
  mustBool(key: string, def?: boolean): boolean;
  mustInt(key: string, def?: number): number;
  mustFloat64(key: string, def?: number): number;
  mustString(key: string, def?: string): string;
  clone(): FeatureProperties;
}
```

## Geometry

```ts
// A raw orb geometry (feature.geometry, bounds.union()). Also array-like,
// holding coordinates in GeoJSON order: a Point is [lon, lat], a
// LineString an array of those, a Polygon an array of rings, a
// MultiPolygon an array of polygons.
interface Geometry {
  geoJSONType(): string; // "Point" | "LineString" | "Polygon" | "MultiPolygon" | ...
  dimensions(): number; // 0 point, 1 line, 2 area
  bound(): OrbBound;
}
```

## OrbPoint

```ts
// A raw orb.Point: array-like [lon, lat] (GeoJSON order) with helper
// methods. Not the wrapper Point above — no asFeature()/asBound().
interface OrbPoint {
  0: number; // lon
  1: number; // lat
  lat(): number;
  lon(): number;
}
```

## OrbBound

```ts
// The raw orb.Bound behind geometry.bound(). NOT the wrapper Bound above:
// no asFeature()/asBB(), extend() takes a point not meters, and pad() is
// in DEGREES. min/max are fields here, not methods. Point arguments accept
// a plain [lon, lat] array or a wrapper Point.
interface OrbBound {
  min: OrbPoint;
  max: OrbPoint;
  center(): OrbPoint;
  contains(point: OrbPoint | [number, number] | Point): boolean;
  intersects(other: OrbBound): boolean;
  extend(point: OrbPoint | [number, number] | Point): OrbBound;
  union(other: OrbBound): OrbBound;
  pad(degrees: number): OrbBound;
  left(): number; // minLon
  right(): number; // maxLon
  top(): number; // maxLat
  bottom(): number; // minLat
  isEmpty(): boolean;
  toPolygon(): Geometry;
  geoJSONType(): string;
}
```

## Assert

```ts
interface Assert {
  geoJSON(payload: unknown): void;
  eq(value: boolean, message: string): void;
  stab(msg: string): void;
}
```

## Colors

```ts
interface Colors {
  pick(index: number): string;
}
```

## Bound

```ts
interface Bound {
  extend(distanceMeters: number): Bound;
  center(): Point;
  intersects(other: Locatable): boolean; // throws on a non-location argument
  contains(point: Locatable | [number, number]): boolean; // raw array is [lon, lat]; throws on a non-location
  min(): [number, number]; // [minLon, minLat]
  max(): [number, number]; // [maxLon, maxLat]
  left(): number; // minLon
  right(): number; // maxLon
  top(): number; // maxLat
  bottom(): number; // minLat
  asFeature(properties?: Properties, appendTags?: boolean): Feature;
  asBB(): string; // "minLon,minLat,maxLon,maxLat" — usable in (bb=...)
}
```

## BoundArray

```ts
interface BoundArray extends Array<Bound> {
  asFeature(properties?: Properties, appendTags?: boolean): Feature;
  asBound(): Bound; // union bounding box
  union(): Geometry; // true polygon union (a MultiPolygon, not just a bbox)
}
```

## Point

```ts
// Also array-like: p[0] is LON, p[1] is LAT (GeoJSON order — the opposite
// of asPoint's argument order). Prefer the methods.
interface Point {
  asFeature(properties?: Properties, appendTags?: boolean): Feature;
  asBound(): Bound;
  lat(): number;
  lon(): number;
}
```

## Area

```ts
interface Area {
  name: string;
  fullName: string;
  minLat: number;
  minLon: number;
  maxLat: number;
  maxLon: number;
  asBound(): Bound;
}
```

## Locatable

```ts
// Anything location-shaped. Every geometry entry point accepts any of
// these and THROWS on everything else — never a silent (0, 0).
type Locatable = Result | Point | Bound | Area | { lat: number; lon: number };
```

## ResultCopy

```ts
// A plain-object copy of a Result ({...r}), recognized by carrying the
// bounding box. The read-only refusal on a Result recommends making one;
// asResults accepts it back.
type ResultCopy = {
  min_lat: number;
  min_lon: number;
  max_lat: number;
  max_lon: number;
};
```

## Geo

```ts
interface Geo {
  asPoint(lat: number, lon: number): Point; // throws if lat/lon out of range
  point(coords: { lat: number; lon: number }): Point; // named keys — order can't be swapped
  bound(box: { minLat: number; minLon: number; maxLat: number; maxLon: number }): Bound;
  asResults(...results: (Result | ResultCopy)[]): ResultArray;
  asBounds(...bounds: Locatable[]): BoundArray; // a point collapses to a degenerate box
  distance(a: Locatable, b: Locatable): number; // meters, between centers
}
```

## Result

```ts
// Reading any property NOT declared here throws UNKNOWN_PROPERTY (with the
// full roster and a did-you-mean) instead of returning undefined — a typo
// fails at the line that made it. Feature-test with ("x" in r); tag access
// (r.tags.anything) is ordinary and stays probe-free. Results are read-only.
interface Result {
  asFeature(properties?: Properties, appendTags?: boolean): Feature;
  name(): string; // method, not a property — reads the "name" tag if present
  id: number;
  osm_id: number;
  osm_type: number; // 1=node, 2=way, 3=relation
  lat: number; // bbox centroid — same value the search tool reports
  lon: number;
  min_lat: number;
  min_lon: number;
  max_lat: number;
  max_lon: number;
  tags: { [key: string]: unknown };
  asBound(): Bound;
  asPoint(): Point;
}
```

## ResultArray

```ts
interface ResultArray extends Array<Result> {
  tagCount(): { [key: string]: number };
}
```

## Query

```ts
interface Query {
  // Both stop at a row cap (50,000 by default) and THROW TOO_MANY_RESULTS
  // rather than returning a shortened array — a truncated set reads exactly
  // like a complete one. Use count() when you only need how many.
  union(...queries: string[]): ResultArray;
  execute(queryString: string): ResultArray; // sync — no await
  count(queryString: string): number; // COUNT(*), no rows materialised
  areas(): Area[];
  // area defaults to the address's state, snake_cased, if omitted.
  fromAddress(fullAddress: string, area?: string): ResultArray;
}
```

## Tags

```ts
interface Tags {
  // the tag_keys / tag_values tools, in-runtime; both ranked by frequency.
  keys(area: string): { key: string; count: number }[];
  values(area: string, key: string): { value: string; count: number }[];
}
```

## Routing

```ts
interface Routing {
  // travel modes this area has a graph for, e.g. ["car", "foot"].
  // Empty means the area carries no routing at all.
  profiles(area: string): string[];
  // travel seconds, or null when unreachable. Cheaper than route():
  // no shortcut expansion, no geometry.
  time(area: string, from: PointLike, to: PointLike, options?: RouteOptions): number | null;
  // one origin against many destinations, sharing one search setup.
  // Aligned with destinations by index; null where unreachable. This is
  // the one to use for ranking — N separate time() calls redo the setup.
  times(
    area: string,
    origin: PointLike,
    destinations: PointLike[],
    options?: RouteOptions,
  ): (number | null)[];
  // full trip: time, distance, snapped ends, and geometry on request.
  route(area: string, from: PointLike, to: PointLike, options?: RouteOptions): Leg | null;
  // resolve a coordinate onto the graph; null if nothing routable is near.
  snap(area: string, at: PointLike, options?: RouteOptions): Snapped | null;
  // every junction reachable ON FOOT within maxWalkMeters, measured along
  // real paths. null only when the origin does not snap onto the network.
  //
  // Walking only, and profile defaults to "foot" here for that reason:
  // the car graph is contracted, where a one-sided search returns the
  // search space rather than the reachable set, so asking for "car"
  // stops the script instead of answering wrongly.
  //
  // Capped at 5000 m — the neighbourhood is held in memory and grows with
  // the square of the radius. Over that throws rather than clamping.
  //
  // This is the raw primitive: thousands of junctions, for bucketing and
  // filtering in a script. For "what places can I walk to" prefer the
  // walk_reach tool, which annotates POIs instead.
  reach(area: string, origin: PointLike, options?: RouteOptions): Reached[] | null;
}
```

## PointLike

```ts
// Routing coordinates share the geometry layer's coercion, so anything
// location-shaped works: a Result straight from query.execute, a Point, a
// Bound or Area (centre used), or a plain {lat, lon} object.
type PointLike = Locatable;
```

## RouteOptions

```ts
interface RouteOptions {
  profile?: "car" | "foot"; // default "car"
  maxSnapMeters?: number; // reject an endpoint further than this from a road
  maxWalkMeters?: number; // foot only; default 10000, max 25000 (reach(): default and max 5000)
  geometry?: boolean; // route() only — off by default, a long route is 1000s of points
  steps?: boolean; // route() only — the road log; costs about half a route again
}
```

## Snapped

```ts
interface Snapped {
  point: Point;
  offsetMeters: number; // how far the coordinate moved to reach the graph
  // The road this landed on. The router resolves this edge to choose the
  // junction, so it costs nothing extra — and it is the answer to "which
  // street is this", measured against the road's real shape rather than
  // its bounding-box centre. null only on an artifact built before the
  // edge-to-way mapping existed.
  road: Road | null;
}
```

## Road

```ts
interface Road {
  osmId: number;
  name: string; // "Dwight D. Eisenhower Highway"; "" on the 73% of ways with none
  ref: string; // route number: "I 70", "US 40"; "" on an unnumbered street
  highway: string; // OSM class: motorway, trunk, primary, residential, service
}
```

## Step

```ts
// One run of a route along a single road. NOT a turn instruction: there
// are no turn angles, no lane tags and no "turn left". Consecutive
// stretches of the same road are one step, and slip roads are folded
// into the road they lead onto.
interface Step {
  road: Road;
  atMeters: number; // how far into the trip this road begins; steps tile
  atMiles: number;
  meters: number;
  miles: number;
  via: string; // the motorway junction reached through, e.g. "exit 232"; "" if none
}
```

## Reached

```ts
interface Reached {
  point: Point;
  seconds: number;
  minutes: number;
  // Network distance derived from cost at walking pace, not measured from
  // geometry — unpacking polylines for thousands of nodes would cost more
  // than the search. Exact enough to pick candidates; use route() when a
  // distance is going to be reported to someone.
  meters: number;
  miles: number;
}
```

## Leg

```ts
interface Leg {
  seconds: number;
  minutes: number;
  meters: number; // along the road, not straight-line
  miles: number;
  from: Snapped;
  to: Snapped;
  geometry: Point[]; // empty unless options.geometry was set
  steps: Step[]; // empty unless options.steps was set
}
```

## Budget

```ts
interface Budget {
  remainingMs(): number; // ms until the VM is interrupted at the deadline (0 if past)
  elapsedMs(): number; // ms this call has been running
}
```

## URL

```ts
// The only web globals in the VM: WHATWG URL parsing (from goja_nodejs).
// There is no fetch — the runtime cannot reach the network.
declare class URL {
  constructor(url: string, base?: string);
  href: string;
  protocol: string;
  host: string;
  hostname: string;
  port: string;
  pathname: string;
  search: string;
  hash: string;
  searchParams: URLSearchParams;
  toString(): string;
}
```

## URLSearchParams

```ts
declare class URLSearchParams {
  constructor(init?: string);
  get(name: string): string | null;
  getAll(name: string): string[];
  has(name: string): boolean;
  set(name: string, value: string): void;
  append(name: string, value: string): void;
  delete(name: string): void;
  toString(): string;
}
```

## Address

```ts
interface Address {
  // Returns [parsedParts, found]. found is false (and parsedParts empty)
  // when the address could not be parsed.
  parse(fullAddress: string): [{ [key: string]: string }, boolean];
}
```

## input

```ts
// ---------------------------------------------------------------------------
// Checks
//
// These apply only to a script run as a CHECK (server/checks) — a question
// asked once and then run against many addresses. A plain `runtime` tool call
// has no `input` and may export any JSON it likes.
// ---------------------------------------------------------------------------
// The house being checked. Go resolves the address and binds this before the
// VM runs, because the runtime has no geocoder and a script that guessed its
// own anchor would measure every number from the wrong place.
declare const input: CheckInput;
```

## CheckInput

```ts
interface CheckInput {
  address: string; // exactly what was typed
  label: string; // what the geocoder resolved it to
  lat: number; // the anchor. Measure from here.
  lon: number;
  area: string; // snake_case; every query and every route needs it
  kind: "address" | "poi" | "street";
  // False when this area's build carries no foot graph. A walking criterion
  // must then report "unknown" rather than "fail": the house is not wrong,
  // we simply cannot measure it.
  walkGraph: boolean;
  // Present only when the target was a listing URL.
  listing?: CheckListing;
}
```

## CheckListing

```ts
interface CheckListing {
  source_url: string;
  price?: number;
  beds?: number;
  baths?: number;
  sqft?: number;
  lot_size?: number;
  lot_units?: string;
  year_built?: number;
  water_source?: string;
  sewer?: string;
  hoa_fee?: number;
  hoa_frequency?: string;
  taxes_annual?: number;
  days_on_market?: number;
}
```

## Criterion

```ts
// One named finding.
//
// `status` is OPTIONAL, and that is the point: a criterion carrying one is a
// test, a criterion carrying only a value is a measurement. Omit it whenever
// the question has no threshold ("how many", "how far") rather than inventing
// one — measurements are what make a column sortable across addresses.
interface Criterion {
  name: string;
  status?: "pass" | "fail" | "unknown";
  // Omit entirely when there is nothing to measure. Never write 0 as a
  // placeholder: "0 min walk" renders as the BEST possible answer.
  value?: number | null;
  unit?: string;
  // Built from the numbers just computed, so it cannot contradict the value
  // printed beside it.
  detail?: string;
  // Evidence: where the thing that matched actually is. Dropped by the server
  // if it is further than 200km from the anchor, because at that distance it
  // names something else.
  at?: { lat: number; lon: number };
  items?: CriterionItem[]; // capped at 25
}
```

## CriterionItem

```ts
interface CriterionItem {
  label: string;
  value?: number | null;
  unit?: string;
  at?: { lat: number; lon: number };
}
```

## CheckPayload

```ts
// What a check exports. Note there is no overall pass/fail: the server
// derives it (any fail -> fail, else any unknown -> unknown, else pass), so a
// summary boolean can never disagree with the criteria it summarises.
interface CheckPayload {
  criteria: Criterion[]; // at least one, at most 12
  // Anything else, stored and returned untouched. The escape hatch for what
  // the criteria shape cannot express.
  data?: unknown;
}
```



---

# Error Codes

A failed tool call comes back as:

```
<CODE>: <message> (hint: <one-line guidance>)
```

The code is stable and machine-readable — branch retry logic on it. The hint
is one sentence of self-correction guidance, for a human.

## Query

**`AREA_REQUIRED`** — every query must name one area. Add `(area=<snake_case>)`
or `"$area": "<snake_case>"`. From `geocode` it means something else: that tool
takes an `area` parameter and has no directive syntax at all, so pass `area`,
name a state in the text itself, or resolve the area first with
`list_areas(filter: "<place name>")`.

**`AREA_NOT_FOUND`** — no such area here. The message lists up to five close
candidates; `list_areas` is authoritative. Names are `snake_case` and
lower-case (`new_york`, not `New York`).

**`SYNTAX_INVALID`** — bracket syntax would not parse: missing bracket,
unknown element type, malformed operator. See `knowhere://docs/query`.

**`INVALID_JSON`** — the query starts with `{` but is not valid JSON. Check
trailing commas and quoting, or rewrite in bracket syntax.

**`UNSUPPORTED_DIRECTIVE`** — a top-level `$key` that is not `$type`, `$area`,
`$bb`, `$around`, `$id`, or `$and`. Tag filters use plain keys, no `$`.

**`UNSUPPORTED_OPERATOR`** — an unknown tag operator, or `$regex` with
modifiers. Operator list is in `knowhere://docs/query`.

**`INVALID_OPERATOR_VALUE`** — right operator, wrong value type: `$in` with a
non-array, `$exists` with a non-boolean, `$gt` with a string.

**`INVALID_BOUNDING_BOX`** — not an array, not a multiple of four
coordinates, or inverted. Use `[minLon, minLat, maxLon, maxLat]` with
`min <= max`.

**`INVALID_COORDINATES`** — latitude outside `[-90, 90]`, longitude outside
`[-180, 180]`, or `NaN`/`Inf`. Check the ordering too: geometry is
**longitude first**.

**`QUERY_TOO_LONG`** — the generated SQL passed 1,000,000 characters. Narrow
with more specific filters, a smaller area, or a bounding box.

**`QUERY_TIMEOUT`** — same fix. `count_only` and a tight `(bb=…)` are the
cheapest narrowings.

**`TOO_MANY_RESULTS`** — the query matched more rows than the server will hold
in memory at once. It is refused rather than truncated: truncation would hand
back a *wrong* answer that reads exactly like a complete one. Narrow the area,
add tag filters, or split the work by bounding box.

The cap depends on which tool raised it. From `runtime`'s `query.execute` and
`query.union` it is 50,000, and `query.count()` answers the how-many question
with no cap at all. From `near` it is 100,000 per side, the message names
which side overran, and there is no count-only equivalent — narrow the `find`
or `near` query itself.

**`QUERY_EXECUTION`** — the SQL engine could not run it. Usually server-side;
retry after adjusting, and if it persists the server may be misconfigured.

**`QUERY_UNKNOWN`** — a category unknown to the classifier. Read the message.

## Routing

Behaviour behind these is in `knowhere://docs/routing`.

**`NO_ROUTING_DATA`** — the area has no road graph, because it was built with
routing disabled. Straight-line tools still work.

**`NO_ROUTE`** — both ends snapped, but nothing connects them. Almost always
different areas; routing never crosses an area boundary. On `profile: "foot"`
it can also be an isolated footway, which is common in OSM.

**`NO_ROAD_NEARBY`** — nothing usable within `max_snap_meters` (default
2000). Usually the coordinate is not in the area you named, and the message
says which area does contain it — re-run against that one rather than raising
the ceiling.

**`BEYOND_RANGE`** — a route exists but is longer than the mode allows.
Walking is capped (`max_walk_meters`, default 10,000, max 25,000). Raise the
cap or use `profile: "car"`.

**`NO_ISOCHRONE`** — reachability was asked for over the driving graph. It is
walking-only, and this refuses rather than returning a search space that
looks like a reachable set. For driving, name destinations and use
`drive_times`.

**`REACH_TOO_LARGE`** — `max_walk_meters` above the 5 km reachability cap.
Lower it, or use `route` with `profile: "foot"` for one long trip.

**`NO_PLACE_NEARBY`** — from `reverse_geocode`, nothing named within the
radius; open water and wilderness genuinely have no answer, otherwise raise
`max_meters` (widening is cheap — the search starts at 30 m). From `geocode`,
nothing matched the text: house numbers match exactly, and OSM has roughly a
quarter of US house numbers, so retry without the number for a street-level
answer. A named feature needs at least 3 characters to be searched at all.
When most — not all — of a name's words match something, `geocode` returns it
flagged `partial_name: true` instead of failing; a query that still gets this
error shares too little with anything indexed for even a flagged guess.
`geocode` searches one area at a time, so a place that plainly exists but is
not found is often in another area — `list_areas` with the name as `filter`
reports which one holds it.

## Runtime

**`PAYLOAD_TOO_LARGE`** — the exported `payload` exceeded the size cap after
JSON encoding. Aggregate, slice, or project inside the script — counts, a tag
histogram, the top N — instead of the full result set.

**`PAYLOAD_NOT_SERIALIZABLE`** — the payload holds a function, `undefined`, or
a circular reference. A `Result`'s `name`/`asBound`/`asPoint`/`asFeature` are
methods: call them.

**`UNKNOWN_PROPERTY`** — the script read a property a `Result` does not have
(`r.lat` exists; `r.minLat` and `r.center` do not). In plain goja that read
would return `undefined` and flow NaNs silently into the payload; a `Result`
throws instead, and the error carries the full roster of properties and
methods plus a did-you-mean. Feature-test with `("x" in r)` — the `in`
operator never throws. Tag values are exempt: `r.tags.anything` is an
ordinary object access and `undefined` for an absent tag is normal.

## Zillow

**`ZILLOW_BAD_INPUT`** — empty `bounding_boxes`, or a box that is not exactly
`[minLat, minLon, maxLat, maxLon]`. Latitude first — this tool is the
exception.

**`ZILLOW_UPSTREAM`** — the upstream request failed or returned an unexpected
shape. Usually transient: retry, or narrow the boxes.

## Correction loop

1. Call the tool.
2. If the response starts with `<CODE>:`, branch on the code.
3. `AREA_NOT_FOUND` — retry with the closest candidate, or `list_areas`.
4. `SYNTAX_INVALID` / `INVALID_JSON` — reread `knowhere://docs/query`.
5. `INVALID_COORDINATES` / `INVALID_BOUNDING_BOX` — check `[lon, lat]` order
   and range.
6. `QUERY_TIMEOUT` / `QUERY_TOO_LONG` / `TOO_MANY_RESULTS` — narrow, or use
   `tag_values` to find a more selective filter; for a count alone use
   `query.count()`.
7. `NO_ROUTE` / `NO_ROAD_NEARBY` — check both ends are in the same area;
   `NO_ROUTING_DATA` — fall back to `near`.
8. `PAYLOAD_TOO_LARGE` — export less.

## Related

- `knowhere://docs/query` — syntax reference.
- `knowhere://docs/routing` — what the routing limits are and why.
- `knowhere://docs/schema` — coordinate and area-name conventions.


---

# Changelog

Newest first. Dates are when the change reached the serving database — not when
the code landed. Anything built but not yet serving belongs under an
`## Unreleased` heading, and takes a date only once it is deployed.

That distinction is not pedantry. Two entries below were once dated for a day
they had not reached: the artifact serving on that date carried neither, and a
query for the tags they promised answered zero rows rather than an error. A date
here is a claim about the database a caller is talking to.

## Unreleased

- **Resolving a place name in `list_areas` is no longer slow.** Asking
  `list_areas` for "seattle" — the first call the golden path tells you to make
  — took 2.7 seconds on the serving database, because a name that is not an
  area was looked up by searching all sixty-five areas in turn, one text query
  each. It now reads the cross-area place index the build already writes, which
  is one query for all of them. The per-area search stays behind it, for a
  database built before that index existed and for an area the index does not
  cover, so nothing that resolved before stops resolving.

- **A city resolves to its centre, not to the middle of its bounding box.** A
  place is often two entries in OpenStreetMap: a point where a mapper marked the
  centre, and an outline of its administrative boundary. We stored one
  coordinate for each, and for the outline that coordinate was the centre of its
  bounding box — which for a city shaped like Aurora, reaching east to the
  airport, is 13 km from anywhere anyone lives. The marked point now wins.
  Affects 237 places in colorado alone, which sat a median of 1.7 km from their
  own boundary's box centre.

## 2026-08-27

- **A bare postal code now geocodes.** `geocode` with nothing but `80202` used
  to fail — every lane read the digits as a house number with no street, or as a
  name — and now resolves to the centre of the addresses carrying that code.
  Results come back with `kind: "postcode"`. That centre covers a whole region,
  so the brief and check pipelines refuse it for the same reason they refuse a
  town centre: any distance measured from it would be wrong.

- **Accented street names are findable by their unaccented spelling.** Searching
  "Canon City" used to return a point 50 km from Cañon City, confidently. Street
  names are now stored with accents folded, matching the search index, which had
  been folding them all along.

- **A named feature no longer loses to the signage named after it.** "Denver
  International Airport" returned a `tourism=information` welcome sign 7.4 km
  from the aerodrome, because both matched the name exactly and nothing else
  separated them. Features OpenStreetMap marks as canonical now win that tie.
  901 colorado names are decided by it.

- **A `geocode` result for a place carries `osm_id`, `osm_type` and a bounding
  box.** It can be handed straight to `search` or `near` instead of being looked
  up again by hand. Absent fields mean the lane that answered cannot say.

- **Postal-code centres are more accurate.** They were the mean of every address
  carrying the code, which a handful of mistagged rows dragged a long way — 91
  of colorado's 417 well-populated codes moved by more than 1 km when this was
  corrected, and the worst by 14.5 km.

- **New Mexico's addresses are New Mexico's.** An OpenAddresses file published
  as Sandoval County, New Mexico actually contained San Diego County, California
  — 1.1 million addresses, five degrees west of the state. They had been loaded
  as New Mexico addresses in every build, which is 44% of that area's addresses;
  searching or measuring to one gave a confident wrong answer. Addresses that
  fall outside the area they are being loaded into are now refused, and the
  build says how many and from which source.

- **A broad tag with a tight radius is no longer slow.** A query like "every
  amenity within 30 m of this point" had to read every amenity in the state and
  check each one — 597 ms on colorado through the serving path, and worse on
  larger areas. It now reads only the features near the point: 2.3 ms for the
  same query. Wide-radius searches are unchanged. No answers change.

- **Tag searches naming a value are faster.** A filter like `[shop=yes]` used to
  fetch about 43x more rows than it needed, because `yes` is a value on millions
  of features. Measured on the serving path, `[shop=yes]` on colorado went from
  205 ms to 21 ms. No answers change, including for the few hundred tag values
  that contain an `=` of their own.

- **`near` reports an over-large side as `TOO_MANY_RESULTS`, not
  `PAYLOAD_TOO_LARGE`.** The cap (100,000 features per side) is unchanged in
  value but is now applied inside the query rather than counted after both sides
  were already in memory, and the message names which of `find` or `near`
  overran.

- **`walk_reach` and `walk_loop` inherit the 50,000-row cap** on the query that
  selects their candidate features. A `find` matching more than that is now an
  error rather than an unbounded read.

- **Results from `query.execute` and `query.union` come back in a different
  order.** Neither ever promised one, and none of the tools built on them read
  it — but a script that happened to rely on the old order will see a different
  arrangement of the same rows. Sort explicitly if the order matters. (The old
  order was produced by a sort over two constant columns, which cost roughly
  half the query's time to compute nothing.)

- **A script can be stopped when the server is nearly out of memory.**

  The row cap bounds one query. It cannot see what a script builds _from_
  several capped queries — an index keyed by grid cell, a list accumulated
  across a loop — and that is what actually exhausted the server: three live
  structures at once, only two of which were query results.

  A `runtime` script that drives the Go heap past its ceiling (512 MB by
  default) is now interrupted with "server memory is nearly exhausted" instead
  of the process being killed. Hold less at once: query a smaller area, or
  aggregate as you go rather than collecting every row and filtering afterwards.

  A script blocked inside a query is not interruptible this way — that half is
  what the row cap is for.

- **A query that matches too much is refused rather than truncated.**

  `query.execute` and `query.union` in the `runtime` tool had no row limit at
  all. A statewide union of `building=warehouse`, `building=industrial` and
  `building=factory` matched enough rows to exhaust the server's memory: the
  process was killed and the site was unreachable for twenty minutes.

  Both now stop at 50,000 rows and return `TOO_MANY_RESULTS` instead of an
  answer. That is deliberately an error and not a quiet truncation — a script
  asking which industrial buildings have rail going in and none coming out
  computes a _wrong_ answer from a truncated set, not a partial one, and nothing
  in the result would have told it which it got.

  If you hit it: narrow the area, add tag filters, or split the work by bounding
  box. If you only need how many there are, `query.count()` never materialises a
  row and has no cap.

- **One index for every area, instead of sixty-five.** _(needs a rebuild — the
  code is in, the speed arrives with the next database build.)_

  Every autocomplete keystroke and every geocode without a state asked a
  question no single area can answer: which of the sixty-five has a street
  starting like this? The only way to ask was to ask each area in turn, and the
  server is one shared core reading a compressed 37 GB file, so the answer was
  routinely "9 of 65 areas, nothing found".

  The database now carries one street index and one place index covering every
  area, beside the per-area ones. Measured on a 65-area database through the
  same read path, on one core:

  | what a keystroke does  | before                      | after                  |
  | ---------------------- | --------------------------- | ---------------------- |
  | `blake st`             | 203 ms, 6-7 of 65 areas     | **40 ms, all 65**      |
  | `3101 blake st`        | 212-220 ms, 3-4 of 65 areas | **138-149 ms, all 65** |
  | `east colfax ave`      | 159-162 ms, 5-7 of 65 areas | **57-71 ms, all 65**   |
  | `washington sto`       | 296-337 ms                  | **41-65 ms**           |
  | `3101 blake st denver` | 41 ms                       | 41-50 ms, unchanged    |

  The important column is not the milliseconds, it is "of 65". A search that
  read a dozen areas and stopped had to say so — that is the "searched N of 65
  areas" note under the dropdown — and a street in one of the other fifty simply
  did not appear. Cross-area searches are now complete rather than partial, so
  the note goes away.

  **Geocoding a city without its state now works.** `geocode` used to refuse
  "3101 Blake St, Denver" and ask for a state, because resolving "Denver" meant
  probing all sixty-five areas on the critical path of every lookup. With one
  place index it is a single query, so the address resolves as written.

  Two things deliberately kept: a lookup that already knows its area still reads
  that area's own tables, which are a smaller haystack (3 ms against 11); and a
  database built before this index still works, falling back to the old fan-out,
  so the code and the data can ship on their own schedules.

  The new index is about 1% of the artifact.

- **Autocomplete now narrows before it searches, which is why it answers at
  all.** Typing `3101 blake st denver` into the address field returned nothing,
  with a note saying 9 of 65 areas had been searched — on an address the
  database holds. Everything about that was a cost problem rather than a parsing
  one.

  What it costs to answer a keystroke, measured on a 65-area database through
  the same compressed read path the server uses, on one core — which is what the
  server has:

  | what the search does                                | cost   |
  | --------------------------------------------------- | ------ |
  | read every area's street index                      | 190 ms |
  | read one area's street index                        | 3 ms   |
  | read every area's place index (which city is this?) | 22 ms  |

  The search used to read every street index first and only work out the city if
  that came back empty. On this hardware that spends the entire keystroke
  establishing nothing, and leaves the reading that would have answered with no
  time to run. It now asks the cheap question first: which areas have a Denver?
  Then it searches those. Same work, opposite order.

  What changed for a person typing:

  - `3101 blake st denver` — was an empty list, now the address, complete rather
    than partial.
  - `3101 blake st denver co` — a state costs no lookup at all, because the list
    of states is already in memory. About 15 ms end to end.
  - `3101 blake st` — with nothing typed yet to narrow on, this is unchanged:
    the search reads what it can inside its budget and says how far it got.
  - The budget itself went from 150 ms to 400 ms, with the search-everything
    pass still capped at the old 150. An answer at 400 ms beats an empty list at
    150, and the fragments that can be narrowed now finish well inside it.

  What this does not fix: a fragment with no city in it still cannot read 65
  street indexes inside any sensible budget. The structural answer is one index
  covering every area instead of 65 — measured at 10 ms against 190 — and it is
  the entry above this one, arriving with the next database build.

- **Address autocomplete now finishes the address, however it is written.**
  Autocomplete is the dropdown under the address field on `/briefs`: you type
  part of an address and it offers the real ones it could become, each carrying
  the coordinate it resolved to. It reads what you type with its own small
  parser — the full-address parser the geocoder uses refuses anything it cannot
  read whole, which is every keystroke before the last one — and that parser
  only understood a few of the ways people write an address.

  Measured against a real Colorado database: 120 addresses that are in it, each
  typed thirteen ways. Five of those ways used to fail, two of them completely —
  **0 of 120** — and every one of them is an ordinary thing to type. All
  thirteen now return the address.

  What was broken, and what it does now:

  - **A postal code at the end emptied the dropdown.**
    `150 s havana st
    aurora co 80012` — a complete, correct address —
    returned nothing, because the code and the state were searched for as part
    of the street name. The state and the code are now recognised and taken off
    first, and the city is worked out from what is left. 0 of 120 before, all
    120 now.
  - **Cities of three words were unreachable without a comma.**
    `Commerce
    City North`, `Old Colorado City` and `Piney Creek Ranches` are
    real places; the field guessed at most two trailing words as a city name.
    Recognising the state and postal code separately freed the guess to run to
    three words.
  - **A unit written before the address hid it.**
    `Apt 4B, 1500 Illinois St,
    Denver` is how a delivery form and a mail
    merge write it, and the field read `Apt 4B` as the whole address. The unit
    is now lifted off the front.
  - **Abbreviations that are not the start of the word they stand for.**
    `2550 h rd` could not reach `2550 H Road`, because a half-typed word is
    matched as a prefix and `rd` is not the start of `road`. The same applies to
    `ln`/`lane`, `ct`/`court`, `hwy`/`highway`. Both spellings are now searched,
    so a person typing either finds the street.
  - **A post office box offered nonsense.** `PO Box 417, Aurora, CO` returned a
    list of unrelated streets, because the box number was read as a house number
    and the search shortened `po box 417` until something matched. A box is a
    mail destination, not a place the database can locate, so the dropdown is
    now quiet.

  Two more shapes that failed, both found by reviewing the fix rather than by
  typing addresses:

  - **Connecticut and Nebraska were invisible.** Before the state is looked up,
    a street fragment is folded onto the spelling the database stores — which
    rewrites "ct" to "court" and "ne" to "northeast". Those are the only two US
    state codes that are also street words, so
    `100 asylum st hartford
    ct 06103` and `1234 farnam st omaha ne 68102`
    failed in exactly the way the headline fix was supposed to repair. The state
    is now read off the words as typed.
  - **A state with no city after it narrowed nothing.** `washington st co`
    resolved the "co" and then threw it away, because a bare two-letter locality
    is deliberately refused — "de" is Delaware and also the second keystroke of
    Denver. The state now comes back under its full name, which that check
    accepts.

  Two things it can do that it could not before:

  - **A typed postal code decides between identical addresses.** Where the same
    house number sits on the same street name in two places, the code sorts the
    matching one to the top — across the whole answer, not just within one
    state's share of it. It sorts rather than filters: the database's postal
    codes come from whichever source published each address, so requiring one
    would hide addresses that simply have none. Both sides are compared with
    their case and spacing removed, so a Canadian code typed `h3b4w8` matches
    the stored `H3B 4W8`.
  - **Intersections complete.** `washington st & wino` now offers
    `Washington
    Street & Winona Court`, which is the form the geocoder
    resolves to the point where the two roads meet. The first street is named as
    the database spells it, not as it was typed — `colfax & wino` reads back as
    `East
    Colfax Avenue & …`. Only roads that pass through the same ~20 km
    grid cell are paired, which rules out no crossing that exists and rules out
    every Colfax-in-Denver-with-a-Broadway-in-Cortez. Where the crossing cannot
    be found at all, the street that is offered instead is marked as not
    precise, so a brief re-checks the address rather than measuring from a point
    on one of the two roads.

  One thing outside the dropdown changed with it: the geocoder read
  `PO Box
  417, Denver, CO` — a box with no postal code after it — as house
  number 417 on a street called "PO Box", and looked that street up. It now
  reads the box and answers with the place, which is the most a box can resolve
  to. The second half of that was found by geocoding a box against the running
  server rather than by reading the code: recognising the box was not enough,
  because the reading that produced it had also lost the state, and the lookup
  then refused for want of an area on an address that names one.

  What it costs. Every re-reading of the input runs only after the plain reading
  has already come back with nothing, inside the same time budget. Measured over
  the same 120 addresses typed one character at a time, arms alternated three
  times: the middle keystroke went from 1.25 ms to 1.35 ms and the 95th from
  3.69 ms to 3.79 ms, while the share of keystrokes answering with an empty list
  fell from 36.2% to 30.7%. About a tenth of a millisecond, for one keystroke in
  eighteen that used to show nothing.

  Both measurements are in the repository — `TestSuggestWrittenForms` and
  `BenchmarkSuggestKeystrokes`, run against a built database — rather than taken
  once and written down here.

- **Addresses outside the US now parse.** "Parsing" here means splitting a typed
  address into its parts — house number, street, city, postcode — which is what
  the geocoder needs before it can look anything up. That splitting is done by a
  set of hand-written patterns, one per address shape, and it only ever knew the
  shapes somebody had written down: mostly American ones. Measured against a
  12,832-address reference set, **66.98% of addresses parsed exactly right; it
  is now 76.15%** — about 1,180 more addresses.

  Where the gains are. The United Kingdom went from 12% to 56%, France 14% to
  92%, Australia 15% to 80%, Hungary 2% to 82%, New Zealand 4% to 59%, Russia
  49% to 75%, Luxembourg and Taiwan from nothing at all to 88% and 70%. Taiwan
  is the clearest case: 177 addresses, none of them written in Latin script, and
  every existing pattern needed a comma, a US state or a numeric postcode to
  find its footing.

  Nothing was traded away to get it. The countries the patterns already read
  well are unchanged or better — Denmark 99.7%, Norway 100%, Lithuania 97%, the
  United States 93%.

  Four things did it. **A postcode shape for the UK**, which is the only anchor
  a British address reliably offers and which nothing understood. **Address
  orders that were simply missing** — the house number written before the street
  with the postcode after it (France), the city written first (Hungary), the
  postcode written last (Russia). **Punctuation in town names**: "lauf
  a.d.pegnitz", "zell (mosel)" and "sulzbach/saar" are ordinary German
  municipalities, and the city field accepted only letters and hyphens, so those
  addresses failed entirely and fell through to a pattern that read the trailing
  "de" as _Delaware_. And **separating a suburb from its city**, which is worth
  doing because the city is the field the search area is inferred from: "50181
  bedburg kaster de" is Bedburg, not "Bedburg Kaster".

  Some fields still cannot be recovered without a place-name database, which is
  the honest limit of a pattern-based approach. Germany sits at 80% rather than
  higher for exactly that reason.

- **Address autocomplete no longer needs a comma.** Typing
  `3101 blake st denver` used to return nothing at all. The field read the whole
  string as a street name — it looked for a street literally called "Blake
  Street Denver", which does not exist — so an address that is in the database
  came back with an empty dropdown. It now reads the input a second way whenever
  the first way finds nothing, taking the last word or two as a place rather
  than as part of the street: `denver`, `denver co` and `denver colorado` all
  work, with or without the comma, and the search narrows to the areas that
  carry that city exactly as the comma'd form already did. Two safeguards. The
  second reading only runs when the plain one found nothing, so a half-typed
  street is never mistaken for a city — `washington sto` still completes to
  Washington Stone Lane instead of jumping to Stockton, California. And when the
  trailing word names a place the database has never heard of (a typo, or a town
  too small to carry a record), the field drops it and shows the street anyway,
  which is a near miss rather than an empty list. Both readings share one time
  budget, so this does not make a keystroke slower to answer.

- **Address autocomplete answers streets and addresses only.** The dropdown used
  to offer settlements and postal codes as well. Both resolve to a centroid —
  the middle of a town, or the average of every address sharing a code — and a
  brief measured from one is wrong in every number, so the form refused them on
  submit. They were rows that could not be acted on. What the places index is
  genuinely good for on this surface is narrowing: name a city after the street
  and the search covers the areas that carry that name instead of all 65, which
  is unchanged.

## 2026-08-22

- **`walk_loop`: walking loops as a first-class tool.** "I want to walk a mile
  in a loop" was a documented runtime recipe (#62); it is now one call.
  `walk_loop` spreads turnaround points around the origin and rejects any
  circuit that retraces more than 25% of itself — the tuned thresholds from the
  recipe, unchanged. Two things the recipe never had: a **time target**
  (`target_minutes=60` is "walk for an hour", at 5 km/h), and **themed loops**
  via `passing` — a Knowhere query the circuit routes through, so "a mile loop
  that sees all the parks" is `passing="nw[leisure=park](area=...)"`. Passing
  matches are snapped to the path network (a park with no path within 150 m is
  not somewhere a walk can pass) and visited in a bearing sweep, all of them
  when the full circuit holds up, pairs of them otherwise. Each loop reports the
  length it **actually** came out at, and `none` with a reason is a real answer
  — a cul-de-sac subdivision genuinely has no mile loop. Foot-only, within one
  area, target capped at 10 km. Works against the artifact already serving; no
  rebuild involved.

- **Address autocomplete on the brief form.** The address field on `/briefs` now
  completes as you type — settlements, postal codes, streets and house numbers,
  across every area in the artifact rather than one you have to name first.
  Picking a suggestion carries the coordinate it resolved to into the brief, so
  the brief measures from what you chose instead of geocoding the text again and
  possibly landing on a different street of the same name. This is a web-form
  feature: no MCP tool changed, and `geocode` answers exactly what it answered
  before.

  Two columns were added to the artifact to support it —
  `{area}_street_names.rank` (how many addresses and road segments a name
  carries, which is what orders the suggestions) and a second `alias` column on
  `{area}_street_search` holding the name without a leading directional or
  French type word, so "Colfax" finds "East Colfax Avenue" and
  "Sainte-Catherine" finds "Rue Sainte-Catherine Ouest". Canadian postal codes
  complete too, with or without the space they are written with.

  A typed unit does not derail it: "551 Castle Dr Apt 6" falls back to the
  building rather than matching nothing, and a building says how many units its
  source listed. Which unit is not offered — nothing downstream measures
  per-unit, so a drive time from apartment 4B is a drive time from the building.

  **A comma narrows the search.** "Main St, Golden" or "Main St, CO" searches
  only the areas that carry that name — at most three — instead of all 64. That
  is both faster per keystroke and the difference between one Main Street and
  every Main Street on the continent. A city and a state written together ("Main
  St, Golden, CO") works too. A locality nothing answers to costs nothing: the
  search falls back to every area. Note that `areas_total` in the reply is how
  many areas the query was meant to cover, so a narrowed search that finished
  reports complete rather than partial.

  **Unit counts changed with this build.** A building's units are stored as bare
  identifiers now — "6", not "Unit 6". Sources spell one door several ways in
  the same file, and the step that counts a building's doors is a SQL
  `DISTINCT`, which read "Unit 6" and "6" as two. Measured on colorado, 3,046
  addresses carried both spellings, so their counts were too high and their unit
  lists held the same door twice. Both are right now. The cost is that the
  designator words ("Apt", "Ste", "#") are no longer kept, so the artifact can
  say a building has six doors but not that door six is a suite rather than an
  apartment.

  Nothing has to be in the list for the form to work. A "Use what you typed" row
  is always the last option, and picking it submits the address exactly as
  written — coverage is not total, so an address the artifact has never heard of
  is a normal case rather than a refusal.

  **The artifact's schema version is now 3.** The database serving this was
  rebuilt from scratch to carry it; a server pointed at a version 2 artifact
  refuses to start rather than answering the streets lane with an error on every
  keystroke.

## 2026-08-21

- **`route` and `snap` now say which road you are on.** Every `route` reply
  names the road each endpoint landed on — `from.road` / `to.road`, carrying the
  OSM way id, the street name, the route number (`ref`, e.g. "I 70") and the
  road class (`highway`, e.g. motorway or residential). The router had always
  worked this out in order to pick a junction and then thrown it away, so
  callers who needed a street name went back to `search`, where a road is
  indexed by its bounding box: stand at one end of a kilometre of Main Street
  and its box centre is 500 m away, behind every side street you are not on.
  Asking the road network instead measures to the road's real shape. Costs
  nothing extra. Also on `routing.snap()` and `routing.route()` in scripts.

- **`route(steps: true)` returns the road log.** The list of roads a trip runs
  along, in order, each with the distance it begins at and the motorway exit it
  was reached through:

  ```
  US 6  at 2.2 km  |  I 70 at 17.4 km  |  US 40 at 63.6 km (via exit 232)
  ```

  Consecutive stretches of the same road collapse into one entry, and slip roads
  fold into the road they lead onto, so a hundred-kilometre drive is a handful
  of lines rather than a few thousand. It is **not** turn-by-turn — no "turn
  left", no lane guidance, no turn angles. It answers "which roads does this
  drive use", which previously meant sampling the returned polyline every couple
  of kilometres and running a radius query per sample: 54 queries for one Denver
  drive, about 40% of which came back with no label. Opt-in, because it costs
  roughly half a route again.

- **`list_areas` stops quietly picking between places of the same name.**
  Settlement names repeat — there is a Winter Park in Colorado and another in
  Florida — and filtering for one used to return whichever ranked highest by
  place type, with nothing to say the other existed. It now returns
  `resolved_candidates`, every place that matched, best first, and a note saying
  the top one was a guess and on what grounds. New `near_lat` / `near_lon`
  parameters break the tie by distance instead, which is the right answer when
  the caller knows where they are asking from. The chosen answer has not
  changed; what changed is that the alternatives are visible.

- **A failed query inside `runtime` explains itself.** Scripts that hit a bad
  query were interrupted with `could not execute query: "<your query>"` — the
  string the caller already had, and no error code. The same query through
  `search` reported `SYNTAX_INVALID` with a hint. The sandbox now uses the
  server-wide `<CODE>: <message> (hint: …)` format like everything else. Parse
  errors also name the character they stopped at and, for the near-misses, the
  spelling that works: `[name~Berthoud]` now says the contains operator is `=~`,
  instead of failing with "unparsable query" and nothing further.

- **House numbers from OpenAddresses.** Address coverage previously came from
  OpenStreetMap alone. OpenAddresses — an open aggregation of government address
  registers — is now folded in alongside it, which is roughly eight times the
  address rows. Sources whose licence is share-alike are refused outright rather
  than mixed with ODbL data, so a few municipalities are deliberately absent;
  `knowhere://attribution` lists what a given database actually contains.
  Addresses that exist in both sources are collapsed, and the units of one
  building fold into a single address row carrying its unit list rather than one
  row per apartment.

- **The FEMA tags that 2026-08-18 announced are actually present.**
  `fema:zone_subtype` and the eighteen `nri:*` hazard ratings were documented on
  2026-08-18 but never reached a served database — see the correction in that
  section. They are in this build: on colorado, `fema:zone_subtype` covers
  2,049,847 entries. `fema:zone_subtype` is what makes zone X readable,
  separating the 500-year floodplain from genuinely minimal hazard.

- **The `g` / `"geojson"` element type is gone.** Queries could name a fourth
  element type alongside `node`, `way` and `relation`, which selected features
  imported from a GeoJSON file rather than an OpenStreetMap extract. No served
  area was ever built that way — every one of the 65 areas comes from a PBF
  extract — so `g[...]` and `{"$type": "geojson"}` only ever matched zero rows.
  Both now fail as an unknown type instead of returning nothing, and `*` means
  the three real types. Nothing that returned results before returns fewer.

- **The artifact is about a third smaller, and wide-area search is far faster —
  but one shape of query got slower.** The database was rebuilt around a
  different spatial index, and the trade is uneven enough to be worth stating in
  full rather than summarising as a win.

  What got faster is anything covering real ground. Measured on colorado:
  `amenity=cafe` within 20 km went from 725 ms to 9.6 ms, a restaurant search
  within 1 km from 88 ms to 37 ms, within 200 m from 10.2 ms to 6.9 ms. Through
  the serving path that 20 km query answers in 83 ms, where it used to be slow
  enough to feel broken.

  What got slower is a _tight_ radius, and most of all a tight radius on a bare
  key. `amenity=restaurant` within 30 m went from 0.74 ms to 6.7 ms — a large
  multiple of a number too small to perceive. But a bare `[amenity]` within 30 m
  went from 0.70 ms to 74 ms, and within 1 km from 95 ms to 380 ms; on the
  serving path those are about 1.8 s and 2.7 s. If you ask for a broad category
  within a short distance, expect seconds.

  The cause is structural rather than a tuning miss. The old index carried the
  location _inside_ the text index, so one lookup pruned by tag and by place at
  once. They are separate now, and when the tag matches a great many features
  and the radius excludes almost all of them, the text side leads and the
  location index is reduced to checking its output row by row. Narrowing the tag
  (`amenity=cafe` rather than `amenity`) or widening the radius both avoid it.

  Plain tag lookups with no location at all are about 9% slower. The artifact
  itself went from 322.6 MB to 208.5 MB raw on wyoming, and from 34.7 GB to 21.9
  GB compressed across all 65 areas, so a cold start downloads a good deal less.

- **The synthetic `geohash` tag is gone from results.** Every entry used to
  carry a `geohash` key in its tags — not from OpenStreetMap, but written by the
  build to make spatial queries possible. It was never documented, never usable
  in a filter, and it is no longer there. `tag_keys` never listed it and no
  query could match on it, so nothing that worked before stops working; but
  anything reading raw tag payloads and expecting that key will not find it.

- **Results in the same rank order may come back in a different sequence.**
  Ranking is unchanged, and so is paging: a query that ranks one result above
  another still does. What changed is the tiebreak among results that rank
  _equally_ — the internal id they fall back on is now assigned in geographic
  order rather than in the order the source file happened to list them.
  Practically: page one of a broad query like
  `nw[amenity=restaurant](area=colorado)` holds a different fifty than it did,
  all equally good matches.

- **`route` and `walk_reach` report a different `node_id`.** The identifier for
  a snapped junction used to be its OpenStreetMap node id; it is now an internal
  index into the area's road graph. It has always been an opaque handle — it is
  only useful for passing back into another routing call in the same area, which
  still works — but it is no longer an OSM id and should not be treated as one.

- **Travel times are quantised to a tenth of a second per road segment.** Route
  durations are computed from fixed speeds per road class, so they were always
  estimates with minutes of slack; the stored precision now matches that rather
  than exceeding it. A reported duration can differ from before by a fraction of
  a second.

- **A server refuses to start against an artifact it cannot read.** Previously a
  mismatch between the code and the database produced empty answers rather than
  errors — "nothing nearby" for a place surrounded by things. The artifact now
  records which generation of the build produced it, and the server checks
  before serving.

## 2026-08-19 (later)

- **`geocode` guesses out loud when a name doesn't quite match.** Real names
  disagree with real queries: OSM writes "Buckley Space Force Base" and you
  remember "Buckley Air Force Base"; an airport's formal name carries a county
  yours doesn't. Those lookups used to fail outright. Now, when no name carries
  every word, the search reruns on the query's most distinctive word and returns
  the candidates matching the largest share of the rest — every one flagged
  **`partial_name: true`**. The flag is the point: measured against a public
  corpus of real airport queries across three states, the relaxed pass recovers
  right answers and wrong ones in roughly a 2-to-1 ratio, so an unflagged
  partial match would just be a confident mistake. Anything ranking or
  summarising results should treat a flagged match as a lead to verify. Location
  checks now warn on it, and location briefs refuse to anchor on one — the same
  posture as `outside_context`. Full matches always outrank partial ones, and a
  query that used to succeed returns exactly what it returned before.

- **Accented names match their unaccented spellings in ranking.** "San Jose" and
  "San José" now count as the same name when geocode decides whether a
  feature-name match is exact, matching what the search indexes already did.

## 2026-08-19

- **`geocode` parses comma-less USPS-shaped addresses.** An address pasted from
  a shipping label or a maps app — `3377 Blake Street STE 113 Denver CO
  80205`
  — carries no commas, and the parser needed them to tell where the street
  ended. The whole line was read as a street name, the state was never seen, and
  the query failed with `AREA_REQUIRED` even though it named one. The
  street-type word (`Street`, `St`, `Ave`, …) now marks the boundary instead,
  and a unit (`STE 113`, `APT 4B`, `# 5`) is recognised and set aside rather
  than searched for. A comma-less address without a street-type word
  (`3377 Broadway Denver CO`) still needs its commas: without either there is
  nothing to say where the street stops and the city starts.

- **`geocode` finds named places, not just addresses and streets.** A point of
  interest — an airport, a stadium, a park, a supermarket — is not an address
  and is not a town, and `geocode` had no layer that could hold one. "Denver
  International Airport" returned `NO_PLACE_NEARBY` no matter which area you
  passed. Worse, when a road happened to share the name, the road answered:
  "Costco, Denver" resolved to a street called Costco Avenue and reported it as
  confidently as a correct result. Both were the same missing layer, and it now
  exists. `geocode` searches four layers together — house numbers, street names,
  settlements, and named features — and returns the best. Add a city to pick
  between branches of a chain: `Costco, Denver`. Results say which layer
  answered, in `kind`: `address`, `street`, `place` or the new `poi`. Two limits
  worth knowing. A name needs at least three characters to be searched, because
  shorter than that the index cannot narrow the search and the query has to read
  the whole area. And bus stops are excluded: they are named after the street
  they stand on ("Washington Ave & 16th St"), so leaving them in meant every
  street query was answered by a bus stop.

- **Punctuation in a name no longer has to match.** Names are matched a word at
  a time rather than as one exact run of characters, so a hyphen you did not
  type is no longer a dead end. `Durango La Plata County Airport` finds OSM's
  `Durango-La Plata County Airport`; so do
  `Gunnison Crested Butte Regional Airport` and `Aspen-Pitkin Co/Sardy Field`.
  Measured against a published corpus of real airport names and coordinates
  (1,435 US airports from OpenFlights, via geocoder-tester), this took colorado
  from 57.1% of airports answered within their own tolerance to 75.0%, with no
  change to the city corpus scored alongside it as a control.

- **Duplicate results are folded together.** One building can be mapped many
  times over — the building outline, each entrance, each business inside — and
  every copy carries the same street address. `1701 Wynkoop St, Denver` returned
  28 of them, so a single unambiguous building read as an ambiguous answer and
  filled the whole `alternatives` list with itself. Candidates that share a name
  and sit within 100 m of a better-ranked one are now dropped. Places that
  merely share a name are untouched — two towns' Main Streets stay two answers.

- **`geocode` errors say what to do about them.** Its errors were being
  explained with advice written for other tools. `AREA_REQUIRED` told callers to
  add an `(area=...)` directive or `$area`, which is `search` syntax that
  `geocode` does not accept — it takes an `area` parameter. `NO_PLACE_NEARBY`
  told them to check their latitude and longitude order, which they had not
  supplied. Both now describe `geocode`, and both point at
  `list_areas(filter: "<place name>")` as the way to find which area holds a
  place when you do not know.

## 2026-08-18

> **Correction (2026-08-21).** The two FEMA entries in this section — `nri:*`
> and `fema:zone_subtype` — did not reach the serving database on this date and
> have not reached it since. The artifact published on 2026-08-18 at 07:18 UTC
> is still the one being served; the overlay configuration that produces both
> tags landed at 13:42 and 17:11 the same day, hours after that build was cut,
> and no build was published between then and this correction. Checked against
> the live server: `fema:flood_zone` returns 2,171,042 rows for colorado, while
> `fema:zone_subtype`, `nri:hail` and `nri:wildfire` each return **0**. Both
> tags are carried by the artifact now built and pending deployment; they will
> be dated here when it is serving.

- **Natural-hazard ratings, as `nri:*`.** FEMA's National Risk Index rates 18
  hazards for every US census tract, and the elevated ones now ride along as
  tags: `nri:wildfire`, `nri:hail`, `nri:tornado`, `nri:earthquake`,
  `nri:riverine_flood`, `nri:landslide`, `nri:heat_wave` and eleven more. Two
  values only — `high` and `very_high`, NRI's own "Relatively High" and "Very
  High" — because a tract carries a rating for all 18 and the quiet ones are
  most of them. **An absent tag means "not elevated, or not rated".** NRI writes
  two kinds of unknown — "No Rating" and "Insufficient Data", 15,507 and 1,061
  tracts respectively for wildfire — and those are dropped the same way a "Very
  Low" is, so absence is not proof of safety. Answers which hazards are worth
  asking about at all: `nw[nri:wildfire=very_high](area=colorado)`, or read them
  off a result. Two caveats worth stating. A census tract is coarse — a whole
  neighbourhood shares one rating — so this screens a question rather than
  settling it for a building. And the ratings are relative to the rest of the
  country, so a `high` in one state is not the same absolute risk as a `high` in
  another. NRI's composite "Risk Index" is deliberately **not** included: it
  folds social vulnerability and community resilience into the score, so two
  tracts with identical hazard rate differently by who lives in them.

- **`fema:zone_subtype` makes zone X readable.** A third tag from the FEMA
  National Flood Hazard Layer, and the one that decides what `fema:flood_zone`
  actually means. 76% of the flood layer is zone `X`, and 76% of _that_ is the
  0.2%-annual-chance (500-year) floodplain rather than the "minimal hazard" the
  bare letter suggests — so `X` alone pointed the wrong way on the base rate.
  Call `tag_values(area, "fema:zone_subtype")` for the vocabulary an area
  actually holds — `0.2pct` and `minimal` are most of it, with `floodway`,
  `coastal`, `shallow` and four flavours of `levee` behind them. A rare subtype
  is left untagged rather than stamped as a category nobody can query for.
  `floodway` is worth knowing separately: it sits inside zone `AE` and is where
  building is effectively prohibited. Try
  `nw[fema:zone_subtype=floodway](area=pennsylvania)`. Insurance requirements
  still follow `fema:sfha`, which has not changed.

- **Numeric comparisons compare numbers.** Tag values are stored as text, and
  SQLite orders every text value above every number regardless of what the text
  says, so `nw[bts:noise>=70](area=colorado)` returned all 518,832 entries
  carrying the key — the same answer as `>=99999` — while `<=` matched none and
  `nw[bts:noise=114]` found none of the nine entries `tag_values` reports. `>`
  `>=` `<` `<=` (and `$gt` `$gte` `$lt` `$lte`) now read the number out of the
  stored value and skip values that do not start with one; `=` and `!=` against
  a numeric literal are an exact text match, matching what `tag_values` shows.
  Queries written to work around the old behaviour by quoting the value are
  unaffected.

## 2026-08-17

- **Transportation noise, as `bts:noise`.** Every place-like entry in a US area
  now carries the modelled 24-hour average loudness at its location in
  A-weighted decibels, from the US DOT National Transportation Noise Map
  (2022/2023): `nw[bts:noise](area=colorado)`. Roughly one entry in five has a
  value — the model floors at 45 dBA and covers road, rail and air traffic only,
  so a quiet address has no tag rather than a low number. It is a good relative
  signal and a poor absolute one: DOT states it "should not be used to evaluate
  noise levels in individual locations", and the model ignores shielding from
  terrain and barriers, so it skews high. Canadian areas have none; the source
  is US-only.

- **`attributions` records which snapshot of a source an artifact holds.** Two
  new columns: `retrieved_at`, when this build acquired the source, and
  `version`, the vintage where a source declares one (`bts-2022`). An artifact
  could previously say _that_ it used OpenStreetMap and FEMA but not _which_
  extract or revision, so two builds of the same pipeline could disagree with
  nothing recording why. `version` is null for live services, which have no
  vintage to state.

- **A brief credits the sources its own area was built from.** The footer used
  to carry a hard-coded "Map data © OpenStreetMap contributors (ODbL)", which
  was true when OpenStreetMap was the only source and would have quietly stopped
  being the whole truth as soon as another one landed. It now reads the
  artifact's `attributions` table for the area the brief covers and names
  everything that requires a credit. Scoped to the area on purpose: a brief is
  about one address, and listing every source in the database would be a
  paragraph of credit for data the document never touched. Briefs rebuilt from a
  stored input, or generated without a database, fall back to the original
  sentence.

- `knowhere://docs/legal` now describes OpenAddresses as a possible source of
  house numbers and points at `knowhere://attribution` for which sources a given
  database actually contains. No served database carries OpenAddresses data yet.

## 2026-08-16

- **Attribution, privacy, and terms now ship with the server.** New resource
  `knowhere://docs/legal` carries the OpenStreetMap/ODbL credit, the privacy
  policy, and the terms of service from knowhere.live. The ODbL credit and the
  "these answers measure the data, not the world" caveat also went into the
  server instructions, so they reach every session without a client having to
  fetch a resource — a credit nobody reads is not a credit.
  `knowhere://attribution` is unchanged and still reports what this particular
  artifact was built from, per source and per area.

- **`around=` stops losing features just over a cell boundary.** The radius
  filter narrows the candidate set with a geohash prefix index before it
  measures anything, and it was choosing a cell one size too small — for an 800
  m radius, a cell 610 m tall. Features past that edge were dropped before the
  distance check ever saw them, with no error and no warning: a flood-zone
  screen around a Denver address reported nothing within 800 m while a Zone AE
  feature sat 709 m away. The radius had to be inflated roughly 3.5x before the
  feature appeared. The cell is now always at least as large as the radius asked
  for, so `around=r` and a distance computed on the returned rows agree. `bb=`
  picked its cells the same way and had the same silent loss near a box edge; it
  is fixed by the same change. Wider radii now scan more candidate rows — the
  distance test is unchanged and still exact, so results only gain the rows that
  were wrongly missing.

  The largest geohash cell is 45 degrees across, so a `bb` wider or taller than
  90 degrees (or an `around` radius of a few thousand kilometres) has no cell
  big enough to hold it. Those queries now skip the geohash step entirely rather
  than settle for the biggest cell and drop everything outside it — a
  whole-world `bb` used to return only what sat near (0°, 0°), losing every
  entry in the Americas.

- **`around=` names a bad radius instead of leaking a database error.** A radius
  or centre that is not a finite number (`1e309` overflows to infinity, `NaN`)
  used to slip past the range checks and reach SQLite as a bare word, which came
  back as `no such column: Inf`. It now returns the same "radius must be > 0
  metres" / "centre out of range" error as any other bad value.

## 2026-08-15

- **Multiple bounding boxes work.** A query with more than one `bb` group
  (`(bb=a...,b...)`) either errored ("table does not support scanning") or
  silently applied the other filters to only the first box, because the boxes
  were OR'd into the WHERE clause without parentheses. The index prune had the
  same problem in reverse: it required a match in _every_ box at once, so two
  boxes that do not overlap returned nothing. Both now treat the boxes as a
  union: an entry matches if it falls in any box, and every other filter still
  applies.

- **Multi-word values need a tag key.** The any-key form (`[*=...]`, `[*=~...]`,
  and their negations) searches the whole entry rather than one tag, so nothing
  downstream can check its answer. With a value of two or more words it can no
  longer tell "Bobs Burgers" from an entry named Bobs whose operator is Burgers,
  so it now returns an error naming the problem instead of answering with the
  wrong entries. Single-word values are unaffected, and naming the key
  (`[name="Bobs Burgers"]`) always works.

- **Negative filters no longer drop matches by accident.** `!=`, `!~`, and
  `[!key]` used to exclude an entry when the negated key and value appeared
  anywhere in it, even under different tags — `[cuisine!=pizza]` dropped a
  restaurant named "Pizza Hut" with `cuisine=italian`, and `[!cuisine]` dropped
  entries whose only cuisine-ish key was `cuisine:vegan`. Also fixed:
  `[key!~a,b]` now requires the value to contain none of the terms (it could
  pass on a value containing both), several `[*!=...]` filters in one query now
  all apply, and the bare-key forms `[!*]` and `[*>n]` — which always errored —
  plus a negation-only `[*!=...]` query — which was silently ignored — now
  return a clear error.

- **FEMA flood data.** Every place-like entry in the US areas now carries two
  tags from the FEMA National Flood Hazard Layer: `fema:flood_zone`, the zone
  code (`X`, `AE`, `A`, ...), and `fema:sfha`, `yes` or `no` for whether the
  entry sits in a Special Flood Hazard Area — the 1%-annual-chance floodplain
  where flood insurance is typically required. Filter on them like any other
  tag: `nw[fema:sfha=yes](area=texas)`. Zone geometry is not stored, only the
  per-entry answer.


---

# Attribution, Privacy, and Terms

The notice below travels with the server rather than living only on
`knowhere.live`, because a credit on a website does not reach a client that
only ever speaks MCP.

## Attribution

Contains information from OpenStreetMap, which is made available here under
the [Open Database License (ODbL) v1.0](https://opendatacommons.org/licenses/odbl/1-0/).

Map data © OpenStreetMap contributors. Every place, road, trail, and boundary
Knowhere answers with was mapped by volunteers. Regional extracts are prepared
and distributed by [Geofabrik](https://www.geofabrik.de/).

House numbers may also come from [OpenAddresses](https://openaddresses.io/),
which is not one dataset under one licence but a few thousand county, city and
state open-data publications, each with its own terms. Some require a specific
credit — Statistics Canada's, for example, carries a sentence it must be quoted
with. Sources whose terms conflict with the ODbL, and any restricted to
non-commercial use, are excluded when the artifact is built rather than noted
in it.

**Which sources are actually in the database being served is a question only
that database can answer, and `knowhere://attribution` answers it** — per
source, per area, with each licence and the credit it requires. Read it before
republishing anything derived from these answers. This page carries the terms
that always apply; that resource carries the ones that apply to this build.

Results returned by these tools are individual extracts, not the database.
They may be stored and used alongside other data. What they may not be used
for is systematically reassembling the underlying database — aggregating all
or substantially all features of a given type across an area city-sized or
larger. That crosses from using the answers to copying the source.

`knowhere://attribution` renders the credits for the specific artifact being
served, including any source that only covers some areas. Read that one when
the question is "what is in *this* database"; read this one for the terms that
always apply.

## The software

The Knowhere server software is proprietary. It is built on open source Go
libraries — among them Echo, SQLite, goja, and the Model Context Protocol Go
SDK — which keep their own licences.

## Privacy

Full policy: <https://www.knowhere.live/privacy.html>

Knowhere is operated by JT Archie. The website collects one thing: the email
address typed into the invite form, with the date it was submitted. No
analytics, advertising, or tracking scripts, and no cookies of its own. Top
Banana hosts the site and stores form submissions; Cloudflare serves it as CDN
and routes contact email. Addresses are kept until you ask us to stop, and
every mail carries an unsubscribe link. To see, correct, or delete what we
hold, mail <hello@knowhere.live>.

## Terms of service

Full terms: <https://www.knowhere.live/terms.html>

The two that matter when a model is reading:

**Answers describe the data, not the world.** Coverage varies by region,
records go stale, and some things were never mapped. Counts and distances are
measurements of OpenStreetMap, not guarantees about what is actually there.

**Nothing here is advice.** Not real-estate, financial, legal, or professional
advice. Verify anything load-bearing — drive times, school assignments, road
maintenance, insurability, utilities — with the relevant local authority
before acting on it.

The service is provided "as is" and "as available", without warranty. Total
liability for any claim is capped at US $100. Colorado law governs. The ODbL
terms above govern your use of the underlying data regardless of these terms.

Questions or corrections: <hello@knowhere.live>.

