Skip to main content
Glama

Server Configuration

Describes the environment variables required to run the server.

NameRequiredDescriptionDefault
PLACEROOT_TOOLSNoComma-separated list of tool profiles to load, reducing schema overhead. See docs/REFERENCE.md for details.
PLACEROOT_RECREATION_LAYERNoSet to '0' to disable the recreation layer (which adds OSM-derived places data). Default is enabled.

Instructions

Guidance the server publishes about itself, which clients place ahead of the tool catalog so the model reads it before choosing anything.

This server publishes no instructions, or was last inspected before Glama recorded them.

Capabilities

Features and capabilities supported by this server

Protocol revision2025-11-25

CapabilityDetails
tools
{
  "listChanged": false
}
prompts
{
  "listChanged": false
}
resources
{
  "subscribe": false,
  "listChanged": false
}
experimental
{}

Tools

Functions exposed to the LLM to take actions

NameDescription
find_placesA

Find named places, either near a point or inside an area's boundary.

Three mutually exclusive modes:
- Point + radius: pass lat and lon (radius_m defaults to 1000m), or the
  same center as `where` — a {"lat", "lon"} dict, a GERS id, or a
  free-text place name ("Alamo Square, SF"), resolved here, so a named
  search is one hop: no geocode()/resolve_place() call first. lat/lon
  or where, not both, and neither with division_id/area. An id/name
  `where` adds a compact "resolved": {"name", "id", "lat", "lon",
  "matched_by"} (absent for lat/lon or a {lat,lon} where); an ambiguous
  name returns {"error": "ambiguous_place", "candidates": [...]} rather
  than picking one. Results are nearest-first around that center.
- Division polygon: pass division_id (a GERS division id, e.g. one from
  an admin-hierarchy chain) instead of lat/lon. Results are every matching place whose
  point falls inside that division's true boundary polygon — no radius
  to guess, and no circle clipping a coastline or straddling a border.
  Results are ordered by name (there's no reference point to rank
  distance from).
- Area by name: pass area ("Palo Alto") to get the division-polygon mode
  without first resolving the id yourself. The name is resolved with the
  same ranking geocode/resolve_place use; the resolved division is
  echoed back as "area" on the response so it's clear which one was
  searched. A name matching several equally-ranked divisions returns
  {"error": "ambiguous_area", "candidates": [...]} listing their
  division_ids rather than silently picking one, and an unresolvable
  name returns {"error": "not_found"} rather than an empty result that
  would read as "this place has no coffee shops".

category matches Overture's taxonomy (e.g. 'coffee_shop', 'restaurant',
'grocery'); name is a substring match on the place name — both compose
with either mode. Results include operating_status ("in business" /
"permanently closed" / null when unknown) — a business-lifecycle
signal, NOT opening hours; this data has no open-now information —
and a compact trust_note calibrated from confidence and that status.

min_confidence (0.0-1.0) keeps only rows whose confidence score is at
least that value; out-of-range values return a bad_request error.
operating_status filters to a single status (see the schema's enum for
accepted relabeled/raw values); "permanently closed"/"closed" also
match Overture's separate "closed_permanently" raw value, since both
relabel the same way. Unrecognized values return a bad_request error.

brand is a substring match on the place's brand name (e.g. 'Starbucks').
Brand data is sparse — most independent businesses have no brand at all,
so brand=X narrows results down to that chain only; the absence of a
result does NOT mean "not a Starbucks", it may just mean brand isn't
populated for that place. has_website/has_phone filter on whether a
place has any website/phone entries at all (presence, not content) —
each result row carries brand (string or null) and has_website/has_phone
(booleans) so an agent can see why a place matched, but the full
websites/phones arrays are only returned by place_details.

Every filter above composes with either mode, and each is a silent
no-op (not an error) if the column it needs (confidence /
operating_status / brand / websites / phones) is absent from the active
dataset — see degraded_fields() on the response.

name has two fallback tiers (point + radius mode only, #373) for when
the literal substring search finds nothing: an alternate-spelling match
("Munich" -> a place named "München") then a typo-tolerant fuzzy match
("Startbucks" -> "Starbucks"). A row found either way carries
"matched_by": "alt_name" | "fuzzy" (absent on an ordinary match), and a
top-level "note" names the spelling actually matched.

Returns {"results": [...]}, plus truncated/omitted_count if the answer
didn't fit the token budget. Returns a structured {"error": "bad_request",
...} if no mode's inputs are given (or more than one is), {"error":
"not_found", ...} if division_id/area doesn't match any known division, or
a structured {"error": ...} if the upstream dataset is unavailable or
missing columns this tool depends on. If a category filter was given and
it matched nothing (in either mode), a non-fatal "note" field hints that
the category slug may be wrong and points at search_categories.

A truncated answer — either token-budget-trimmed or because more
matching rows exist beyond limit — also carries "cursor"; pass it back
unchanged, with every other argument identical, to fetch the next page.
cursor is only ever issued for a literal or ambiguity-free match: an
answer built from #373's alt-name/fuzzy name fallback never carries one
(those pools are small; ROADMAP §4.4). A cursor for a different query,
or one that's malformed, returns {"error": "bad_cursor", ...} naming
the mismatch; a cursor issued against an older Overture release is
honored anyway, against the current release, with a one-line "note"
that rows may have shifted.

detail picks how much of each row comes back (ROADMAP §4.5, roadmap
feature 5): "compact" (the DEFAULT) is {id, name, category, lat, lon,
distance_m, trust} — trust is a tier string ("strong"/"ok"/"weak"/
"unknown") derived from the same confidence/operating-status signals
as "full"'s trust_note, so the two can never disagree; the payload
also carries one "trust_legend" line explaining the tiers. compact
keeps lat/lon (unlike "ids") so a composed call feeding these rows
into a map-rendering tool still has coordinates under the default
tier. "ids" is just {id,
distance_m} — deliberately coordinate-free, the cheapest shape, for
chaining straight into a batch id lookup or place_details. "full" is
every field, unchanged from before this param existed, including
trust_note's prose. division-polygon rows have no distance_m at any
tier (no reference point to measure from) — the key is omitted, never
null; they still carry lat/lon at every tier but "ids". detail is
presentation only: it does not affect which rows match or their
order, is NOT part of a cursor's query identity, and a cursor issued
under one detail continues correctly under a different one.
Projection happens before the token budget is applied, so a smaller
detail tier fits more rows per answer — that's the point of a tier
smaller than "full". Unrecognized values return a bad_request error
naming the accepted ones.

categories (mutually exclusive with category — passing both is a
bad_request) runs a checklist of up to 5 slugs in ONE scan instead of
category's one slug, each matched with identical substring/prefix
semantics. group_by_category=False (the default) merges every
category's matches into one nearest-first list, same shape and cursor
pagination as a single category (categories, sorted, is part of the
cursor's query identity). group_by_category=True instead buckets the
answer as {"results": {category: [rows...]}}, up to `limit` rows PER
category — a category with zero matches is simply absent from the
dict. Grouped answers carry no cursor at all (each category is already
limit-bounded from a single scan; page a specific one further by
re-running with category=<that slug> instead). More than 5 slugs, or
categories together with category, both return a bad_request error;
group_by_category=True together with a non-null cursor is also a
bad_request (there is nothing to continue). The category-miss "note"
(see above) also covers categories: it fires when the scan matched
none of the requested slugs at all.

within = {"minutes", "mode"?, "of"?} (roadmap §4.2) keeps only results
truly reachable from `of` within `minutes` by street-graph `mode` —
the real graph, not a radius guess; radius_m is ignored when set. `of`
(a LocationRef) defaults to the search center — lat/lon or whatever
`where` resolved to; required in division_id/area mode. An id/name `of`
adds a "resolved" echo, unless `where` already claimed that key, in
which case `of`'s match is named in the note. The answer gets a short
reachability note. A cold graph returns
{"error": "needs_confirm", ...} — retry with confirm=true once the
user agrees to wait (5-25s).
summarize_areaA

Summarize what's in an area: total places and top categories.

Give the center as lat/lon, or as `where` — a {"lat", "lon"} dict, a
GERS id, or a free-text place name — but not both (and not neither);
either way returns {"error": "bad_request"} naming the choice. A
`where` given as an id/name adds a compact "resolved": {"name", "id",
"lat", "lon", "matched_by"} to the answer; absent for lat/lon or a
{lat,lon} where.

Returns a structured {"error": ...} instead of raising if upstream is
unavailable or the dataset is missing columns this tool depends on.
place_detailsA

One place, in full: addresses, websites, phones, socials, brand, source attribution, GERS id, confidence, operating status, and a compact trust_note.

Resolve either by GERS id (the `id` field find_places and other tools
return) or by name + lat/lon (nearest name match within radius_m of
that point). Pass id, or pass name together with lat and lon — not
both. Long array fields (addresses, websites, phones, socials, sources)
are capped and never silently dropped: a truncated field carries a
matching "<field>_omitted_count". Returns {"error": "not_found", ...}
if nothing matches, or a structured {"error": ...} if the upstream
dataset is unavailable or missing columns this tool depends on.

When looking up by id, also pass near_lat/near_lon — the lat/lon from
the find_places (or other tool) row the id came from — so the lookup
can be narrowed to a ~50km box instead of scanning the whole dataset.
Ignored when resolving by name. Omitting it still works, just slower on
a cold, uncached id.

lang (#410) requests Overture's language-tagged name variant for this
place, when the data has one: `name` becomes the variant and
`name_primary` is added only when it differs. Default: the stored
`preferences()` lang, else the primary name unchanged. Never invented
or transliterated.
within_distanceA

Is the nearest place matching category/name within max_distance_m of (lat, lon)?

Give the center as lat/lon, or as `where` — a {"lat", "lon"} dict, a
GERS id, or a free-text place name — but not both (and not neither);
either way returns {"error": "bad_request"} naming the choice. A
`where` given as an id/name adds a compact "resolved": {"name", "id",
"lat", "lon", "matched_by"} to the answer; absent for lat/lon or a
{lat,lon} where.

max_distance_m is required and must be a positive number of meters — a
zero, negative, non-finite, or missing value returns {"error":
"bad_request"} rather than silently searching a 0m (or omitted from a
call entirely, in which case the schema itself rejects it before this
tool ever runs) window and answering a confident-looking "false".

Returns {"within": bool, "nearest": {...place row with id...} | None,
"distance_m": float | None}. nearest is None if nothing matches within
a search window capped at max_distance_m * 2 — a real match further out
than that isn't found (documented, not a bug: keeps the search bounded).
name is a literal substring match only — no alt-spelling or typo
fallback applies here, so a misspelled name is an honest "no match",
never a silent yes about a different name.
Returns a structured {"error": ...} if upstream is unavailable or the
dataset is missing columns this tool depends on.
distance_matrixA

Straight-line (great-circle) distance in meters between every origin and destination.

origins and destinations are each a list of LocationRefs — a {"lat":
..., "lon": ...} dict, a GERS id, or a free-text place name, mixed
freely — capped at 10 each (100 pairs max). This is a plain haversine
calculation, not a routed distance or travel time, so it's cheap but it
is NOT what Google/Mapbox distance-matrix APIs return: no roads, no
turns, no travel time. For "how far can I get in N minutes" use
isochrone() instead; for actual routed times/distances between several
points use travel_time_matrix().

An id/name that failed to resolve returns an indexed error
(origins[i]: ... or destinations[i]: ...) with candidates on ambiguity
— checked after the 10-point cap, so an over-cap list always fails on
the cap first. Any origin/destination given by id/name adds "resolved":
{"origins": [{"index", "name", "id", "lat", "lon", "matched_by"}, ...],
"destinations": [...]} covering just those entries; each side is
present only if it had a string entry, and the whole key is absent when
every point was already coordinates.

Returns {"elements": [{"origin_idx": 0, "dest_idx": 0, "distance_m":
812}, ...]}, flat and origin-major (all destinations for origin 0,
then origin 1, ...), budgeted like every other tool. Empty origins or
destinations returns {"elements": []}. Returns a structured {"error":
"bad_request", ...} instead of raising if either list exceeds 10
points or a point is missing/non-numeric lat or lon.
meeting_pointA

Where several people should meet, fairly: candidate venues ranked by equalized travel time, not geometric distance.

Fairness objective: minimize the MAXIMUM per-person travel time to the
venue ("no one gets screwed"), tie-broken by the smaller spread
(max - min across everyone), then by the smaller total. This is
deliberately not "minimize the average" — that objective can strand
one person with a long trip so two others get a short one.

origins is 2-5 points, each a {"lat": ..., "lon": ..., "mode": ...}
dict, a GERS id, or a free-text place name, mixed freely — mode is
"walk", "cycle", or "drive", defaulting to "walk" when omitted (a
string origin always gets the default mode; give a dict with "mode" to
pick otherwise), and can differ per person (e.g. one driving, one
walking). An id/name that failed to resolve returns an indexed error
(origins[i]: ...) with candidates on ambiguity. Any origin given by
id/name adds "resolved": [{"index", "name", "id", "lat", "lon",
"matched_by"}, ...] for just those origins; absent when every origin
was already coordinates. category optionally filters candidate venues
to an Overture taxonomy slug (e.g. 'coffee_shop'); a wrong or
unrecognized slug is a silent zero-match, not an error.

Method: a seed center is computed from each origin's implied
straight-line travel time (not raw distance, so a walking participant
pulls the center toward them more than a driving one at the same
distance), venues are searched for near that seed, and each
candidate's real per-person times come from routing.route() — the
exact routed number, not the seed's approximation. The total routed
(candidate, origin) fan-out is capped at 16 pairs, regardless of
`limit` — 8 candidates at 2 origins, down to 3 candidates at 5.

Returns {"center": {"lat", "lon"}, "candidates": [{"id", "name",
"category", "lat", "lon", "per_person": [{"origin_idx", "mode",
"travel_time_min", "distance_m"}, ...], "max_travel_time_min",
"spread_min"}, ...]}, ranked fairest-first, capped at `limit` (default
3, max 5). per_person entries carry origin_idx aligned to the input
origins list, one entry per origin — a candidate that can't be routed
from every origin (no street graph nearby, or genuinely disconnected)
is dropped from the ranking entirely rather than ranked on a partial,
unfair comparison. A per_person leg whose street graph hit its
internal size cap carries "truncated": true (as does its candidate,
and the answer carries a note) — that leg's time may be off. An empty
"candidates" list is a valid answer (nothing matched the category
nearby, or nothing routed from every origin) — it carries a "note"
explaining which, including when every pair was over the mode's
straight-line routing cap (try a faster mode).

confirm=true after the user agreed to wait for a first-time
street-graph build (about 5–25 seconds; see `route`). Without it, a
fan-out that would need a cold graph build returns {"error":
"needs_confirm"} instead of silently blocking. Omit confirm unless you
just asked and they said yes.

A non-empty result also carries "map" (#369) — a render-ready payload,
keyword-splattable straight into this server's map-rendering tool (its
keys are exactly that tool's keyword arguments): pins every origin, the
fairest candidate picked out by class, the rest, and the fairness seed
center, plus a one-line summary naming the fairest venue and its
numbers. Absent when "candidates" is empty.

Returns a structured {"error": "bad_request", ...} if origins has
fewer than 2 or more than 5 points, a point is missing/non-numeric
lat or lon, or a mode isn't walk/cycle/drive; {"error": "bad_request",
...} with the offending coordinate if lat/lon is out of range; or a
structured {"error": ...} if the upstream places or transportation
dataset is unavailable or missing columns this tool depends on.
travel_time_matrixA

Routed travel time + distance between every origin and destination, by mode.

origins and destinations are each a list of LocationRefs — a {"lat":
..., "lon": ...} dict, a GERS id, or a free-text place name, mixed
freely — capped at 5 each (25 pairs max). Unlike distance_matrix's plain
haversine, this is a real shortest-path search over Overture's open
street graph — roads, one-ways, and each mode's own speed model, the
same cost model route() uses for a single pair, one mode per call;
omit mode to use the stored preferences mode, else walk.

An id/name that failed to resolve returns an indexed error
(origins[i]: ... or destinations[i]: ...) with candidates on ambiguity
— checked after the 5-point cap. Any origin/destination given by
id/name adds "resolved": {"origins": [{"index", "name", "id", "lat",
"lon", "matched_by"}, ...], "destinations": [...]} covering just those
entries; each side present only if it had a string entry, absent when
every point was already coordinates.

Reuses a single cached street graph across every origin and
destination when every origin-destination pair fits the mode's
straight-line cap and the whole point set fits one extraction circle,
running one Dijkstra per origin against every destination at once
rather than a search per pair — for a same-city matrix this costs
about what a single isochrone does, not one route() call per pair.
When the points are too spread out for one shared graph, falls back to
a route() call per pair (up to 25).

Returns {"mode", "elements": [{"origin_idx", "dest_idx", "duration_min",
"distance_m"}, ...], "durations_note"}, flat and origin-major like
distance_matrix. durations_note says these are speed-model estimates
over the open street graph, not live traffic. An unroutable pair (off
the street network, or on a disconnected fragment of it) gets
{"duration_min": null, "distance_m": null, "note": "unroutable"}
instead of failing the whole call; if every pair in the matrix is
unroutable the response also carries a top-level "note" saying so.
If the street graph hit its size cap the response carries "truncated":
true plus a note — capped extractions may present reachable pairs as
unroutable. Empty origins or destinations returns {"elements": []}.

Returns a structured {"error": "bad_request", ...} instead of raising
if either list exceeds 5 points, a point is missing/non-numeric lat or
lon, or mode isn't walk/cycle/drive. If no street graph exists
anywhere near every point in the matrix, returns {"error":
"no_graph_nearby"} — the same top-level failure route() and
optimize_route() give when nothing in the area is on the mapped
network, rather than a matrix of nulls.
suggest_areasA

Where within reach: neighborhoods ranked by travel budget + amenities.

The inverse of every other area tool — instead of "describe this place",
"find me a place". anchors is 1-3 {"lat", "lon", "mode"?, "minutes"?}
points (mode: walk/cycle/drive, default from stored preferences;
minutes: default 15). requirements is 1-8 free-text amenity/character
strings, scored the same way as area_score.score_locality — "parks",
"groceries", "coffee shop" resolve against the Overture taxonomy;
a subjective phrase ("quiet streets", "safe neighborhood", "good
schools") comes back {"measurable": false} rather than a guessed score
(see "honesty" in the response).

Method: the same street-graph reach analysis behind PlaceRoot's other
travel-time tools computes each anchor's reachable shed; with more than
one anchor, the sheds are intersected (a candidate must be reachable
within EVERY anchor's own time budget, not just one — "office" and
"gym" both mean both). divisions.divisions_in_polygon (#348) partitions
the (intersected) shed into candidate neighborhoods/localities; each
candidate is scored against requirements the same way
area_score.score_locality (#349) does. Returns {"anchors": [...],
"results": [{"division_id", "name", "subtype", "overlap_fraction",
"lat", "lon", "travel": [{"anchor_idx", "mode", "minutes_budget",
"travel_time_min", "distance_m"} or {..., "note": "unroutable"/
"no_graph_nearby"/...}, ...], "requirements": [...], "overall_score",
"reason"}, ...], "honesty"}, ranked by overall_score (unmeasurable-only
candidates sort last, never dropped) then overlap_fraction, capped at
`limit` (1-10, default 5). division_id is a stable GERS id — chain a
result into admin_lookup or summarize_area for more detail without
re-running the search. No polygons in the response by default.

An empty "results" list is a valid answer (e.g. two anchors' sheds don't
overlap at all, or nothing in the reachable area is a neighborhood/
locality) with a "note" saying which. A per-anchor travel leg that can't
be routed (the polygon-approximated shed boundary occasionally includes
a point routing itself can't reach) gets "note" instead of a time,
without dropping the whole candidate.

confirm=true after the user agreed to wait for a first-time street-graph
build (about 5-25 seconds per anchor that needs one). Every anchor is
checked before any graph is built, so a fan-out never starts some
anchors and then stalls needing confirm on the next. Omit confirm
unless you just asked and they said yes.

Returns a structured {"error": "bad_request", ...} if anchors isn't 1-3
points, a point is missing/non-numeric lat, lon, or minutes, minutes is
not > 0, or a mode isn't walk/cycle/drive; likewise if requirements
isn't 1-8 non-empty strings. Propagates the same structured errors as
the underlying reach analysis (unsupported_mode, no_graph_nearby,
radius_too_large) and divisions_in_polygon/score_locality (upstream_unavailable,
schema_degraded) — a partial shortlist from a failed anchor or scan is
never returned.
compare_areasA

Compare 2-5 areas side by side: category mix, density, and what differs.

areas is a list of centers sharing one radius_m, each a {"lat": ...,
"lon": ...} dict, a GERS id, or a free-text place/area name, mixed
freely — a named area compares the same radius_m circle around its
resolved point as a coordinate would (not its actual boundary; that's a
later feature). An id/name that failed to resolve returns an indexed
error (areas[i]: ...) with candidates on ambiguity. Any area given by
id/name adds "resolved": [{"index": i, "name", "id", "lat", "lon",
"matched_by"}, ...] for just those areas; absent when every area was
already coordinates. Returns per-area total_places, place density per
km^2, and category_counts aligned across areas for the top ~10
categories by combined count, plus "differentiators" — those categories
ranked by how much they differ, relatively, between areas (the fastest
way to answer "how is area A different from area B"). Returns a
structured {"error": ...} if areas isn't 2-5 centers, or if upstream is
unavailable or the dataset is missing columns this tool depends on for
any area (a partial comparison is not returned).

priorities (optional, up to 6) turns the comparison into a scored
verdict: each entry is {"label": your own term for the criterion,
e.g. "competition"; "category": an Overture taxonomy slug, or
"__density__" for overall place density as a foot-traffic proxy;
"prefer": "more" | "fewer"; "weight": 0.1-5, default 1}. Each area's raw
measure per priority is that category's count (or density) within
radius_m — matched exactly against the category taxonomy (slug plus its
descendants, so "park" never counts parking garages) and counted
explicitly even for categories outside the top-10 alignment above; the
per-priority winner is whichever area is better on that raw measure (a
tie has no winner for that priority); each area's verdict score is the
weight-summed share of each priority normalized against the best area
(measure/max for "more", min/measure for "fewer" — the best area always
gets 1.0, and every area measuring 0 makes all shares 1.0), and the
highest score wins overall (a tie leaves winner_idx null). Adds (never
replaces) "verdict": {"winner_idx", "scores", "reasons" (one sentence
per priority), "margin", and a fixed "measured_note"} — the note,
always present when priorities are given, states plainly that these are
open-data place counts/density, never revenue, rent, actual foot
traffic, or demographics, and that "__density__" is only a proxy. If
the dataset's category columns are all degraded, count-based priorities
can't be measured and the verdict comes back with null winner_idx and
scores plus "degraded": true rather than a fabricated score. Returns
bad_request for more than 6 priorities or a malformed one (missing
label/category, an unrecognized prefer, or a non-numeric weight).

When priorities produced a verdict, the response also carries "map"
(#369) — a render-ready payload, keyword-splattable straight into this
server's map-rendering tool (its keys are exactly that tool's keyword
arguments): a pin per area (the winner picked out by class), a cheap
circle outline per area (radius_m, not a real boundary) labeled with
its score, and a one-line summary restating the winner. Absent when
priorities weren't given, or a verdict couldn't be scored.
admin_lookup

Containing admin hierarchy for a point: neighborhood up to country.

Point-in-polygon against Overture's divisions theme. Returns {"chain":
[{"name": ..., "type": "locality", "id": ...}, ...]} smallest division
first (e.g. neighborhood, then locality, county, region, country) — an
empty chain means no division in the active dataset contains the
point, which is a valid answer for remote areas, not an error. Chain
rows also carry "country" (ISO 3166-1 alpha-2, e.g. "DE") and "region"
(ISO 3166-2, e.g. "DE-BY") where the source row has them — omitted,
not null-filled, when the dataset or row lacks a value. Returns
a structured {"error": ...} if upstream is unavailable or the divisions
dataset is missing the geometry column this tool depends on.
changes_in_areaA

What's opened or closed around here since a past Overture release.

Use this for "what's new around here", "what's closed since spring",
or any question with a time dimension — every other tool here answers
against a single, current snapshot of the data; this is the only tool
that compares two.

Area, exactly one of:
- place: a free-text area name ("Palo Alto"), resolved with this
  server's usual free-text area matching (prominence-ranked, "City,
  ST" suffix aware). The resolved division is echoed back as "area"
  on the response. A name
  matching several equally-ranked divisions returns {"error":
  "ambiguous_area", "candidates": [...]} rather than silently picking
  one; an unresolvable name returns {"error": "not_found"}. If the
  resolved division is too large to diff (bigger than this tool's
  per-side degree cap — countries, large regions), returns a
  {"error": "bad_request"} naming the area and suggesting a smaller
  one (a neighborhood or district instead of a whole city/region) or
  an explicit bbox.
- min_lon/min_lat/max_lon/max_lat: an explicit bbox (all four
  together, or none) — for when the caller already has coordinates
  rather than a name. Same size cap as the named-area path.

category, when given, filters both releases' scans identically before
diffing — see diff_places; a place that changed OUT of the category
between releases reads as "disappeared" from this filtered view, which
is the correct reading of "restaurants that changed", not a bug.

from_release/to_release: Overture release strings (YYYY-MM-DD.N). Omit
both to diff the previous release against the ACTIVE one — to_release
defaults to release.resolve_release(), the same release every other
tool in this conversation queries (env pins included), and
from_release defaults to the newest listed release older than it: an
adjacent, recent window. Not the oldest release Overture still serves
— years-old releases are schema-drifted enough that most compared
columns NULL out and everything reads as "changed"; pass explicit
releases for a wider window, and check "degraded_fields" on the
response when you do. Pass both to pick a
specific window; passing only one is a {"error": "bad_request"}. When
the release listing is reachable, an explicit release not in it is
also a {"error": "bad_request"} rather than a silent typo'd diff; when
the listing itself is unreachable (network trouble), explicit releases
are tried directly instead — they may still resolve even though the
list couldn't be built.

If neither release is given and no listed release is older than the
active one (a listing failure, or a world with only one live release),
this returns a structured {"error": "upstream_unavailable"} naming
whatever releases WERE found — never an empty diff that would read as
"nothing changed here" when the real answer is "the window couldn't be
built".

limit caps how many ranked rows each of appeared/disappeared/changed
carries (default 8, hard cap 25 — the same per-answer row bound every
other tool here uses); "counts" and the "*_by_category" breakdowns are
never reduced by it.

Returns a compact digest, not the full diff: headline "counts"
(appeared/disappeared/changed/unchanged, always exact within the
scanned bound), a `limit`-capped ranked slice of each of
appeared/disappeared/changed (id, name, category, confidence, lat,
lon — changed rows carry old_name/new_name and
old_category/new_category instead of name/category), a small
per-bucket "*_by_category" breakdown computed over everything the
scans saw (same denominator as "counts", not the limit-capped slice),
the "releases" window actually used, "degraded_fields" when either
release's schema is missing a compared column (that field was NULL on
that side, so treat "changed" with suspicion), and
"truncated"/"omitted_count" when more exists than fits
(omitted_count sums omissions across the three lists).

Honest framing (issue #309): a "notes" list is attached whenever it
applies — a disappearance may be delisting or data cleanup, not a
closure; an appearance may be newly-mapped, not newly-opened. Neither
claim is inferable from this data alone, so the digest says so rather
than implying otherwise.

Returns a structured {"error": ...} if upstream is unavailable or the
active places dataset is missing the id/bbox columns this tool depends
on, for either release.
summarize_buildingsA

Summarize building footprints in an area: count, footprint area, height/floor coverage, mix.

From Overture's buildings theme (issue #23). Returns count,
total/mean footprint area in m^2, height_known_pct/num_floors_known_pct
(height and floor count are sparse in real Overture data — this reports
coverage rather than pretending every building has a value, with
mean_height_m/mean_num_floors alongside when any are known), and
top_subtypes/top_classes (top 10 each by count). Returns a structured
{"error": ...} if upstream is unavailable or the dataset is missing
geometry/bbox.
buildings_atA

Nearest building footprints to a point, nearest first.

From Overture's buildings theme (issue #23). Returns {"results": [{id
(GERS), subtype, class, footprint_area_m2, height_m, num_floors,
distance_m}, ...]}. No raw geometry by default (design rule: answers,
not data) — pass include_geometry=true to also get each row's
footprint as GeoJSON, simplified to a small per-row token cap (each row
then also carries geometry_max_deviation_m, reporting what was lost).
Returns a structured {"error": ...} if upstream is unavailable or the
dataset is missing geometry/bbox.
land_use_atA

What kind of land is this: land use and land cover classification at a point.

From Overture's base theme (issue #167) — PlaceRoot's first tool over
base, distinct from the place-search and area-summary tools (those cover
discrete POIs, not the land itself). Returns {"lat", "lon", "land_use":
{"subtype", "class", "name"} or null, "land_cover": {"subtype",
"class"} or null}. No raw geometry (design rule: answers, not data).

null for either field means no polygon of that type covers the point —
coverage is OSM-derived and patchy outside well-mapped cities, so this
is a common, valid answer for a rural or remote point, not an error.
When multiple polygons overlap (Overture nests them, e.g. a park inside
a residential parcel), the smallest/most specific one is returned and
a "note" flags that the pick was made among several valid candidates.
Returns a structured {"error": ...} if upstream is unavailable or a
base-theme dataset is missing geometry/bbox, and {"error":
"bad_request"} for a non-finite or out-of-range coordinate.
infrastructure_atA

Infrastructure near a point, nearest first: bridges, towers, piers — and street furniture.

From Overture's base theme (issue #179), type=infrastructure — the
built things that are neither buildings nor POIs. Read the data
honestly before trusting an answer: this layer is dominated by street
furniture (street_lamp, bench, waste_basket, bollard, kerb, crossing),
which outnumbers landmark infrastructure roughly 50:1 in a city
centre. An unfiltered query in a dense area returns lamps and benches
and says nothing about whether a bridge is nearby. To ask about
landmarks, filter: subtype/infra_class match Overture's `subtype` and
`class` columns (case-insensitive substring; infra_class is `class`
under a non-reserved name) — e.g. subtype="bridge", subtype="tower",
subtype="power", infra_class="pier".

Returns {"center", "radius_m", "results": [{"id", "subtype", "class",
"name", "distance_m"}, ...]}, plus "truncated": true, "total_in_range"
and an explanatory "note" whenever more features matched than were
returned. id is the GERS id, usable with other GERS-keyed tools. No raw
geometry (design rule: answers, not data).

Radius search, not containment: most infrastructure is linear or a
bare point, so "what's within radius_m" is the answerable question.
distance_m is measured to the closest point on the feature, not its
centroid — a bridge you are standing on reads ~0 m, not "distance to
the middle of the bridge". radius_m echoes the effective radius, which
may be lower than requested (large values are clamped).

An empty results list is a valid answer, not an error: base-theme
coverage is OSM-derived and patchy, and "no infrastructure within
500 m" is a real finding. Returns a structured {"error": ...} if
upstream is unavailable or the dataset is missing geometry/bbox, and
{"error": "bad_request"} for a non-finite or out-of-range coordinate.
transit_stops_near

Nearest transit stops to a point, nearest first — bus, rail, subway, tram, ferry.

A filtered view over Overture's base/infrastructure `subtype='transit'`
rows (the same theme infrastructure_at reads), restricted to real,
boardable stop classes: bus_stop, bus_station, railway_station,
railway_halt, subway_station, tram_stop, ferry_terminal,
aerialway_station. Unfiltered, that layer is dominated by
bicycle_parking and by platform/stop_position — OSM's per-geometry
re-tagging of the same physical stop — which this tool never returns
by default; if zero stop-class rows are within radius_m it falls back
to platform/stop_position rows instead of an empty answer, and says so
in "note". `kind` restricts the search to exactly one class (any of
the stop classes above, or "platform"/"stop_position" directly);
an unrecognized kind returns a bad_request naming the accepted values.

Returns {"center", "radius_m", "results": [{"id", "kind", "name",
"distance_m"}, ...], "total_in_range"}, plus "truncated": true and an
explanatory "note" whenever fewer rows came back than matched, and a
"note" when the fallback fired or the search found nothing at all. id
is the GERS id, usable with other GERS-keyed tools. No raw geometry and
no schedules or live arrivals — Overture base/infrastructure is a
static OpenStreetMap conflation, and neither exists in it even in
principle; a caller wanting "next bus" needs a live transit API.

An empty results list is a valid answer, not an error: base-theme
coverage is OSM-derived and patchy, and "no stop within radius_m" is a
real finding. radius_m echoes the effective radius, which may be lower
than requested (large values are clamped).

Returns a structured {"error": ...} if upstream is unavailable or the
dataset is missing geometry/bbox, and {"error": "bad_request"} for a
non-finite or out-of-range coordinate, or an unrecognized kind.
water_nearA

Water near a point, nearest first: waterfront check, distance to river/canal/lake.

From Overture's base theme (issue #200), type=water — oceans, bays,
lakes, ponds, reservoirs, rivers, streams, canals, springs, pools.
Returns {"center", "radius_m", "in_range_count", "results": [{"name"
(when named), "subtype", "class", "distance_m", "is_salt"/
"is_intermittent" (only when true)}, ...]}, plus "truncated": true and
a "note" when more matched than were returned. No raw geometry.

distance_m is to the closest point on the feature, not its centroid —
a canal bank you are standing on reads ~0 m. Water gets dense (an
Amsterdam canal district puts hundreds of rows in a 500 m circle),
which is what in_range_count and the filters are for:
subtype/water_class match Overture's `subtype`/`class` columns
(case-insensitive substring; water_class is `class` under a
non-reserved name) — e.g. subtype="canal", subtype="river",
water_class="lake".

"on_water": true plus "water_body" means the point is *inside* a water
polygon — a lake, a reservoir, a river. For oceans and seas that is a
coarse signal: Overture cuts them into 1-degree tiles whose landward
edge covers dry coastal land, so a waterfront building reads as inside
the ocean. Those bodies are reported this way rather than as a bogus
0 m "nearest water" row, and no distance-to-coast is derived from them
(their tile boundaries include phantom cuts through open water), which
also means subtype="ocean" cannot return distance rows. Lakes and
rivers carry none of that: however large, they appear in results with
a real edge distance. The "note" says which case applies.

An empty results list is a valid answer: coverage is OSM-derived, and
"no water within 500 m" is a real finding about an arid or unmapped
place. radius_m echoes the effective radius (large values are
clamped). Returns a structured {"error": ...} if upstream is
unavailable or the dataset is missing geometry/bbox, and {"error":
"bad_request"} for a bad coordinate.
geocode

Free-text place name -> ranked candidate locations, from Overture divisions and places.

No Nominatim, no third-party geocoding API. Matches localities,
neighborhoods, regions, and countries by name (exact > prefix >
substring), falling back to named places if that doesn't fill `limit`.
Returns {"results": [{name, type, lat, lon, id (GERS), admin_context,
rank_score}, ...]}, budgeted like every other tool. Returns a structured
{"error": ...} instead of raising if the remote scan fails.

A trailing "City, ST"/"City, Region" suffix has resolved against a US
state or another region since #46; a trailing "City, Country" suffix
resolves the same way (#457) — "Cambridge, UK", "Cambridge, GB",
"Cambridge, GBR" and "Cambridge, United Kingdom" all constrain the
search to the UK's Cambridge, excluding any same-named division
elsewhere. `country` (ISO 3166-1 alpha-2, also accepting alpha-3 and
aliases like "UK"/"USA", case-insensitive) is the explicit form of the
same constraint, for a caller that already knows the country instead
of writing it into `query`. An unrecognized `country`, or one that
disagrees with a country/region suffix parsed off `query` itself,
returns {"error": "bad_request", ...} naming both. A comma-suffix that
names neither a region nor a country still searches the name half
alone rather than returning nothing, with a "note" naming the
unrecognized qualifier; a recognized country/region that matches
nothing degrades the same way, with a "note".

A query with no location context in it at all (a bare place name that
matches no division, e.g. "Blue Bottle Roastery") can't be bounded to a
region, so the places half of the search is skipped rather than
scanning the global places dataset — minutes, not seconds (#105). That
case comes back empty with a "note" saying so and what to do instead.

A misspelled name that matches no division literally ("Berekley", or
"Berekley, CA" — the region suffix is set aside first) gets one
close-spelling retry over the local divisions table (#215); those
results rank below any literal match, carry "matched_by": "fuzzy", and
come with a "note" naming the spelling they were corrected to.

A query that is entirely a postcode ("94110", "1011AB") is answered as
one (#223): one result per country whose address points carry that code,
with "type": "postcode", "country", "address_count" and a null "id" (a
postcode is not a GERS entity). Codes are shared across countries far
more often than not, so the alternates below the top row are real
ambiguity. The accompanying "note" carries the granularity caveat (a
Dutch code is a street block, a US ZIP a district) and the coverage
limits -- including the countries the addresses theme covers but that
carry no postcode values at all, which is why a valid postcode can still
come back empty.

Exonyms work too (#214): names are matched against Overture's ~100
localized alternates as well as its canonical one, so "Munich" answers
München and "Tokyo" answers 東京都. `name` is always the canonical
spelling; such rows carry an extra "matched_name" naming the alternate
that matched.

lang (#410) requests Overture's language-tagged name variant instead
of a division row's primary name, when the data has one for that row
and language: `name` becomes the variant and `name_primary` is added
only when it differs. Default: the stored `preferences()` lang, else
the primary name unchanged. Never invented or transliterated — only a
variant actually present in Overture's data is ever returned.
`PLACEROOT_HOME=<city/area>` (#406) sets a home region once at startup;
a bounded score bonus then nudges same-tier ambiguous namesakes (the
"which Springfield" case) toward it — a bias, never a filter, so a
distant result stays in the answer, just not first. Only when the bias
actually changed the top result does a "note" say so, e.g. "ranked
toward your configured home region (Seattle); pass a city/near hint to
override". No home configured -> no bias, no note, behavior unchanged.
geocode_batch

Geocode up to 20 free-text queries in one call, one best match each.

Cuts N round-trips of geocode() into one and, more importantly,
shares ONE local divisions name table across the batch (#329) so a
two-name walk is not N cold S3 scans. For each query, keeps only
the top candidate.
Returns {"results": [{"query", "name", "type", "lat", "lon", "id"
(GERS), "rank_score"}, ...]}, one row per query, in input order — a
query with no match gets the standard error envelope {"query",
"error": "not_found", "detail"} instead, and does not fail the rest of
the batch. queries is capped at 20; a longer list returns a structured
{"error": ...} rather than truncating silently. Budgeted like every
other tool. Returns a structured {"error": ...} instead of raising if
the remote scan itself fails.

country (#457, ISO 3166-1 alpha-2, also accepting alpha-3 and aliases
like "UK"/"USA", case-insensitive) applies the same "City, Country"
constraint geocode() takes to every query in the batch — e.g.
`geocode_batch(["Cambridge", "London"], country="CA")` answers with
Ontario's London and a not_found row for Cambridge (no Canadian
Cambridge in the dataset), rather than the UK/US namesakes each query
would otherwise resolve to on its own. An unrecognized `country`, or a
per-query suffix that disagrees with it, returns a structured
{"error": "bad_request", ...} for the whole call.
search_categoriesA

Free text -> valid Overture category slugs, for the category param the place-search and area-summary tools take.

Lookup only — no geo filtering, no upstream dataset dependency; matches
against a bundled snapshot of Overture's places taxonomy (pinned to
schema v1.9.0). Ranks exact slug match > slug prefix > slug substring >
a match on any taxonomy path segment, so close siblings like "cafe" vs
"coffee_shop" both surface rather than one silently winning. If the
whole query matches nothing, falls back to a lexical phrase-intent
match against a curated synonym lexicon (e.g. "fix my cracked phone
screen" -> mobile_phone_repair). Returns {"results": [{"slug", "path",
"confidence"}, ...]} — path is the root-to-leaf taxonomy (e.g.
["eat_and_drink", "cafe", "coffee_shop"]), confidence is 0-1 and
descending, budgeted like every other tool. An empty/whitespace query
returns {"results": []}. limit is clamped to 0-50, matching every
other tool's limit handling (out-of-range values are not an error).
resolve_place

Free-text place reference -> ranked, typed GERS ids to hold onto.

Turns something like "the Whole Foods on Lamar" or "Travis County" into
stable Overture ids: merges geocode()'s division matches (locality,
region, county, country, ...) with a name-filtered find_places search
(a business or POI), bbox-limited to near_lat/near_lon if given, else to
the ~20km vicinity of the top division match.

**Split the location out of the query, and pass `city`.** You know
things this server does not: that "san jose airport" means San Jose,
California, that the Eiffel Tower is in Paris, that a user asking about
"BASIS Silicon Valley" means Sunnyvale. This server knows only what
exists at which coordinates in the current Overture release. When the
location arrives inside one string, it has to guess which words are the
place — and it guesses from map data alone, where "san" names a division
in Henan and "palo" names one in Leyte. Given `city="San Jose, CA"` and
`query="airport"` there is nothing to guess.

A wrong hint costs a miss and a retry, never a wrong answer: `city`
only bounds where the search looks, and the returned rows still come
from the data. Pass `near_lat`/`near_lon` instead when you have real
coordinates — they are the strongest hint of all.

When nothing resolves for want of a location, the reply carries
`need: "location"` and a `retry_with` sketch rather than only prose,
so the second call can be made without parsing English.

Returns {"results": [{"id" (GERS), "kind": "division" | "place",
"name", "lat", "lon", "match": "exact" | "prefix" | "contains" |
"substring" | "fuzzy", plus "admin_context" for a division or
"category" for a place}, ...]}, ranked by match tier then prominence
("contains", places only, #475: the name holds the whole query or the
query the whole name, above "substring", which for a place can mean
one shared word; "fuzzy" — reached by close spelling rather than by
containing the query at all, #215 for divisions and #373 for places —
ranking below every literal match).
A place found through #373's alt-spelling/typo fallback additionally
carries "matched_by": "alt_name" | "fuzzy", and a top-level "note"
names the spelling actually matched. Budgeted like every other tool.
An unresolvable query returns {"results": []} — not an error. Returns a
structured {"error": ...} instead of raising if the remote scan fails
or the places dataset is missing columns this tool depends on.

lang (#410) requests Overture's language-tagged name variant, same as
geocode() — but only for "kind": "division" rows; "kind": "place" rows
(from find_places, out of scope for #410 this round) always carry
their primary name. Default: the stored `preferences()` lang, else the
primary name unchanged.
Division candidates come from geocode() (#406), so a configured
`PLACEROOT_HOME` nudges the same ambiguous-namesake ties this tool
merges from — see geocode()'s docstring. resolve_place does not add its
own disclosure note for that; its own ranking already leads with
distance to `near_lat`/`near_lon`/`city` when one is given.

country (#457, ISO 3166-1 alpha-2, also accepting alpha-3 and aliases
like "UK"/"USA", case-insensitive) composes with `city`: it constrains
the divisions half of the merge the same way it constrains geocode()
itself — `resolve_place("Cambridge", country="GB")` resolves the UK
Cambridge rather than whichever namesake ranks first unconstrained. An
unrecognized `country`, or one that disagrees with a country/region
suffix parsed off `query` itself, returns a structured
{"error": "bad_request", ...} naming both.
resolve_place_batchA

Resolve up to 25 GERS ids to compact place rows in one call.

Collapses N place_details(id=...) round-trips into one: for each id,
resolves it via the same lookup place_details uses and keeps only a
compact row — {"gers_id", "name", "category", "lat", "lon"} — not the
full place_details payload (addresses, websites, phones, socials,
sources, brand, confidence, ...). Use place_details for full detail on
a single id. Results are returned in input order; an id that doesn't
resolve gets the standard error envelope {"gers_id", "error":
"not_found", "detail"} instead and does not fail the rest of the
batch. gers_ids is capped at 25; a longer list
returns a structured {"error": ...} rather than truncating silently.
An empty list returns {"results": []}. Budgeted like every other tool.
Returns a structured {"error": ...} instead of raising if the remote
scan fails or the places dataset is missing columns this tool depends
on.
gers_lookupA

Any GERS id -> what it is, across themes, plus its cheap cross-theme joins.

The reverse of every other tool: hand back an id one of them returned
(a place, a division, or a building) and get the entity it names —
{"id", "theme", "type", "name", "lat", "lon", "summary", "related"} —
without needing to know which theme it came from. summary carries a few
theme-specific fields (place: category, confidence, brand; division:
subtype, country, region; building: class, height, floors); related
carries the containing division, plus the building at the point when
the id is a place. Never geometry.

Also pass near_lat/near_lon — the lat/lon of the row the id came from —
whenever you have them: the lookup is an id scan across up to three
themes, and the hint narrows each one to a ~50km box instead of a
full-theme scan. Omitting it still works, just much slower on a cold id.
The hint *bounds* the search rather than merely ordering it: an id
outside the box comes back not_found with a note saying so, and the
exhaustive lookup is the same call without near_lat/near_lon. Pass a
hint you are sure of, or none at all.

Transportation segment/connector ids are not resolvable yet and come
back as not_found. Returns {"error": "not_found"} if no theme claims
the id, {"error": "bad_request"} for a malformed id (a GERS id is an
opaque token — 32 lowercase hex characters) or an out-of-range hint,
or a structured {"error": ...} if upstream is unavailable.
reverse_geocode

Point -> nearest address (street/number/postcode) and its containing division chain.

Degrades to a divisions-only result (source: "divisions_only", plus a
note) if the addresses theme is unreachable, missing, or has no nearby
coverage — addresses is Overture's newest, least complete theme, so
this is the expected degraded path. Returns a structured {"error": ...}
instead of raising if the remote scan fails outright.

The answer also carries top-level "country" (ISO 3166-1 alpha-2) and
"region" (ISO 3166-2) beside admin_context, from the same nearest
division the chain is built from — omitted, not null-filled, when the
dataset or division lacks a value.
address_atA

Nearest street addresses to a point, nearest first: number, street, unit, postcode.

The address-level counterpart to reverse_geocode (issue #188): where
that returns one collapsed hop plus the admin chain, this returns the
few doorways around the point with the attributes an address lookup
wants. Returns {"results": [{number, street, unit, postcode,
postal_city, address_levels, country, distance_m}, ...]}, capped at 5.
Optional attributes are omitted when the source has no value for them.

No id is returned: Overture documents address ids as not GERS-stable, so
unlike a place/division/building id there is no durable handle to hand
out. For a stable reference to what is at a coordinate, use
reverse_geocode and hold onto the division it names.

Coverage is the thing to read carefully. The addresses theme is
Overture's only alpha theme and covers 39 countries — no UK, Ireland,
India, China, Korea or Russia, no Africa or Middle East, and little of
Latin America outside Brazil, Mexico, Chile, Colombia and Uruguay. An
empty results list is a valid answer, never an error, and always carries
a "note" saying whether the country is outside the theme's coverage
entirely or is covered but had nothing within the search radius. That
country is the one whose division polygon contains the point, so the note
stays correct next to a border; if the lookup behind it cannot run, the
note says so rather than asserting anything about the data.

Returns a structured {"error": ...} if upstream is unavailable, if the
dataset is missing the bbox/street columns this depends on, or for an
out-of-range coordinate.
geocode_address

Street address -> coordinates: "1600 Amphitheatre Parkway, Mountain View".

The forward counterpart to address_at, and finer than geocode, which
answers at city/neighborhood granularity and never at a doorway. The
first comma splits the street from the city; a bare integer at either end
of the street part is the house number ("1600 Amphitheatre Parkway",
"Hauptstraße 5"). Pass `number`/`street`/`city` instead if you already
have the parts. Unit/apartment numbers are not parsed.

A street part written as a crossing with no house number — "5th Ave &
42nd St, New York, NY", also "A and B", "A at B", "A / B", "A @ B" — is
routed to geocode_intersection with the same city and returns that tool's
answer ({"results": [{lat, lon, streets}], "anchor", "note"}) plus
"delegated_to": "geocode_intersection". "and"/"at" only split when both
halves look like streets (a street-type word or an ordinal each), so
"Rock and Roll Hall of Fame Blvd, Cleveland" is still one street.

The city is resolved first and its boundary bounds the search, so a city
that resolves to no boundary — or to something far larger than a city,
like a state — returns an empty list plus a note rather than a scan. If a
same-named runner-up in the same country supplies the boundary instead,
the note names it — the answer is never silently about a different city,
and never about one in another country. Check `anchor` (name, country,
admin_context) to see which one it was. Street names match in either
spelling (Parkway/Pkwy, West/W, NW/Northwest).

Returns {"results": [{number, street, unit, postcode, country,
distance_m, lat, lon}, ...], "anchor": {name, id, country,
admin_context}, "match": "exact"|"nearest_number"|"street"},
deduplicated to distinct number+street+postcode and nearest the city's
own point first. More matches than `limit` adds "truncated",
"distinct_in_range" and a note. `match` is absent only when no street
was scanned at all (no street name, no city, or an unresolved anchor).

A requested number with no address point is never interpolated: when the
street has other numbered points, `results` holds the real nearest known
numbers bracketing the miss instead (`match: "nearest_number"`, each row
its own genuine coordinates, plus a note naming the miss and neighbors)
— never a synthesized coordinate for the missing number. No usable
numbers on the street falls to `match: "street"`, today's empty-plus-note.

Coverage is alpha: 39 countries, no UK, Ireland, India or China. An empty
list is a valid answer and always carries a note saying whether the
country is uncovered or the street simply wasn't found.
geocode_intersection

Locate where two named streets cross: "5th Avenue", "Main Street", "Portland".

Resolves the city anchor, loads the street walk graph around its center
(the city's extent, up to 5 km from its center), and finds the junctions
inside it where an edge of one street meets an edge of the other.

Street names match case-insensitively and through the same USPS abbreviation
set as geocode_address (Avenue/Ave, Parkway/Pkwy, West/W, NW/Northwest,
5th/Fifth), plus a directional suffix the caller left off ("Pennsylvania
Avenue" finds "Pennsylvania Avenue NW").

Returns {"results": [{"lat": ..., "lon": ..., "streets": [name_a, name_b]}, ...],
"anchor": {"name": ..., "id": ..., "country": ..., "admin_context": [...]},
"note": ...}. `streets` is the map's own spelling of the two streets at that
crossing, in street_a/street_b order — not an echo of the inputs. When
multiple crossings exist, results are ordered nearest to the city center
first and capped at 5. If one or both streets do not resolve in the city,
returns empty results and a note naming the unresolved street; "truncated":
true means the street graph hit its size cap and may be missing some.
resolve_map_url

Pasted Google/Apple/OpenStreetMap/geo: map link -> its coordinate and place.

One call for the "meet here: https://maps.app.goo.gl/..." message: hand
over the link as pasted (scheme optional; a wrapping <...> and trailing
punctuation are tolerated) and get back {"lat", "lon", "provider"} plus
whatever the link itself carried — "zoom" when it had one, "label" when
it named a place or search ("Ferry Building", URL-decoded), "note" for a
directions link whose coordinate is only the viewport centre.

Parsed offline, no API key: Google Maps (/maps/place/<name>/@lat,lon,17z,
/maps/@lat,lon, the !3d!4d pin inside data= — preferred over the viewport
centre when both exist — ?q=lat,lon, ?ll=, ?z=, /maps/search/, /maps/dir/,
any google.<tld> host), Apple Maps (?ll=, ?q=, ?address=, ?sll=, ?z=),
OpenStreetMap (#map=z/lat/lon, ?mlat=&mlon= — the marker wins over the
viewport) and geo:lat,lon?z= URIs. The only network hop is a Google short
link (maps.app.goo.gl, goo.gl/maps, g.co): its redirects are followed
(at most 5, 5 s each) and the destination parsed; then "resolved_via" is
"redirect" and "final_url" is the page it landed on. Otherwise
"resolved_via" is "url". A link that names a place but carries no
coordinate (google.com/maps?q=Ferry+Building, Apple ?address=) is
resolved by name through resolve_place — "resolved_via": "name", with
the top hit's coordinate and "match": {"name", "id"}.

include_place (default true) adds "place": the reverse_geocode answer for
the coordinate (nearest address and containing admin areas), or that
call's own error dict — a place lookup failure never fails this call.
Pass false for the bare coordinate.

Errors are structured, never raised: "unsupported_url" (with the
"supported" provider list) for a link on none of these hosts,
"no_location" for a link with no usable coordinate or name (a Google
?cid= id, an OSM /node/<id> page, out-of-range coordinates, or a name
the place search cannot find — then "label" is kept), "redirect_failed"
(with "status" when HTTP said so) for a short link that would not
expand, "bad_request" for an empty or non-string url.
reverse_geocode_batchA

Reverse-geocode many points in one call, to cut N round-trips down to one.

Accepts at most 20 points; a longer list returns a structured
{"error": "bad_request"} instead of processing anything. Returns one
row per point in `points`, in the same order — each row is whatever
reverse_geocode(lat, lon) returns (address/divisions chain, or a
"divisions_only" degrade — see reverse_geocode's docstring). A
malformed point (missing/non-numeric lat/lon, or a lat/lon out of
range) doesn't fail the whole batch — it yields the standard error
envelope {"lat", "lon", "error": "bad_request", "detail"} in its slot
instead.
simplify_geometryA

Simplify a GeoJSON geometry to fit a token budget, reporting what was lost.

Works on caller-supplied GeoJSON (Polygon, MultiPolygon, LineString,
MultiLineString; Points/MultiPoints pass through unchanged). Binary
searches the simplification tolerance until the result fits max_tokens
instead of asking the caller to guess one. Returns {"geometry": ...,
"max_deviation_m": ..., "original_points": N, "kept_points": M}, or a
structured {"error": "invalid_geometry", ...} for malformed input.
geometry_opA

Geometry math and predicates — one tool, many ops, no Overture scan.

`op` selects the operation; pass only the params it needs (points are
`{"lat": ..., "lon": ...}`; `geometry` is a GeoJSON object):

- `distance(point, point2)` -> `{"distance_m"}` (great-circle haversine distance)
- `bearing(point, point2)` -> `{"bearing_deg"}` (initial compass bearing)
- `destination(point, bearing_deg, distance_m)` -> `{"point"}`
- `midpoint(point, point2)` -> `{"point"}` (great-circle midpoint)
- `area(geometry)` -> `{"area_m2", "area_km2"}` (Polygon/MultiPolygon)
- `length(geometry)` -> `{"length_m"}` (LineString/MultiLineString)
- `bbox(geometry)` -> `{"bbox": [xmin, ymin, xmax, ymax]}` (any geometry)
- `centroid(geometry)` -> `{"point"}` (any geometry)
- `buffer(point, radius_m)` -> `{"geometry"}` (Polygon, ~32-vertex circle approximation)
- `convex_hull(points)` -> `{"geometry"}` (Polygon; points capped at 100)
- `point_in_polygon(points, geometry)` -> `{"results": [bool, ...]}` (Polygon/MultiPolygon,
  holes honored; points capped at 100)
- `nearest_point(point, points)` -> `{"index", "distance_m"}` (points capped at 100)
- `nearest_point_on_line(point, geometry)` -> `{"point", "distance_m", "fraction"}` (LineString)
- `union(geometry, geometry2)` -> `{"geometry", "area_km2"}` (Polygon/MultiPolygon, either slot)
- `intersect(geometry, geometry2)` -> `{"geometry", "area_km2"}`, or `{"empty": true, "note"}`
  when the two inputs don't overlap
- `difference(geometry, geometry2)` -> `{"geometry", "area_km2"}` (geometry minus geometry2),
  or `{"empty": true, "note"}` when geometry2 fully covers geometry

`buffer`, `convex_hull`, and `union`/`intersect`/`difference` are the
ops that return geometry; that output is simplified to fit the same
token budget `simplify_geometry`'s own default targets, so there's no
need to chain a second call. `union`/`intersect`/`difference` run via
the DuckDB spatial extension already loaded for other tools (see
geometry_setops.py) rather than geometry_ops.py's pure-Python math.

An unknown op returns `{"error": "bad_request", ...}` listing valid ops.
Missing/wrong-shaped params for the given op return `{"error":
"bad_request", ...}` naming exactly what that op needs, e.g. "op=buffer
needs point and radius_m". Point-like inputs are range-checked (lat in
[-90, 90], lon in [-180, 180]); `geometry`/`geometry2` get structural
validation only (right type, non-empty numeric coordinates) — see
geometry_ops.py's module docstring for the accuracy notes behind
area/centroid (a local meters projection, not a geodesic computation)
and buffer/convex_hull (planar approximations, fine at city/regional
scale).
render_mapA

Render any result as a shareable one-pager: map, verdict, and stop list.

Writes ONE self-contained HTML file — interactive SVG map (inline CSS/JS,
vector markers with labels and click popups, polygon/line shapes
including reachability output shaped {"polygon": ..., "stats": {...}}),
a composed verdict, per-stop details, a scale bar, and required
attribution. A shape feature's properties may carry "role": "shed"
(soft translucent fill, dashed edge — for travel-time sheds) or
"role": "outline" (no fill, strong edge — for a compared-area boundary);
any other/absent role keeps the default style. Properties may also carry
a short "label" and one-line "callout", rendered as a text chip over the
shape (capped ~40/~80 chars); for the reachability payload, set
role/label/callout at the payload's top level. No CDN, no tile server,
no API key, zero
network requests when opened — a local file the user can send as-is.
Pass `summary` for
the verdict you want on the page (the sentence you'd tell a spouse,
co-founder, or landlord); when omitted a short fallback is composed
from the payload. Written to PLACEROOT_ARTIFACT_DIR (default: alongside
the tile cache directory). The file itself is the artifact; this tool's
response stays small on purpose. Returns {"path", "bytes",
"features_rendered", "skipped_features"} (plus "truncated": True when
applicable) — skipped_features counts rows/features that couldn't be
rendered (missing coordinates, malformed geometry, or dropped past
mapview.MAX_RENDER_VERTICES) rather than failing the call outright. Pass
inline=true to also get the HTML back in the response when it's small
enough to be worth it.

A point in `result` carrying a "class" property gets a contrasting
marker dot when `legend` maps that class to {"label": str, "color":
str?} — pass e.g. {"open": {"label": "Open now"}, "closed": {"label":
"Closed", "color": "#d55e00"}}. A missing color is assigned from a
fixed color-blind-safe palette; an invalid one (not #rgb/#rrggbb hex)
is dropped rather than used. Classes actually present get a legend box
on the page; a class not in `legend` keeps the default dot and is
reported in the response's "note". Omitting `legend` (or a result with
no "class" properties) renders exactly as before.
isochroneA

Isochrone: the area reachable from a point within minutes, by mode.

Give the point as lat/lon, or as `where` — a {"lat", "lon"} dict, a
GERS id, or a free-text place name — but not both (and not neither);
either way returns {"error": "bad_request"} naming the choice. A
`where` given as an id/name adds a compact "resolved": {"name", "id",
"lat", "lon", "matched_by"} to the answer; absent for lat/lon or a
{lat,lon} where.

Builds a street graph from Overture's transportation theme and runs
Dijkstra out to the time budget. Each mode
excludes its own set of unusable road classes (e.g.
drive excludes footway/path/steps; cycle and drive exclude
motorway/trunk... drive itself allows motorways) and respects one-way
restrictions for cycle/drive (walk ignores them). speed_m_s overrides
the mode's default speed model (walk 1.4 m/s, cycle 4.2 m/s, drive
per-edge from Overture's speed_limits or a class-based default table)
with a single constant. Returns {"polygon": <GeoJSON Polygon>, "stats":
{reachable_nodes, max_radius_m, area_km2}, ...}. The polygon traces the
boundary of reached nodes' occupied grid cells (falling back to a
convex hull for very small reachable sets); reachable_nodes/
max_radius_m are always exact, only the drawn polygon shape
approximates, and is decimated/simplified to fit the token budget.

radius_m optionally overrides the auto-derived graph extraction radius
(capped per mode: 5km walk, 15km cycle, 60km drive); passing something
larger than the cap returns a structured error instead of silently
truncating. An unrecognized mode string returns a structured
{"error": "unsupported_mode"}. minutes must be > 0 and radius_m (if
given) must be >= 0, else returns {"error": "bad_request"}.
routeA

Route: shortest-path distance and duration between two points, by mode.

Give the two ends as from_lat/from_lon and to_lat/to_lon, or as
from/to — each a {"lat", "lon"} dict, a GERS id, or a free-text place
name — but not both (and not neither); either way returns
{"error": "bad_request"} naming the choice. Do not call geocode(),
resolve_place(), or geocode_batch() first: names and ids resolve in
parallel inside this call, and the "from"/"to" blocks come back
carrying whatever each end resolved to.

Compact directions, not turn-by-turn: builds a street graph from
Overture's transportation theme around the two points and returns

{"distance_m", "duration_s", "mode", "from", "to", "export"} for the fastest path — no polyline unless you ask for one. export is the pocket handoff: Google/Apple Maps directions URLs built from the same two coordinates (URL schemes only — no Maps API, no extra network), a GPX 1.1 document, and a printable stop list. Same cost model every routing tool uses (walk 1.4 m/s, cycle 4.2 m/s, drive per-edge from Overture's speed_limits or a class-based default table). drive's duration is a posted-speed model with no live traffic; all modes snap each endpoint to the nearest usable street-graph node (real routes rarely start/end exactly on a segment).

Each mode has a straight-line-distance cap on the two points, rejected
before any graph is built (see routing.ROUTE_MAX_STRAIGHT_LINE_M, derived
per-mode from the shared graph-extraction radius cap — roughly
walk 7.5km, cycle 23.5km, drive 95.5km) — real road distance only ever
exceeds straight-line, so anything past the cap can't produce a route
worth extracting for anyway; returns {"error": "route_too_long"} with
the exact cap in "max_distance_m". An unrecognized mode string returns
{"error": "unsupported_mode"}; non-finite or out-of-range coordinates
(lat outside [-90, 90], lon outside [-180, 180]) return
{"error": "bad_request"}. If no usable graph or street node is found
near either point, returns {"error": "no_graph_nearby"}. If both points
snap into the graph but no path connects them (e.g. disconnected
islands of road data), returns {"error": "no_route", "try": ...}
rather than raising — "try" is a mode-tuned next move (roadmap §4). If
the extraction graph hit its internal size cap, the result
carries "truncated": true — the route may be suboptimal or incomplete.

include_path=true adds "path", a GeoJSON LineString from the origin's
snapped node to the destination's that follows the streets' own
geometry (curves included), simplified to fit the token budget, with
"path_max_deviation_m" bounding how far it strays from the exact
street path. Off by default (the polyline dwarfs the rest of the
answer) — ask for it only to draw or trace the route. If even a fully
simplified line won't fit, you get "path_omitted": true instead of a
line that stops short of the destination.

include_elevation=true adds "elevation": a compact climb profile from
the same keyless Copernicus GLO-30 DEM reader as point elevation lookups
use, sampled along the route —
"total_climb_m", "total_descent_m", "max_grade_pct", and a small
"samples" array of [distance_along_m, elevation_m] points, thinned to
fit the token budget. Off by default. Where the DEM has no coverage
along part or all of the route, the affected numbers are never faked as
0.0 — you get a "note" saying so instead (and no climb/descent/grade
keys at all if there's no coverage anywhere on the route). If even the
note-only form can't fit the budget, you get "elevation_omitted": true.

prefer="flat" asks the router to trade distance for climb — steeper
detours cost more than gentler ones, so a longer-but-gentler path can
win over a shorter-but-steeper one. Only meaningful for mode="walk" or
"cycle" (returns {"error": "bad_request"} for mode="drive"); needs
per-node elevations for the extracted street graph, fetched from the
same Copernicus source (bounded — see routing.FLAT_MAX_ELEVATION_NODES),
so if that data isn't reachable or has no coverage here, the route falls
back to plain-distance routing and says so in "prefer_note" rather than
silently ignoring the preference. IMPORTANT: prefer="flat" minimizes
elevation grade only — it is NOT a step-free, stroller-, or
wheelchair-accessible mode. Overture's transportation data (as read
here) carries no step-count, kerb-ramp, or surface attributes, so a
flight of stairs classified as ordinary walkway geometry can still
appear on a "flat" route if it's short and roughly level. Don't offer
this as an accessibility guarantee to the user; it isn't one.

avoid=["motorway"] (and/or "trunk") is the "no highways" ask: those
classes and their on/off ramps are dropped from the street graph before
the search, and the answer echoes "avoid". Those two values are the
whole vocabulary — anything else is a bad_request listing them. There is
no toll or ferry option, deliberately: Overture's road data carries no
toll attribute at all, and this graph is road-only so ferries are never
routed over. Tell the user that rather than approximating either with
avoid=["motorway"]. On walk and cycle it is a no-op (both already
exclude those classes) and says so in "avoid_note" instead of erroring.
An avoiding route is a different graph, so the first one in an area can
need its own confirm even where a plain route is warm; if the avoided
roads were the only link, the usual no_route comes back with "try"
naming avoid.

confirm=true after the user agreed to wait for a first-time street-graph
build (about 5–25 seconds). Pass it only after a needs_confirm reply
and they said yes. A warm or cached graph never needs it.
Omit confirm unless you just asked and they said yes.

A from/to name matching several equally-ranked places returns
{"error": "ambiguous_place", "candidates": [...]} instead of picking a
city; an unresolvable name or id returns {"error": "not_found"}; a
malformed one (empty string, dict missing lat/lon, wrong type) returns
{"error": "bad_request"} — the offending side is named in "field".
Ends that resolve a city apart return {"error": "too_far"} with both
ends and the mode cap, before any graph is built. from_to is this same
routing with a walk default.
elevation_atA

Ground elevation in meters at a point, from Copernicus GLO-30 (~30 m resolution).

Reads the Copernicus DEM directly from AWS Open Data (no API key, no
third-party elevation service) — the same open-data pattern every other
tool here uses, just a different bucket than Overture's. Nearest-cell
sampling, not interpolated: at ~30 m ground resolution the answer is
"the elevation of the DEM cell containing this point", which can be off
by a few meters from the exact spot on a steep slope.

Returns {"elevation_m": <float>}. No coverage at this point — open
ocean, or a tile the Copernicus release excludes from public
distribution — is a real, non-error answer: {"elevation_m": null,
"note": "..."} explaining why. Returns a structured {"error": ...} for
an out-of-range coordinate, or if the DEM tile can't be fetched
(network/upstream failure).

Attribution: Copernicus DEM © DLR/ESA, accessed via AWS Open Data.
map_match

Map-match a GPS trace: snap points onto the street graph, stitch a route.

points is an ordered list of {"lat": ..., "lon": ...} — up to 100 of
them (Mapbox's Map Matching tool takes the same cap; anything past it
returns {"error": "bad_request"} rather than truncating the trace
silently). A malformed point (missing/non-numeric lat or lon, or a
coordinate out of range) returns {"error": "bad_request"} naming the
offending index as "points[i]: ...".

Returns {"matched_length_m", "roads", "confidence", "geometry",
"unmatched_points"}: matched_length_m is the summed routed (not
straight-line) distance across every stitched leg; roads lists the
street names crossed in travel order, deduplicated only where they
repeat back to back; geometry is the stitched polyline as a GeoJSON
LineString, simplified to fit the same token budget every geometry-
returning tool here targets; unmatched_points lists the input
indices that never made it into the stitched route — points too far
from any street, and points the stitching's outlier guard rejected
(a routed detour wildly out of proportion to the straight-line gap,
e.g. a trace point that jumped to a different block). confidence
(0..1) blends how much of the trace stitched against how far the
matched points sat from their snapped street position.

A trace that matches nothing is a real answer, not an error:
matched_length_m 0, empty roads/geometry, every index in
unmatched_points, with a "note" explaining it. Do not treat that shape
as a failure — it means the trace is genuinely off the mapped street
network here, which is itself useful to know. A trace where only ONE
point matches gets the same empty-geometry shape with its own note: a
single position can't make a LineString (RFC 7946 requires two) or a
route.

Offline graph work, same as route()/isochrone(): builds one street
graph from Overture's transportation theme over the trace's own padded
bounding circle (no network beyond the existing tile/graph path) and
reuses route()'s per-mode class exclusions, so e.g. a drive trace never
snaps onto a footway. Omit mode to use the stored preferences mode,
else walk. An unrecognized mode string returns
{"error": "unsupported_mode"}. If no usable street graph exists near
the trace at all, returns {"error": "no_graph_nearby"}; a trace whose
padded bounding circle exceeds the mode's extraction cap (e.g. a walk
trace spanning well past ~10 km) returns {"error": "radius_too_large",
"max_radius_m": ...} — try the trace in segments, or a faster mode; a
scan that can't reach the upstream Overture data returns
{"error": "upstream_unavailable", "retry_advised": true}; missing
essential source columns returns {"error": "schema_degraded"}.

Attribution: street geometry and names from Overture Maps
transportation data.
timezone_at

IANA timezone and current local time at a point, fully offline.

Looks up the tzdb zone containing (lat, lon) from timezone-boundary-
builder polygons (via tzfpy, bundled — no network, no third-party
timezone API) and derives the rest from stdlib zoneinfo against the
current instant.

Returns {"tzid": "America/Los_Angeles", "utc_offset": "-07:00",
"dst_active": true, "local_time": "2026-08-30T14:05:00-07:00",
"abbreviation": "PDT"}. Open ocean generally still resolves — to a
fixed-offset, no-DST "Etc/GMT±N" nautical zone rather than a named
tzdb zone — but a point with no resolvable zone at all is a real,
non-error answer: {"tzid": null, "note": "..."}. Returns a structured
{"error": ...} for an out-of-range coordinate.

Attribution: IANA tzdb via tzfpy / timezone-boundary-builder.
from_toA

Shortest-path walk, cycle, or drive between two places.

from_to is route() with LocationRef ends and a walk default; route is
the canonical routing tool and is growing the same from/to ends, so
prefer route(from=..., to=...) once it takes them.

Pass each of from/to as a free-text place name, a {"lat", "lon"} dict,
or a GERS id — mixed freely. Do not call geocode(), resolve_place(), or
geocode_batch() first. Plain names resolve in parallel exactly as
before; coordinates pass through untouched. Builds one street graph and
returns distance, duration, export maps/gpx/text, and a "from"/"to"
block carrying whatever the input resolved to (name/id when it was a
name or GERS id, lat/lon always).

A comma qualifies: "Alamo Square, SF" searches inside SF only.

If a name matches several equally-ranked places, returns
{"error": "ambiguous_place", "candidates": [...]} instead of picking
a city. If the two ends resolve a city apart, returns
{"error": "too_far"} with the resolved ends and the mode cap rather
than extracting a continent graph. Same per-mode straight-line caps
as a coordinate route (walk ~7.5 km, cycle ~23.5 km, drive ~95.5 km).
An unresolvable name or GERS id returns {"error": "not_found"}; a
malformed from/to (empty string, dict missing lat/lon, wrong type)
returns {"error": "bad_request"} — either way the offending side is
named in "field": "from" | "to". Omit mode to use the stored
preferences mode, else walk.

include_path, include_elevation, prefer, and avoid pass straight through
to route() — see that tool's docstring for what each returns/means
("elevation" climb profile, prefer="flat" grade-avoiding preference and
its honest step-free/accessibility caveats, avoid=["motorway"|"trunk"]
class avoidance and why no toll or ferry option exists). avoid needs
mode="drive": the walk default already excludes those classes.

confirm=true after the user agreed to wait for a first-time street-graph
build (about 5–25 seconds). Pass it only after a needs_confirm reply
and they said yes. A warm or cached graph never needs it.
Omit confirm unless you just asked and they said yes.
compare_modes

Walk vs cycle vs drive between two places, in one call.

"Should I walk, bike or drive from A to B?" is three route() calls;
this is one. The ends resolve exactly once and every mode routes
between the same two coordinates, so the rows are comparable. Use it
when the user is choosing a mode; when the mode is already known,
route() or from_to() gives the fuller answer (export maps/gpx/text,
optional polyline).

Pass each of from/to as a free-text place name, a {"lat", "lon"} dict,
or a GERS id — mixed freely, exactly as from_to takes them.
Do not call geocode(), resolve_place(), or geocode_batch() first. modes
is an optional subset of ["walk", "cycle", "drive"]; omitted means all three
in that order (stored mode preferences are not consulted — every mode
asked for is computed). A mode outside that vocabulary, or an empty
list, returns {"error": "bad_request"}; duplicates collapse, order kept.

Returns {"from", "to", "modes", "fastest", "shortest", "summary"}.
"from"/"to" carry what each end resolved to (name/id when the input was
a name or GERS id, lat/lon always). "modes" is one compact row per
requested mode, in the requested order: {"mode", "distance_m",
"duration_s", "duration_min"} — no path, no export, no per-row
endpoints; with include_elevation=true a row also carries
"total_climb_m"/"total_descent_m" where the DEM covered the route
(never faked as 0). "fastest"/"shortest" name the mode with the lowest
duration/distance among the rows that routed, or null if none did.
"summary" is a short deterministic sentence or two built from the rows.

A mode that cannot be routed fails inline in its own row — {"mode",
"error", "detail"} — and never aborts the call: "too_far" when the ends
exceed that mode's straight-line cap (walk ~7.5 km, cycle ~23.5 km,
drive ~95.5 km — so a cross-town pair often gives a drive row and a
too_far walk row), "needs_confirm" with its eta/eta_s when that mode's
street graph is cold and confirm is false, or whatever error route()
returned (no_graph_nearby, no_route, ...). Only end resolution fails
the whole call: {"error": "ambiguous_place" | "not_found" |
"bad_request"} with "field": "from" | "to", as from_to does.

Same cost model as route(): walk 1.4 m/s, cycle 4.2 m/s, drive from
posted speed limits or a class default — a posted-speed model with no
live traffic, which the summary says whenever drive routed.

confirm=true after the user agreed to wait for a first-time street-graph
build (about 5–25 seconds per cold mode — each mode is its own graph).
Pass it only after a needs_confirm row and they said yes. A warm or
cached graph never needs it. Omit confirm unless you just asked and
they said yes.
find_nearA

Places of a category near a named place or city.

Prefer find_places(where=..., category=...): it is the canonical form of
this search — the same one hop, plus every find_places filter, detail
tier, and mode. find_near stays as a thin alias.

Pass the user's place name as near. Do not call geocode(),
resolve_place(), or geocode_batch() first. One hop for a category
near a named landmark. Resolves near, then searches like a point
find. Returns compact rows (name, category, distance, trust_note)
plus the resolved near (name and coordinates).

A comma qualifies: "Le Marais, Paris" searches inside Paris only.

If near matches several equally-ranked places, returns
{"error": "ambiguous_place", "candidates": [...]} instead of picking
a city. An unresolvable name returns {"error": "not_found"}; empty
category or near returns {"error": "bad_request"}. radius_m and
limit follow the same clamps as a point search.

A truncated answer carries "cursor" (delegated straight through from
find_places); pass it back with the same category/near/radius_m/limit
to continue. See find_places' docstring for the bad_cursor/release-
mismatch details — they apply here unchanged.
ground_locationA

One-hop location grounding: where, surroundings, reach, notable.

Answers "orient me at this point" in a single call instead of chaining
a reverse lookup, an area summary, a reachable-area scan, and a
nearby-places search. Give the point as lat/lon, or as `where` — a
{"lat", "lon"} dict, a GERS id, or a free-text place name — but not
both (and not neither); either way returns {"error": "bad_request"}
naming the choice. A `where` given as an id/name adds a compact
"resolved": {"name", "id", "lat", "lon", "matched_by"} to the answer
(a separate key from the answer's own "where" section below); absent
for lat/lon or a {lat,lon} where. Returns:
- where: reverse_geocode's answer for the point (address/divisions
  chain, or a "divisions_only" degrade).
- surroundings: total places and the top few categories within a fixed
  500m radius, plus density_per_km2.
- reach: reachable-area stats only for (minutes, mode) —
  {reachable_nodes, max_radius_m, area_km2}. Never includes the
  reachable-area polygon; this tool returns no geometry, ever.
- notable: the nearest 2-3 named places, no category filter.

Each section is independent: if its underlying call fails or comes
back empty, that section is dropped and a short line explaining why is
added to "notes" instead — the call only fails outright if every
section failed, returning a structured {"error":
"upstream_unavailable", ...}.

minutes must be > 0 and <= 60; omit mode to use the stored
preferences mode, else walk.
Both, plus out-of-range coordinates, return {"error": "bad_request"}.
No confirm gate: the reach scan runs with the requested minutes/mode
as-is (it self-caps its graph extraction radius; no nearby street
graph just degrades the reach section to a note).
places_along_routeA

Places on the way from A to B: corridor search along the route.

Answers "find a coffee shop on my drive to the airport" — the route tool
plus find_places in one call. Builds the same street-graph shortest path
`route` returns, then finds places whose nearest point on that path is
within max_detour_m (default 1000m, capped at 5000m; larger values
return a bad_request error rather than being silently clamped).

Each result row is a find_places row plus two numbers: detour_m, the
straight-line distance to the route doubled — an approximation of the
round trip off and back on, not a re-routed detour — and along_m, how
far along the route from the origin that place sits, so "roughly
halfway" is answerable. Results are ordered by along_m (route order,
reading as an itinerary) rather than by detour cost. When more than
limit places are on the way, the response is an even sample spanning the
whole route — never just the first limit, which would drop the far end
of the journey — and carries "truncated": true saying so. It also
carries {"route": {"distance_m", "duration_s", "mode"}} for the
underlying route. A composed itinerary also carries
verify_before_going when any stop is low-confidence or listed closed,
naming the 1–2 places most worth checking.

category and name narrow the search exactly as they do in find_places
(category matches Overture's taxonomy, e.g. 'coffee_shop'; name is a
substring match) — worth passing on a long route, since an unfiltered
corridor through a dense area can hold more places than the search
considers, in which case the response carries "truncated": true and a
note saying so.

Omit mode to use the stored preferences mode, else drive. Same cost model
and the same straight-line-distance caps as `route`, and the same
structured errors: route_too_long, no_graph_nearby, no_route,
unsupported_mode, and bad_request for non-finite/out-of-range
coordinates or an invalid max_detour_m.
neighborhood_verdict

Life-decision neighborhood verdict, not a data dump.

Accepts a point plus free-form life context (household, mobility,
priorities) and returns a ranked verdict: strengths, weak points, and
the one thing to verify in person. Empty context still answers a
generic walk-first daily-needs check and says what was assumed.
Optional radius_m / minutes / mode override what the context implies
(no car / walk-first -> walk, bike -> cycle, car -> drive; default
walk, 15 minutes). Does not call out to extra remote APIs; the
"transit" need additionally reads base/infrastructure transit stops
(bus_stop, subway_station, ferry_terminal, ...) alongside the
places-theme categories, since places alone under-reports bus stops
(#454).

Returns a structured {"error": ...} for bad coordinates, an unknown
mode, a radius past the mode cap, upstream failure, or a degraded
schema. Missing street graph degrades to straight-line times with a
note rather than failing the verdict.
verify_claimsA

Grade spatial listing claims ("8 min to the metro", "shops on the doorstep", "green space nearby") against real routing and places data.

Free-text claim parsing needs an LLM — this tool takes already-decomposed
structured checks; the verify_listing_claims prompt teaches an agent how
to turn listing text into them. Each of claims (max 8; max 5 of kind
travel_time, since each costs a routed call) is one of:

- {"kind": "travel_time", "to_category": str|None, "to_name": str|None,
  "mode": "walk"|"cycle"|"drive" (default walk), "claimed_minutes": number}
  Finds the nearest place matching to_category (an Overture taxonomy
  slug) and/or to_name (a substring match), then routes to it and
  compares the routed minutes against claimed_minutes.
- {"kind": "count_nearby", "category": str|None, "name": str|None,
  "radius_m": number (default 500, capped at 2000), "claimed_at_least": int}
  Counts matching places within radius_m and compares against
  claimed_at_least.
- {"kind": "distance", "to_category": str|None, "to_name": str|None,
  "claimed_max_m": number}
  Straight-line distance (haversine, not routed) to the nearest match,
  compared against claimed_max_m.

Every kind needs at least one of its category/name fields; giving
neither is a bad_request. A category is an Overture taxonomy slug,
matched exactly (including its taxonomy descendants), never as a
substring — "park" does not match a parking garage; a name is a
substring match.

Verdict per claim: "confirmed" when the measured number is within the
claimed number x1.15 (count_nearby: measured count >= claimed),
"stretched" within x1.5 (count_nearby: count >= half the claim, floor
1), otherwise "false". A claim asserting a place exists at all, when
none is found within the search bound, is "false" with a note —
absence is a verdict, not an error. A claim the measurement cannot
decide is "unverifiable" instead of "false": a travel_time claim whose
place is found but cannot be routed to (no street graph nearby, or the
network doesn't connect the two points), or whose failing measurement
came from a size-cap-truncated street graph, and a count_nearby claim
whose claimed_at_least exceeds the row cap the count stopped at.

Returns {"results": [{"claim": <echo of the input>, "verdict":
"confirmed"|"stretched"|"false"|"unverifiable", "measured": {...
kind-appropriate minutes/count/distance_m, plus the matched place's id
and name when there is one}, "note": optional}, ...], "verdict_rule":
a one-line summary of the thresholds above}.

Returns a structured {"error": "bad_request", ...} for anything
malformed in claims (unknown/missing kind, more than 8 claims, more
than 5 travel_time claims, a missing or non-numeric claimed value,
neither target field given, or an unsupported mode), {"error":
"bad_request", ...} for invalid lat/lon, or a structured {"error": ...}
if the upstream dataset is unavailable or missing columns this tool
depends on.
optimize_routeA

Best order to visit several stops: multi-stop route ordering (a small TSP).

Answers "I have these five errands, what order costs least" — stops is a
list of 2-10 points, each a {"lat": ..., "lon": ..., "name": ...
(optional)} dict, a GERS id, or a free-text name, mixed freely. The
answer is the cheapest visiting order over the real street graph, not a
straight-line guess. Solved exactly (Held-Karp over the routed cost
matrix), so it is the optimum, not a nearest-neighbour approximation.
Any stop given by id/name that failed to resolve returns an indexed
error (stops[i]: ...) with candidates on ambiguity — the whole call
fails, not silently drops that stop.

Returns {"order": [stop indices, in visiting order], "legs":
[{"from_idx", "to_idx", "distance_m", "duration_s"}, ...],
"total_distance_m", "total_duration_s", "mode", "roundtrip", "export"}
— indices refer to the input `stops` list, and there is no
polyline/geometry — for a single pair's numbers on their own, call
`route`. export is the pocket handoff: a multi-stop Google/Apple Maps
directions URL (coordinates only — no Maps API), a GPX 1.1 document
with every stop as a waypoint, and a printable list that keeps any
names the caller passed. If a stop already carries confidence or
operating_status (from a prior place lookup), the response adds
verify_before_going naming the 1–2 weakest. Any stop given as an id or
name adds "resolved": [{"stop": i, "name", "id", "lat", "lon",
"matched_by"}, ...] for just those stops — plain {lat,lon} stops need
no echo and the key is absent when every stop was already coordinates.

keep_order=true visits the stops in the order you gave and never
reorders them: the itinerary is the caller's, and this tool supplies
routed (not straight-line) per-leg numbers, the totals, and the export
for it — one street-graph build for the whole run instead of chaining
`route` per leg. Use it whenever the order came from the user ("first
the bank, then the school, then home"); leave it off to be told the
cheapest order. The response echoes "keep_order": true, and "order" is
then just 0..n-1. start_index must stay 0 with it (there is nothing to
fix — the given order already starts where it starts), and roundtrip
still chooses whether the last leg closes back to the first stop, so a
one-way itinerary wants roundtrip=false.

start_index (default 0) is fixed as the first stop. roundtrip=true (the
default) returns to it; the closing leg is in "legs" but the start is not
repeated in "order". roundtrip=false is an open path that ends wherever
is cheapest. Omit mode to use the stored preferences mode, else drive. Same
cost model every routing tool uses; one-ways make the drive/cycle cost
matrix asymmetric and that is solved for exactly. The objective minimized
is total duration.

If some pair of stops has no route between them (disconnected road data),
the call still succeeds: that leg's numbers are a straight-line estimate,
the leg carries "estimated": true, and the response carries
"estimated": true plus a note naming the estimated legs — so a flagged
approximation, never a crash.

confirm=true after the user agreed to wait for a first-time street-graph
build (about 5–25 seconds). Pass it only after a needs_confirm reply
and they said yes. One gate for the whole call — every stop rides the
same graph, so it asks once, never once per leg. A warm or cached graph
never needs it. Omit confirm unless you just asked and they said yes.

Errors are structured, not raised: fewer than 2 or more than 10 stops, a
stop that is not a valid location reference, an out-of-range
start_index, or keep_order=true with a non-zero start_index return
{"error": "bad_request"} naming the offending stop
index; an unresolvable name/id returns {"error": "not_found"} or
{"error": "ambiguous_place", "candidates": [...]}, indexed the same way;
an unknown mode returns {"error": "unsupported_mode"}; a stop set whose
two furthest-apart stops are further apart than the mode's straight-line
cap (see `route`) returns {"error": "route_too_long"}; a stop with no
usable street node near it returns {"error": "no_graph_nearby"} naming
that stop's index.
preferences

Travel defaults.

State "I bike everywhere, I have a dog" once. Routing tools use the
stored mode when you omit theirs; an explicit argument always wins.
pace and household are stored for later features and do not change
answers yet. lang (#410) is the stored result-language preference: the
name-lookup tools that accept their own `lang` use this one when
theirs is omitted, returning an Overture-tagged name variant (e.g.
"Munich" for "München" with lang="en") — a per-call `lang` always
wins. The same document is the placeroot://preferences resource.

Call with no arguments to read. Pass mode, pace, household tags, a
free-text note, or lang to merge those fields.
clear=true deletes the file and cannot be combined with other fields.
Nothing is sent off this machine. Over HTTP, preferences are
per-connection and not persisted (this session's values overlay the
host's file and are gone when the session ends).
warmup_cityA

Pre-cache a city.

Copies places and transportation tiles into the same local cache later
queries read. Does not build the routing graph (the first route still
pays that cost) and does not pre-cache buildings. The warmup call is
the slow one; later place searches over the area read locally.

radius_m defaults to 8000 (a city core) and is capped at 25 km so a
warmup cannot fan into a planet-sized tile fetch.

confirm=true after the user agreed to wait for a first-time tile
warmup (about 5–25 seconds). Pass it only after a needs_confirm reply
and they said yes. An already-cached city never needs it.
Omit confirm unless you just asked and they said yes.
data_versionA

Which Overture Maps release backs the answers from every other tool.

Reports the active release string, its date, and whether it came from
live S3 discovery, an operator env override, or the pinned fallback
baked into this build. Resolved once at process start and cached for
the process lifetime — this tool just reports that cached value, it
doesn't re-check upstream, so it's small and has no upstream DB
dependency.

The body is resources.data_version_payload(), shared verbatim with the
placeroot://data-version MCP resource so the two surfaces cannot drift
(issue #195); tests/test_resources.py asserts they stay equal.

Prompts

Interactive templates invoked by user choice

NameDescription
site_selectionPick where to open a business: category lookup, area baseline, candidate comparison, competitor proximity, one recommendation.
compare_neighborhoodsCompare two neighborhoods side by side on amenity mix, density, and building stock.
plan_errandsOrder a list of errand stops into an efficient route with per-leg distances and durations.
should_i_live_hereShould I live in this neighborhood? One verdict shaped around the asker's life, not a table of counts.
get_to_know_my_cityPre-cache places and transportation tiles for a city — the optional first-five-minutes warmup. Does not build the routing graph or cache buildings.
verify_listing_claimsCheck a listing's spatial claims (travel time, nearby counts, distances) against real map data — confirmed / stretched / false / unverifiable, claimed vs. measured.
plan_area_visitPlan a visit around specific interests: one multi-category scan, an optimized route, and a map — trust tiers surfaced, not hidden.

Resources

Contextual data attached and managed by the client

NameDescription
data_versionWhich Overture Maps release backs every PlaceRoot answer, and how it was resolved (live S3 discovery, an operator env override, or the pinned fallback). Same value the data_version tool returns.
categoriesSummary of the Overture places category taxonomy: every top-level category with how many slugs sit under it, plus how to look up the exact slug for a query. Compact by design — use the search_categories tool for individual slugs.
preferencesLocal travel and household preferences (mode, pace, household, result-language lang). The same document the preferences tool reads and updates. Nothing in this file leaves the machine.

TDQS

A3.9/5.0

Scored across 42 tools

Disambiguation3/5

Several tools overlap or are explicit aliases: find_near is a thin alias of find_places, from_to is a legacy alias of route, resolve_place_batch is actually a batch of place_details rather than resolve_place, and geocode/resolve_place, reverse_geocode/address_at, and admin_lookup/reverse_geocode share overlapping outputs. However, the descriptions are unusually detailed and cross-reference canonical forms, so an agent reading them can usually pick correctly.

Naming Consistency3/5

Most names are descriptive lowercase snake_case and there are coherent families (_at suffix, geocode*, *_matrix, summarize_*), but the set mixes verb_noun (find_places, summarize_area), noun_at (buildings_at, land_use_at), single nouns (isochrone, route, preferences), and awkward aliases (from_to, find_near). No case mixing, but the overall pattern is not uniform.

Tool Count2/5

42 tools is a heavy surface for any MCP server. The broad domain justifies much of the count, but several tools are aliases or convenience composites that could be consolidated (find_near, from_to, within_distance, ground_location), and there are multiple geocoding/resolution variants plus batch versions. Agents face a large, navigationally dense tool list.

Completeness4/5

The server covers an unusually complete geospatial surface: place search/details, forward/reverse geocoding with batches, routing, matrices, isochrones, multi-stop optimization, meeting points, corridor search, area summaries/comparisons/verdicts, changes over time, building/land/water/infrastructure themes, geometry ops, map rendering, elevation, and system tools. Minor gaps include no arbitrary-GeoJSON-polygon place search, no division-boundary retrieval, and no direct address-to-place lookup, but these are workaroundable or consistent with the 'answers, not data' design.

Maintenance

ActivityActive
ResponsivenessResponsive