# MCP tool reference

Every tool the hosted MapMap MCP server serves, with its inputs, its output and one real call. This page is generated from the server's own `tools/list` (input and output JSON Schemas built from the server's types), captured from `https://mcp.mapmap.ai/mcp` on 2026-11-06 (sn-mcp 0.3.0, 56 tools). Do not edit it by hand: run `node --experimental-strip-types scripts/generate-mcp-tools-md.mjs` in `website/`.

How to connect, keys and billing, resources, prompts and self-hosting are on [MCP server](/docs/mcp). Recipes per assistant are on [Use MapMap from any AI assistant](/docs/agents).

## Reading this page

- **Access** says how the tool can be called on the hosted endpoint. *Keyless* tools answer without a key under fair use; present your own `snk_` key as `Authorization: Bearer snk_…` and their metered calls bill to you instead. *Keyless, local* tools are pure computation: no network call, never metered. *Your own key* tools refuse keyless calls on the hosted endpoint. *Operator tool* means it refuses on the hosted endpoint.
- **Inputs** lists every argument the server accepts. *Default* is the schema's own default or the default the server's description states; empty means none is stated. *Limits* are the bounds the schema enforces; limits stated only in prose are in the description.
- A coordinate (`LatLon`) is a `{lat, lon}` object (preferred) or a GeoJSON `[lon, lat]` array, longitude first. Distances are metres and durations seconds unless a field name says otherwise.
- **Caveats** are the server's own sentences about what an answer does and does not mean, quoted from its description. Pass them on when you relay the answer.
- **Example** calls were made keyless on the hosted endpoint on the date shown. Results are trimmed for length: long strings end in `…` and arrays show their first two entries.
- Tool failures come back as MCP tool errors (`isError: true`) with a message naming the fix, so an agent can correct itself.

## Tools by group

- **Routing**: [`route`](#route), [`route_observations`](#route_observations), [`heritage_narration`](#heritage_narration), [`matrix`](#matrix), [`reachable_area`](#reachable_area), [`elevation`](#elevation), [`match_trace`](#match_trace)
- **Fleet and optimisation**: [`optimise_routes`](#optimise_routes), [`order_stops`](#order_stops), [`cluster`](#cluster), [`replan_routes`](#replan_routes), [`plan_day`](#plan_day), [`plan_errands`](#plan_errands), [`plan_ev_route`](#plan_ev_route)
- **Along-route search**: [`search_along_route`](#search_along_route), [`cheapest_fuel_along_route`](#cheapest_fuel_along_route), [`cheapest_charging_along_route`](#cheapest_charging_along_route)
- **Places and geocoding**: [`geocode`](#geocode), [`reverse_geocode`](#reverse_geocode), [`nearby_places`](#nearby_places), [`places_in_view`](#places_in_view), [`list_place_categories`](#list_place_categories)
- **Transit**: [`nearby_stops`](#nearby_stops), [`next_departures`](#next_departures)
- **Weather and events**: [`weather_along_route`](#weather_along_route), [`weather_frames`](#weather_frames), [`events_in_area`](#events_in_area), [`space_weather_now`](#space_weather_now), [`camera_incidents`](#camera_incidents)
- **Clearance and ADR**: [`check_adr_tunnel`](#check_adr_tunnel), [`check_clearance_on_route`](#check_clearance_on_route)
- **Verify**: [`verify_places`](#verify_places), [`validate_geodata`](#validate_geodata)
- **Map styling**: [`list_style_layers`](#list_style_layers), [`get_style`](#get_style), [`create_style`](#create_style), [`set_palette`](#set_palette), [`set_layer_paint`](#set_layer_paint), [`set_layer_layout`](#set_layer_layout), [`check_style_contrast`](#check_style_contrast)
- **Geo helpers**: [`geo_distance`](#geo_distance), [`geo_bearing`](#geo_bearing), [`geo_destination`](#geo_destination), [`geo_point_in_polygon`](#geo_point_in_polygon), [`geo_bbox`](#geo_bbox), [`geo_centroid`](#geo_centroid), [`geo_length`](#geo_length), [`geo_area`](#geo_area), [`geo_simplify`](#geo_simplify), [`geo_nearest_point_on_line`](#geo_nearest_point_on_line)
- **Jobs**: [`submit_optimise_job`](#submit_optimise_job), [`get_job`](#get_job)
- **Account and usage**: [`get_usage`](#get_usage)
- **Feedback and operator tools**: [`report_map_issue`](#report_map_issue), [`submit_integration_retro`](#submit_integration_retro), [`set_temporary_closure`](#set_temporary_closure)

## Routing

Routes in every costing, what a route passes, reachability, travel matrices, elevation and trace matching.

### `route`

Compute a turn-by-turn route between origin and destination (optionally via waypoints). Costing "auto" = car, "truck" = lorry, "bicycle", "pedestrian" = walking, "motor_scooter" = moped.

**Access:** Keyless. Answers without a key on the hosted endpoint under fair use; present your own key to meter it to you.

**Caveats the server states:**

- A wheelchair route through the MapMap gateway returns a `grade` block (maximum grade, ascent and descent sampled from a 30 m elevation model, the `use_hills` preference sent, and `cap.enforced` for a `max_grade_pct`, which the engine accepts but does not enforce yet: say so) and, where the deployment has a surface graph, a `surface_quality` block: `applied: true` means ways rated smoothness bad or worse in OpenStreetMap were avoided in the named region, a preference from ratings where they exist that says nothing about kerbs, widths, steps or inclines; `applied: false` gives the `reason`.
- Relay both caveats and never describe such a route as suitable for every user; `bicycle` {bicycle_type, use_roads 0-1, use_living_streets 0-1, avoid_bad_surfaces 0-1, use_hills 0-1} for quiet-ride and surface preferences.
- Any of truck, auto, bicycle, pedestrian or motor_scooter routes may set `rationale: true` (opt-in, costs up to 1 + N extra routing calls) to learn which declared truck constraints or avoidance-side preferences (hills, surfaces, tolls, unlit streets, …) actually changed the route (`rationale.avoided[]`, basis route_divergence, it proves a field was binding, it does not identify the physical restriction or feature, and no live traffic or incident data is ever attributed).
- ADR honesty: `applied_adr.forbidden_tunnel_categories` describes the LOAD, not the returned route, and `applied_adr.tunnel_enforcement` states the boundary: roads are excluded only where the routing graph records an ADR tunnel category, so an unchanged route is not a clearance.
- Set `landmarks: true` for turn instructions anchored to recognisable places, each manoeuvre that passes one gains a `landmark_instruction` like "Turn right just after the Shell garage" beside the engine's own street-name `instruction`, which is never replaced.
- Nothing is named unless it is recognisable from the road, within 40 m of the junction and not tagged as closed, so many routes return none and a `landmarks.annotated` of 0 with no `note` means this route genuinely passes nothing recognisable.
- Through the gateway each manoeuvre also carries the road it starts on: `country`, `subdivision`, `ref` (route numbers only), `road_class`, `speed_limit_unit` (`mph` or `km/h`, from the road's country), `speed_limit_sign` (`vienna` or `mutcd`), `speed_limit_posted` (the limit as the sign shows it, in the road's unit) and `driving_side`; each is absent when the map does not know it, never guessed.
- Optional `avoid` and `exclude` name road features to keep off, and the difference between them is not cosmetic.
- `avoid` ("tolls", "highways", "ferries") is a PREFERENCE: it sets the costing's willingness to zero, and the engine's own reference says that is not guaranteed to avoid the feature, measured, avoiding tolls across the Dartford Crossing returns the same tolled route, because the untolled alternative is fifty kilometres further.
- `exclude` ("tolls", "highways", "ferries", "bridges", "tunnels") is a hard exclusion: it can answer NO ROUTE rather than a detour, and it depends on the routing engine's own hard-exclusion setting, which the engine does not report and this server cannot read, so it is requested, never promised.
- "tolls" and "highways" under `avoid` exist only on motorised costings; asking for one on a bicycle is refused rather than silently ignored.
- Whatever is applied comes back in `avoidance`, with the caveats, relay them, because "avoid tolls" read as a guarantee is the failure mode here.
- It is a snapping preference, not a manoeuvre guarantee, and it needs a coordinate genuinely offset from the road centreline; `street_side_tolerance_m` and `street_side_max_distance_m` bound the window in which it applies, and a pair leaving no window is refused rather than answered with the preference silently inert.
- Without it a zone can only be named, never priced.
- Set `scenic: true` (auto costing only) to ask whether there is a prettier way.
- It is a PEER OFFER, never a substitution: the route in `geometry_polyline6` is byte-for-byte what the same request returns without the flag, and the prettier way, when there is one, arrives beside it in `scenic`, with its own geometry in `scenic.alternative_geometry_polyline6` and what it costs in `scenic.spoken` ("About seven minutes longer than the quick way").
- That is an ANSWER, not a failure, and it is the one to relay.
- It is a re-ranking of routes the engine already proposed, not a scenic search: a beautiful road the engine never offered was never considered, and `scenic.caveats` says so.
- Like `rationale`, IT COSTS EXTRA METERED COMPUTATIONS, at most two, which is why it is opt-in and never on by default.
- `available: false` with a `note` means this deployment could not look, which is not the same claim as nothing being there.

**Inputs**

| Input | Type | Required | Default | Limits | Description |
|---|---|---|---|---|---|
| `destination` | RouteLocation | yes |  |  | Route destination. |
| `origin` | RouteLocation | yes |  |  | Route origin. |
| `avoid` | AvoidFeature[] | no |  |  | Road features to avoid as a PREFERENCE. Rendered as the costing's willingness factor set to zero, a thumb on the scale, not a ban. The engine's own reference is explicit that a value of zero is not guaranteed to avoid the feature entirely, and it does not: asking to avoid tolls across the Dartford Crossing returns the same tolled route, because the untolled alternative is fifty kilometres further. Use `exclude` when you mean a ban. |
| `bicycle` | BicycleSpec | no |  |  | Bicycle options (bicycle type, road/surface/hill preferences). Requires costing "bicycle". |
| `costing` | [CostingKind](#costingkind) | no | `"auto"` |  | Costing model: "auto" (default), "truck", "bicycle", "pedestrian" or "motor_scooter". |
| `emissions` | [EmissionsSpec](#emissionsspec) | no |  |  | The vehicle's emission declaration, for UK clean-air / low-emission zone assessment. Given, the response's `zones` block names every zone the route enters and what THIS vehicle pays there, with the publishing authority cited. Without it a zone can only be named, never priced. Needs the MapMap gateway, which holds the curated zone dataset; the full dataset is readable at `GET /v1/zones`. |
| `exclude` | ExcludeFeature[] | no |  |  | Road features to exclude outright, rendered as the costing's hard exclusion flag. Two things follow from that and both matter: a hard exclusion can return NO ROUTE rather than a detour (excluding tunnels on a Rotherhithe crossing has no answer), and the flags depend on the routing engine's `allow_hard_exclusions` setting, which the engine does not expose and nothing here can read, so this is never promised, only requested. |
| `exclude_polygons` | number[][][] | no |  |  | Areas to avoid, scenario analysis ("close this bridge and re-route"): an array of polygons, each an array of `[lon, lat]` pairs forming one exterior ring (GeoJSON-style, longitude FIRST). Roads intersecting any ring are excluded from the search. When no route avoids them the answer is "no path": the search is never widened to snap a stop onto a road beyond the area. Applies to the Valhalla engine; unsupported on the GraphHopper engine, where it is ignored. |
| `landmarks` | boolean | no | false |  | Name landmarks in the turn instructions (default false): each manoeuvre that passes a recognisable place, a petrol station, a supermarket, a household-name chain, gains a `landmark_instruction` like "Turn right just after the Shell garage" beside the engine's own street-name instruction, which is never replaced. Nothing is named unless it is recognisable from the road, within 40 m of the junction and not tagged as closed, so many routes come back with none: a wrong landmark is worse than no landmark. Needs the MapMap gateway, whose place index does the lookup. |
| `pedestrian` | PedestrianSpec | no |  |  | Pedestrian options (lit-street preference, wheelchair/blind type, hiking difficulty). Requires costing "pedestrian". |
| `rationale` | boolean | no | false |  | Explain the route (default false): re-routes with each declared truck constraint (truck costing) or avoidance-side routing preference (auto, bicycle, pedestrian, motor_scooter) relaxed and reports the ones that actually changed the route as `rationale.avoided[]`. Opt-in, it costs up to 1 + N extra routing calls, one per declared field plus one combined probe, and it is billed for the ones it actually makes: at most 8 in total, typically fewer, and 1 when there is nothing to probe. Against the hosted gateway each probe is its own metered route call, which is exactly what `POST /route` with `rationale: true` charges for its own fan-out, so the two surfaces price the same explanation the same way. |
| `scenic` | boolean | no | false, `auto` costing only |  | Ask whether there is a prettier way (default false, `auto` costing only). The route in `geometry_polyline6` is NEVER swapped: what comes back is a peer offer beside it, in `scenic`, carrying the plain sentence that justifies it. No sentence, no offer: a route that scores well but cannot support one is dropped rather than dressed up, and `scenic.reason` is then null, which is an answer. Every candidate that lost says why in `scenic.rejections[]` (`same_route`, `no_scenic_gain`, `not_scenic_enough`, `over_time_budget`, `nothing_to_say`), so "no prettier way" is always attributable. Opt-in because it costs: at most two extra metered route computations, the same billing shape as `rationale`. Needs the MapMap gateway, whose basemap archive the scoring is measured against. |
| `truck` | [TruckSpec](#truckspec) | no |  |  | Truck profile (dimensions + ADR declaration). Requires costing "truck"; when present, ADR dangerous-goods costing options are merged into the request. |
| `waypoints` | RouteLocation[] | no |  |  | Optional intermediate stops, visited in order between origin and destination. |

**`AvoidFeature`**: A road feature to avoid as a PREFERENCE, not a ban. "tolls" and "highways" apply to motorised costings only (auto, truck, bus, motor_scooter, motorcycle); "ferries" applies to every costing. Asking for one on a costing whose engine table has no field for it is refused rather than silently ignored.

One of `"tolls"`, `"highways"`, `"ferries"`.

**`BicycleSpec`**: Bicycle options for costing "bicycle": the bicycle type plus road, surface and hill preference weights, mapped onto Valhalla `costing_options.bicycle`.

| Input | Type | Required | Default | Limits | Description |
|---|---|---|---|---|---|
| `avoid_bad_surfaces` | number | no |  |  | Avoidance of surfaces unsuited to the bicycle type, 0.0–1.0 (1.0 = strictly avoid bad surfaces). |
| `bicycle_type` | string | no | speed and surface tolerance |  | Bicycle type: "road", "hybrid" (default), "city", "cross" or "mountain". Sets default speed and surface tolerance. |
| `use_hills` | number | no |  |  | Willingness to take hills, 0.0–1.0 (0.0 = avoid climbs even at the cost of longer routes). |
| `use_living_streets` | number | no |  |  | Preference for living/shared streets, 0.0–1.0. |
| `use_roads` | number | no |  |  | Willingness to ride roads alongside motor traffic, 0.0–1.0 (0.0 = prefer cycleways and quiet streets, the quiet-ride slider). |

**`RouteLocation`**: One location of a `route` request: a coordinate, plus the optional kerbside approach for arriving at it. A bare `{lat, lon}` is still a complete location, every kerbside field is optional and omitting all of them is exactly the request that was made before they existed.

| Input | Type | Required | Default | Limits | Description |
|---|---|---|---|---|---|
| `preferred_side` | PreferredSideKind | no |  |  | Which side of the street to arrive on (or depart from). `same` (or `curb`) puts the vehicle on the door's side of the road, resolved against the locale's driving side. Two honest limits: this is a **snapping preference**, not a manoeuvre guarantee, the engine prefers an edge on that side, it does not promise the driver never crosses, and it needs a coordinate genuinely offset from the road centreline, because a point on the centreline has no side. |
| `street_side_max_distance_m` | integer | no | 1000 m |  | Metres: further than this from the road centreline, the side of street is treated as `none` and `preferred_side` does nothing. Engine default 1000 m. Together with `street_side_tolerance_m` this is a WINDOW: a pair that leaves no window (tolerance at or above max distance) is refused here rather than answered with a route on which the kerbside preference was silently inert. |
| `street_side_tolerance_m` | integer | no | 5 m |  | Metres: nearer than this to the road centreline, the side of street is treated as `none` and `preferred_side` does nothing. Engine default 5 m. |

**`PreferredSideKind`**: Side-of-street preference for arriving at or departing from a location. Carries both vocabularies: MapMap's own `same`/`opposite`/`either` and the OSRM/Mapbox `approaches` words `curb`/`unrestricted`, which are aliases for `same` and `either`. Both spell the same request, on this surface and on the HTTP API.

| Value | Meaning |
|---|---|
| `"same"` | The side the location itself projects to, the kerb, resolved against the locale's driving side (the left kerb in the UK, the right in Germany). |
| `"opposite"` | The far side of the road from the location. |
| `"either"` | No side preference. |
| `"curb"` | Alias for `same`, from the OSRM/Mapbox `approaches` vocabulary. |
| `"unrestricted"` | Alias for `either`, from the OSRM/Mapbox `approaches` vocabulary. |

**`ExcludeFeature`**: A road feature to exclude outright. A hard exclusion can leave a request with no path at all (that is the honest answer, not a failure) and it depends on the routing engine's own hard-exclusion setting, which nothing here can read.

One of `"tolls"`, `"highways"`, `"ferries"`, `"bridges"`, `"tunnels"`.

**`PedestrianSpec`**: Pedestrian options for costing "pedestrian": lit-street preference, pedestrian type, hills, surface quality and hiking difficulty, mapped onto Valhalla `costing_options.pedestrian` (surface quality and the grade cap are MapMap gateway fields). All preferences, never guarantees: the router prefers matching ways where the map data supports it.

| Input | Type | Required | Default | Limits | Description |
|---|---|---|---|---|---|
| `max_grade_pct` | integer | no |  |  | Maximum grade in whole percent, 1–90. Needs the MapMap gateway: the routing engine accepts this cap but does not enforce it yet, and the gateway's `grade.cap` says so on every route that sets it. |
| `max_hiking_difficulty` | integer | no | 1 |  | Maximum hiking-trail difficulty the route may use, as OSM `sac_scale` 1–6 (1 = well-cleared, mostly flat trails; 6 = demanding alpine terrain). Default 1. |
| `surface_quality` | string | no | where the deployment has a surface graph |  | Surface quality for wheelchair routes: "avoid" (the default where the deployment has a surface graph covering the route) or "ignore" (use the ordinary graph). Wheelchair only, and needs the MapMap gateway; the `surface_quality` block in the output says whether it was applied. |
| `type` | string | no | on-foot profile |  | Pedestrian type: "wheelchair" (avoids mapped steps and kerbs and the rougher surface classes; hills per `use_hills`) or "blind" (richer guidance detail). Omit for the default on-foot profile. |
| `use_hills` | number | no | 0 |  | Willingness to take hills, 0.0–1.0 (0.0 = avoid climbs as strongly as the engine can; the engine default is 0.5). A preference, not a cap. The MapMap gateway sends 0.0 on a wheelchair route that does not set it, and reports that in `grade.preference`. |
| `use_lit` | number | no |  |  | Preference for lit streets, 0.0–1.0 (1.0 = prefer lit paths as strongly as possible, the "walk me home on lit streets" option). Unlit ways are still used when no lit alternative exists. |

**Output**

| Field | Type | Always present | Description |
|---|---|---|---|
| `distance_m` | number | yes | Total route distance in metres. |
| `duration_s` | number | yes | Total estimated travel time in seconds. |
| `geometry_polyline6` | string | yes | Full route geometry as a Google encoded polyline with six digits of decimal precision (polyline6). |
| `maneuvers` | ManeuverOut[] | yes | Ordered turn-by-turn maneuvers across all legs. |
| `summary` | string | yes | One-line human-readable summary of the route. |
| `applied_adr` | AppliedAdr | no | The ADR costing merged into the request, or null when no truck profile was given. |
| `avoidance` | AppliedAvoidance | no | What `avoid`/`exclude` became, and the caveats that go with it (only when either list carried something). |
| `grade` | any JSON | no | The hill report on a pedestrian route (only through the MapMap gateway): maximum grade, total ascent and descent sampled along the route from the elevation model, the hill preference sent, and whether a requested grade cap was enforced. Relay `cap.enforced` and the caveat as given. |
| `landmarks` | LandmarkSummary | no | The landmark-annotation summary (only when `landmarks: true` was requested): how many manoeuvres gained a `landmark_instruction`, and a `note` when the per-route cap was hit or the deployment has no place index. A zero with no note means this route genuinely passes nothing recognisable. |
| `rationale` | RouteRationale | no | Why the route goes this way (only when `rationale: true` was requested; computed for truck, auto, bicycle, pedestrian and motor_scooter costings). |
| `scenic` | ScenicOffer | no | The scenic peer offer (only when `scenic: true` was requested): whether there is a prettier way, the sentence that justifies it, what the detour costs, and why every candidate that lost lost. The route above is never swapped for it. |
| `surface_quality` | any JSON | no | Surface quality on a wheelchair route (only through the MapMap gateway): `applied` true with the region, the exact rule, the data timestamp and a caveat when the route was computed on the surface graph; `applied` false with a `reason` otherwise. Relay the caveat: it is a preference from OpenStreetMap ratings where they exist, and says nothing about kerbs, widths, steps or inclines. |
| `view_url` | string | no | A mapmap.ai link that draws this route on a map: open it or pass it on as the last line of the answer. Nothing is stored server-side and opening it needs no key; the link itself carries the stop coordinates and stop names (plus any arrival clocks, dwell times and totals) and nothing else, so anyone holding it can read those stops. Absent when the plan cannot be linked (see `view_url_note`) or the server runs with links turned off (`SN_MCP_SITE_URL` empty). |
| `view_url_note` | string | no | Why there is no `view_url` (too many vehicles or stops for one link, or over the 6,000 character cap), or what the link left out to fit under the cap (arrival times, dwell times, then stop names). Relay it with the link. |
| `zones` | any JSON | no | Clean-air / low-emission zones this route enters and what the declared vehicle pays in each, with the publishing authority cited (only when `emissions` was declared). Passed through verbatim from the gateway's curated dataset, the same figures `GET /v1/zones` publishes, so a charge can always be audited back to its source. |

**`AppliedAdr`**: The ADR costing that was merged into a truck route request. Every field here describes the **request**: the declared load and the costing options built from it. Nothing here is an assertion about the returned geometry, see `TunnelEnforcement`.

| Field | Type | Always present | Description |
|---|---|---|---|
| `costing_options` | any JSON | yes | The exact Valhalla `costing_options` JSON merged into the request. |
| `forbidden_tunnel_categories` | string[] | yes | ADR tunnel categories **the vehicle** is forbidden from under the worst-case reading of ADR 8.6.4 (conditional clauses assumed to apply). Empty when unrestricted. This is not a claim that the returned route contains no tunnel of these categories, read `tunnel_enforcement` for what was actually applied. |
| `hazmat` | boolean | yes | Whether the profile declared dangerous goods. |
| `tunnel_enforcement` | TunnelEnforcement | yes | What the router did with `forbidden_tunnel_categories`, and the boundary of the resulting guarantee. |
| `tunnel_code` | string | no | Canonical ADR tunnel restriction code applied ("B/D", "(-)", …), or null when no code was declared. |

**`TunnelEnforcement`**: How the declared ADR tunnel code reaches the routing engine, and what a returned route therefore does (and does not) prove. This block exists because `forbidden_tunnel_categories` is a statement about the **load** (ADR 8.6.4 applied to the declared code), and on its own it reads like a statement about the **route**. It is not one. The engine excludes a road only where the routing graph records an ADR tunnel category for it, and that category is derived from OSM `hazmat:*` tagging. Coverage is therefore uneven: a corridor with no such tagging is not excluded and looks, in the response, exactly like a corridor that was checked and cleared. The field is emitted on every `applied_adr`, so a caller can never receive the categories without the boundary that qualifies them.

| Field | Type | Always present | Description |
|---|---|---|---|
| `basis` | string | yes | How the code is applied. `"graph_adr_tunnel_category"`: merged into `costing_options.truck.adr_tunnel_code` and matched, during the search, against each road's ADR tunnel category as recorded in the routing graph. |
| `caveat` | string | yes | What an unchanged route does and does not prove, in one sentence. |
| `route_certified` | boolean | yes | Whether the returned route has been checked against a tunnel inventory *after* it was computed. Always `false`: exclusion happens during the search, and only for roads the graph has a category for. A 200 is not a compliance certificate, and must not be relied on as one. |

**`AppliedAvoidance`**: What the avoidance lists actually did, reported back so a 200 is never readable as a certificate.

| Field | Type | Always present | Description |
|---|---|---|---|
| `caveats` | string[] | yes | The caveats that apply to this request, in words fit to repeat to a user. Always present when anything was applied. |
| `costing_option_fields` | any JSON | yes | The engine costing-option fields these words became, e.g. `{"use_tolls": 0.0, "exclude_ferries": true}`. This is the whole of what was sent, there is no hidden second mechanism. |
| `avoid` | string[] | no | The `avoid` values that were applied, echoed back. |
| `exclude` | string[] | no | The `exclude` values that were applied, echoed back. |

**`LandmarkSummary`**: The gateway's `landmarks` summary block.

| Field | Type | Always present | Description |
|---|---|---|---|
| `annotated` | integer | yes | How many manoeuvres gained a `landmark_instruction`. |
| `note` | string | no | Why the count is what it is, when there is something to say: the per-route lookup cap was reached, or the deployment has no place index at all. Absent means the number is simply the number, so a zero with no note means this route genuinely passes nothing recognisable rather than that landmarks could not be looked up. |

**`ManeuverOut`**: One turn-by-turn instruction of a computed route.

| Field | Type | Always present | Description |
|---|---|---|---|
| `distance_m` | number | yes | Length of the maneuver in metres. |
| `duration_s` | number | yes | Estimated duration of the maneuver in seconds. |
| `instruction` | string | yes | Written instruction, e.g. "Turn right onto Main Street". |
| `country` | string | no | ISO 3166-1 alpha-2 country of the road the manoeuvre starts on, e.g. `"GB"`. This and the road fields below come from the MapMap gateway's road walk: present when the route was computed through the gateway (GATEWAY_URL + GATEWAY_API_KEY) and the map knows the road, absent otherwise. Never inferred. |
| `driving_side` | string | no | The side of the road traffic keeps to there, `"left"` or `"right"`; present only when the road's country is known. |
| `landmark_instruction` | string | no | The same manoeuvre anchored to a recognisable place, e.g. "Turn right just after the Shell garage", present only when the request set `landmarks: true` and a place near the junction cleared the salience bar. Prefer reading this aloud when it is there: it is how a passenger would give the direction. `instruction` is always the engine's own and is never replaced. |
| `ref` | string | no | Route numbers of that road (`"; "` separated, e.g. `"A27"` or `"I 5; US 101"`); absent when it carries none, never read from a street name. |
| `road_class` | string | no | Engine road class of that road (`motorway`, `trunk`, `primary`, `secondary`, `tertiary`, `unclassified`, `residential`, `service_other`). |
| `speed_limit_posted` | any JSON | no | The posted limit as the sign shows it, in the road's own unit: `{"type":"known","value":30,"unit":"mph"}` (plus `"national":true` for a national limit), `{"type":"unlimited"}` or `{"type":"unknown"}`. Read from the road the manoeuvre mostly travels along. |
| `speed_limit_sign` | string | no | The speed-limit sign family there: `"vienna"` (roundel) or `"mutcd"` (US and Canadian rectangle). |
| `speed_limit_unit` | string | no | The unit speed limits are posted in on that road, `"mph"` or `"km/h"`, from its country (never from the request's units). |
| `subdivision` | string | no | ISO 3166-2 subdivision of that road where the map carries one, e.g. `"GB-ENG"` or `"US-CA"`. |

**`RouteRationale`**: Why the route goes the way it does, the `route` tool's opt-in rationale block, computed for truck, auto, bicycle, pedestrian and motor_scooter costings. **Method honesty:** the routing engine exposes no exclusion set, so entries are derived by re-routing with each declared constraint or preference relaxed (`basis: "route_divergence"`); this proves a field was binding and where, but never names the physical restriction or feature (the specific signed bridge, hill or unlit street). No live traffic or incident data enters the comparison and no delay is ever estimated from one, the engine has no live speed field.

| Field | Type | Always present | Description |
|---|---|---|---|
| `avoided` | AvoidedConstraint[] | yes | The declared constraints or preferences that changed the route, with locations and costs. Empty when nothing was binding. |
| `caveats` | string | yes | The method's limits, spelled out for relaying to users. |
| `method` | string | yes | Always "route_divergence". |
| `note` | string | no | Why the list is empty or incomplete, when it is. |

**`AvoidedConstraint`**: One constraint or preference the route provably changed for, derived by route divergence (see `RouteRationale`).

| Field | Type | Always present | Description |
|---|---|---|---|
| `baseline` | string | yes | What the probe compared against: "relaxed_to_non_binding" (truck constraints set to explicitly non-binding values), "engine_default" (preference removed so the engine default applies) or "cap_lifted" (a hard cap raised to its maximum). |
| `basis` | string | yes | Always "route_divergence": the entry was derived by re-routing without the field and diffing geometry, not from sign or map-feature data. |
| `constraint` | string | yes | The costing-options field that was relaxed ("height", "use_hills", …, or "combined"). |
| `distance_saved_m` | number | yes | Distance the constraint costs, metres. |
| `diverged_length_m` | number | yes | Length of the diverging span along the constrained route, metres. |
| `kind` | string | yes | Reason kind. Truck: "max_height", "max_width", "max_length", "max_weight", "hazmat", "adr_tunnel". Preferences: "hill_avoided", "surface_avoided", "road_avoided", "highway_avoided", "toll_avoided", "ferry_avoided", "living_street_avoided", "primary_road_avoided", "unlit_street_avoided", "hiking_difficulty_limited", "access_profile". Any costing: "combined". There is deliberately no "incident_avoided" or "traffic_delay_avoided": the engine consumes no live incident or traffic-speed input, so no divergence can be attributed to them. |
| `location` | [LatLon](#latlon) | yes | Where the constrained and unconstrained routes part ways. |
| `reason` | string | yes | Human-readable explanation, honest about the method. |
| `rejoins_at` | [LatLon](#latlon) | yes | Where they rejoin. |
| `time_saved_s` | number | yes | Travel time the constraint costs (unconstrained is this much faster), seconds. Zero when the alternative is no faster. |
| `value` | any JSON | yes | The declared value for the field (null for "combined"). NOTE: for truck this is the vehicle's dimension, not the infrastructure limit, the physical restriction is not identified. |
| `fields` | string[] | no | For kind "combined": the constraint fields that only bind together. |
| `unit` | string | no | Unit of `value` ("m", "t") when dimensional. |

**`ScenicOffer`**: The scenic assessment beside a route: a peer offer, never a substitution. The fields here are the ones a client must actually read before saying anything. `block` keeps the gateway's whole answer beside them so a figure can always be audited back to the source that produced it, rather than to this mapping.

| Field | Type | Always present | Description |
|---|---|---|---|
| `available` | boolean | yes | Whether scenery could be assessed at all on this deployment. When false, `note` says why and nothing below was measured: that is "we could not look", never "there is nothing there". |
| `block` | any JSON | yes | The gateway's whole `scenic` block, verbatim: candidates, feature shares, tile coverage, metering and budget. |
| `caveats` | string | yes | The standing limits of the method, for relaying to users. |
| `chosen` | string | yes | Which route the assessment would offer: `"fastest"` (the quick way is already the pretty one, or nothing beat it) or `"scenic"`. |
| `alternative_geometry_polyline6` | string | no | The scenic route's own geometry, polyline6, when there is one to offer. `geometry_polyline6` on the route itself is byte-for-byte what the same request returns without the flag. |
| `extra_time_s` | number | no | How much longer the offered route takes than the fastest one, seconds, and the sentence for it. Absent when nothing was offered. |
| `note` | string | no | Why no assessment ran (only when `available` is false). |
| `reason` | string | no | The plain, speakable sentence behind the offer. **Null is an answer**: nothing on this corridor measured above the floor, so no claim is made rather than one being invented. |
| `rejections` | ScenicRejection[] | no | Why each candidate that lost lost. Empty when none did. Relay it: it is what makes "no prettier way" an attributable answer instead of a shrug. |
| `spoken` | string | no | The trade phrased for speech, e.g. "About seven minutes longer than the quick way." Prefer reading this aloud over the seconds. |

**`ScenicRejection`**: Why one candidate route was not offered.

| Field | Type | Always present | Description |
|---|---|---|---|
| `code` | string | yes | The rejection code: `same_route`, `no_scenic_gain`, `not_scenic_enough`, `over_time_budget` or `nothing_to_say`. |
| `detail` | string | yes | The code in words, including the minutes an `over_time_budget` candidate would have cost, so raising the budget is an informed choice rather than a guess. |
| `source` | string | yes | Where the candidate came from: `"fastest"`, `"engine_alternate"` or `"no_motorway"`. |

**Example** (called 2026-11-05)

```json
{
  "origin": {
    "lat": 51.5007,
    "lon": -0.1246
  },
  "destination": {
    "lat": 51.5308,
    "lon": -0.1238
  },
  "costing": "auto"
}
```

Result (trimmed):

```json
{
  "distance_m": 5106,
  "duration_s": 917.918,
  "geometry_polyline6": "gejfaB~zrFDCRGXC^?rCXvD`@|@H`@J`@\\\\b@P^Pf@Nd@Px@Jp@HfA@fAC`Aq@fKeA|OK`ASlAY|@_@r…",
  "maneuvers": [
    {
      "country": "GB",
      "distance_m": 39,
      "driving_side": "left",
      "duration_s": 7.271,
      "instruction": "Drive south.",
      "road_class": "service_other",
      "speed_limit_posted": {
        "type": "unknown"
      },
      "speed_limit_sign": "vienna",
      "speed_limit_unit": "mph",
      "subdivision": "GB-ENG"
    },
    {
      "country": "GB",
      "distance_m": 23,
      "driving_side": "left",
      "duration_s": 5.026,
      "instruction": "Turn right.",
      "road_class": "service_other",
      "speed_limit_posted": {
        "type": "unknown"
      },
      "speed_limit_sign": "vienna",
      "speed_limit_unit": "mph",
      "subdivision": "GB-ENG"
    },
    "… 16 more"
  ],
  "summary": "5.1 km, 15 min (18 maneuvers), Found route between points",
  "view_url": "https://mapmap.ai/view?p=eyJ2IjoxLCJraW5kIjoicm91dGUiLCJjb3N0aW5nIjoiYXV0byIsInJ…"
}
```

**More from the server's description:**

Pass `truck` {height_m, width_m, length_m, gross_weight_t, hazmat, tunnel_code} to apply dimensional limits and the ADR dangerous-goods tunnel matrix to the search; `pedestrian` {use_lit 0-1, type "wheelchair"|"blind", max_hiking_difficulty 1-6, use_hills 0-1, max_grade_pct 1-90, surface_quality "avoid"|"ignore"} for lit-street walking, wheelchair routing and trail limits. Returns distance (m), duration (s), maneuvers, polyline6 geometry and the ADR costing that was applied. Prefer reading it aloud: it is how a passenger gives directions. Needs the MapMap gateway (GATEWAY_URL + GATEWAY_API_KEY), whose place index does the lookup. Optional `exclude_polygons` for what-if scenarios ("close this bridge and re-route"): an array of polygons, each an array of [lon, lat] pairs forming one ring (longitude FIRST) whose intersecting roads are excluded from the search. Applies to the Valhalla engine; unsupported on the GraphHopper engine, where it is ignored. Each location also takes a kerbside approach: `preferred_side` "same" (alias "curb") stops on the door's side of the road, resolved against the locale's driving side, the left kerb in the UK, the right in Germany, with "opposite" and "either" (alias "unrestricted") for the rest. Pass `emissions` {vehicle_category, fuel, euro_standard} for UK clean-air-zone assessment: the response's `zones` block then names every zone the route enters and what THIS vehicle pays there, with the publishing authority cited. Needs the gateway, which holds the curated zone dataset; the whole dataset, every scheme, charge, boundary and provenance record, is readable at `GET /v1/zones` on the HTTP API when an agent needs to audit a figure or list zones without routing. EVERY OFFER STATES ITS REASON OR THERE IS NO OFFER: a route that scores well but cannot support a plain sentence is dropped rather than dressed up, so a `scenic.reason` of null means nothing here measured above the floor. A REJECTION IS ALSO AN ANSWER: every candidate that lost says why in `scenic.rejections[]` (`same_route`, `no_scenic_gain`, `not_scenic_enough`, `over_time_budget` with the minutes it would have cost, `nothing_to_say`), so "no prettier way" is always attributable and raising the budget is an informed choice. Needs the gateway, whose basemap archive the scoring is measured against. Returns `view_url`, a mapmap.ai link that draws the answer on a map (no key needed); the link carries the stop coordinates and names.

### `route_observations`

What a journey PASSES, in sentences ready to be read aloud, each with its position along the route.

**Access:** Keyless. Answers without a key on the hosted endpoint under fair use; present your own key to meter it to you.

**Caveats the server states:**

- NONE OF THIS IS IN A ROUTE: a route is a list of movements and a river crossing is not a movement, so do not try to read it out of `route`'s answer.
- Each observation carries `at_m` (WHERE it belongs (delivered anywhere else it is trivia, not an observation), a `kind`, a `say` sentence to relay verbatim, and a `basis` naming the map layer and tag the claim rests on.
- THE SILENCE BUDGET IS THE DESIGN: `min_gap_m` is a floor, not a target, and when a route yields more than `max_observations` the gap WIDENS on its own so the survivors stay spread over the whole journey rather than clustering where the map happened to be richest.
- So a short list on a long route is the budget working, not thin data.
- READ `coverage` BEFORE REPORTING AN EMPTY LIST: an empty `observations` with `tiles_read` of 0 means NO DATA WAS READ, which is not the same claim as quiet countryside, and `uncovered_m` says how much of the route could not be seen at all.
- Two things it will never say, deliberately: it never names a hill (a peak two kilometres away behind a ridge is named confidently and seen by nobody), and it never reads a junction number (an observation mistakable for an instruction is not safe to speak beside real guidance).
- A settlement is a labelled POINT, not a boundary, and `offset_m` publishes the distance behind the word "through".
- Requires the MapMap gateway (GATEWAY_URL + GATEWAY_API_KEY), whose basemap archive and elevation model answer it; a deployment with no archive answers 501 and says so.

**Inputs**

| Input | Type | Required | Default | Limits | Description |
|---|---|---|---|---|---|
| `geometry_polyline6` | string | yes |  |  | The route shape, precision-6 encoded: `geometry_polyline6` straight out of the `route` tool's answer. Nothing else is accepted: there is no origin/destination form, because this tool annotates a route somebody already chose rather than computing one. |
| `max_observations` | integer | no | 40 |  | Ceiling on how many observations come back, 1 to 200 (default 40). |
| `min_gap_m` | number | no | 2000 |  | Least distance along the route between two observations, metres (default 2000). A FLOOR, not a target: when the route holds more than `max_observations`, the gap widens past this on its own so the survivors stay spread over the whole journey. |
| `units` | string | no |  |  | Units for the spoken distances: `miles` (default) or `kilometers`. `kilometres` and `km` are accepted too. |

**Output**

| Field | Type | Always present | Description |
|---|---|---|---|
| `attribution` | string | yes | The licence notice for this answer. The obligation attaches to the product, not to each spoken sentence, so a visible or linked credit in the app satisfies it. |
| `caveat` | string | yes | The standing limits of the method, for relaying to users. |
| `coverage` | any JSON | yes | What the archive and the elevation model could and could not say: `tiles_read`, `tiles_missing`, `uncovered_m`, and the per-kind censuses. **Read it before reporting an empty list.** `tiles_read` of 0 means nothing was looked at, which is not the same claim as quiet countryside. |
| `observations` | RouteObservation[] | yes | The observations, in route order, already thinned to the silence budget. |
| `route_length_m` | number | yes | The route's own length, metres, as walked. |

**`RouteObservation`**: One thing worth saying about one point on a route.

| Field | Type | Always present | Description |
|---|---|---|---|
| `at_m` | number | yes | Distance along the route where this applies, metres. The whole point of the answer: said anywhere else it is trivia, not an observation. |
| `basis` | string | yes | Which map layer and tag the claim rests on, so anybody who doubts one can go and look at the same feature. |
| `kind` | string | yes | `crossing`, `landscape`, `settlement`, `watercourse`, `road` or `climb`. Each claims a different thing and rests on different evidence. |
| `say` | string | yes | The sentence to say. Prefer reading this aloud verbatim: it is already shaped for speech and already checked for length. |
| `subject` | string | yes | The subject's name exactly as the map records it, before any wording. |
| `elevation_m` | number | no | For a climb, the height at the top, metres above sea level. |
| `offset_m` | number | no | For a settlement, how far off the route the mapped centre lies, metres. A settlement is a labelled POINT, not a boundary, so this is the number behind the word "through" and nobody has to take it on trust. |
| `run_m` | number | no | For a run-length observation (a road or a landscape), how far the run lasted, metres. |

**Example** (called 2026-11-05)

```json
{
  "geometry_polyline6": "gejfaB~zrFDCRGXC^?rCXvD`…"
}
```

Result (trimmed):

```json
{
  "attribution": "Map data © OpenStreetMap contributors, ODbL. Basemap schema © OpenMapTiles, CC-BY 4.0.",
  "caveat": "Observations are computed by intersecting the route's geometry with OpenStreetMa…",
  "coverage": {
    "elevation": {
      "found": 21,
      "named": 21
    },
    "elevation_source": "model",
    "road": {
      "ambiguous_m": 0,
      "named_m": 100,
      "unnamed_m": 5000
    },
    "settlements": {
      "found": 16,
      "named": 8
    },
    "tiles_missing": 0,
    "tiles_read": 2,
    "tiles_unreadable": 0,
    "uncovered_m": 0,
    "watercourse_crossings": {
      "found": 0,
      "named": 0
    }
  },
  "observations": [
    {
      "at_m": 1700,
      "basis": "place.name",
      "kind": "settlement",
      "offset_m": 397.87514065536885,
      "say": "Passing through Covent Garden.",
      "subject": "Covent Garden"
    },
    {
      "at_m": 4900,
      "basis": "place.name",
      "kind": "settlement",
      "offset_m": 584.8831887682724,
      "say": "Passing through St Pancras.",
      "subject": "St Pancras"
    }
  ],
  "route_length_m": 5098.2
}
```

**More from the server's description:**

The named rivers and canals it crosses, the road it runs on and for how far, the settlements it goes through, the protected landscapes it enters and how high the road climbs: the things a passenger who knew the area would say. Give `geometry_polyline6` straight from the `route` tool, plus optional `units` ("miles" default, or "kilometers"), `min_gap_m` and `max_observations`. Inside a window the rarer kind wins. It is a query and it triggers nothing: no position is held and nothing is pushed. COSTS 10 UNITS a call, flat, whatever is asked for.

### `heritage_narration`

Three to five short spoken lines an hour about the ground a route is on: one sentence, at the point the driver reaches the thing it is about, and then silence.

**Access:** Keyless. Answers without a key on the hosted endpoint under fair use; present your own key to meter it to you.

**Caveats the server states:**

- Not a tour and not a chatbot.
- Optional `min_gap_s`, `min_gap_m`, `max_offset_m` (default 60, hard ceiling 80), `max_lines` and `voice_format` ("opus" or "m4a", which adds each line's audio cache key and URL and NEVER synthesises).
- COVERAGE IS ONE CORRIDOR, deliberately: the Chelsea riverside, 18 reviewed lines over 5 km.
- ALWAYS READ THE CENSUS, which is the record of WHAT WAS NOT SAID: no lines with every entry in `beyond_reach` means this feature does not cover the journey; no lines with entries in `silenced_by_budget` means the corridor is covered and the budget is holding them back.
- They are different answers and must never be relayed the same way.
- On the reference route the census reports 1 spoken and 17 silenced, which is the design working, not a thin corpus.
- Do not add either.

**Inputs**

| Input | Type | Required | Default | Limits | Description |
|---|---|---|---|---|---|
| `geometry_polyline6` | string | yes |  |  | The route shape, precision-6 encoded: `geometry_polyline6` straight out of the `route` tool's answer. |
| `duration_s` | number | no |  |  | That route's own duration, seconds: `duration_s` from the same answer. **Supply it.** Without it the silence budget falls back to a distance, and "three to five lines an hour" is a claim about time that a distance answers wrongly at both ends. |
| `max_lines` | integer | no | 12 |  | Ceiling on how many lines come back, 1 to 50 (default 12). |
| `max_offset_m` | number | no | 60, hard ceiling 80 |  | How near a plaque must come to the route before its line may be spoken, metres (default 60, hard ceiling 80). |
| `min_gap_m` | number | no | 400 |  | Least distance between two lines, metres (default 400). A floor under the time budget in every case. |
| `min_gap_s` | number | no | 900, which is four an hour |  | Least time between two lines, seconds (default 900, which is four an hour). Applied only with `duration_s`. |
| `voice_format` | string | no |  |  | Container to address each line's audio clip in: `opus` or `m4a`. Given, every line gains a `clip` with its synthesis cache key and URL. It NEVER synthesises: the key is a pure function of the sentence, so asking here buys nothing. |

**Output**

| Field | Type | Always present | Description |
|---|---|---|---|
| `attribution` | string | yes | The licence notice for this answer. |
| `caveat` | string | yes | The standing limits, for relaying to users. |
| `census` | any JSON | yes | The census of what was NOT said: `corpus_entries`, `within_reach`, `beyond_reach`, `spoken`, `silenced_by_budget`, the gap that was applied and what it was derived from. An empty `lines` with every entry in `beyond_reach` means this feature does not cover the journey; an empty `lines` with entries in `silenced_by_budget` means the budget is working. They are different answers and must not be reported the same way. |
| `corridor` | any JSON | yes | The corridor that was consulted: its id, name, the road it runs on and the date its lines were reviewed. |
| `lines` | HeritageLine[] | yes | The lines, in route order. Empty is the ordinary answer nearly everywhere: read `census` before saying anything about it. |
| `route_length_m` | number | yes | The route's own length, metres. |

**`HeritageLine`**: One reviewed line about the ground the route is on.

| Field | Type | Always present | Description |
|---|---|---|---|
| `at_m` | number | yes | Distance along the route where this belongs, metres. |
| `basis` | string | yes | What the line rests on, in words. |
| `id` | string | yes | The corpus entry's own id, stable across responses, so a client can remember which lines it has already played. |
| `offset_m` | number | yes | Measured perpendicular distance from the plaque's own recorded coordinate to the route, metres. Published on every line because it is the entire warrant for the word "here", and because the coordinate behind it was recorded by a volunteer and has never been surveyed. |
| `say` | string | yes | The sentence to say, verbatim. Written by a person from a public-domain plaque inscription and reviewed by a person. |
| `subject` | string | yes | The subject's name as the source dataset records it. |
| `clip` | any JSON | no | The line's audio address (only when `voice_format` was given): `hash`, `url` and `format`. Nothing was synthesised to produce it. |
| `plaque_ids` | integer[] | no | The source plaque records, by their OpenPlaques id, so any claim can be taken back to its source. |

**Example** (called 2026-11-05)

```json
{
  "geometry_polyline6": "wekeaBxksIsQzPtFdN~HxR`@…",
  "duration_s": 492.248
}
```

Result (trimmed):

```json
{
  "attribution": "Plaque inscriptions from the OpenPlaques United Kingdom dataset (snapshot of 14 …",
  "caveat": "Every line is a sentence written and reviewed by a person from the inscription o…",
  "census": {
    "beyond_reach": 8,
    "corpus_entries": 18,
    "corridor": "chelsea-riverside",
    "gap_basis": "time",
    "gap_m": 5368.4,
    "mean_speed_kph": 21.5,
    "silenced_by_budget": 9,
    "spoken": 1,
    "within_reach": 10
  },
  "corridor": {
    "id": "chelsea-riverside",
    "name": "Chelsea riverside",
    "reviewed_on": "2026-09-21",
    "road": "A3220 and A3212, Lots Road to Millbank"
  },
  "lines": [
    {
      "at_m": 446.6,
      "basis": "inscription, and the dataset's own role for her",
      "id": "gaskell",
      "offset_m": 34.9,
      "plaque_ids": [
        380
      ],
      "say": "Elizabeth Gaskell was born along here. She wrote Cranford.",
      "subject": "Elizabeth Gaskell"
    }
  ],
  "route_length_m": 2936.2
}
```

**More from the server's description:**

NOTHING IS GENERATED HERE: every line is a sentence a person wrote from a public-domain plaque inscription and a person reviewed, compiled into the gateway binary, so no model runs in this path and the lines are identical for every driver. Give `geometry_polyline6` from the `route` tool and, IMPORTANT, `duration_s` from the same answer: without it the silence budget falls back to a distance, and "four lines an hour" is a claim about time that a distance answers wrongly at both ends. EVERY OTHER ROUTE ANSWERS WITH NO LINES, and that is a correct answer rather than a failure. Every line publishes `offset_m`, the measured distance from the plaque to the route, because it is the entire warrant for the word "here", and `plaque_ids` so any claim can be taken back to its source. TWO OMISSIONS ARE DELIBERATE: no line says anything is visible, still standing or unchanged (a plaque records that somebody was somewhere, and nothing more), and NO LINE SAYS WHICH SIDE OF THE ROAD anything is on, because nothing in the source records it and a look that finds nothing spends the driver's trust in everything else. COSTS 1 UNIT a call: it reads no tiles and opens no archive. Requires the MapMap gateway (GATEWAY_URL + GATEWAY_API_KEY).

### `matrix`

Compute a travel time/distance matrix between origins (rows) and destinations (columns). Costing "auto", "truck" (with optional `truck` profile as in `route`), "bicycle", "pedestrian" or "motor_scooter".

**Access:** Keyless. Answers without a key on the hosted endpoint under fair use; present your own key to meter it to you.

**Inputs**

| Input | Type | Required | Default | Limits | Description |
|---|---|---|---|---|---|
| `destinations` | [LatLon](#latlon)[] | yes |  |  | Destination locations (matrix columns). |
| `origins` | [LatLon](#latlon)[] | yes |  |  | Origin locations (matrix rows). |
| `costing` | [CostingKind](#costingkind) | no | `"auto"` |  | Costing model: "auto" (default), "truck", "bicycle", "pedestrian" or "motor_scooter". |
| `exclude_polygons` | number[][][] | no |  |  | Areas to avoid, scenario analysis ("close this bridge and recompute the matrix"): an array of polygons, each an array of `[lon, lat]` pairs forming one exterior ring (GeoJSON-style, longitude FIRST). Roads intersecting any ring are excluded from every cell's path finding; a pair no path joins without them stays null (the search is never widened round them). Applies to the Valhalla engine; unsupported on the GraphHopper engine, where it is ignored. |
| `truck` | [TruckSpec](#truckspec) | no |  |  | Truck profile (dimensions + ADR declaration). Requires costing "truck". |

**Output**

| Field | Type | Always present | Description |
|---|---|---|---|
| `distances_m` | number[][] | yes | Travel distances in metres, same shape as `durations_s`. Null cells are unreachable pairs. |
| `durations_s` | number[][] | yes | Travel times in seconds; `durations_s[i][j]` is origin `i` → destination `j`. Null cells are unreachable pairs. |

**Example** (called 2026-11-05)

```json
{
  "origins": [
    {
      "lat": 51.5007,
      "lon": -0.1246
    }
  ],
  "destinations": [
    {
      "lat": 51.5308,
      "lon": -0.1238
    },
    {
      "lat": 51.5194,
      "lon": -0.127
    }
  ],
  "costing": "auto"
}
```

Result (trimmed):

```json
{
  "distances_m": [
    [
      5106,
      2846
    ]
  ],
  "durations_s": [
    [
      917.823,
      519.729
    ]
  ]
}
```

**More from the server's description:**

Returns durations_s[i][j] in seconds and distances_m[i][j] in metres; null cells are unreachable pairs. Up to 10,000 cells per call (origins × destinations). Optional `exclude_polygons` for before/after scenarios ("close this bridge and recompute the matrix"): an array of polygons, each an array of [lon, lat] pairs forming one ring (longitude FIRST) whose intersecting roads are excluded from every cell's path finding. Applies to the Valhalla engine; unsupported on the GraphHopper engine, where it is ignored.

### `reachable_area`

Compute the area reachable from an origin within one or more travel-time budgets, walkability/cyclability rings.

**Access:** Keyless. Answers without a key on the hosted endpoint under fair use; present your own key to meter it to you.

**Caveats the server states:**

- Costing "pedestrian" answers "how far can I walk in 15 minutes?", "bicycle" the cycling equivalent; "auto", "truck" and "motor_scooter" work too (e.g. delivery coverage).
- An origin with no road within 5 km (open sea, desert, an unmapped area) is refused with "no roads near this point" rather than answered with a speck, and that refusal is not billed; move it onto land near a road.

**Inputs**

| Input | Type | Required | Default | Limits | Description |
|---|---|---|---|---|---|
| `contours_minutes` | number[] | yes |  |  | Contour boundaries in minutes of travel time, e.g. [5, 10, 15] for 5/10/15-minute rings. 1 to 4 values (the routing engine's limit), each between 0 and 120 minutes. |
| `origin` | [LatLon](#latlon) | yes |  |  | Origin the reachable area is computed from. |
| `costing` | [CostingKind](#costingkind) | no | `"auto"` |  | Travel mode: "auto" (default), "truck", "bicycle", "pedestrian" or "motor_scooter". |
| `exclude_polygons` | number[][][] | no |  |  | Areas to avoid, scenario analysis ("close this bridge and recompute reachability"): an array of polygons, each an array of `[lon, lat]` pairs forming one exterior ring (GeoJSON-style, longitude FIRST). Roads intersecting any ring are excluded from the reachability search. Applies to the Valhalla engine; unsupported on the GraphHopper engine, where it is ignored. |
| `polygons` | boolean | no | false |  | Return filled polygons instead of contour linestrings (default false). Polygons draw directly as a MapLibre fill layer. |

**Output**

| Field | Type | Always present | Description |
|---|---|---|---|
| `geojson` | any JSON | yes | GeoJSON FeatureCollection of the reachability contours, one feature per requested minute value (each feature's `contour` property is its minutes), as returned by the routing engine, drawn from the origin snapped onto the nearest usable road: the collection's `snapped_origin` `{lat, lon, distance_m}` (repeated on every feature's properties) says where that was. |

**Example** (called 2026-11-05)

```json
{
  "origin": {
    "lat": 51.5007,
    "lon": -0.1246
  },
  "contours_minutes": [
    5
  ],
  "costing": "auto"
}
```

Result (trimmed):

```json
{
  "geojson": {
    "engine": "valhalla",
    "eta_calibration": {
      "applied": false,
      "method": "gb_typical_delay_v1",
      "note": "Isochrone contours are drawn on the routing engine's free-flowing times, which d…",
      "travel_times": "free_flow"
    },
    "features": [
      {
        "geometry": "{…}",
        "properties": "{…}",
        "type": "Feature"
      }
    ],
    "snapped_origin": {
      "distance_m": 19.2,
      "lat": 51.500644,
      "lon": -0.124863
    },
    "type": "FeatureCollection"
  }
}
```

**More from the server's description:**

`contours_minutes` lists the ring boundaries in minutes (1 to 4 values, each up to 120); set `polygons` true for filled polygons ready to render as a map fill layer instead of contour lines. Returns a GeoJSON FeatureCollection, one feature per contour. The origin is first moved onto the nearest road the travel mode can use (ferries excluded, at most 5 km away) and the rings are drawn from there: `snapped_origin` {lat, lon, distance_m} on the collection and on every feature's properties says where. Optional `exclude_polygons` for before/after scenarios ("close this bridge and recompute reachability"): an array of polygons, each an array of [lon, lat] pairs forming one ring, longitude FIRST, whose intersecting roads are excluded from the reachability search. Applies to the Valhalla engine; unsupported on the GraphHopper engine, where it is ignored.

### `elevation`

Sample terrain elevation. Provide `points` (a bare list of coordinates) for point elevation, or `encoded_polyline` (optionally with `resample_distance_m`) for an along-route profile, not both.

**Access:** Keyless. Answers without a key on the hosted endpoint under fair use; present your own key to meter it to you.

**Caveats the server states:**

- Returns one sample per point/resampled point in order; `elevation_m` is null wherever the engine's DEM tile set has no coverage at that point (never a guess).

**Inputs**

| Input | Type | Required | Default | Limits | Description |
|---|---|---|---|---|---|
| `encoded_polyline` | string | no |  |  | A route as a Google encoded polyline with six digits of precision. Provide this or `points`, not both. |
| `points` | [LatLon](#latlon)[] | no |  |  | Points to sample. Provide this or `encoded_polyline`, not both. |
| `resample_distance_m` | number | no |  |  | Resamples `encoded_polyline` at this spacing in metres before sampling height (ignored for `points`). |

**Output**

| Field | Type | Always present | Description |
|---|---|---|---|
| `samples` | ElevationSample[] | yes | One sample per input point (or per resampled shape point, in the `encoded_polyline` form), in order. |

**`ElevationSample`**: One elevation sample: `elevation_m` and, when the request set `encoded_polyline`, the resampled point and its cumulative distance from the start.

| Field | Type | Always present | Description |
|---|---|---|---|
| `elevation_m` | number | no | Elevation in metres, or `null` when the engine has no DEM tile coverage at this point, Valhalla's own honest value, never a guess. |
| `lat` | number | no | Latitude of the (possibly resampled) point, when known. |
| `lon` | number | no | Longitude of the (possibly resampled) point, when known. |
| `range_km` | number | no | Cumulative distance from the first point in kilometres, present only for the `encoded_polyline` form. |

**Example** (called 2026-11-05)

```json
{
  "points": [
    {
      "lat": 51.5007,
      "lon": -0.1246
    },
    {
      "lat": 51.4769,
      "lon": -0.0005
    }
  ]
}
```

Result (trimmed):

```json
{
  "samples": [
    {
      "elevation_m": 16.37
    },
    {
      "elevation_m": 48.28
    }
  ]
}
```

**More from the server's description:**

The `encoded_polyline` form also returns each sample's resampled lat/lon and cumulative `range_km` from the start.

### `match_trace`

Snap a recorded GPS trace to the road network and say what it actually travelled over. Provide `shape` (2 to 2000 recorded points, oldest first) or `encoded_polyline` (the same trace as a polyline6 string) (not both) plus the `costing` it was travelled under: "auto" (default), "truck", "bicycle", "pedestrian" or "motor_scooter".

**Access:** Keyless. Answers without a key on the hosted endpoint under fair use; present your own key to meter it to you.

**Caveats the server states:**

- Returns the matched path as `geometry_polyline6` (the snapped roads, not your raw points) with its `distance_m` and `duration_s`, then the roll-ups: `by_road_class` and `by_admin` (distance and time, longest first), `by_surface` (distance), and `toll`, `bridge` and `tunnel` totals.
- Honesty: the roll-ups are summed per matched road segment, so they need not add up to `distance_m` exactly, and segments the map records no surface or admin area for are left out of that breakdown rather than filed under a guess, an entry in `by_admin` with null codes is exactly that, counted and not attributed.

**Inputs**

| Input | Type | Required | Default | Limits | Description |
|---|---|---|---|---|---|
| `costing` | [CostingKind](#costingkind) | no |  |  | Costing model the trace was travelled under: `auto` (default), `truck`, `bicycle`, `pedestrian` or `motor_scooter`. It decides which roads the trace may be matched onto, so a walked trace matched as `auto` snaps to the carriageway rather than the path. |
| `encoded_polyline` | string | no |  |  | The trace as a Google encoded polyline with six digits of decimal precision (polyline6), the geometry `route` and `match_trace` themselves return. Provide this or `shape`. |
| `shape` | [LatLon](#latlon)[] | no |  |  | The trace as an ordered list of recorded points, oldest first. Between 2 and 2000 points. Provide this or `encoded_polyline`. |

**Output**

| Field | Type | Always present | Description |
|---|---|---|---|
| `bridge` | SegmentSummary | yes | Bridge portion of the matched path. |
| `by_admin` | AdminAreaSummary[] | yes | Distance and time by administrative area, longest first. An entry whose codes are all null covers segments the graph could not place in any admin area, counted honestly rather than guessed at. |
| `by_road_class` | RoadClassSummary[] | yes | Distance and time by road class (`motorway`, `primary`, `residential`, …), longest first. |
| `by_surface` | SurfaceSummary[] | yes | Distance by road surface (`paved`, `paved_smooth`, `gravel`, …), longest first. Empty when the graph records no surface for any matched segment. |
| `costing` | string | yes | Costing the trace was matched under. |
| `distance_m` | number | yes | Length of the matched path in metres. |
| `duration_s` | number | yes | Travel time along the matched path in seconds, from the engine's own time model. |
| `edge_count` | integer | yes | How many road segments the trace matched onto. |
| `geometry_polyline6` | string | yes | The matched path as a Google encoded polyline with six digits of decimal precision (polyline6). This is the trace snapped to real roads, not the raw input. |
| `summary` | string | yes | One-line human summary of the match, for reading aloud. |
| `toll` | SegmentSummary | yes | Tolled portion of the matched path. |
| `tunnel` | SegmentSummary | yes | Tunnel portion of the matched path. |

**`SegmentSummary`**: How much of the matched path carried one flag (toll, bridge, tunnel). The engine reports no separate time for these, so distance and a count are all that can honestly be given.

| Field | Type | Always present | Description |
|---|---|---|---|
| `distance_m` | number | yes | Distance carrying this flag in metres. Zero means none of the matched path did. |
| `edge_count` | integer | yes | How many matched segments carried this flag. |

**`AdminAreaSummary`**: Distance and time the matched path spent in one administrative area.

| Field | Type | Always present | Description |
|---|---|---|---|
| `distance_m` | number | yes | Distance in this area in metres. |
| `duration_s` | number | yes | Time in this area in seconds. |
| `edge_count` | integer | yes | How many matched segments fell in this area. |
| `country` | string | no | Country name, or null as for `country_code`. |
| `country_code` | string | no | ISO 3166-1 alpha-2 country code, or null where the routing graph places the segment in no admin area at all. |
| `state` | string | no | State/region name, or null when the graph records none. |
| `state_code` | string | no | State/region code, or null when the graph records none. |

**`RoadClassSummary`**: Distance and time the matched path spent on one road class.

| Field | Type | Always present | Description |
|---|---|---|---|
| `distance_m` | number | yes | Distance on this road class in metres. |
| `duration_s` | number | yes | Time on this road class in seconds. |
| `edge_count` | integer | yes | How many matched segments carried this road class. |
| `road_class` | string | yes | The road class, as the routing graph records it: `motorway`, `trunk`, `primary`, `secondary`, `tertiary`, `unclassified`, `residential`, `service_other`. |

**`SurfaceSummary`**: Distance the matched path spent on one road surface.

| Field | Type | Always present | Description |
|---|---|---|---|
| `distance_m` | number | yes | Distance on this surface in metres. |
| `edge_count` | integer | yes | How many matched segments carried this surface. |
| `surface` | string | yes | The surface, as the routing graph records it: `paved_smooth`, `paved`, `paved_rough`, `compacted`, `dirt`, `gravel`, `path`, `impassable`. |

**Example** (called 2026-11-05)

```json
{
  "shape": [
    {
      "lat": 51.5007,
      "lon": -0.1246
    },
    {
      "lat": 51.5033,
      "lon": -0.1276
    },
    {
      "lat": 51.5068,
      "lon": -0.128
    }
  ],
  "costing": "auto"
}
```

Result (trimmed):

```json
{
  "bridge": {
    "distance_m": 0,
    "edge_count": 0
  },
  "by_admin": [
    {
      "country": "United Kingdom",
      "country_code": "GB",
      "distance_m": 1123,
      "duration_s": 314.7,
      "edge_count": 60,
      "state": "England",
      "state_code": "ENG"
    }
  ],
  "by_road_class": [
    {
      "distance_m": 657,
      "duration_s": 81.7,
      "edge_count": 29,
      "road_class": "primary"
    },
    {
      "distance_m": 229,
      "duration_s": 139.1,
      "edge_count": 11,
      "road_class": "service_other"
    },
    "… 2 more"
  ],
  "by_surface": [
    {
      "distance_m": 1086,
      "edge_count": 57,
      "surface": "paved_smooth"
    },
    {
      "distance_m": 37,
      "edge_count": 3,
      "surface": "paved"
    }
  ],
  "costing": "auto",
  "distance_m": 1065,
  "duration_s": 293.945,
  "edge_count": 60,
  "geometry_polyline6": "myjfaBphrFGdEI`\\EvEInN{DIkFK{Wc@cNUqHa@e@r[E~BC`CSpJiDA{CQmBGgD?ySLwOHuADaLPAbB?…",
  "summary": "matched 1.1 km, 4 min over 60 road segment(s) as auto",
  "toll": {
    "distance_m": 0,
    "edge_count": 0
  },
  "tunnel": {
    "distance_m": 0,
    "edge_count": 0
  }
}
```

**More from the server's description:**

Costing decides which roads the trace may match onto, so a walk matched as "auto" snaps to the carriageway rather than the footpath. This is how you turn a dashcam or telematics log into a report, which country and region the driving happened in, how much of it was motorway, how much was tolled, how much was unpaved. Needs the MapMap gateway (GATEWAY_URL + GATEWAY_API_KEY); there is no direct-backend fallback.

## Fleet and optimisation

Multi-stop and multi-vehicle planning, re-planning mid-shift, EV journeys and errands against a deadline.

### `optimise_routes`

Optimise multi-vehicle, multi-stop delivery plans (VRP). Provide `vehicles` (id, start/end, capacity, skills, time_window), `jobs` (id, location, service_s, delivery/pickup, skills, time_windows) and/or `shipments` (pickup+delivery pairs that ride the same vehicle).

**Access:** Keyless. Answers without a key on the hosted endpoint under fair use; present your own key to meter it to you.

**Caveats the server states:**

- Optional `territories` are named polygons ([{id, polygon}], GeoJSON [lon, lat] rings, LONGITUDE FIRST) that bound who serves what: a vehicle listing `territory_ids` may serve a task only if that task sits inside at least one of the territories it names, while a vehicle listing none is unrestricted and may serve anything, inside a round or outside every one.
- That split is fixed BEFORE the solve, so the plan is conservative and never optimistic, it cannot put a lorry in two places at once, but it is an approximation: a trip that finishes early cannot lend its spare time to the next, so stops can come back unassigned that a truly sequential model would have served, and `max_trips` is a budget rather than a prediction (ask for five on a shift that supports three and every window shrinks to a fifth).
- `relax_if_unassigned` {time_windows_by_s?, allow_overtime_s?} re-solves ONCE with those relaxations if the first plan left work unassigned, and the `relaxation` block says honestly which plan came back: at most one second solve, never beyond the caps stated, and the relaxed plan is returned ONLY if it assigns more work than the first.
- Breaks are never widened, a driver's rest is not a preference to trade for a fuller van , and neither are capacities, skills, territories or task caps; only time windows move.
- Territories, reloads, relaxation and zones are computed by the MapMap gateway; without one configured the tool refuses rather than returning a plan that quietly ignored them.

**Inputs**

| Input | Type | Required | Default | Limits | Description |
|---|---|---|---|---|---|
| `vehicles` | [OptimiseVehicleSpec](#optimisevehiclespec)[] | yes |  |  | The fleet (at least one vehicle, each with a start and/or end). |
| `avoid_zones` | boolean | no |  |  | Keep the optimisation's travel-time matrix out of every zone the declared vehicle would be charged or banned in. Requires `emissions`. |
| `costing` | [CostingKind](#costingkind) | no | `"auto"` |  | Costing model for the travel-time matrix: "auto" (default), "truck", "bicycle", "pedestrian" or "motor_scooter". |
| `emissions` | [EmissionsSpec](#emissionsspec) | no |  |  | The fleet's emission declaration, for UK clean-air / low-emission zone assessment. On its own it annotates: the response's `zones` block names every zone containing one of the problem's own locations and what this vehicle would pay there. With `avoid_zones` it also steers the internal travel-time matrix away from those zones, so the plan itself changes. |
| `jobs` | [OptimiseJobSpec](#optimisejobspec)[] | no |  |  | Single-stop jobs (at least one job or shipment overall). |
| `relax_if_unassigned` | [RelaxSpec](#relaxspec) | no |  |  | Re-solve ONCE with these relaxations if the first solve leaves work unassigned, and say honestly which plan came back. At most one second solve, never beyond the caps you state, and the relaxed plan is returned only if it assigns MORE work than the first, giving away constraints for nothing is strictly worse than not giving them away. Breaks are never widened, nor are capacities, skills, territories or task caps: only time windows move, and only by the stated amounts. Bills as two solves when the second one runs. |
| `shipments` | [OptimiseShipmentSpec](#optimiseshipmentspec)[] | no |  |  | Pickup+delivery pairs. |
| `territories` | [TerritorySpec](#territoryspec)[] | no |  |  | Fleet territories: named polygons that bound which vehicle may serve which stop, referenced by `vehicles[].territory_ids`. These are request data, caller-drawn rounds, validated per call and never stored. Nothing to do with clean-air zones or with the offline map packages of the same word. |
| `truck` | [TruckSpec](#truckspec) | no |  |  | Truck profile (dimensions + ADR declaration). Requires costing "truck"; the travel-time matrix then respects dimensional and dangerous-goods restrictions, so the whole plan is truck-legal. |

**Output**

| Field | Type | Always present | Description |
|---|---|---|---|
| `profile` | string | yes | The matrix costing profile the plan was computed with ("auto", "truck", "bicycle", "pedestrian" or "motor_scooter"). |
| `routes` | [OptimisedRoute](#optimisedroute)[] | yes | One optimised route per used vehicle. |
| `summary` | [OptimiseSummary](#optimisesummary) | yes | Solution summary. |
| `unassigned` | [UnassignedTask](#unassignedtask)[] | yes | Tasks the solver could not assign to any vehicle. |
| `relaxation` | any JSON | no | The relaxation report: the caps requested, whether a second solve ran, whether ITS plan is the one returned, what was widened, and what is still unassigned. Present only when `relax_if_unassigned` was declared. Always read `second_solve` and `relaxed_plan_used` before telling anyone the day fits. A plan produced under relaxation has had promises moved, and the block is what says so. |
| `reloads` | any JSON | no | The multi-trip split: each vehicle's trips, their windows, the depot each returns to, and the stated approximation. Present only when a vehicle declared `reloads`. Read the `basis` inside it before quoting arrival times. The trip windows are fixed BEFORE the solve, so a lorry that tips early cannot lend the spare time to its next trip: stops can come back unassigned that a truly sequential model would have served. The plan is feasible, never optimistic, it cannot put a vehicle in two places at once, but it is not optimal. Re-plan after each tip through `replan_routes` for the tighter answer. |
| `territories` | any JSON | no | How the territories bound the plan: which vehicle was eligible for what, and any task no eligible vehicle existed for. Present only when `territories` was declared. |
| `view_url` | string | no | A mapmap.ai link that draws this fleet plan, one route per vehicle, on a map: open it or pass it on as the last line of the answer. Nothing is stored server-side and opening it needs no key; the link itself carries the stop coordinates and stop names (plus any arrival clocks, dwell times and totals) and nothing else, so anyone holding it can read those stops. Absent when the plan cannot be linked (see `view_url_note`) or the server runs with links turned off (`SN_MCP_SITE_URL` empty). |
| `view_url_note` | string | no | Why there is no `view_url` (too many vehicles or stops for one link, or over the 6,000 character cap), or what the link left out to fit under the cap (arrival times, dwell times, then stop names). Relay it with the link. |
| `zones` | any JSON | no | Clean-air / low-emission zones touching the problem's own locations, and what the declared vehicle pays in each. Present only when `emissions` was declared. |

**Example** (called 2026-11-05)

```json
{
  "vehicles": [
    {
      "id": 1,
      "start": {
        "lat": 51.5007,
        "lon": -0.1246
      },
      "end": {
        "lat": 51.5007,
        "lon": -0.1246
      }
    }
  ],
  "jobs": [
    {
      "id": 1,
      "location": {
        "lat": 51.5308,
        "lon": -0.1238
      }
    },
    {
      "id": 2,
      "location": {
        "lat": 51.5194,
        "lon": -0.127
      }
    }
  ],
  "costing": "auto"
}
```

Result (trimmed):

```json
{
  "profile": "auto",
  "routes": [
    {
      "distance_m": 9166,
      "duration_s": 1638,
      "service_s": 0,
      "steps": [
        "{…}",
        "{…}",
        "… 2 more"
      ],
      "vehicle": 1,
      "waiting_time_s": 0
    }
  ],
  "summary": {
    "cost": 1638,
    "distance_m": 9166,
    "duration_s": 1638,
    "routes": 1,
    "service_s": 0,
    "unassigned": 0,
    "waiting_time_s": 0
  },
  "unassigned": [],
  "view_url": "https://mapmap.ai/view?p=eyJ2IjoxLCJraW5kIjoiZmxlZXQiLCJjb3N0aW5nIjoiYXV0byIsInJ…",
  "zones": {
    "at_locations": [
      {
        "assessment": "unknown",
        "currency": "GBP",
        "daily_charge": null,
        "enforcement": "charge",
        "id": "gb-london-lez",
        "locations_inside": 3,
        "name": "London Low Emission Zone",
        "operator": "Transport for London",
        "source": "{…}",
        "type": "lez"
      },
      {
        "assessment": "unknown",
        "currency": "GBP",
        "daily_charge": null,
        "enforcement": "charge",
        "id": "gb-london-ulez",
        "locations_inside": 3,
        "name": "London Ultra Low Emission Zone",
        "operator": "Transport for London",
        "source": "{…}",
        "type": "ulez"
      }
    ],
    "basis": "the optimiser is fed a travel-time matrix, not road geometry, so this block repo…",
    "caveats": "Zone boundaries are simplified from the published geometry to the tolerance stat…",
    "note": "charge depends on vehicle emissions, supply `emissions` (vehicle_category, fuel,…"
  }
}
```

**More from the server's description:**

Costing "auto", "truck", "bicycle", "pedestrian" or "motor_scooter" (cargo-bike and courier fleets welcome): with a `truck` profile (dimensions + ADR declaration, as in `route`), the travel-time matrix respects dimensional and dangerous-goods restrictions, so every optimised route is truck-legal. Returns a summary, unassigned tasks and per-vehicle routes with ordered steps (arrival_s/duration_s in seconds, distance_m in metres). Fair use: at most 200 unique locations per problem, and no wider than the routing engine's matrix span (1,500 km on the hosted endpoint for motor costings, 400 km stock on a self-host). Past that, cluster the stops with `cluster` and optimise each group, or submit the whole problem to the asynchronous lane with `submit_optimise_job` (2,000 locations). The response's `territories` block says which vehicle was eligible for what and names any task no vehicle could take. A vehicle may also declare `reloads` {max_trips 2-5, reload_time_s, depot?} to return to a depot, reload and go out again, the tipping round. It needs a `time_window`, because the shift is what gets split: it is cut into that many consecutive non-overlapping windows separated by the reload time, each trip carrying the vehicle's FULL capacity and task caps. Read the returned `reloads` block before quoting arrival times, and re-plan after each tip with `replan_routes` for the tighter answer. It bills as two solves when the second one runs. Always check `relaxation.relaxed_plan_used` before telling anyone the day fits: a plan produced under relaxation has had promises moved. `emissions` {vehicle_category, fuel, euro_standard} annotates the plan with the clean-air zones its own stops sit in and what this vehicle pays in each; add `avoid_zones: true` to steer the travel-time matrix out of them, which changes the plan itself. Returns `view_url`, a mapmap.ai link that draws the answer on a map (no key needed); the link carries the stop coordinates and names.

### `order_stops`

Put a single run's stops in the best visiting order ("order my errands"). Provide `start` {lat, lon} and `stops` (1-100 entries of {location, label?, service_s?}); optionally an `end` destination or `round_trip`: true to return to the start.

**Access:** Keyless. Answers without a key on the hosted endpoint under fair use; present your own key to meter it to you.

**Inputs**

| Input | Type | Required | Default | Limits | Description |
|---|---|---|---|---|---|
| `start` | [LatLon](#latlon) | yes |  |  | Where the run starts. |
| `stops` | OrderStopSpec[] | yes |  |  | The stops to put in the best visiting order (1–100). |
| `costing` | [CostingKind](#costingkind) | no | `"auto"` |  | Costing model: "auto" (default) or "truck". |
| `end` | [LatLon](#latlon) | no |  |  | Optional fixed final destination. Mutually exclusive with `round_trip`; omitted (and not a round trip), the run ends at whichever stop the solver visits last. |
| `round_trip` | boolean | no | `false` |  | Return to `start` after the last stop (default false). |
| `truck` | [TruckSpec](#truckspec) | no |  |  | Truck profile (dimensions + ADR declaration); requires costing "truck". The travel-time matrix then respects dimensional and dangerous-goods restrictions. |

**`OrderStopSpec`**: One errand stop of an `order_stops` request.

| Input | Type | Required | Default | Limits | Description |
|---|---|---|---|---|---|
| `location` | [LatLon](#latlon) | yes |  |  | The stop's location. |
| `label` | string | no |  |  | Human-readable label echoed back in the ordered plan (e.g. "chemist" or "site B"). |
| `service_s` | integer | no |  |  | On-site time in seconds (waiting, loading, shopping). |

**Output**

| Field | Type | Always present | Description |
|---|---|---|---|
| `duration_s` | integer | yes | Total travel time in seconds. |
| `ordered` | OrderedStop[] | yes | The stops in optimal visiting order. |
| `profile` | string | yes | The matrix costing profile ("auto" or "truck"). |
| `unassigned_stop_indexes` | integer[] | yes | 0-based indexes of stops the solver could not fit (empty in the normal, unconstrained case). |
| `distance_m` | integer | no | Total travel distance in metres, when reported. |

**`OrderedStop`**: One stop of an ordered errand plan.

| Field | Type | Always present | Description |
|---|---|---|---|
| `arrival_s` | integer | yes | Arrival time as an offset from departure, in seconds. |
| `location` | [LatLon](#latlon) | yes | The stop's location. |
| `order` | integer | yes | 1-based visit order. |
| `stop_index` | integer | yes | The stop's 0-based index in the request's `stops` array. |
| `label` | string | no | The request's label for this stop, when one was given. |

**Example** (called 2026-11-05)

```json
{
  "start": {
    "lat": 51.5007,
    "lon": -0.1246
  },
  "stops": [
    {
      "location": {
        "lat": 51.5308,
        "lon": -0.1238
      },
      "label": "B"
    },
    {
      "location": {
        "lat": 51.5194,
        "lon": -0.127
      },
      "label": "C"
    }
  ]
}
```

Result (trimmed):

```json
{
  "distance_m": 4791,
  "duration_s": 868,
  "ordered": [
    {
      "arrival_s": 520,
      "label": "C",
      "location": {
        "lat": 51.5194,
        "lon": -0.127
      },
      "order": 1,
      "stop_index": 1
    },
    {
      "arrival_s": 868,
      "label": "B",
      "location": {
        "lat": 51.5308,
        "lon": -0.1238
      },
      "order": 2,
      "stop_index": 0
    }
  ],
  "profile": "auto",
  "unassigned_stop_indexes": []
}
```

**More from the server's description:**

Costing "auto" = car, "truck" = lorry (pass `truck` as in `route` for a truck-legal order). Returns the stops in visit order with arrival offsets in seconds, plus total duration and distance.

### `cluster`

Group stops into balanced geographic clusters, so a day too large for one optimisation can be optimised one cluster at a time.

**Access:** Keyless. Answers without a key on the hosted endpoint under fair use; present your own key to meter it to you.

**Caveats the server states:**

- IMPORTANT, this is STRAIGHT-LINE clustering.
- Distances are measured between coordinates, not along the road network: no road, river, motorway junction or one-way system is consulted, and two stops either side of an estuary look adjacent.
- The answer carries a `basis` sentence saying exactly this; show it, so a centroid is never read as a plan.

**Inputs**

| Input | Type | Required | Default | Limits | Description |
|---|---|---|---|---|---|
| `locations` | ClusterLocationSpec[] | yes |  |  | The stops to group. Ids must be unique; at most 5,000. |
| `clusters` | integer | no |  |  | How many clusters to produce, balanced by stop count. Give exactly one of `clusters`, `max_cluster_locations` or `max_cluster_load`. |
| `max_cluster_load` | number | no |  |  | At most this much summed `load` per cluster; the cluster count is derived from it. A load ceiling with lumpy loads is a bin-packing problem and may have no solution at the derived count, the response says so rather than pretending. |
| `max_cluster_locations` | integer | no |  |  | At most this many stops per cluster; the cluster count is derived from it. |
| `seed` | integer | no | 42 |  | Seed for the k-means++ seeding (default 42). The same request with the same seed always returns the same clusters, on every deployment. |
| `territories` | [TerritorySpec](#territoryspec)[] | no |  |  | Optional territories. Given, no cluster straddles one: each territory is clustered on its own, and so are the stops inside none of them. |

**`ClusterLocationSpec`**: One stop to be clustered.

| Input | Type | Required | Default | Limits | Description |
|---|---|---|---|---|---|
| `id` | string | yes |  |  | Caller-chosen id, echoed back as the cluster's membership. Must be unique within the request. |
| `lat` | number | yes |  |  | Latitude in decimal degrees. |
| `lon` | number | yes |  |  | Longitude in decimal degrees. |
| `load` | number | no |  |  | Optional weight, parcels, kilograms, litres, minutes of service. Summed per cluster and reported; constrains the clustering only under `max_cluster_load`. |

**Output**

| Field | Type | Always present | Description |
|---|---|---|---|
| `balance` | any JSON | yes | The constraint applied, the largest cluster produced, and whether the ceiling had to be relaxed to place every stop. |
| `basis` | string | yes | The method statement: this is STRAIGHT-LINE clustering. Distances are between coordinates, not along roads, two stops either side of an estuary look adjacent. Show it. It is the sentence that stops a centroid being read as a plan. |
| `clusters` | any JSON | yes | The clusters: each with its `id`, member `locations` (your ids), `count`, summed `load`, `centroid` and the `territory` it belongs to. |
| `parameters` | any JSON | yes | The seed, the cluster count, the locations seen, how many territories were used, the iterations run and whether it converged. |

**Example** (called 2026-11-05)

```json
{
  "locations": [
    {
      "id": "a",
      "lat": 51.5,
      "lon": -0.12
    },
    {
      "id": "b",
      "lat": 51.51,
      "lon": -0.13
    },
    {
      "id": "c",
      "lat": 51.6,
      "lon": -0.2
    },
    {
      "id": "d",
      "lat": 51.61,
      "lon": -0.21
    }
  ],
  "clusters": 2
}
```

Result (trimmed):

```json
{
  "balance": {
    "basis": "locations",
    "largest_cluster_load": 0,
    "largest_cluster_locations": 2,
    "max_load_per_cluster": null,
    "max_locations_per_cluster": null,
    "note": "every cluster is inside the ceiling.",
    "relaxed": false
  },
  "basis": "Straight-line clustering. Distances are measured between coordinates on a local …",
  "clusters": [
    {
      "centroid": {
        "lat": 51.504999999999995,
        "lon": -0.125
      },
      "count": 2,
      "id": 0,
      "load": 0,
      "locations": [
        "a",
        "b"
      ],
      "territory": null
    },
    {
      "centroid": {
        "lat": 51.605,
        "lon": -0.205
      },
      "count": 2,
      "id": 1,
      "load": 0,
      "locations": [
        "c",
        "d"
      ],
      "territory": null
    }
  ],
  "parameters": {
    "clusters": 2,
    "converged": true,
    "iterations": 2,
    "locations": 4,
    "locations_outside_all_territories": 0,
    "seed": 42,
    "territories": 0
  }
}
```

**More from the server's description:**

This is the front half of the recipe for a thousand-stop day: cluster here, then call `optimise_routes` per cluster, where the routing engine's own matrix decides the visiting order. That makes it the right tool for deciding which stops belong TOGETHER and the wrong one for deciding what ORDER to visit them in. Provide `locations` ([{id, lat, lon, load?}], ids unique, at most 5,000) and EXACTLY ONE of `clusters` (how many groups, balanced by stop count), `max_cluster_locations` or `max_cluster_load` (a per-cluster ceiling the count is derived from). Optional `territories` keep a cluster from straddling a round: each is clustered on its own, and so are the stops inside none of them. Optional `seed` (default 42) drives the seeding, the same request with the same seed always returns the same clusters, on every deployment, so a re-run is a re-run. Returns each cluster's member ids, count, summed load, centroid and territory, plus a `balance` block naming the constraint applied and whether it had to be relaxed to place every stop: a load ceiling with lumpy loads is a bin-packing problem and may have no solution at the derived count. Requires the MapMap gateway.

### `replan_routes`

Re-plan a fleet part-way through its shift. Send the day back; nothing is stored.

**Access:** Keyless. Answers without a key on the hosted endpoint under fair use; present your own key to meter it to you.

**Caveats the server states:**

- MapMap holds NO dispatch state, no plan, no vehicle position, no completion log, so a re-plan is not a delta against something we remember: you pass the ORIGINAL `optimise_routes` problem in full, plus `progress` (per vehicle: `completed_stop_ids` IN THE ORDER SERVED, an optional `current_position`, and `unavailable: true` for a breakdown or an end of hours) and/or `changes` (`cancel_job_ids`, `add_jobs`, `add_shipments`), and get a fresh plan for what is left.
- That costs bandwidth and buys the absence of a server-side plan that can go stale, leak, or fall out of step with the telematics platform that actually owns the truth, and it makes a re-plan reproducible: the same body always yields the same answer.
- Completed stops are LOCKED by construction: they are removed from the problem entirely and each vehicle starts from where it actually is, so the solver cannot move a stop that has already happened, a guarantee the solver cannot break, rather than a hint it is free to ignore.
- Returns the same plan shape as `optimise_routes` for the REMAINING work, plus a `replan` block: the prefix locked per vehicle, where each re-plans from and how that was decided, stops released from a vehicle that can no longer serve them, tasks forced unassigned, and a note for every id that did not resolve.
- Read that block, a stop that vanished from the plan is named there rather than left for a dispatcher to notice at four in the afternoon; a cancelled id that matched nothing is reported there too rather than refused.
- Multi-trip vehicles cannot be re-planned: a completion does not say which trip it belongs to, so a problem whose vehicles declare `reloads` is refused with what to send instead.
- Billed on the remaining problem, not the original.

**Inputs**

| Input | Type | Required | Default | Limits | Description |
|---|---|---|---|---|---|
| `vehicles` | [OptimiseVehicleSpec](#optimisevehiclespec)[] | yes |  |  | The fleet (at least one vehicle, each with a start and/or end). |
| `avoid_zones` | boolean | no |  |  | Keep the optimisation's travel-time matrix out of every zone the declared vehicle would be charged or banned in. Requires `emissions`. |
| `changes` | ReplanChangesSpec | no |  |  | Changes to the work itself. Optional, on the same condition. |
| `costing` | [CostingKind](#costingkind) | no | `"auto"` |  | Costing model for the travel-time matrix: "auto" (default), "truck", "bicycle", "pedestrian" or "motor_scooter". |
| `emissions` | [EmissionsSpec](#emissionsspec) | no |  |  | The fleet's emission declaration, for UK clean-air / low-emission zone assessment. On its own it annotates: the response's `zones` block names every zone containing one of the problem's own locations and what this vehicle would pay there. With `avoid_zones` it also steers the internal travel-time matrix away from those zones, so the plan itself changes. |
| `jobs` | [OptimiseJobSpec](#optimisejobspec)[] | no |  |  | Single-stop jobs (at least one job or shipment overall). |
| `progress` | ReplanProgressSpec[] | no |  |  | Per-vehicle progress. Optional, but at least one progress entry or one change is required, a re-plan that reports nothing new is the original problem. |
| `relax_if_unassigned` | [RelaxSpec](#relaxspec) | no |  |  | Re-solve ONCE with these relaxations if the first solve leaves work unassigned, and say honestly which plan came back. At most one second solve, never beyond the caps you state, and the relaxed plan is returned only if it assigns MORE work than the first, giving away constraints for nothing is strictly worse than not giving them away. Breaks are never widened, nor are capacities, skills, territories or task caps: only time windows move, and only by the stated amounts. Bills as two solves when the second one runs. |
| `shipments` | [OptimiseShipmentSpec](#optimiseshipmentspec)[] | no |  |  | Pickup+delivery pairs. |
| `territories` | [TerritorySpec](#territoryspec)[] | no |  |  | Fleet territories: named polygons that bound which vehicle may serve which stop, referenced by `vehicles[].territory_ids`. These are request data, caller-drawn rounds, validated per call and never stored. Nothing to do with clean-air zones or with the offline map packages of the same word. |
| `truck` | [TruckSpec](#truckspec) | no |  |  | Truck profile (dimensions + ADR declaration). Requires costing "truck"; the travel-time matrix then respects dimensional and dangerous-goods restrictions, so the whole plan is truck-legal. |

**`ReplanChangesSpec`**: What has changed about the work since the plan was made.

| Input | Type | Required | Default | Limits | Description |
|---|---|---|---|---|---|
| `add_jobs` | [OptimiseJobSpec](#optimisejobspec)[] | no |  |  | New single-stop jobs, in exactly the `optimise_routes` job shape. |
| `add_shipments` | [OptimiseShipmentSpec](#optimiseshipmentspec)[] | no |  |  | New pickup+delivery pairs, in exactly the `optimise_routes` shipment shape. |
| `cancel_job_ids` | integer[] | no |  |  | Ids of jobs that no longer need doing. A job already reported completed cannot be cancelled; the response says so rather than silently dropping it. |

**`ReplanProgressSpec`**: One vehicle's progress through its shift.

| Input | Type | Required | Default | Limits | Description |
|---|---|---|---|---|---|
| `vehicle` | integer | yes |  |  | The vehicle this progress belongs to, an `id` from `vehicles`. |
| `completed_stop_ids` | integer[] | no |  |  | Ids of the stops this vehicle has already served, IN THE ORDER IT SERVED THEM. Each is a job id or a shipment pickup/delivery id. This is the locked prefix: it already happened, so no re-plan may move it. |
| `current_position` | [LatLon](#latlon) | no |  |  | Where the vehicle is now; becomes its start for the re-plan. Omitted, its last completed stop is used; with neither, its original start. |
| `unavailable` | boolean | no |  |  | The vehicle has dropped out of the shift, breakdown, illness, end of hours. It leaves the fleet for the re-plan; what it already completed stays completed. |

**Output**

| Field | Type | Always present | Description |
|---|---|---|---|
| `profile` | string | yes | The matrix costing profile the plan was computed with ("auto", "truck", "bicycle", "pedestrian" or "motor_scooter"). |
| `replan` | any JSON | yes | What the re-plan locked and why: the prefix held per vehicle, where each vehicle re-plans from and how that was decided, stops released from a vehicle that could no longer serve them, tasks forced unassigned, and a note for every id that did not resolve. Read it. A stop that vanished from the plan is named here rather than left for the dispatcher to notice at four in the afternoon. |
| `routes` | [OptimisedRoute](#optimisedroute)[] | yes | One optimised route per used vehicle. |
| `summary` | [OptimiseSummary](#optimisesummary) | yes | Solution summary. |
| `unassigned` | [UnassignedTask](#unassignedtask)[] | yes | Tasks the solver could not assign to any vehicle. |
| `relaxation` | any JSON | no | The relaxation report: the caps requested, whether a second solve ran, whether ITS plan is the one returned, what was widened, and what is still unassigned. Present only when `relax_if_unassigned` was declared. Always read `second_solve` and `relaxed_plan_used` before telling anyone the day fits. A plan produced under relaxation has had promises moved, and the block is what says so. |
| `reloads` | any JSON | no | The multi-trip split: each vehicle's trips, their windows, the depot each returns to, and the stated approximation. Present only when a vehicle declared `reloads`. Read the `basis` inside it before quoting arrival times. The trip windows are fixed BEFORE the solve, so a lorry that tips early cannot lend the spare time to its next trip: stops can come back unassigned that a truly sequential model would have served. The plan is feasible, never optimistic, it cannot put a vehicle in two places at once, but it is not optimal. Re-plan after each tip through `replan_routes` for the tighter answer. |
| `territories` | any JSON | no | How the territories bound the plan: which vehicle was eligible for what, and any task no eligible vehicle existed for. Present only when `territories` was declared. |
| `view_url` | string | no | A mapmap.ai link that draws this fleet plan, one route per vehicle, on a map: open it or pass it on as the last line of the answer. Nothing is stored server-side and opening it needs no key; the link itself carries the stop coordinates and stop names (plus any arrival clocks, dwell times and totals) and nothing else, so anyone holding it can read those stops. Absent when the plan cannot be linked (see `view_url_note`) or the server runs with links turned off (`SN_MCP_SITE_URL` empty). |
| `view_url_note` | string | no | Why there is no `view_url` (too many vehicles or stops for one link, or over the 6,000 character cap), or what the link left out to fit under the cap (arrival times, dwell times, then stop names). Relay it with the link. |
| `zones` | any JSON | no | Clean-air / low-emission zones touching the problem's own locations, and what the declared vehicle pays in each. Present only when `emissions` was declared. |

**Example** (called 2026-11-05)

```json
{
  "vehicles": [
    {
      "id": 1,
      "start": {
        "lat": 51.5007,
        "lon": -0.1246
      },
      "end": {
        "lat": 51.5007,
        "lon": -0.1246
      }
    }
  ],
  "jobs": [
    {
      "id": 1,
      "location": {
        "lat": 51.5308,
        "lon": -0.1238
      }
    },
    {
      "id": 2,
      "location": {
        "lat": 51.5194,
        "lon": -0.127
      }
    }
  ],
  "costing": "auto",
  "progress": [
    {
      "vehicle": 1,
      "completed_stop_ids": [
        1
      ]
    }
  ]
}
```

Result (trimmed):

```json
{
  "profile": "auto",
  "replan": {
    "basis": "MapMap stores no dispatch state. Completed stops are locked by removing them fro…",
    "changes_applied": {
      "added_jobs": [],
      "added_shipments": [],
      "cancelled_jobs": [],
      "completed_stops": 1,
      "vehicles_unavailable": []
    },
    "locked_stops_per_vehicle": [
      {
        "start_basis": "last_completed_stop",
        "start_from": "{…}",
        "stops": [
          1
        ],
        "unavailable": false,
        "vehicle": 1
      }
    ],
    "mechanism": "locked-prefix elision",
    "notes": [],
    "released_stops": []
  },
  "routes": [
    {
      "distance_m": 4288,
      "duration_s": 770,
      "service_s": 0,
      "steps": [
        "{…}",
        "{…}",
        "… 1 more"
      ],
      "vehicle": 1,
      "waiting_time_s": 0
    }
  ],
  "summary": {
    "cost": 770,
    "distance_m": 4288,
    "duration_s": 770,
    "routes": 1,
    "service_s": 0,
    "unassigned": 0,
    "waiting_time_s": 0
  },
  "unassigned": [],
  "zones": {
    "at_locations": [
      {
        "assessment": "unknown",
        "currency": "GBP",
        "daily_charge": null,
        "enforcement": "charge",
        "id": "gb-london-lez",
        "locations_inside": 3,
        "name": "London Low Emission Zone",
        "operator": "Transport for London",
        "source": "{…}",
        "type": "lez"
      },
      {
        "assessment": "unknown",
        "currency": "GBP",
        "daily_charge": null,
        "enforcement": "charge",
        "id": "gb-london-ulez",
        "locations_inside": 3,
        "name": "London Ultra Low Emission Zone",
        "operator": "Transport for London",
        "source": "{…}",
        "type": "ulez"
      }
    ],
    "basis": "the optimiser is fed a travel-time matrix, not road geometry, so this block repo…",
    "caveats": "Zone boundaries are simplified from the published geometry to the tolerance stat…",
    "note": "charge depends on vehicle emissions, supply `emissions` (vehicle_category, fuel,…"
  }
}
```

**More from the server's description:**

At least one progress entry or one change is required. Requires the MapMap gateway.

### `plan_day`

Turn an itinerary into one navigable multi-stop route. Provide a `start` and `stops` (each a `location` {lat, lon} or a free-text `name` to geocode, plus optional `dwell_minutes` time at the stop), optional `depart_at` (RFC 3339) for absolute ETAs, `optimise: true` to reorder stops for the shortest day (VROOM solver), and `return_to_start`.

**Access:** Keyless. Answers without a key on the hosted endpoint under fair use; present your own key to meter it to you.

**Inputs**

| Input | Type | Required | Default | Limits | Description |
|---|---|---|---|---|---|
| `start` | PlanDayStopInput | yes |  |  | Where the day starts (name or location; `dwell_minutes` ignored). |
| `stops` | PlanDayStopInput[] | yes |  |  | The stops to visit (1–20). Visited in the given order unless `optimise` is true. |
| `costing` | [CostingKind](#costingkind) | no | `"auto"` |  | Costing model: "auto" (default), "truck", "bicycle", "pedestrian" or "motor_scooter". |
| `depart_at` | string | no |  |  | Departure time as RFC 3339 (e.g. "2026-07-18T09:00:00Z"); when given, every ETA is also returned as an absolute timestamp. |
| `optimise` | boolean | no | `false` |  | Reorder the stops for the shortest day (VROOM solver; requires the optimisation sidecar). Default false: visit in the given order. |
| `return_to_start` | boolean | no | `false` |  | End the day back at the start (default false). |
| `truck` | [TruckSpec](#truckspec) | no |  |  | Truck profile (dimensions + ADR declaration). Requires costing "truck". |

**`PlanDayStopInput`**: One stop of a `plan_day` itinerary: an exact location, or a free-text name to geocode (ambiguous matches are flagged in the response, never guessed silently).

| Input | Type | Required | Default | Limits | Description |
|---|---|---|---|---|---|
| `dwell_minutes` | number | no | 0 |  | Time spent at the stop in minutes (default 0); shifts every later ETA. |
| `location` | [LatLon](#latlon) | no |  |  | Exact coordinates; skips geocoding. |
| `name` | string | no |  |  | Free-text name or address, geocoded when no `location` is given. |

**Output**

| Field | Type | Always present | Description |
|---|---|---|---|
| `costing` | string | yes | The costing the plan was routed with. |
| `finish_offset_s` | number | yes | End of the day (last arrival + dwell), seconds after departure. |
| `geometry_polyline6` | string | yes | Full multi-stop route geometry (polyline6), hand it to the map SDK or the `route` tool consumers directly. |
| `optimised` | boolean | yes | Whether the stop order was optimised (VROOM) or kept as given. |
| `start` | PlanStart | yes | The day's starting point. |
| `stops` | PlannedStop[] | yes | The stops in visit order, each with leg, ETAs and any geocoding resolution to double-check. |
| `summary` | string | yes | One-line human-readable summary of the day. |
| `total_distance_m` | number | yes | Total travel distance, metres. |
| `total_dwell_s` | number | yes | Total time at stops, seconds. |
| `total_travel_duration_s` | number | yes | Total driving/travel time, seconds. |
| `depart_at` | string | no | The departure time echoed back, when one was given. |
| `finish_at` | string | no | Absolute end of the day (RFC 3339), when `depart_at` was given. |
| `return_leg` | ReturnLeg | no | The leg back to the start, when `return_to_start` was set. |
| `view_url` | string | no | A mapmap.ai link that draws this day plan on a map: open it or pass it on as the last line of the answer. Nothing is stored server-side and opening it needs no key; the link itself carries the stop coordinates and stop names (plus any arrival clocks, dwell times and totals) and nothing else, so anyone holding it can read those stops. Absent when the plan cannot be linked (see `view_url_note`) or the server runs with links turned off (`SN_MCP_SITE_URL` empty). |
| `view_url_note` | string | no | Why there is no `view_url` (too many vehicles or stops for one link, or over the 6,000 character cap), or what the link left out to fit under the cap (arrival times, dwell times, then stop names). Relay it with the link. |

**`ReturnLeg`**: The return leg of a `return_to_start` plan.

| Field | Type | Always present | Description |
|---|---|---|---|
| `arrival_offset_s` | number | yes | Arrival back at the start, seconds after departure. |
| `travel_distance_m` | number | yes | Distance back to the start, metres. |
| `travel_duration_s` | number | yes | Travel time back to the start, seconds. |
| `arrival_at` | string | no | Absolute arrival time (RFC 3339), when `depart_at` was given. |

**`PlanStart`**: The day's starting point as resolved.

| Field | Type | Always present | Description |
|---|---|---|---|
| `location` | [LatLon](#latlon) | yes | The start's resolved coordinates. |
| `name` | string | no | The start's name, when one was given. |
| `resolution` | StopResolution | no | Geocoding details when the start was given as a name. |

**`StopResolution`**: How a free-text stop name was resolved to coordinates.

| Field | Type | Always present | Description |
|---|---|---|---|
| `alternatives` | [GeocodeHit](#geocodehit)[] | yes | Up to three alternative matches, best first. |
| `ambiguous` | boolean | yes | True when other plausible matches exist somewhere else, check `alternatives` and re-run with an explicit `location` if the chosen one is wrong. |
| `chosen` | [GeocodeHit](#geocodehit) | yes | The chosen match (best geocoder hit). |
| `query` | string | yes | The name that was geocoded. |

**`PlannedStop`**: One planned stop with its leg and ETAs.

| Field | Type | Always present | Description |
|---|---|---|---|
| `arrival_offset_s` | number | yes | Arrival, as seconds after departure from the start. |
| `departure_offset_s` | number | yes | Departure (arrival + dwell), as seconds after the day's start. |
| `dwell_minutes` | number | yes | Dwell time applied at this stop, minutes. |
| `input_index` | integer | yes | Index of this stop in the request's `stops` array (visit order may differ when optimised). |
| `location` | [LatLon](#latlon) | yes | The stop's resolved coordinates. |
| `travel_distance_m` | number | yes | Distance of the leg arriving at this stop, metres. |
| `travel_duration_s` | number | yes | Travel time of the leg arriving at this stop, seconds. |
| `arrival_at` | string | no | Absolute arrival time (RFC 3339), when `depart_at` was given. |
| `departure_at` | string | no | Absolute departure time (RFC 3339), when `depart_at` was given. |
| `name` | string | no | The stop's name (from the request or the geocoder). |
| `resolution` | StopResolution | no | Geocoding details when the stop was given as a name. |

**Example** (called 2026-11-05)

```json
{
  "start": {
    "location": {
      "lat": 51.5007,
      "lon": -0.1246
    }
  },
  "stops": [
    {
      "location": {
        "lat": 51.5308,
        "lon": -0.1238
      },
      "dwell_minutes": 15
    },
    {
      "location": {
        "lat": 51.5194,
        "lon": -0.127
      }
    }
  ],
  "costing": "auto"
}
```

Result (trimmed):

```json
{
  "costing": "auto",
  "finish_offset_s": 2120.168,
  "geometry_polyline6": "gejfaB~zrFDCRGXC^?rCXvD`@|@H`@J`@\\\\b@P^Pf@Nd@Px@Jp@HfA@fAC`Aq@fKeA|OK`ASlAY|@_@r…",
  "optimised": false,
  "start": {
    "location": {
      "lat": 51.5007,
      "lon": -0.1246
    }
  },
  "stops": [
    {
      "arrival_offset_s": 917.918,
      "departure_offset_s": 1817.9180000000001,
      "dwell_minutes": 15,
      "input_index": 0,
      "location": {
        "lat": 51.5308,
        "lon": -0.1238
      },
      "travel_distance_m": 5106,
      "travel_duration_s": 917.918
    },
    {
      "arrival_offset_s": 2120.168,
      "departure_offset_s": 2120.168,
      "dwell_minutes": 0,
      "input_index": 1,
      "location": {
        "lat": 51.5194,
        "lon": -0.127
      },
      "travel_distance_m": 1724,
      "travel_duration_s": 302.25
    }
  ],
  "summary": "2 stops, 6.8 km, 20 min travelling + 15 min at stops",
  "total_distance_m": 6830,
  "total_dwell_s": 900,
  "total_travel_duration_s": 1220.168,
  "view_url": "https://mapmap.ai/view?p=eyJ2IjoxLCJraW5kIjoicGxhbiIsImNvc3RpbmciOiJhdXRvIiwicm9…"
}
```

**More from the server's description:**

Costing "auto", "truck" (with a `truck` profile the whole day respects dimensional/ADR restrictions), "bicycle", "pedestrian" or "motor_scooter". Returns the stops in visit order with per-leg duration/distance and arrival/departure times, totals, and the full route geometry (polyline6). Geocoded names carry a `resolution`, when `ambiguous` is true, check `alternatives` and re-run with an explicit location rather than trusting the guess. Returns `view_url`, a mapmap.ai link that draws the answer on a map (no key needed); the link carries the stop coordinates and names.

### `plan_errands`

Order a handful of errands against a hard arrival time, and say honestly whether they fit.

**Access:** Keyless. Answers without a key on the hosted endpoint under fair use; present your own key to meter it to you.

**Caveats the server states:**

- Resolve a named place with `geocode` first and pass the coordinate: never invent one.
- The server chooses the order and the actual shops, exhaustively, over engine-computed travel times -- do NOT attempt the ordering or the arithmetic yourself.
- IMPORTANT: `feasible: false` is an ANSWER, not an error to retry.
- Every stop is a real indexed place carrying its id: never mention a shop the answer did not return.
- `hours` is "open_on_the_tag" (the map's tag covers your arrival, which is evidence and not a promise) or "unknown" (most places carry no hours at all); a place the tag proves shut is never proposed.
- ALWAYS show `usage_note`: this is planned before setting off or by a passenger, never at the wheel.

**Inputs**

| Input | Type | Required | Default | Limits | Description |
|---|---|---|---|---|---|
| `arrive_by` | string | yes |  |  | The hard arrival time, RFC 3339 with an offset ("2026-09-21T15:15:00+01:00"). Convert "quarter past three" to an absolute instant in the driver's own offset before calling. |
| `destination` | [LatLon](#latlon) | yes |  |  | Where they have to be by `arrive_by`. |
| `errands` | ErrandSpec[] | yes |  |  | The errands, in any order: the answer decides the order. One to five. |
| `origin` | [LatLon](#latlon) | yes |  |  | Where the driver sets off from. |
| `depart_at` | string | no | now |  | When they set off, RFC 3339. Defaults to now. |
| `destination_name` | string | no |  |  | What to call the destination in the answer ("the school"). |
| `max_detour_minutes` | number | no | 12, at most 45 |  | How far off the direct route a candidate place may sit, as a detour in minutes (default 12, at most 45). |

**`ErrandSpec`**: One errand in a `plan_errands` chain: a kind of place to find, or a place the caller already knows.

| Input | Type | Required | Default | Limits | Description |
|---|---|---|---|---|---|
| `category` | string | no |  |  | The KIND of place wanted: a category from `list_place_categories` ("pharmacy", "fuel", "supermarket"), or a colloquial phrase the server normalises ("petrol station", "chemist"). Use this for anything the driver described by what it is rather than by name. |
| `dwell_minutes` | number | no | 5 |  | How long the driver is out of the car here, minutes (default 5). |
| `id` | string | no | the category |  | What to call this errand in the answer ("prescription", "petrol"). Defaults to the category, or to "stop N". |
| `place` | [LatLon](#latlon) | no |  |  | A specific place the driver already knows, as a coordinate. Use this for "the school", "the nursery", "Mum's": places the map cannot be expected to resolve from the words alone. Resolve a NAMED place with `geocode` first and pass the coordinate here; never guess one. |

**Output**

| Field | Type | Always present | Description |
|---|---|---|---|
| `arrive_by` | string | yes | The deadline, echoed. |
| `corridor_note` | string | yes | **Always present.** What the corridor search did and did not look at. An empty answer is a statement about this index and this corridor, never about what exists on the ground. |
| `departure` | string | yes | When the driver sets off, RFC 3339. |
| `dropped` | string[] | yes | Errands that had to be dropped for the plan to fit. Empty when feasible. |
| `feasible` | boolean | yes | **Whether every errand fits before the deadline.** False is an ANSWER, not an error: report `reason`, the errand named in `blocking_errand`, and what `dropped` says has to go. |
| `opening_hours_note` | string | yes | **Always present.** How opening hours were used, and how little of the map carries them. |
| `stops` | ErrandStop[] | yes | The stops, in visit order. On an infeasible answer these are the stops of the reduced plan, the one that DOES fit. |
| `usage_note` | string | yes | **Always present.** Why this is planned stationary rather than at the wheel. Show it. |
| `verification_note` | string | yes | **Always present.** That every stop is an indexed place or a supplied coordinate, never a generated one. |
| `arrival` | string | no | When the plan arrives, RFC 3339. Present whenever there is a plan, including the reduced plan on an infeasible answer. |
| `attribution` | string | no | Credit owed for the place data, when the answer used the index. |
| `blocking_errand` | string | no | The errand that costs the most time, when the deadline is missed. |
| `failed_errand` | string | no | The errand a "no_candidates_in_corridor" or "all_candidates_closed" answer is about. |
| `geometry_polyline6` | string | no | The planned journey as an encoded polyline6. |
| `over_by_s` | number | no | How late the whole chain would be, seconds. |
| `reason` | string | no | The same cause in plain language, for the driver. |
| `reason_code` | string | no | Machine token for why the chain does not fit: "deadline_missed", "destination_unreachable_in_time", "no_candidates_in_corridor", "all_candidates_closed" or "unroutable". |
| `slack_s` | number | no | Spare seconds before the deadline. |
| `total_drive_s` | number | no | Total driving time, seconds. |
| `total_dwell_s` | number | no | Total time out of the car, seconds. |

**`ErrandStop`**: One planned stop in an errand chain.

| Field | Type | Always present | Description |
|---|---|---|---|
| `arrive` | string | yes | When the driver arrives, RFC 3339. |
| `depart` | string | yes | When they leave, RFC 3339. |
| `drive_s` | number | yes | Driving time of the leg into this stop, seconds. |
| `errand` | string | yes | Which errand this stop discharges. |
| `hours` | string | yes | What the map's opening-hours tag says about the arrival time: "open_on_the_tag" (the tag covers it, which is evidence and NOT a promise) or "unknown" (no usable tag). A stop the tag proves shut is never proposed, so "closed" never appears here. |
| `kind` | string | yes | "category" for a place the index found, "place" for one the caller supplied. |
| `lat` | number | yes | Latitude. |
| `lon` | number | yes | Longitude. |
| `hours_tag` | string | no | The tag itself, when the map carries one. |
| `id` | string | no | The index id, for an indexed place. Its presence is what makes the stop checkable; a stop without one is a coordinate the caller gave. |
| `label` | string | no | Its full label, for reading aloud. |
| `name` | string | no | The place's name. |

**Example** (called 2026-11-05)

```json
{
  "origin": {
    "lat": 51.5007,
    "lon": -0.1246
  },
  "destination": {
    "lat": 51.5308,
    "lon": -0.1238
  },
  "arrive_by": "2026-11-05T11:57:15Z",
  "errands": [
    {
      "category": "pharmacy"
    }
  ]
}
```

Result (trimmed):

```json
{
  "arrival": "2026-11-05T09:17:32Z",
  "arrive_by": "2026-11-05T11:57:15Z",
  "attribution": "Place data © OpenStreetMap contributors, ODbL.",
  "corridor_note": "Candidates come from the corridor of the direct route. A place further off it th…",
  "departure": "2026-11-05T08:57:15Z",
  "dropped": [],
  "feasible": true,
  "geometry_polyline6": "gejfaB~zrFDCRGXC^?rCXvD`@|@H`@J`@\\\\b@P^Pf@Nd@Px@Jp@HfA@fAC`Aq@fKeA|OK`ASlAY|@_@r…",
  "opening_hours_note": "Opening hours come from OpenStreetMap's `opening_hours` tag, which most places d…",
  "slack_s": 9582.5,
  "stops": [
    {
      "arrive": "2026-11-05T09:05:16Z",
      "depart": "2026-11-05T09:10:16Z",
      "drive_s": 480.8,
      "errand": "pharmacy",
      "hours": "unknown",
      "id": "osm:n13260552152:poi",
      "kind": "category",
      "label": "Kerrs, BLOOMSBURY WAY 41, London, Knepp Wildland, United Kingdom",
      "lat": 51.5173794,
      "lon": -0.1250005,
      "name": "Kerrs"
    }
  ],
  "total_drive_s": 916.8,
  "total_dwell_s": 300,
  "usage_note": "Plan before setting off, or let a passenger do it. Stating a request like this i…",
  "…": "1 more fields"
}
```

**More from the server's description:**

This is the "pick up a prescription, get petrol, and be at the school by quarter past three" tool. Give `origin`, `destination` (+ `destination_name`), `arrive_by` (RFC 3339 with an offset: convert "quarter past three" yourself), optional `depart_at`, and 1 to 5 `errands`. Each errand is EITHER a `category` (a kind of place: "pharmacy", "fuel", "supermarket", or a colloquial phrase the server normalises; use `list_place_categories` for the vocabulary) OR a `place` {lat, lon} the driver already knows ("the school", "the nursery"). Optional `dwell_minutes` per errand (default 5). It names the errand that costs the most (`blocking_errand`), says how late the whole chain would be (`over_by_s`), and returns the stops of the chain that DOES fit with `dropped` naming what had to go. Tell the driver what to drop. Requires the MapMap gateway.

### `plan_ev_route`

Plan a whole electric-vehicle journey, charge stops included. Give `origin` and `destination` (plus optional `waypoints`) and a `vehicle`, a published profile ("small_hatch", "saloon", "suv", "van") and/or inline figures (battery_kwh, mass_kg, drag_area_m2, aux_kw, connectors), with `start_soc` (default 0.9), `min_arrival_soc` (default 0.1), `reserve_soc` (default 0.1, the floor the charge must never drop below mid-route), optional `connectors` and `min_kw` filters and `ambient_temperature_c`.

**Access:** Keyless. Answers without a key on the hosted endpoint under fair use; present your own key to meter it to you.

**Caveats the server states:**

- Energy comes from a published road-load physics model over the route's own legs; charge times are integrated over the vehicle's charging curve capped by the charge point, NOT energy divided by peak power, which is the single biggest error in naive EV planners.
- That is an ANSWER, not an error to retry: report the reason and never describe it as a plan.
- `gradient_data` says whether elevation was available: "absent" means consumption was modelled on the flat and under-reads a hilly route.
- There is no national charge-point registry, so ALWAYS show the returned `coverage_note`, an infeasible plan means "none from these operators", never "there are no chargers here", and statuses are current only when `availability_live` is true.
- Display the returned charging_attribution with the plan.

**Inputs**

| Input | Type | Required | Default | Limits | Description |
|---|---|---|---|---|---|
| `destination` | [LatLon](#latlon) | yes |  |  | Where it ends. |
| `origin` | [LatLon](#latlon) | yes |  |  | Where the journey starts. |
| `ambient_temperature_c` | number | no |  |  | Ambient temperature in °C. Derates traction energy from a published study; cabin heating belongs in the vehicle's `aux_kw`. |
| `connectors` | string[] | no |  |  | Keep only charge points offering at least one of these connector standards: "type2", "type1", "ccs", "chademo", "tesla", "domestic", "other". This narrows the vehicle's own set, never widens it. |
| `max_detour_minutes` | number | no | 15, at most 120 |  | How far off the route a charge point may sit, as a detour in minutes (default 15, at most 120). |
| `min_arrival_soc` | number | no | 0.1 |  | Lowest acceptable state of charge on arrival (default 0.1). |
| `min_kw` | number | no |  |  | Keep only charge points with a usable connector rated at least this many kW (e.g. 50 for rapid charging only). |
| `reserve_soc` | number | no | 0.1 |  | The floor the state of charge must never fall below mid-route (default 0.1). Distinct from the arrival figure. |
| `start_soc` | number | no | 0.9 |  | State of charge at the start, 0–1 (default 0.9). |
| `vehicle` | EvVehicleSpec | no | the published "saloon" default |  | The vehicle. Omitted ⇒ the published "saloon" default, and the answer says which vehicle it used. |
| `waypoints` | [LatLon](#latlon)[] | no |  |  | Intermediate points the route must pass through, in order (at most 8). Charge stops are inserted around them. |

**`EvVehicleSpec`**: The vehicle for `plan_ev_route`: one of the published defaults by name, inline figures, or a named default with inline figures over it.

| Input | Type | Required | Default | Limits | Description |
|---|---|---|---|---|---|
| `aux_kw` | number | no |  |  | Auxiliary load in kW, lights, electronics, cabin conditioning. Winter heating is several times the mild-weather default. |
| `battery_kwh` | number | no |  |  | Gross nominal battery capacity, kWh. |
| `connectors` | string[] | no |  |  | Connectors the vehicle accepts: "type1", "type2", "ccs1", "ccs2", "chademo", "tesla", "gbt". |
| `drag_area_m2` | number | no |  |  | Drag area (drag coefficient × frontal area), m². |
| `mass_kg` | number | no |  |  | Kerb mass, kg. |
| `name` | string | no |  |  | Display name for the vehicle in the answer. |
| `profile` | string | no | profile: "small_hatch" |  | A published default profile: "small_hatch", "saloon", "suv" or "van". On its own it selects that vehicle; alongside any inline figure below it is the base the figure overrides. |
| `usable_fraction` | number | no |  |  | Fraction of the gross pack the vehicle will actually use, 0–1. |

**Output**

| Field | Type | Always present | Description |
|---|---|---|---|
| `availability_live` | boolean | yes | Whether charge-point statuses came from a live availability feed. A static planner is the default: without a feed, nothing in this answer is a claim about which bays are free right now. |
| `coverage_note` | string | yes | **Always present.** What this deployment's charge-point dataset does and does not cover. An infeasible plan means "none from these operators", never "there are no chargers here". Show this alongside the answer. |
| `feasible` | boolean | yes | **Whether the journey is possible at all.** False means no plan exists, a charger desert, a connector mismatch, or a gap wider than the car's range. Report the `reason` and the furthest reachable point; never describe an infeasible answer as a plan. |
| `gradient_data` | string | yes | "complete", "partial" or "absent". **"absent" means the deployment had no elevation data and consumption was modelled on the flat**, which under-reads a hilly route. Say so rather than presenting the figure as measured. |
| `legs` | EvPlanLeg[] | yes | The driving legs, in order. |
| `soc_trace` | EvPlanSocPoint[] | yes | State of charge at every point of the journey. |
| `stops` | EvPlanStop[] | yes | The charge stops, in visit order. Always empty when `feasible` is false: a journey that cannot be completed has no stop list. |
| `summary` | EvPlanSummary | yes | The plan at a glance. |
| `charging_attribution` | string | no | Attribution string for the charge-point operators actually used, display it with the plan (a licence obligation). |
| `furthest_reachable_lat` | number | no | Latitude of that furthest reachable point. |
| `furthest_reachable_lon` | number | no | Longitude of that furthest reachable point. |
| `furthest_reachable_position` | number | no | How far along the route the vehicle can get unaided, 0.0–1.0, when no plan exists. |
| `geometry_polyline6` | string | no | The planned journey's geometry as an encoded polyline6, through the charge stops. |
| `profile_source` | string | no | "default" when a published profile supplied the figures, "inline" when the caller did. |
| `reason` | string | no | The same cause in plain language, for the user. |
| `reason_code` | string | no | Machine token for why no plan exists, when none does: "no_chargers_in_corridor", "connector_mismatch", "out_of_range", "dead_end", "below_min_kw", "chargers_unrated", "stop_limit", "dataset_empty", "no_charge_curve" or "unroutable". |
| `route_distance_m` | number | no | Distance of the planned route, metres. |
| `route_duration_s` | number | no | Driving time of the planned route, seconds. |
| `vehicle` | string | no | The vehicle the plan was computed for. |

**`EvPlanLeg`**: One driving leg of the plan, with its state-of-charge bookkeeping.

| Field | Type | Always present | Description |
|---|---|---|---|
| `consumed_wh` | number | yes | Battery energy the leg spends, Wh. |
| `distance_m` | number | yes | Engine-computed distance, metres. |
| `duration_s` | number | yes | Engine-computed driving time, seconds. |
| `end_soc` | number | yes | State of charge at the end. |
| `from` | string | yes | Where the leg starts: "origin" or a charge point's id. |
| `regen_wh` | number | yes | Battery energy regenerative braking gives back, Wh. |
| `start_soc` | number | yes | State of charge at the start of the leg. |
| `to` | string | yes | Where it ends: a charge point's id or "destination". |
| `min_soc` | number | no | Lowest state of charge anywhere within the leg. |

**`EvPlanSocPoint`**: One point on the state-of-charge trace.

| Field | Type | Always present | Description |
|---|---|---|---|
| `along_route_position` | number | yes | How far along the route this point sits, 0.0–1.0. |
| `at` | string | yes | "origin", a charge point's id, or "destination". |
| `soc` | number | yes | State of charge on arrival at this point. |
| `departing_soc` | number | no | State of charge on leaving, at a charge stop. |

**`EvPlanStop`**: One charge stop in the plan.

| Field | Type | Always present | Description |
|---|---|---|---|
| `along_route_position` | number | yes | How far along the route this stop sits, 0.0–1.0. |
| `arrive_soc` | number | yes | State of charge on arrival. Never below the reserve floor. |
| `charge_s` | number | yes | Time plugged in, seconds, integrated over the vehicle's charging curve capped by the charge point, not energy ÷ peak power. |
| `charger_id` | string | yes | Operator-scoped stable id. |
| `dc` | boolean | yes | Whether the connector delivers DC (rapid) rather than AC. |
| `depart_soc` | number | yes | State of charge on departure. |
| `lat` | number | yes | WGS84 latitude in decimal degrees. |
| `lon` | number | yes | WGS84 longitude in decimal degrees. |
| `off_route_m` | number | yes | Straight-line offset from the route geometry, metres. |
| `source` | string | yes | Which operator feed this came from. |
| `status_live` | boolean | yes | Whether the status came from a live feed rather than the last ingest. |
| `available_now` | boolean | no | Whether a bay is free right now. Present only where a live availability feed backs the claim, absent means unknown, never "occupied". |
| `charger_kw` | number | no | That connector's rated power, kW, what the charge time was computed against. |
| `connector_standard` | string | no | The connector the plan charges on. |
| `detour_s` | number | no | Extra driving time this stop costs against going straight past it, seconds. |
| `name` | string | no | Site name, where the operator publishes one. |
| `operator` | string | no | Operator display name. |
| `power_kw_source` | string | no | "declared" when the operator published the rating, "derived" when it was computed from voltage × amperage × phases. Never present the two as the same thing to a user. |

**`EvPlanSummary`**: The plan at a glance.

| Field | Type | Always present | Description |
|---|---|---|---|
| `energy_kwh` | number | yes | Net battery energy the journey costs, kWh. |
| `start_soc` | number | yes | State of charge the journey starts at. |
| `stops` | integer | yes | How many charge stops the plan contains. Zero on a journey the car makes on its own, and zero on an infeasible one. |
| `total_charge_s` | number | yes | Time spent plugged in, seconds. |
| `total_distance_m` | number | yes | Distance of the planned journey, metres. |
| `total_drive_s` | number | yes | Time spent driving, seconds. |
| `total_duration_s` | number | yes | Driving plus charging, seconds, the number a user cares about. |
| `arrival_soc` | number | no | State of charge on arrival, when the journey is feasible. |

**Example** (called 2026-11-05)

```json
{
  "origin": {
    "lat": 51.5074,
    "lon": -0.1278
  },
  "destination": {
    "lat": 52.4862,
    "lon": -1.8904
  },
  "vehicle": {
    "profile": "saloon"
  }
}
```

Result (trimmed):

```json
{
  "availability_live": false,
  "coverage_note": "Covers 6050 charge point(s) (6052 charging position(s)) from char.gy (on-street …",
  "feasible": true,
  "geometry_polyline6": "kpwfaBx|xFQU]i@]o@_@iA]yAWuBSsBM_D@sCLuBXyA^}Ap@cBb@aAb@m@^]PGb@CfANnA\\x@\\x@r@~A…",
  "gradient_data": "complete",
  "legs": [
    {
      "consumed_wh": 21581.3,
      "distance_m": 189635,
      "duration_s": 8291.3,
      "end_soc": 0.523,
      "from": "origin",
      "min_soc": 0.523,
      "regen_wh": 68.7,
      "start_soc": 0.9,
      "to": "destination"
    }
  ],
  "profile_source": "default",
  "route_distance_m": 189635,
  "route_duration_s": 8291.3,
  "soc_trace": [
    {
      "along_route_position": 0,
      "at": "origin",
      "soc": 0.9
    },
    {
      "along_route_position": 1,
      "at": "destination",
      "soc": 0.523
    }
  ],
  "stops": [],
  "summary": {
    "arrival_soc": 0.523,
    "energy_kwh": 21.51,
    "start_soc": 0.9,
    "stops": 0,
    "total_charge_s": 0,
    "total_distance_m": 189635,
    "total_drive_s": 8291.3,
    "total_duration_s": 8291.3
  },
  "vehicle": "saloon (Tesla Model 3 RWD, Highland)"
}
```

**More from the server's description:**

Returns the stops with arrive/depart state of charge, charge time and detour, a per-leg state-of-charge trace, and the journey's driving and charging time. IMPORTANT: when no plan exists, a charger desert, a connector mismatch, a gap wider than the car's range, the answer comes back with `feasible: false`, a `reason` and the furthest point on the route the car can actually reach. Requires the MapMap gateway; answers a clear error when the deployment has no charge-point dataset.

## Along-route search

Places, fuel and charge points along a route, each priced by its real detour.

### `search_along_route`

Find places (POIs) along a route with the REAL extra travel time of stopping at each, never a straight-line guess.

**Access:** Keyless. Answers without a key on the hosted endpoint under fair use; present your own key to meter it to you.

**Inputs**

| Input | Type | Required | Default | Limits | Description |
|---|---|---|---|---|---|
| `query` | string | yes |  |  | Free-text POI query, e.g. "coffee", "EV charger", "truck stop". |
| `category` | string | no |  |  | Explicit place category ("fuel", "cafe", "charging_station", same vocabulary as nearby_places). Colloquial phrases are normalised server-side; prefer this over query for category intents. |
| `costing` | [CostingKind](#costingkind) | no | `"auto"` |  | Costing model for the route and detour matrix: "auto" (default), "truck", "bicycle", "pedestrian" or "motor_scooter". |
| `destination` | [LatLon](#latlon) | no |  |  | Route destination. |
| `geometry_polyline6` | string | no |  |  | An existing route geometry as an encoded polyline6 (the `route` tool's `geometry_polyline6`). Provide either this or `origin` + `destination`, not both. |
| `max_detour_minutes` | number | no | 10, at most 120 |  | Largest acceptable detour in minutes (default 10, at most 120). |
| `max_results` | integer | no | 5, at most 25 |  | Maximum results (default 5, at most 25). |
| `origin` | [LatLon](#latlon) | no |  |  | Route origin (with `destination`, when no geometry is given). |
| `truck` | [TruckSpec](#truckspec) | no |  |  | Truck profile (dimensions + ADR declaration). Requires costing "truck"; the detours then respect dimensional/ADR restrictions. |

**Output**

| Field | Type | Always present | Description |
|---|---|---|---|
| `candidate_cap` | integer | yes | The matrix fan-out cap in force. |
| `candidates_considered` | integer | yes | Candidates found near the corridor before pricing. |
| `candidates_costed` | integer | yes | Candidates actually priced through the engine (fan-out is capped at `candidate_cap` nearest-to-route). |
| `costing` | string | yes | The costing the detours were priced with. |
| `max_detour_minutes` | number | yes | The detour budget applied, minutes. |
| `query` | string | yes | The query as interpreted. |
| `results` | AlongRouteHit[] | yes | Places within the detour budget, cheapest detour first. |
| `route_length_m` | number | yes | Length of the route geometry in metres. |
| `fuel_attribution` | string | no | Attribution string for fuel-price data sources, present only when at least one returned result carries `fuel_prices` (gateway-preferred mode with `SN_FUEL_PRICES` configured; see `AlongRouteHit`). |
| `matched_categories` | string[] | no | The normalised category tokens the candidates were browsed by, present only when a category browse actually ran (an explicit `category`, or a query the server promoted to one). Absent means free-text name matching answered the call, so a caller can tell how its words were understood rather than inferring it from the results. |
| `route_distance_m` | number | no | Direct origin→destination distance in metres. |
| `route_duration_s` | number | no | Direct origin→destination travel time in seconds (same estimator as the detour legs), when routable. |

**`AlongRouteHit`**: One place found along the route, with its honest detour cost.

| Field | Type | Always present | Description |
|---|---|---|---|
| `along_route_position` | number | yes | Where along the route the place sits, 0.0 (origin) to 1.0 (destination), by distance along the geometry. |
| `detour_minutes` | number | yes | Extra travel time of visiting this place, in minutes (rounded to 0.1): (origin→place) + (place→destination) − (origin→destination), all computed by the routing engine with the request's costing. |
| `detour_s` | number | yes | The same detour in raw seconds. |
| `off_route_m` | number | yes | Straight-line distance from the place to the route, metres. |
| `place` | [GeocodeHit](#geocodehit) | yes | The place (geocoder hit: name, label, coordinates, type). |
| `detour_km` | number | no | Extra travel distance in kilometres, when the engine reported distances. |
| `fuel_brand` | string | no | The matched fuel station's brand, when known (same conditions as `fuel_prices`). |
| `fuel_prices` | any JSON | no | Live pump prices for this fuel/petrol station, keyed by fuel code (e.g. "diesel", "petrol_95"), each `{value, currency, updated_at}`. Only ever present when the server is gateway-preferred AND the deployment configured `SN_FUEL_PRICES` AND this result matched a station within range, never fabricated. Direct-backend fallback never sets this (see the gap this closes in the PR description). |
| `fuel_updated_at` | string | no | When the matched fuel station's prices were last refreshed (RFC 3339), when known (same conditions as `fuel_prices`). |

**Example** (called 2026-11-05)

```json
{
  "origin": {
    "lat": 51.5007,
    "lon": -0.1246
  },
  "destination": {
    "lat": 51.5308,
    "lon": -0.1238
  },
  "query": "cafe",
  "category": "cafe"
}
```

Result (trimmed):

```json
{
  "candidate_cap": 25,
  "candidates_considered": 42,
  "candidates_costed": 25,
  "costing": "auto",
  "matched_categories": [
    "cafe"
  ],
  "max_detour_minutes": 10,
  "query": "cafe",
  "results": [
    {
      "along_route_position": 0.776,
      "detour_km": 0,
      "detour_minutes": 0,
      "detour_s": 0,
      "off_route_m": 13,
      "place": {
        "label": "Cafe Angel, London, City of London, Knepp Wildland, United Kingdom",
        "lat": 51.524764100000006,
        "lon": -0.1161125,
        "name": "Cafe Angel",
        "type": "cafe"
      }
    },
    {
      "along_route_position": 0.567,
      "detour_km": 0,
      "detour_minutes": 0,
      "detour_s": 0,
      "off_route_m": 73,
      "place": {
        "label": "Espresso House, London, Knepp Wildland, United Kingdom",
        "lat": 51.51825,
        "lon": -0.120913,
        "name": "Espresso House",
        "type": "cafe"
      }
    },
    "… 3 more"
  ],
  "route_distance_m": 5105,
  "route_duration_s": 917.823,
  "route_length_m": 5098
}
```

**More from the server's description:**

Provide `origin` + `destination` (a route is computed) or an existing route's `geometry_polyline6`, plus a free-text `query` ("coffee", "EV charger", "truck stop") and `max_detour_minutes` (default 10). For a category intent ("fuel", "EV charger", "coffee") pass `category` instead of relying on words alone: it takes the same vocabulary as `nearby_places` (lowercased OSM tag values such as "fuel", "cafe", "charging_station", "parking", "pharmacy"), and common colloquial phrases are normalised server-side ("petrol station" and "gas station" to fuel, "coffee" to cafe, "EV charger" to charging_station). `query` alone also promotes a pure category phrase to the same browse, so "fuel" finds fuel stations rather than places whose NAME starts "Ful"; anything else stays free-text name matching. When a browse ran, the response echoes the tokens used in `matched_categories`. Candidates near the route corridor are priced through the routing engine with your costing: detour = (origin→place) + (place→destination) − (origin→destination). Costing "auto", "truck" (with a `truck` profile the detours respect dimensional/ADR restrictions), "bicycle", "pedestrian" or "motor_scooter". Returns results sorted by detour with detour_minutes, detour_km, along_route_position (0-1) and off_route_m; at most 25 candidates are priced per call (candidate_cap).

### `cheapest_fuel_along_route`

Find the cheapest fuel along a route, with the REAL extra travel time of stopping at each station, never a straight-line guess.

**Access:** Keyless. Answers without a key on the hosted endpoint under fair use; present your own key to meter it to you.

**Caveats the server states:**

- Each result carries price {value, currency, updated_at, stale}, detour_minutes/detour_km, and saving_per_litre vs the cheapest on-route baseline (pass `fill_litres` to also get saving_total).
- `stale` = not verifiably fresher than 24 h: true unless BOTH the price's own updated_at and the snapshot's fetch time are inside that window, and true whenever either is missing.
- A stale price is ranked below every fresh one, is never the baseline, and carries NO saving_per_litre, quote it as "last seen at X on <date>", never as a saving.
- `say` is ALWAYS present and is the whole answer as one short spoken line, composed by the gateway with the number, the detour and the honest qualifier already in it.
- Prefer reading it verbatim to writing your own summary: a stale price's line states the figure and the day it was last seen and claims no saving, and an empty result's line names the cause rather than implying there is no fuel on that road.
- `coverage` says what the price snapshot holds (stations, countries, sources) and, when it holds UK stations, carries a `statement` naming the UK retailers covered, with `uk_retailers` and `uk_partial`.
- Show the statement with any UK answer: when `uk_partial` is true UK prices come only from those retailers' feeds, so an empty UK result means "none from these retailers", never "no fuel here".
- Display the returned fuel_attribution with the prices.

**Inputs**

| Input | Type | Required | Default | Limits | Description |
|---|---|---|---|---|---|
| `costing` | [CostingKind](#costingkind) | no | `"auto"` |  | Costing model for the route and detour matrix: "auto" (default), "truck", "bicycle", "pedestrian" or "motor_scooter". |
| `destination` | [LatLon](#latlon) | no |  |  | Route destination. |
| `fill_litres` | number | no |  |  | Optional fill size in litres; each result then also carries `saving_total` = `saving_per_litre` × `fill_litres`. |
| `fuel` | string | no |  |  | Which fuel to price: "diesel" (default), "petrol_95", "petrol_98", "premium_diesel", "e85" or "lpg" (aliases "petrol", "unleaded", "e10", "super_unleaded", "e5", "b7" and "sdv" are accepted). |
| `geometry_polyline6` | string | no |  |  | An existing route geometry as an encoded polyline6 (the `route` tool's `geometry_polyline6`). Provide either this or `origin` + `destination`, not both. |
| `max_detour_minutes` | number | no | 10, at most 120 |  | Largest acceptable detour in minutes (default 10, at most 120). |
| `max_results` | integer | no | 5, at most 25 |  | Maximum results (default 5, at most 25). |
| `origin` | [LatLon](#latlon) | no |  |  | Route origin (with `destination`, when no geometry is given). |
| `truck` | [TruckSpec](#truckspec) | no |  |  | Truck profile (dimensions + ADR declaration). Requires costing "truck"; the detours then respect dimensional/ADR restrictions. |

**Output**

| Field | Type | Always present | Description |
|---|---|---|---|
| `baseline` | FuelBaseline[] | yes | The cheapest effectively-on-route option per currency (empty when no station sits on the route itself). |
| `candidate_cap` | integer | yes | The matrix fan-out cap in force. |
| `candidates_considered` | integer | yes | Stations selling the fuel that passed the corridor pre-filter. |
| `candidates_costed` | integer | yes | Candidates actually priced through the engine (fan-out capped at `candidate_cap`, cheapest kept). |
| `costing` | string | yes | The costing the detours were priced with. |
| `fuel` | string | yes | The normalised fuel code that was priced (e.g. "diesel"). |
| `max_detour_minutes` | number | yes | The detour budget applied, minutes. |
| `results` | FuelStationHit[] | yes | Stations within the detour budget, cheapest first (price, then detour). |
| `route_length_m` | number | yes | Length of the route geometry in metres. |
| `say` | string | yes | **Always present.** The whole answer as one short spoken line, composed by the gateway: the number, the detour, and the honest qualifier where one applies. Safe to read to a driver verbatim, and safe on an empty result, where it names the cause rather than implying there is no fuel on the road. A stale price's line states the figure and the day it was last seen and claims no saving. |
| `coverage` | FuelCoverage | no | What the deployment's price snapshot covers. On a UK route, show `coverage.statement` to the user alongside the results: it names the UK retailers covered, and an empty UK result means "none from these retailers", never "no fuel here". Absent when the gateway is too old to report coverage. |
| `fuel_attribution` | string | no | Attribution string for the fuel-price data sources actually returned, display it with the prices (a licence obligation). |
| `note` | string | no | Why `results` is empty, when it is: the cause, not a bare list. On a UK route where UK prices come only from some retailers' feeds it ends with `coverage.statement`, so an empty answer is never read as "no fuel here". Absent when there are results. |
| `route_distance_m` | number | no | Direct origin→destination distance in metres. |
| `route_duration_s` | number | no | Direct origin→destination travel time in seconds (same estimator as the detour legs), when routable. |

**`FuelBaseline`**: The cheapest effectively-on-route option in one currency, what the driver pays by just pulling in without a detour; the reference the per-result savings are computed against. Only a fresh price is eligible, so this is `stale: false` by construction.

| Field | Type | Always present | Description |
|---|---|---|---|
| `currency` | string | yes | ISO 4217 currency this baseline covers. |
| `stale` | boolean | yes | True when the baseline price could not be verified fresher than 24 hours. |
| `station_id` | string | yes | The baseline station's id. |
| `updated_at` | string | yes | The baseline price's source timestamp, verbatim. |
| `value` | number | yes | Baseline price per litre. |
| `brand` | string | no | The baseline station's brand, when known. |
| `name` | string | no | The baseline station's human label, when known. |

**`FuelCoverage`**: What the deployment's fuel-price snapshot actually holds, as the gateway reports it.

| Field | Type | Always present | Description |
|---|---|---|---|
| `countries` | string[] | yes | ISO 3166-1 alpha-2 countries the snapshot holds stations for. A country absent here cannot produce results. |
| `sources` | map of integer | yes | Feed code to station count (e.g. "uk", "uk-fuelfinder", "fr", "de"). |
| `stations` | integer | yes | Stations in the whole snapshot. |
| `statement` | string | no | One sentence naming where UK prices come from and which retailers are in. Show it to the user with any UK answer: when `uk_partial` is true, an empty UK result means "none from these retailers", never "no fuel here". Present only when the snapshot holds UK stations. |
| `uk_partial` | boolean | no | True when UK prices come only from the retailer feeds of the closed CMA interim scheme, which cover some retailers and not others, rather than from the statutory Fuel Finder. Present only when the snapshot holds UK stations. |
| `uk_retailers` | FuelRetailer[] | no | The UK retailers in the snapshot, largest first. Present only when the snapshot holds UK stations. |

**`FuelRetailer`**: One UK retailer (or, with Fuel Finder loaded, forecourt brand) in the deployment's fuel-price snapshot.

| Field | Type | Always present | Description |
|---|---|---|---|
| `name` | string | yes | Retailer or brand display name, as the gateway names it. |
| `stations` | integer | yes | How many stations it contributes to the snapshot. |

**`FuelStationHit`**: One fuel station along the route, priced with its honest detour.

| Field | Type | Always present | Description |
|---|---|---|---|
| `along_route_position` | number | yes | Where along the route the station sits, 0.0 (origin) to 1.0 (destination). |
| `detour_minutes` | number | yes | Extra travel time of refuelling here, in minutes (rounded to 0.1): (origin→station) + (station→destination) − (origin→destination), all computed by the routing engine, never a straight-line guess. |
| `detour_s` | number | yes | The same detour in raw seconds. |
| `lat` | number | yes | WGS84 latitude in decimal degrees. |
| `lon` | number | yes | WGS84 longitude. |
| `off_route_m` | number | yes | Straight-line distance from the station to the route, metres. |
| `price` | FuelPriceQuote | yes | The requested fuel's live pump price at this station. |
| `source` | string | yes | Which national feed the station came from ("uk-fuelfinder", "uk", "fr", "de"). |
| `station_id` | string | yes | Source-scoped stable station id. |
| `brand` | string | no | Brand where the feed carries one. |
| `detour_km` | number | no | Extra travel distance in kilometres, when the engine reported distances. |
| `name` | string | no | Human label: trading name, town or address, where the feed carries one. |
| `saving_per_litre` | number | no | Per-litre saving against the cheapest effectively-on-route station in the same currency (negative = dearer than staying on route). Absent when no FRESH station sits on the route itself in this currency, and always absent on a stale result: an unverifiable price may state a number but never claim a saving. |
| `saving_total` | number | no | `saving_per_litre` × the request's `fill_litres`, when both exist. |

**`FuelPriceQuote`**: One live pump price: per-litre value in an ISO 4217 currency, with the source's own update timestamp and a 24-hour staleness flag.

| Field | Type | Always present | Description |
|---|---|---|---|
| `currency` | string | yes | ISO 4217 code (GBP for the UK sources, EUR for FR/DE). |
| `stale` | boolean | yes | True when the price could not be verified fresher than 24 hours: unless BOTH the source's own `updated_at` and the snapshot's fetch time fall inside that window, and always when either is missing. Treat it as indicative, not bindable, say "last seen at X on <date>", never quote it as today's price or as a saving. |
| `updated_at` | string | yes | The source's own price timestamp, verbatim. |
| `value` | number | yes | Price per litre in `currency`. |

**Example** (called 2026-11-05)

```json
{
  "origin": {
    "lat": 51.5007,
    "lon": -0.1246
  },
  "destination": {
    "lat": 51.5308,
    "lon": -0.1238
  }
}
```

Result (trimmed):

```json
{
  "baseline": [],
  "candidate_cap": 25,
  "candidates_considered": 66,
  "candidates_costed": 25,
  "costing": "auto",
  "coverage": {
    "countries": [
      "DE",
      "FR",
      "… 1 more"
    ],
    "sources": {
      "de": 1907,
      "fr": 9824,
      "uk": 1919
    },
    "statement": "UK prices come only from the feeds of 8 retailers: Asda (790), Shell (546), Ront…",
    "stations": 13650,
    "uk_partial": true,
    "uk_retailers": [
      {
        "name": "Asda",
        "stations": 790
      },
      {
        "name": "Shell",
        "stations": 546
      },
      "… 6 more"
    ]
  },
  "fuel": "diesel",
  "fuel_attribution": "Fuel prices: UK retailer scheme (CMA)",
  "max_detour_minutes": 10,
  "results": [
    {
      "along_route_position": 0.922,
      "brand": "ESSO",
      "detour_km": 2.21,
      "detour_minutes": 6.2,
      "detour_s": 372,
      "lat": 51.537939,
      "lon": -0.116776,
      "name": "219-227, CALEDONIAN RD, LONDON",
      "off_route_m": 887,
      "price": {
        "currency": "GBP",
        "stale": false,
        "updated_at": "05/11/2026 00:05:14",
        "value": 2.009
      },
      "source": "uk",
      "station_id": "esso:gcpvjq4yqbjt"
    },
    {
      "along_route_position": 0,
      "brand": "SHELL",
      "detour_km": 2.71,
      "detour_minutes": 7.6,
      "detour_s": 456,
      "lat": 51.503381,
      "lon": -0.095515,
      "name": "101-103 Southwark Bridge Road",
      "off_route_m": 2054,
      "price": {
        "currency": "GBP",
        "stale": true,
        "updated_at": "13/05/2026 00:00:00",
        "value": 1.849
      },
      "source": "uk",
      "station_id": "shell:gcpuvzfkyq4f"
    }
  ],
  "route_distance_m": 5106,
  "route_duration_s": 917.918,
  "route_length_m": 5098,
  "say": "Cheapest diesel is Esso at 200.9p a litre, and it adds six minutes. Nothing sits…"
}
```

**More from the server's description:**

Provide `origin` + `destination` (a route is computed) or an existing route's `geometry_polyline6`, plus a `fuel` code ("diesel" default, "petrol_95", "petrol_98", "premium_diesel", "e85", "lpg") and `max_detour_minutes` (default 10). Stations come from the live open-data price feeds (UK retailer feeds of the closed CMA interim scheme and/or the statutory Fuel Finder, FR prix-carburants, DE Tankerkoenig; the response's `fuel_attribution` names the ones actually matched), are priced through the routing engine with your costing (a `truck` profile makes detours respect dimensional/ADR restrictions) and ranked freshest-priced first and cheapest within that. An empty result also carries `note`, the cause in prose, which on such a route ends with that statement. Requires the MapMap gateway; answers a clear error when the deployment has no fuel-price dataset.

### `cheapest_charging_along_route`

Find the best EV charge points along a route, with the REAL extra travel time of stopping at each one, never a straight-line guess.

**Access:** Keyless. Answers without a key on the hosted endpoint under fair use; present your own key to meter it to you.

**Caveats the server states:**

- Provide `origin` + `destination` (a route is computed) or an existing route's `geometry_polyline6`, plus optional `connectors` ("ccs", "type2", "chademo", "type1", "tesla", "domestic", "other"), `min_kw` (e.g. 50 for rapid only), `available_only` and `max_detour_minutes` (default 10).
- IMPORTANT: there is no national charge-point registry, every deployment covers only the operators it has onboarded, so ALWAYS show the returned `coverage_note` alongside the results.
- An empty `results` means "none from these operators within the detour budget", NEVER "there are no chargers here".
- Statuses are live only when availability_live is true; otherwise they are the values captured at the last ingest and must not be described as current.
- `say` is ALWAYS present and is the whole answer as one short spoken line, composed by the gateway with the coverage already inside the claim rather than appended to it: it scopes its superlative to the operators this deployment holds, and an empty result's line says the emptiness is about those operators.
- Display the returned charging_attribution with the results.

**Inputs**

| Input | Type | Required | Default | Limits | Description |
|---|---|---|---|---|---|
| `available_only` | boolean | no |  |  | Keep only charge points with a bay reported free right now. Needs the deployment to have a live availability feed; without one the call is refused rather than silently returning nothing. |
| `connectors` | string[] | no | every standard |  | Keep only charge points offering at least one of these connector standards: "type2", "type1", "ccs", "chademo", "tesla", "domestic" or "other". Omitted ⇒ every standard. |
| `costing` | [CostingKind](#costingkind) | no | `"auto"` |  | Costing model for the route and detour matrix: "auto" (default), "truck", "bicycle", "pedestrian" or "motor_scooter". |
| `destination` | [LatLon](#latlon) | no |  |  | Route destination. |
| `geometry_polyline6` | string | no |  |  | An existing route geometry as an encoded polyline6 (the `route` tool's `geometry_polyline6`). Provide either this or `origin` + `destination`, not both. |
| `max_detour_minutes` | number | no | 10, at most 120 |  | Largest acceptable detour in minutes (default 10, at most 120). |
| `max_results` | integer | no | 5, at most 25 |  | Maximum results (default 5, at most 25). |
| `min_kw` | number | no |  |  | Keep only charge points whose best connector is rated at least this many kW (e.g. 50 for rapid charging only). |
| `origin` | [LatLon](#latlon) | no |  |  | Route origin (with `destination`, when no geometry is given). |
| `truck` | [TruckSpec](#truckspec) | no |  |  | Truck profile (dimensions + ADR declaration). Requires costing "truck"; the detours then respect dimensional/ADR restrictions. |

**Output**

| Field | Type | Always present | Description |
|---|---|---|---|
| `availability_live` | boolean | yes | Whether statuses are live (a bring-your-own availability feed) or the values captured at the last ingest. |
| `availability_note` | string | yes | Plain-language explanation of what the statuses mean here. |
| `candidate_cap` | integer | yes | The matrix fan-out cap in force. |
| `candidates_considered` | integer | yes | Charge points matching the filters that passed the corridor pre-filter. |
| `candidates_costed` | integer | yes | Candidates actually costed through the engine (fan-out capped at `candidate_cap`, most powerful kept). |
| `costing` | string | yes | The costing the detours were computed with. |
| `coverage_note` | string | yes | **Always present.** What this deployment's charge-point dataset does and does not cover. An empty `results` means "none from these operators within the budget", never "there are no chargers here". Show this to the user alongside the results. |
| `max_detour_minutes` | number | yes | The detour budget applied, minutes. |
| `results` | ChargerHit[] | yes | Charge points within the detour budget, most powerful first (power, then detour). |
| `route_length_m` | number | yes | Length of the route geometry in metres. |
| `say` | string | yes | **Always present.** The whole answer as one short spoken line, composed by the gateway. Coverage is inside the claim rather than appended to it: the line scopes its superlative to the operators this deployment holds, and an empty result's line says the emptiness is about those operators, never about the road. Safe to read to a driver verbatim. |
| `sources` | ChargingSource[] | yes | Every operator in the dataset, with its own coverage note and licence. Present even when `results` is empty. |
| `charging_attribution` | string | no | Attribution string for the charge-point operators actually returned, display it with the results (a licence obligation). |
| `note` | string | no | Why `results` is empty, when it is, the cause, not a bare list. |
| `route_distance_m` | number | no | Direct origin→destination distance in metres. |
| `route_duration_s` | number | no | Direct origin→destination travel time in seconds (same estimator as the detour legs), when routable. |

**`ChargerHit`**: One charge point along the route, with its real engine-computed detour.

| Field | Type | Always present | Description |
|---|---|---|---|
| `along_route_position` | number | yes | How far along the route the charge point sits, 0.0–1.0. |
| `charger_id` | string | yes | Operator-scoped stable id. |
| `connector_standards` | string[] | yes | Every distinct connector standard at the site. |
| `detour_minutes` | number | yes | Real extra travel time of stopping here, minutes. |
| `detour_s` | number | yes | The same detour in seconds. |
| `evse_count` | integer | yes | Number of charging positions (EVSEs) at the site. |
| `lat` | number | yes | WGS84 latitude in decimal degrees. |
| `lon` | number | yes | WGS84 longitude in decimal degrees. |
| `off_route_m` | number | yes | Straight-line offset from the route geometry, metres. |
| `source` | string | yes | Which operator feed this came from. |
| `status_live` | boolean | yes | Whether the status came from a live feed rather than the last ingest. |
| `available_now` | boolean | no | Whether a bay is free right now. Present only where a live availability feed backs the claim, absent means unknown, never "occupied". |
| `best_connector` | ChargerConnector | no | The highest-rated connector at the site. |
| `detour_km` | number | no | Extra distance of the detour in kilometres, when the engine reported distances. |
| `max_power_kw` | number | no | Highest rated power at the site, kW. |
| `name` | string | no | Site name, where the operator publishes one. |
| `operator` | string | no | Operator display name. |
| `updated_at` | string | no | When the operator last updated this record. |

**`ChargerConnector`**: The highest-rated connector at a charge point.

| Field | Type | Always present | Description |
|---|---|---|---|
| `dc` | boolean | yes | Whether this connector delivers DC (rapid) rather than AC. |
| `standard` | string | yes | Normalised standard: "type2", "type1", "ccs", "chademo", "tesla", "domestic" or "other". |
| `power_kw` | number | no | Rated power in kW, where the operator publishes enough to know it. |
| `power_kw_source` | string | no | "declared" when the operator published the rating, "derived" when it was computed from voltage × amperage × phases. Never present the two as the same thing to a user. |

**`ChargingSource`**: One operator's coverage and licence, as published by the deployment.

| Field | Type | Always present | Description |
|---|---|---|---|
| `chargers` | integer | yes | How many charge points this operator contributes. |
| `operator` | string | yes | Operator display name. |
| `source_id` | string | yes | Operator id. |
| `coverage_note` | string | no | Plain-language statement of what this operator's feed does and does not cover. |
| `licence` | string | no | Licence or statutory basis of the feed. |

**Example** (called 2026-11-05)

```json
{
  "origin": {
    "lat": 51.5007,
    "lon": -0.1246
  },
  "destination": {
    "lat": 51.5308,
    "lon": -0.1238
  }
}
```

Result (trimmed):

```json
{
  "availability_live": false,
  "availability_note": "No live availability feed is configured, so every status is the value captured w…",
  "candidate_cap": 25,
  "candidates_considered": 3654,
  "candidates_costed": 25,
  "charging_attribution": "Charge points: Charge-point availability data from char.gy (char.gy), published …",
  "costing": "auto",
  "coverage_note": "Covers 6050 charge point(s) (6052 charging position(s)) from char.gy (on-street …",
  "max_detour_minutes": 10,
  "results": [
    {
      "along_route_position": 0.005,
      "best_connector": {
        "dc": false,
        "power_kw": 5.1,
        "power_kw_source": "derived",
        "standard": "type2"
      },
      "charger_id": "758bc688-03b7-4b59-ab96-38222e565364",
      "connector_standards": [
        "type2"
      ],
      "detour_km": 1.34,
      "detour_minutes": 3.8,
      "detour_s": 226,
      "evse_count": 1,
      "lat": 51.4992747,
      "lon": -0.1090411,
      "max_power_kw": 5.1,
      "name": "12 Morley Street",
      "off_route_m": 1104,
      "operator": "char.gy",
      "…": "3 more fields"
    },
    {
      "along_route_position": 0,
      "best_connector": {
        "dc": false,
        "power_kw": 5.1,
        "power_kw_source": "derived",
        "standard": "type2"
      },
      "charger_id": "20d8c147-706c-42c0-ae40-3a5ec35e9415",
      "connector_standards": [
        "type2"
      ],
      "detour_km": 1.46,
      "detour_minutes": 3.9,
      "detour_s": 236,
      "evse_count": 1,
      "lat": 51.5023477,
      "lon": -0.1066136,
      "max_power_kw": 5.1,
      "name": "Opposite 29 Ufford Street",
      "off_route_m": 1277,
      "operator": "char.gy",
      "…": "3 more fields"
    },
    "… 3 more"
  ],
  "route_distance_m": 5106,
  "route_duration_s": 917.918,
  "route_length_m": 5098,
  "say": "The best charge point we cover is 12 Morley Street, 5 kilowatts, and it adds fou…",
  "…": "1 more fields"
}
```

**More from the server's description:**

Charge points come from operator-published feeds, are costed through the routing engine with your costing (a `truck` profile makes detours respect dimensional/ADR restrictions) and ranked most powerful first, since minutes off the clock are bought with kilowatts. Each result carries max_power_kw, connector_standards, best_connector, evse_count, detour_minutes/detour_km and, where a live feed backs it, available_now. Prefer reading it verbatim to writing your own summary. Requires the MapMap gateway; answers a clear error when the deployment has no charge-point dataset.

## Places and geocoding

Addresses to coordinates and back, what is near a point, what is in view, and the category vocabulary.

### `geocode`

Turn a place (address, POI, town) into coordinates. Ask two ways, and they combine: `query` is free text, one run-together string, the way a person types into a search box, and `street`, `housenumber`, `city`, `postcode` and `country` name the parts of an address separately.

**Access:** Keyless. Answers without a key on the hosted endpoint under fair use; present your own key to meter it to you.

**Caveats the server states:**

- Pass the parts whenever you already hold the address in parts (a form, a CRM row, a manifest): components are REQUIREMENTS, not hints, so `city: "London"` means a result outside London cannot come back at all, where "London" inside `query` only reorders.
- `country` takes an ISO 3166-1 alpha-2 code or a country name ("GB", "United Kingdom"); a value naming no country is refused rather than silently matching nothing.
- READ IT before acting on an address, an unmatched or inferred postcode on the top hit means the answer does not carry the address you asked for, and a small `score_gap` means the ranking barely chose, so show the alternatives instead of picking one.

**Inputs**

| Input | Type | Required | Default | Limits | Description |
|---|---|---|---|---|---|
| `city` | string | no |  |  | Structured component: town or city, e.g. "London". Matches the containing city as well as the immediate locality, so a suburb name works here too. |
| `country` | string | no |  |  | Structured component: ISO 3166-1 alpha-2 code or country name, "GB", "gb", "United Kingdom" and "UK" all mean the same country. A value naming no country is refused rather than quietly applied as a filter that matches nothing. |
| `focus` | [LatLon](#latlon) | no |  |  | Optional location bias: results near this point rank higher. |
| `housenumber` | string | no |  |  | Structured component: house number, e.g. "10" or "221B". Only meaningful alongside `street`, a house number on its own excludes nearly everything and identifies nothing. |
| `lang` | string | no | "en" |  | Language of the place names to match and return, as a two-letter code. Defaults to "en", which is what makes English exonyms ("Munich", "Cologne", "Geneva") resolve to the place meant rather than a same-named town elsewhere. Set it to the language your query is written in; "default" asks for each place's own local name. Deployments support a fixed set (this one: "en", "de", "fr"), and anything outside it is refused. |
| `limit` | integer | no | 10 |  | Maximum number of results (1–50, default 10). |
| `postcode` | string | no |  |  | Structured component: postcode in any spacing or case, "SW1A 2AA" and "sw1a2aa" are one query. A bare UK outward code ("SW1A") selects the whole district. |
| `query` | string | no |  |  | Free-text place query, e.g. "Dover ferry terminal". Required unless at least one structured component is supplied. |
| `street` | string | no |  |  | Structured component: street name, e.g. "Downing Street". Matches the street of addresses and POIs (transliterated street names included) as well as the street itself. |

**Output**

| Field | Type | Always present | Description |
|---|---|---|---|
| `results` | [GeocodeHit](#geocodehit)[] | yes | Matching places, best first. |

**Example** (called 2026-11-05)

```json
{
  "query": "10 Downing Street, London",
  "limit": 2
}
```

Result (trimmed):

```json
{
  "results": [
    {
      "city": "Westminster",
      "country": "United Kingdom",
      "label": "10 Downing Street, Downing Street 10, SW1A 2AA, Westminster, England, United Kingdom",
      "lat": 51.5034878,
      "lon": -0.1276965,
      "match": {
        "components": "{…}",
        "score_gap": 0,
        "source": "photon"
      },
      "name": "10 Downing Street",
      "postcode": "SW1A 2AA",
      "type": "house"
    }
  ]
}
```

**More from the server's description:**

At least one of the two is required. Returns up to `limit` (default 10) candidates with name, one-line label, lat/lon, type and address parts. Pass `focus` {lat, lon} to rank results near a location higher. A bare city or town name (no comma) lists that city's airports and main railway stations directly behind it, ahead of namesakes elsewhere. Each hit also carries `match`: a per-component matched/inferred/unmatched verdict, the `score_gap` to the runner-up, and which backend answered. Use `verify_places` when the address came from a model or a user and needs checking rather than using. Results are matched in `lang` (default "en"), so English exonyms ("Munich", "Cologne", "Geneva") resolve to the place meant; pass `lang` when querying in another language, or "default" for each place's local name.

### `reverse_geocode`

Turn coordinates into the nearest places: addresses, POIs and localities with distance in metres.

**Access:** Keyless. Answers without a key on the hosted endpoint under fair use; present your own key to meter it to you.

**Caveats the server states:**

- Provide `lat` and `lon`; returns up to `limit` (default 5, max 10) results, nearest first, each with name, one-line label, lat/lon, type, address parts and distance_m, plus categories and a `details` object of display tags (opening_hours, website, phone, wikipedia, ...) on POI hits when the index carries them.
- Add `fov_deg` to keep only what lies within that cone of the heading, it is the FULL width, so 90 keeps what lies within 45 degrees either side of dead ahead; anything dropped is counted in `out_of_view`.

**Inputs**

| Input | Type | Required | Default | Limits | Description |
|---|---|---|---|---|---|
| `lat` | number | yes |  |  | Latitude in decimal degrees (−90 to 90). |
| `lon` | number | yes |  |  | Longitude in decimal degrees (−180 to 180). |
| `fov_deg` | number | no |  |  | Field of view: the full width in degrees of a cone centred on `heading_deg`, outside which results are dropped, it is the FULL width, so 90 keeps only what lies within 45 degrees either side of dead ahead. Needs `heading_deg`, a cone has to point somewhere. The count of results removed is reported as `out_of_view`. |
| `heading_deg` | number | no |  |  | Which way the user is facing, in degrees **clockwise from true north** (0 = north, 90 = east, 180 = south, 270 = west). Supply it and every result is also described from the user's point of view ("just ahead on your right"); omit it and results fall back to cardinal directions ("to the north-east"), so the tool works with or without a compass. |
| `limit` | integer | no | 5 |  | Maximum number of results (1–10, default 5). The hosted first-party index answers at most 5 nearest hits per lookup. |

**Output**

| Field | Type | Always present | Description |
|---|---|---|---|
| `results` | ReverseGeocodeHit[] | yes | Nearby places, nearest first. |
| `out_of_view` | integer | no | How many otherwise-matching places were dropped for falling outside `fov_deg`. Non-zero means there are places near the user that are simply not in front of them, say so rather than reporting nothing nearby. Always 0 when no field of view was set. |

**`ReverseGeocodeHit`**: One reverse-geocoding result: a place near the queried point. The same shape family as `GeocodeHit`, plus the reverse-only extras (`distance_m`, `categories`, `details`) and the egocentric extras (`bearing_deg`, `relative_bearing_deg`, `direction`).

| Field | Type | Always present | Description |
|---|---|---|---|
| `label` | string | yes | Human-readable one-line label assembled from the address parts. |
| `lat` | number | yes | Latitude of the place in decimal degrees. |
| `lon` | number | yes | Longitude of the place in decimal degrees. |
| `bearing_deg` | number | no | Bearing from the queried point to this place, degrees clockwise from true north. Always present. |
| `categories` | string[] | no | POI categories (e.g. ["cafe"]), when the index carries them. |
| `city` | string | no | City or town, when known. |
| `country` | string | no | Country, when known: a country name, or the ISO 3166-1 alpha-2 code (e.g. "GB") on first-party hits, which carry only the code. |
| `details` | any JSON | no | Whitelisted OSM display tags on POI hits (opening_hours, website, phone, brand, cuisine, wheelchair, wikipedia, ...), passed through verbatim when present. |
| `direction` | string | no | The direction phrased for speech, "ahead and slightly to your right, about 80 metres" with a heading, "to the north-east, about 80 metres" without one. Deliver this verbatim rather than reading out coordinates. |
| `distance_m` | number | no | Straight-line distance from the queried point in metres, when the backend reports one (first-party gateway hits always do). |
| `name` | string | no | Place name, when the source feature has one. |
| `postcode` | string | no | Postcode, when known. |
| `relative_bearing_deg` | number | no | Where this place is relative to the way the user is facing: negative to the left, positive to the right, −180 to 180. Present only when the request supplied `heading_deg`. |
| `type` | string | no | Feature type, e.g. "address", "street", "poi", "locality". |

**Example** (called 2026-11-05)

```json
{
  "lat": 51.5034,
  "lon": -0.1276,
  "limit": 2
}
```

Result (trimmed):

```json
{
  "results": [
    {
      "bearing_deg": 66.25084657315062,
      "categories": [
        "government"
      ],
      "city": "London",
      "country": "United Kingdom",
      "details": {
        "website": "https://www.gov.uk/government/organisations/prime-ministers-office-10-downing-street",
        "wikidata": "Q169101",
        "wikipedia": "en:10 Downing Street"
      },
      "direction": "to the north-east, a few steps away",
      "distance_m": 8,
      "label": "10 Downing Street, Downing Street 10, SW1A 2AA, London, Knepp Wildland, United Kingdom",
      "lat": 51.50342814127604,
      "lon": -0.12749751759510414,
      "name": "10 Downing Street",
      "postcode": "SW1A 2AA",
      "type": "poi"
    },
    {
      "bearing_deg": 261.14739979814834,
      "city": "London",
      "country": "United Kingdom",
      "direction": "to the west, a few steps away",
      "distance_m": 16,
      "label": "SW1A 2AB, London, Knepp Wildland, United Kingdom",
      "lat": 51.50337715148926,
      "lon": -0.1278350606560707,
      "name": "SW1A 2AB",
      "postcode": "SW1A 2AB",
      "type": "postcode"
    }
  ]
}
```

**More from the server's description:**

The inverse of geocode. Every result also carries `bearing_deg` and a spoken `direction`. Pass `heading_deg` (degrees clockwise from true north, 0 = north, 90 = east) and results are described from where the user stands, "ahead and slightly to your right, about 80 metres", with a signed `relative_bearing_deg` (negative left, positive right); without a heading the phrasing falls back to cardinals ("north-east of you"), so this works with or without a compass. Prefer reading `direction` aloud over coordinates.

### `nearby_places`

Find places near a point, nearest first with distance in metres, by category (cafes, fuel, EV charging, parking), by name or brand ("the nearest Lloyds bank", "nearest Sainsbury's"), or both.

**Access:** Keyless. Answers without a key on the hosted endpoint under fair use; present your own key to meter it to you.

**Caveats the server states:**

- Use this instead of geocode whenever the question is about what is NEAR a location: geocode ranks a brand's branches everywhere and only biases by proximity, so it will happily return a Lloyds in another city over the one 100 m away.
- A category or name the map does not carry returns an empty list, never an error.
- Optional `radius_m` (default 2500, max 100000) bounds the straight-line search distance and `limit` (default 5, max 10) the result count.
- Each result has name, one-line label, lat/lon, address parts, distance_m, categories and a `details` object of display tags (opening_hours, website, phone, ...) when the map carries them.
- Add `fov_deg` to keep only what lies within that cone of the heading, it is the FULL width of the cone, so 90 keeps what lies within 45 degrees either side of dead ahead; anything dropped is counted in `out_of_view`, so a non-zero count means there ARE matching places nearby, just not in front of the user, say that rather than "nothing nearby".

**Inputs**

| Input | Type | Required | Default | Limits | Description |
|---|---|---|---|---|---|
| `lat` | number | yes |  |  | Latitude of the search point in decimal degrees (−90 to 90). |
| `lon` | number | yes |  |  | Longitude of the search point in decimal degrees (−180 to 180). |
| `category` | string | no |  |  | The kind of place to find. The gateway matches it against the index's lowercased OSM tag values (the value of the POI's `amenity`/`shop`/`tourism`/`railway`/… tag), e.g. "cafe", "fuel", "charging_station", "parking", "pharmacy", "supermarket", "hotel", "restaurant", "fast_food", "atm", "bakery", "hospital", "station", and normalises common colloquial names first ("coffee" → cafe; "ev_charging", "ev charging" → charging_station; "petrol" → fuel; "chemist" → pharmacy). A category the index does not carry matches nothing: the result is an empty list, not an error. Optional when `name` is given; at least one of the two is required. |
| `fov_deg` | number | no |  |  | Field of view: the full width in degrees of a cone centred on `heading_deg`, outside which results are dropped, it is the FULL width, so 90 keeps only what lies within 45 degrees either side of dead ahead. Needs `heading_deg`, a cone has to point somewhere. The count of results removed is reported as `out_of_view`. |
| `heading_deg` | number | no |  |  | Which way the user is facing, in degrees **clockwise from true north** (0 = north, 90 = east, 180 = south, 270 = west). Supply it and every result is also described from the user's point of view ("just ahead on your right"); omit it and results fall back to cardinal directions ("to the north-east"), so the tool works with or without a compass. |
| `limit` | integer | no | 5 |  | Maximum number of results (1–10, default 5). |
| `name` | string | no |  |  | The name or brand of the place to find, "Lloyds", "Lloyds Bank", "Sainsbury's", for "where is the nearest X" questions. Every word must appear in the place's name or one of its alternative names, case- and accent-insensitively, with the last word also matching as a prefix ("Sains" finds Sainsbury's). Combine with `category` to disambiguate a brand used by more than one kind of place ("Lloyds" plus "bank" excludes Lloyds Pharmacy). Optional when `category` is given; at least one of the two is required. |
| `radius_m` | integer | no | 2500 |  | Maximum straight-line distance of any result from the point, in metres (1–100000, default 2500). |

**Output**

| Field | Type | Always present | Description |
|---|---|---|---|
| `results` | NearbyPlace[] | yes | Matching places, nearest first. Empty when the index has no such place within the radius. |
| `out_of_view` | integer | no | How many otherwise-matching places were dropped for falling outside `fov_deg`. Non-zero means there are matching places near the user that are simply not in front of them, say so rather than reporting nothing nearby. Always 0 when no field of view was set. |

**`NearbyPlace`**: One `nearby_places` result: a place matching the requested category and/or name near the queried point. The same shape family as `GeocodeHit`, plus the browse-only extras (`distance_m`, `categories`, `details`) and the egocentric extras (`bearing_deg`, `relative_bearing_deg`, `direction`).

| Field | Type | Always present | Description |
|---|---|---|---|
| `label` | string | yes | Human-readable one-line label assembled from the address parts. |
| `lat` | number | yes | Latitude of the place in decimal degrees. |
| `lon` | number | yes | Longitude of the place in decimal degrees. |
| `bearing_deg` | number | no | Bearing from the queried point to this place, degrees clockwise from true north. Always present. |
| `categories` | string[] | no | POI categories (e.g. ["cafe"]), when the index carries them. |
| `city` | string | no | City or town, when known. |
| `country` | string | no | Country, when known: a country name, or the ISO 3166-1 alpha-2 code (e.g. "GB") on first-party hits, which carry only the code. |
| `details` | any JSON | no | Whitelisted OSM display tags on POI hits (opening_hours, website, phone, brand, cuisine, wheelchair, wikipedia, ...), passed through verbatim when present. |
| `direction` | string | no | The direction phrased for speech, "ahead and slightly to your right, about 80 metres" with a heading, "to the north-east, about 80 metres" without one. Deliver this verbatim rather than reading out coordinates. |
| `distance_m` | number | no | Straight-line distance from the queried point in metres. |
| `name` | string | no | Place name, when the source feature has one. |
| `postcode` | string | no | Postcode, when known. |
| `relative_bearing_deg` | number | no | Where this place is relative to the way the user is facing: negative to the left, positive to the right, −180 to 180. Present only when the request supplied `heading_deg`. |
| `type` | string | no | Feature type, "poi" for category-browse hits. |

**Example** (called 2026-11-05)

```json
{
  "lat": 51.5308,
  "lon": -0.1238,
  "category": "cafe",
  "limit": 2
}
```

Result (trimmed):

```json
{
  "results": [
    {
      "bearing_deg": 204.71648510765286,
      "categories": [
        "cafe"
      ],
      "city": "London",
      "country": "United Kingdom",
      "details": {
        "brand": "Costa",
        "cuisine": "coffee_shop",
        "opening_hours": "Mo-Fr 06:00-21:00, Sa,Su 07:00-20:00",
        "outdoor_seating": "no",
        "takeaway": "yes",
        "wheelchair": "limited"
      },
      "direction": "to the south-west, about 100 metres",
      "distance_m": 103,
      "label": "Costa, London, Knepp Wildland, United Kingdom",
      "lat": 51.52996,
      "lon": -0.1244199,
      "name": "Costa",
      "type": "poi"
    },
    {
      "bearing_deg": 206.64196720540315,
      "categories": [
        "cafe"
      ],
      "city": "London",
      "country": "United Kingdom",
      "details": {
        "cuisine": "coffee_shop;turkish",
        "outdoor_seating": "yes"
      },
      "direction": "to the south-west, about 380 metres",
      "distance_m": 383,
      "label": "Sandwich Street Kitchen, London, Knepp Wildland, United Kingdom",
      "lat": 51.5277186,
      "lon": -0.1262783,
      "name": "Sandwich Street Kitchen",
      "type": "poi"
    }
  ]
}
```

**More from the server's description:**

Provide `lat`, `lon` and at least one of `category` or `name`. `category` is matched against the map's lowercased OSM tag values (amenity/shop/tourism/…), e.g. "cafe", "fuel", "charging_station", "parking", "pharmacy", "supermarket", "hotel", "restaurant", "fast_food", "atm", "bakery", "hospital", "station"; common colloquial names are normalised ("coffee" -> cafe, "ev_charging" -> charging_station, "petrol" -> fuel, "chemist" -> pharmacy). `name` matches the place's name or alternative names word by word, case- and accent-insensitively, with the last word also matching as a prefix. Combine the two to disambiguate a brand, "Lloyds" plus "bank" excludes Lloyds Pharmacy. Every result also carries `bearing_deg` and a spoken `direction`. Pass `heading_deg` (degrees clockwise from true north, 0 = north, 90 = east) and results are described from where the user stands, "ahead and slightly to your right, about 80 metres", with a signed `relative_bearing_deg` (negative left, positive right); without a heading the phrasing falls back to cardinals ("to the north-east"), so this works with or without a compass. Prefer reading `direction` aloud over coordinates. Requires the MapMap gateway (GATEWAY_URL + GATEWAY_API_KEY).

### `places_in_view`

What is over THERE: given a position and the direction the user is facing, the places inside that cone, and whether the ground and the buildings in between let them actually be seen.

**Access:** Keyless. Answers without a key on the hosted endpoint under fair use; present your own key to meter it to you.

**Caveats the server states:**

- READ THE VISIBILITY BLOCK BEFORE SAYING ANYTHING: `clear` means nothing in the data stands in the way, `occluded` names what does (a hill, or a building with its height), and `unknown` means NO check could run: with `unknown` say you cannot tell, never that it is visible.
- Read `basis` too: `terrain-only` in a town means hills were checked and buildings were not, which is weak.
- A non-zero `out_of_sector` means there ARE matching places nearby, just not in that direction, so say that rather than "nothing nearby".
- `coverage` names the datasets that answered and `caveat` is the standing limit: visibility is modelled from maps, not observed, and trees, walls, scaffolding and weather are not in it.

**Inputs**

| Input | Type | Required | Default | Limits | Description |
|---|---|---|---|---|---|
| `bearing_deg` | number | yes |  |  | Which way the observer is facing, in degrees **clockwise from true north** (0 = north, 90 = east, 180 = south, 270 = west). Required: this tool answers "what is over there", and "there" has to point somewhere. |
| `lat` | number | yes |  |  | Latitude of the observer in decimal degrees (−90 to 90). |
| `lon` | number | yes |  |  | Longitude of the observer in decimal degrees (−180 to 180). |
| `category` | string | no |  |  | The kind of place to look for, from the same vocabulary `nearby_places` uses ("cafe", "pub", "fuel", "charging_station" …). Use "building" to ask what a named building over there is. At least one of `category` and `name` is required. |
| `eye_height_m` | number | no | 1 |  | Height of the observer's eye above the ground in metres (0–500, default 1.6, a standing adult). A driver's eye is nearer 1.2 m. |
| `fov_deg` | number | no | 60 |  | Full width in degrees of the cone centred on `bearing_deg` (1–360, default 60, so 30 degrees either side). The FULL width, not the half angle. |
| `limit` | integer | no | 5 |  | Maximum number of results (1–25, default 5). |
| `name` | string | no |  |  | A place name or brand to look for. At least one of `category` and `name` is required. |
| `radius_m` | integer | no | 1000 |  | Maximum straight-line range in metres (1–5000, default 1000). |
| `visible_only` | boolean | no | false |  | When true, return only places whose visibility verdict is `clear`. Default false, which returns everything in the cone WITH its verdict, usually the better answer, because "it is there but you cannot see it from here" is worth saying. |

**Output**

| Field | Type | Always present | Description |
|---|---|---|---|
| `caveat` | string | yes | The standing caveat: visibility here is modelled, not observed. |
| `coverage` | string | yes | One sentence naming which datasets the visibility verdicts were actually checked against. Worth repeating when a verdict is being relied on. |
| `results` | PlaceInView[] | yes | Places inside the cone, best first: everything visible before everything unchecked before everything occluded, and within each of those, nearest and most nearly dead-ahead first. |
| `out_of_sector` | integer | no | How many otherwise-matching places were dropped for lying outside the cone. Non-zero means there ARE matching places near the observer, just not in the direction they are facing, so say that rather than "nothing nearby". |

**`PlaceInView`**: One place in the observer's field of view.

| Field | Type | Always present | Description |
|---|---|---|---|
| `angular_offset_deg` | number | yes | Signed angle from the observer's bearing to the place: negative to the left, positive to the right. |
| `bearing_deg` | number | yes | The place's own bearing from the observer, degrees clockwise from true north. |
| `direction` | string | yes | The direction phrased for speech, from where the observer stands ("ahead and slightly to your right, about 80 metres"). Prefer reading this aloud over coordinates. |
| `distance_m` | number | yes | Straight-line distance from the observer, in metres. |
| `label` | string | yes | One-line label: name, street, postcode, town, country. |
| `lat` | number | yes | Latitude in decimal degrees. |
| `lon` | number | yes | Longitude in decimal degrees. |
| `name` | string | yes | Place name. |
| `visibility` | ViewVisibility | yes | Whether it can be seen, and what that rests on. |
| `categories` | string[] | no | The place's categories as the map tags them. |

**`ViewVisibility`**: Whether a place can be seen from the observer, and what that rests on.

| Field | Type | Always present | Description |
|---|---|---|---|
| `basis` | string | yes | What the verdict rests on: `terrain-and-buildings`, `terrain-only`, `buildings-only` or `nothing`. `terrain-only` in a town is weak: it means hills were checked and buildings were not. |
| `buildings` | string | yes | What the building check concluded: `clear`, `occluded`, `no-building-data` or `beyond-building-range`. |
| `terrain` | string | yes | What the terrain check concluded: `clear`, `occluded` or `no-elevation-data`. |
| `verdict` | string | yes | `clear` (nothing in the data stands in the way), `occluded` (something does, and it is named), or `unknown` (no check could run, so this result carries NO visibility claim: say so rather than implying either). |
| `obstruction` | ViewObstruction | no | The obstruction, present exactly when the verdict is `occluded`. |

**`ViewObstruction`**: What stands in the way of seeing a place.

| Field | Type | Always present | Description |
|---|---|---|---|
| `distance_m` | number | yes | How far along the sight line it stands, in metres. |
| `height_above_sightline_m` | number | yes | How far its top rises above the sight line, in metres. |
| `kind` | string | yes | `terrain` or `building`. |
| `building_height_basis` | string | no | `tagged` when somebody mapped the building's height, or `schema-default` when nobody did and the map's flat 5 m fallback was used. A `schema-default` obstruction is a weaker finding and should be described as one. |
| `building_height_m` | number | no | Height of the blocking building above its own ground, in metres. Absent for terrain. |
| `building_name` | string | no | The blocking building's name, when the map carries one. |

**Example** (called 2026-11-05)

```json
{
  "lat": 51.5007,
  "lon": -0.1246,
  "bearing_deg": 0,
  "category": "cafe",
  "limit": 2
}
```

Result (trimmed):

```json
{
  "caveat": "Visibility is modelled, not observed. It is a straight sight line over sampled b…",
  "coverage": "Visibility was checked against both the bare-earth elevation model and OpenStree…",
  "out_of_sector": 73,
  "results": [
    {
      "angular_offset_deg": -1.4,
      "bearing_deg": 358.6,
      "categories": [
        "cafe"
      ],
      "direction": "directly ahead, about 50 metres",
      "distance_m": 46.7,
      "label": "Caffè Nero, 70 Bridge Street, SW1A 2JH, London, Knepp Wildland, United Kingdom",
      "lat": 51.5011196,
      "lon": -0.1246162,
      "name": "Caffè Nero",
      "visibility": {
        "basis": "terrain-and-buildings",
        "buildings": "clear",
        "terrain": "clear",
        "verdict": "clear"
      }
    },
    {
      "angular_offset_deg": 8.5,
      "bearing_deg": 8.5,
      "categories": [
        "cafe"
      ],
      "direction": "directly ahead, about 50 metres",
      "distance_m": 46.9,
      "label": "Treats, 6 Bridge Street, SW1A 2JR, London, Knepp Wildland, United Kingdom",
      "lat": 51.501116800000005,
      "lon": -0.1245003,
      "name": "Treats",
      "visibility": {
        "basis": "terrain-and-buildings",
        "buildings": "clear",
        "terrain": "clear",
        "verdict": "clear"
      }
    }
  ]
}
```

**More from the server's description:**

Use this for "what is that over there", "is there a pub in that direction", "what am I looking at". Use `nearby_places` instead when the question is about what is NEAR a point rather than what is in a direction. Provide `lat`, `lon`, `bearing_deg` (degrees CLOCKWISE FROM TRUE NORTH: 0 north, 90 east, 180 south, 270 west) and at least one of `category` or `name`; `category=building` reaches named buildings. Optional `fov_deg` is the FULL width of the cone (default 60, so 30 degrees either side), `radius_m` the range (default 1000, max 5000), `eye_height_m` the observer's eye height (default 1.6). Each result carries `distance_m`, `bearing_deg`, a signed `angular_offset_deg` (negative left, positive right), a spoken `direction`, and a `visibility` block. Requires the MapMap gateway (GATEWAY_URL + GATEWAY_API_KEY).

### `list_place_categories`

List the canonical place categories you can pass as `category` to `nearby_places` and `search_along_route` (and browse on with `geocode`).

**Access:** Keyless, local. Local computation: no key, never metered, answers on any transport.

**Caveats the server states:**

- Read this before guessing a category: a token that is not on this list matches nothing, and quietly returns an empty result rather than an error.
- Cuisines, brands and names are NOT categories, search those as free text.

**Inputs**

No inputs: call it with `{}`.

**Output**

| Field | Type | Always present | Description |
|---|---|---|---|
| `categories` | PlaceCategory[] | yes | Every category `nearby_places`, `search_along_route` and `geocode` recognise, sorted alphabetically by `category`. |

**`PlaceCategory`**: One canonical place category, the phrases that name it, and what it covers.

| Field | Type | Always present | Description |
|---|---|---|---|
| `aliases` | string[] | yes | Colloquial phrases that resolve to this category, sorted. Passing one of these as free text works too, but the canonical `category` is exact. |
| `category` | string | yes | The canonical token to pass as a category filter, e.g. `fuel`. |
| `description` | string | yes | What the category covers, in one line. |

**Example** (called 2026-11-05)

```json
{}
```

Result (trimmed):

```json
{
  "categories": [
    {
      "aliases": [
        "off licence"
      ],
      "category": "alcohol",
      "description": "Off-licence: a shop selling alcohol to take away."
    },
    {
      "aliases": [
        "atm",
        "cash machine",
        "… 2 more"
      ],
      "category": "atm",
      "description": "Cash machine, whether free-standing or in a wall."
    },
    "… 30 more"
  ]
}
```

**More from the server's description:**

Each entry is a `category` token (the exact value to send, e.g. "fuel", "charging_station", "hgv_parking"), the `aliases` that colloquially name it ("petrol station", "EV charger", "lorry park"), and a one-line `description` of what it covers. Sorted by category and identical on every call. Local lookup, no network, no quota.

## Transit

England bus and coach stops and their timetabled departures.

### `nearby_stops`

England bus and coach stops near a point, nearest first. Pass `lat` and `lon` (WGS84), optional `radius_m` (1 to 2,000, default 500) and `limit` (1 to 50, default 10).

**Access:** Keyless. Answers without a key on the hosted endpoint under fair use; present your own key to meter it to you.

**Caveats the server states:**

- Bus and coach only: rail, tram, metro and ferry stops are not included, and coverage is England (plus stops just across a border that an England service calls at).
- Display data, never a journey plan: pass on the returned `label` and `coverage`.
- Display the returned `attribution` (NaPTAN and BODS, Open Government Licence v3.0).

**Inputs**

| Input | Type | Required | Default | Limits | Description |
|---|---|---|---|---|---|
| `lat` | number | yes |  |  | Latitude of the point, WGS84 degrees. |
| `lon` | number | yes |  |  | Longitude of the point, WGS84 degrees. |
| `limit` | integer | no | 10 |  | Most stops to return, 1 to 50 (default 10), nearest first. |
| `radius_m` | integer | no | 500 |  | Search radius in metres, 1 to 2,000 (default 500). |

**Output**

| Field | Type | Always present | Description |
|---|---|---|---|
| `attribution` | string | yes | NaPTAN and BODS attribution (Open Government Licence v3.0): display it with the stops. |
| `coverage` | string | yes | What the data covers (England bus and coach stops; no rail, tram or ferry). Pass it on with the stops. |
| `label` | string | yes | What the answer is and is not: display data, never a journey plan or an arrival prediction. Pass it on with the stops. |
| `stops` | [TransitStopOut](#transitstopout)[] | yes | Stops inside the radius, nearest first. |

**Example** (called 2026-11-05)

```json
{
  "lat": 51.5308,
  "lon": -0.1238,
  "limit": 3
}
```

Result (trimmed):

```json
{
  "attribution": "Contains public sector information licensed under the Open Government Licence v3…",
  "coverage": "England bus stops (NaPTAN) with services from BODS timetables. Rail, tram and fe…",
  "label": "Bus and coach stops for display, with the lines the Bus Open Data timetables hav…",
  "stops": [
    {
      "bearing_deg": 137,
      "distance_m": 53,
      "id": "490000129D",
      "indicator": "Stop D",
      "lat": 51.53045,
      "lines": [
        "46",
        "63",
        "… 1 more"
      ],
      "locality": "Kings Cross",
      "lon": -0.12328,
      "name": "King's Cross St. Pancras Underground Station"
    },
    {
      "bearing_deg": 123,
      "distance_m": 61,
      "id": "490000129E",
      "indicator": "Stop E",
      "lat": 51.5305,
      "lines": [
        "30",
        "73",
        "… 4 more"
      ],
      "locality": "Kings Cross",
      "lon": -0.12307,
      "name": "King's Cross St. Pancras Underground Station"
    },
    "… 1 more"
  ]
}
```

**More from the server's description:**

Each stop carries its NaPTAN ATCO code `id` (pass it to `next_departures`), `name`, `indicator` (e.g. "Stop S"), `locality`, position, `distance_m` and `bearing_deg` from the point, and `lines`, the line names the Bus Open Data timetables have calling there. Requires the MapMap gateway; a deployment without the stops index answers a clear error.

### `next_departures`

Timetabled bus departures at one stop, from the Bus Open Data (BODS) timetables. Pass `stop` (the NaPTAN ATCO code `nearby_stops` returns), optional `at` (RFC 3339, default now; the index holds three service days from today), `window_minutes` (1 to 180, default 60) and `limit` (1 to 30, default 8).

**Access:** Keyless. Answers without a key on the hosted endpoint under fair use; present your own key to meter it to you.

**Caveats the server states:**

- These are TIMETABLE times: `basis` is always "timetable", and a live entry is an observation, never an arrival time or a delay.
- MapMap does not predict arrivals: never present `scheduled_at` or `live` as one, and pass on the returned `label` and `coverage` verbatim.
- Display the returned `attribution` (NaPTAN and BODS, Open Government Licence v3.0).

**Inputs**

| Input | Type | Required | Default | Limits | Description |
|---|---|---|---|---|---|
| `stop` | string | yes |  |  | The stop's NaPTAN ATCO code, as `nearby_stops` returns it. |
| `at` | string | no | now |  | Start of the window, RFC 3339 (e.g. "2026-10-12T08:00:00+01:00"); default now. The index holds three service days from today. |
| `limit` | integer | no | 8 |  | Most departures to return, 1 to 30 (default 8). |
| `window_minutes` | integer | no | 60 |  | Window length, 1 to 180 minutes (default 60). |

**Output**

| Field | Type | Always present | Description |
|---|---|---|---|
| `attribution` | string | yes | NaPTAN and BODS attribution (Open Government Licence v3.0). |
| `basis` | string | yes | Always "timetable". |
| `coverage` | string | yes | What the data covers. Pass it on with the departures. |
| `departures` | TransitDepartureOut[] | yes | Departures in timetable order. |
| `label` | string | yes | The honesty line, verbatim: timetable times, what a live entry is, and that MapMap does not predict arrival times. Pass it on with the departures. |
| `stop` | [TransitStopOut](#transitstopout) | yes | The stop. |
| `generated_at` | string | no | When the answer was assembled, RFC 3339. |
| `timetable_date` | string | no | The service date the requested time falls on, YYYY-MM-DD. |

**`TransitDepartureOut`**: One timetabled departure.

| Field | Type | Always present | Description |
|---|---|---|---|
| `line` | string | yes | The line name, e.g. "15". |
| `scheduled_at` | string | yes | The timetabled departure, RFC 3339 in local time. A timetable time, never a prediction. |
| `trip` | string | yes | The GTFS trip id. |
| `destination` | string | no | The headsign the timetable gives. |
| `live` | TransitLiveOut | no | A vehicle on this line seen heading towards the stop, or absent. An observation, never an arrival time. |
| `operator` | string | no | National Operator Code, e.g. "TFLO". |

**`TransitLiveOut`**: A vehicle seen on the departure's line heading towards the stop.

| Field | Type | Always present | Description |
|---|---|---|---|
| `distance_m` | integer | yes | Straight-line distance from the vehicle to the stop, metres. |
| `vehicle` | string | yes | The operator's vehicle reference. |
| `age_s` | integer | no | Age of the vehicle's position fix, seconds. |

**Example** (called 2026-11-05)

```json
{
  "stop": "490000129D",
  "limit": 3
}
```

Result (trimmed):

```json
{
  "attribution": "Contains public sector information licensed under the Open Government Licence v3…",
  "basis": "timetable",
  "coverage": "England bus stops (NaPTAN) with services from BODS timetables. Rail, tram and fe…",
  "departures": [
    {
      "line": "63",
      "live": {
        "age_s": 110,
        "distance_m": 1175,
        "vehicle": "LC71KWL"
      },
      "operator": "ABLO",
      "scheduled_at": "2026-11-05T08:58:00+00:00",
      "trip": "VJd0e963a207198700b6db8f45221520412ad82117"
    },
    {
      "destination": "St Bartholomew's Hospital",
      "line": "46",
      "live": {
        "age_s": 157,
        "distance_m": 930,
        "vehicle": "LJ18FHG"
      },
      "operator": "MTLN",
      "scheduled_at": "2026-11-05T09:01:00+00:00",
      "trip": "VJ38a64df11ea4ab8cac5af189bf6a1f3df95f6958"
    },
    "… 1 more"
  ],
  "generated_at": "2026-11-05T08:57:13Z",
  "label": "Timetable times from Bus Open Data timetables. A live entry shows a vehicle on t…",
  "stop": {
    "id": "490000129D",
    "indicator": "Stop D",
    "lat": 51.53045,
    "lines": [
      "46",
      "63",
      "… 1 more"
    ],
    "locality": "Kings Cross",
    "lon": -0.12328,
    "name": "King's Cross St. Pancras Underground Station"
  },
  "timetable_date": "2026-11-05"
}
```

**More from the server's description:**

Each departure carries `line`, `destination`, `operator`, `scheduled_at` (local time) and `trip`, plus `live` when a vehicle on that line was seen in the live positions within 3 km and heading towards the stop (`vehicle`, `distance_m`, `age_s`). Requires the MapMap gateway; a deployment without the departures index answers a clear error.

## Weather and events

Forecast weather along a route and for drawing, reported natural events, space weather and camera-verified road incidents.

### `weather_along_route`

Forecast weather along a route, each sample read for the hour you actually reach it rather than the weather right now.

**Access:** Keyless. Answers without a key on the hosted endpoint under fair use; present your own key to meter it to you.

**Caveats the server states:**

- Each sample carries its ETA, temperature, rain, snowfall, precipitation type and probability, wind and gust speed, visibility, the WMO weather code and `severe` (thunderstorm and heavy precipitation codes only).
- It is a FORECAST, not an observation: read on a 0.05 degree grid (about 5.5 km) at the nearest hour, up to 16 days ahead, and samples past that horizon or without data come back `available: false` with a reason, never painted dry.
- `summary` is one sentence (where rain or snow starts and for how far, the strongest gust, samples without data) and always ends by saying it is advisory, never a routing guarantee: keep that qualifier when you pass it on.
- Display the returned `attribution` with the forecast, linked to `attribution_url`, with `licence_url` (CC BY 4.0) linked; `derived_fields` names the sample fields MapMap derives rather than Open-Meteo supplies.

**Inputs**

| Input | Type | Required | Default | Limits | Description |
|---|---|---|---|---|---|
| `shape` | string | yes |  |  | The route shape as an encoded polyline6 (the `route` tool's `geometry_polyline6`). |
| `depart_at` | string | no | now |  | Departure time, RFC 3339 (e.g. "2026-10-12T14:00:00Z"); defaults to now. Forecasts reach about 16 days ahead; samples past that answer unavailable. |
| `duration_s` | number | no |  |  | The route's duration in seconds, used to work out when you reach each sample. Pass the `route` tool's duration; omitted, a generic 80 km/h average is assumed and `duration_source` says so. |
| `sample_interval_m` | number | no | 25 |  | Distance between samples in metres (default 25,000, clamped to 5,000 to 100,000; at most 40 samples per route). |

**Output**

| Field | Type | Always present | Description |
|---|---|---|---|
| `attribution` | string | yes | Data attribution (CC BY 4.0): display it with the forecast, verbatim. |
| `attribution_url` | string | yes | The link the credit carries ("https://open-meteo.com/"): Open-Meteo's licence asks for it next to any place its data is displayed. |
| `depart_at` | string | yes | The departure time actually used, RFC 3339 UTC. |
| `derived_fields` | string[] | yes | The sample fields MapMap derives rather than reads from Open-Meteo ("precipitation_type", "severe"), so a display can indicate the changes the licence asks to be indicated. |
| `duration_s` | number | yes | The route duration actually used, seconds. |
| `duration_source` | string | yes | "provided" or "estimated_default_speed" (an 80 km/h guess: treat the ETAs as approximate). |
| `licence_url` | string | yes | The data licence to link with the credit (CC BY 4.0). |
| `sample_interval_m` | number | yes | The sample spacing actually used, metres (may be coarser than asked on a long route). |
| `samples` | WeatherSampleOut[] | yes | Route samples, start to end. |
| `severe_ahead` | boolean | yes | True when any available sample carries a severe WMO code. |
| `summary` | string | yes | One honest sentence describing the samples: where rain or snow starts and for how far, the strongest gust, how many samples had no data. Always ends by saying it is an advisory forecast, never a routing guarantee. |

**`WeatherSampleOut`**: One route sample's forecast, read for the hour you reach it.

| Field | Type | Always present | Description |
|---|---|---|---|
| `arc_m` | number | yes | Distance along the route to this sample, metres. |
| `available` | boolean | yes | Whether real forecast data is present. `false` means every weather field is absent and `unavailable_reason` says why; never a guess. |
| `eta` | string | yes | Estimated time of arrival at this sample, RFC 3339 UTC. |
| `lat` | number | yes | Sample latitude. |
| `lon` | number | yes | Sample longitude. |
| `forecast_hour` | string | no | The forecast hour actually served (UTC, `YYYY-MM-DDTHH:00`). |
| `precipitation_mm` | number | no | Total precipitation for the hour, mm. |
| `precipitation_probability_pct` | number | no | Chance of precipitation, percent. |
| `precipitation_type` | string | no | "rain", "snow", "mixed" or "none". |
| `rain_mm` | number | no | Rain for the hour, mm. |
| `severe` | boolean | no | True for thunderstorm and heavy-precipitation WMO codes only. Absent when the sample is unavailable. |
| `snowfall_cm` | number | no | Snowfall for the hour, cm. |
| `temperature_c` | number | no | Air temperature, degrees C. |
| `unavailable_reason` | string | no | Why this sample has no data (beyond the forecast horizon, or the forecast source could not be reached). |
| `visibility_m` | number | no | Visibility, metres. |
| `weather_code` | number | no | The raw WMO weather code. |
| `wind_gusts_kph` | number | no | Gust speed, km/h: the figure that matters for high-sided vehicles. |
| `wind_speed_kph` | number | no | Sustained wind speed, km/h. |

**Example** (called 2026-11-05)

```json
{
  "shape": "gejfaB~zrFDCRGXC^?rCXvD`…",
  "duration_s": 917.918
}
```

Result (trimmed):

```json
{
  "attribution": "Weather data by Open-Meteo.com (CC BY 4.0)",
  "attribution_url": "https://open-meteo.com/",
  "depart_at": "2026-11-05T08:57:12.611948075Z",
  "derived_fields": [
    "precipitation_type",
    "severe"
  ],
  "duration_s": 917.918,
  "duration_source": "provided",
  "licence_url": "https://creativecommons.org/licenses/by/4.0/",
  "sample_interval_m": 5098.2,
  "samples": [
    {
      "arc_m": 0,
      "available": true,
      "eta": "2026-11-05T08:57:12.611948075Z",
      "forecast_hour": "2026-11-05T09:00",
      "lat": 51.500644,
      "lon": -0.124864,
      "precipitation_mm": 0,
      "precipitation_probability_pct": 2,
      "precipitation_type": "none",
      "rain_mm": 0,
      "severe": false,
      "snowfall_cm": 0,
      "temperature_c": 9.9,
      "visibility_m": 22240,
      "…": "3 more fields"
    },
    {
      "arc_m": 5098.2,
      "available": true,
      "eta": "2026-11-05T09:12:30.529948075Z",
      "forecast_hour": "2026-11-05T09:00",
      "lat": 51.530695,
      "lon": -0.124488,
      "precipitation_mm": 0,
      "precipitation_probability_pct": 2,
      "precipitation_type": "none",
      "rain_mm": 0,
      "severe": false,
      "snowfall_cm": 0,
      "temperature_c": 9.6,
      "visibility_m": 19140,
      "…": "3 more fields"
    }
  ],
  "severe_ahead": false,
  "summary": "No rain, snow or strong gusts forecast at the sampled points. This is a forecast…"
}
```

**More from the server's description:**

Pass the route's `shape` (an encoded polyline6, the `route` tool's geometry_polyline6), optional `depart_at` (RFC 3339, default now), the route's `duration_s` (from `route`; omitted, an 80 km/h average is assumed and `duration_source` says so) and optional `sample_interval_m` (default 25,000, 5,000 to 100,000, at most 40 samples). Requires the MapMap gateway; a deployment without a weather source answers a clear error.

### `weather_frames`

The NOAA GFS forecast frames MapMap publishes for drawing weather on a map: 17 frames (0 to 48 hours every 3 hours) of the newest run, each with URLs for the global rain, temperature, cloud, gust and sea-level pressure PNG fields (1440 by 721, value-encoded; decode with `encodings`), the wind field (PNG plus JSON ranges) and XYZ tile templates for rain, temperature and isobars.

**Access:** Keyless. Answers without a key on the hosted endpoint; the frame URLs it returns need your own MapMap key to fetch.

**Caveats the server states:**

- A FORECAST, not an observation, processed by MapMap from NOAA GFS 0.25 degree data (regridded and quantised): never describe it as original NOAA data or imply NOAA endorsement.
- Display the returned `attribution`.

**Inputs**

| Input | Type | Required | Default | Limits | Description |
|---|---|---|---|---|---|
| `fields` | string[] | no |  |  | Which fields to return URLs for, any of `rain`, `temp`, `cloud`, `gust`, `mslp`, `wind`, `isobars`. Default: all. |
| `run` | string | no |  |  | A forecast run as YYYYMMDDHH (UTC), e.g. "2026101700". Default: the newest run. |

**Output**

| Field | Type | Always present | Description |
|---|---|---|---|
| `attribution` | string | yes | NOAA GFS credit. Display it. |
| `encodings` | any JSON | yes | How to decode each PNG field (unit, scale, offset), verbatim from the manifest. |
| `frames` | WeatherFrameOut[] | yes | The frames, in hour order. |
| `generated_at` | string | yes | When MapMap published the manifest (RFC 3339 UTC). |
| `label` | string | yes | "Forecast, not an observation". Pass it on. |
| `model` | WeatherModelOut | yes | The forecast model. |
| `note` | string | yes | Derived from NOAA GFS and modified by MapMap; no NOAA endorsement is implied. Pass it on. |
| `processed_by` | string | yes | "MapMap": the data is processed by MapMap. |
| `run` | string | yes | The run time (RFC 3339 UTC). |
| `run_id` | string | yes | The run as YYYYMMDDHH. |
| `status` | string | yes | `ok`, `stale` (the newest run is older than the threshold) or `unknown` (no frames). Pass a stale or unknown status on. |
| `summary` | string | yes | One sentence for the answer. Pass it on with its qualifier. |
| `threshold_s` | integer | yes | The age in seconds after which the frames count as stale. |
| `age_s` | integer | no | Seconds since `generated_at` when the answer was assembled. |

**`WeatherFrameOut`**: One forecast frame.

| Field | Type | Always present | Description |
|---|---|---|---|
| `hour` | integer | yes | Hours after the run time. |
| `tiles` | map of string | yes | XYZ tile templates by field (`rain`, `temp`: PNG; `isobars`: MVT). |
| `urls` | map of string | yes | Absolute URLs by field (global value-encoded PNGs; wind also has a JSON ranges file under `wind_json`). Each needs the caller's own MapMap key. |
| `valid_at` | string | yes | The time the frame is valid for (RFC 3339 UTC). |
| `half` | map of string | no | Half-resolution PNG URLs by field, when the run has them. |

**`WeatherModelOut`**: The forecast model behind the frames.

| Field | Type | Always present | Description |
|---|---|---|---|
| `id` | string | yes | Model id ("gfs"). |
| `frames_per_run` | integer | no | Frames in a run. |
| `horizon_h` | integer | no | Hours covered from the run time. |
| `resolution_deg` | number | no | Grid resolution of the source data, degrees. |
| `step_h` | integer | no | Hours between frames. |

**Example** (called 2026-11-05)

```json
{
  "fields": [
    "rain"
  ]
}
```

Result (trimmed):

```json
{
  "age_s": 15957,
  "attribution": "Weather: NOAA GFS forecast (public domain), processed by MapMap",
  "encodings": {
    "cloud": {
      "formula": "(R*65536 + G*256 + B) * scale + offset",
      "maplibre": {
        "baseShift": 0,
        "blueFactor": 0.01,
        "encoding": "custom",
        "greenFactor": 2.56,
        "redFactor": 655.36
      },
      "offset": 0,
      "scale": 0.01,
      "unit": "%"
    },
    "gust": {
      "formula": "(R*65536 + G*256 + B) * scale + offset",
      "maplibre": {
        "baseShift": 0,
        "blueFactor": 0.01,
        "encoding": "custom",
        "greenFactor": 2.56,
        "redFactor": 655.36
      },
      "offset": 0,
      "scale": 0.01,
      "unit": "m/s"
    },
    "mslp": {
      "formula": "(R*65536 + G*256 + B) * scale + offset",
      "maplibre": {
        "baseShift": 0,
        "blueFactor": 0.01,
        "encoding": "custom",
        "greenFactor": 2.56,
        "redFactor": 655.36
      },
      "offset": 0,
      "scale": 0.01,
      "unit": "hPa"
    },
    "rain": {
      "formula": "(R*65536 + G*256 + B) * scale + offset",
      "maplibre": {
        "baseShift": 0,
        "blueFactor": 0.01,
        "encoding": "custom",
        "greenFactor": 2.56,
        "redFactor": 655.36
      },
      "offset": 0,
      "scale": 0.01,
      "unit": "mm/h"
    },
    "temp": {
      "formula": "(R*65536 + G*256 + B) * scale + offset",
      "maplibre": {
        "baseShift": 100,
        "blueFactor": 0.01,
        "encoding": "custom",
        "greenFactor": 2.56,
        "redFactor": 655.36
      },
      "offset": -100,
      "scale": 0.01,
      "unit": "degC"
    }
  },
  "frames": [
    {
      "half": {
        "rain": "https://api.mapmap.ai/v1/weather/frames/gfs/2026110500/rain/f000.half.png"
      },
      "hour": 0,
      "tiles": {
        "rain": "https://api.mapmap.ai/tiles/weather-gfs-2026110500-rain-f000/{z}/{x}/{y}.png"
      },
      "urls": {
        "rain": "https://api.mapmap.ai/v1/weather/frames/gfs/2026110500/rain/f000.png"
      },
      "valid_at": "2026-11-05T00:00:00Z"
    },
    {
      "half": {
        "rain": "https://api.mapmap.ai/v1/weather/frames/gfs/2026110500/rain/f003.half.png"
      },
      "hour": 3,
      "tiles": {
        "rain": "https://api.mapmap.ai/tiles/weather-gfs-2026110500-rain-f003/{z}/{x}/{y}.png"
      },
      "urls": {
        "rain": "https://api.mapmap.ai/v1/weather/frames/gfs/2026110500/rain/f003.png"
      },
      "valid_at": "2026-11-05T03:00:00Z"
    },
    "… 15 more"
  ],
  "generated_at": "2026-11-05T04:31:16Z",
  "label": "Forecast, not an observation",
  "model": {
    "frames_per_run": 17,
    "horizon_h": 48,
    "id": "gfs",
    "resolution_deg": 0.25,
    "step_h": 3
  },
  "note": "Derived from NOAA GFS data and modified by MapMap (regridded, quantised and thin…",
  "processed_by": "MapMap",
  "run": "2026-11-05T00:00:00Z",
  "run_id": "2026110500",
  "status": "ok",
  "summary": "GFS run 2026-11-05 00:00 UTC: 17 frames, 0 to 48 h every 3 h. A forecast, not an…",
  "threshold_s": 25200
}
```

**More from the server's description:**

Optional `run` (YYYYMMDDHH) and `fields`. The URLs need the caller's own MapMap key. `status` says whether the newest run is fresh. Requires the MapMap gateway.

### `events_in_area`

Earthquakes, volcano alerts and satellite fire detections in a bounding box, for display. Pass `bbox` [west, south, east, north] (WGS84), optional `types` (quake, volcano, fire; default all), `min_magnitude` (quakes, default 2.5) and `limit` (1 to 500, default 100).

**Access:** Keyless. Answers without a key on the hosted endpoint under fair use; present your own key to meter it to you.

**Caveats the server states:**

- Earthquakes are USGS, magnitude 2.5 and above, past 24 hours, as reported (magnitudes and locations may be revised).
- Volcano alerts are USGS-monitored volcanoes ONLY: an absent alert says nothing about volcanoes elsewhere.
- Fire cells are NASA FIRMS VIIRS detections thinned to 0.5 degree cells: satellite-detected heat, NOT confirmed fires.
- Each feed carries `status` (ok, stale, unknown): pass a stale or unknown status on, never present old data as current.
- This is reported data for display, not a warning service and not safety-critical; keep that qualifier when you pass it on.
- Display the returned `attribution`, and for fire data the FIRMS `acknowledgment` and `disclaimer`.

**Inputs**

| Input | Type | Required | Default | Limits | Description |
|---|---|---|---|---|---|
| `bbox` | number[] | yes |  | exactly 4 items | Bounding box `[west, south, east, north]`, WGS84 degrees. West must be less than east and south less than north; a box crossing the antimeridian is not supported (query two boxes). |
| `limit` | integer | no | 100 |  | Most events to return, 1 to 500 (default 100). Newest quakes first, then volcano alerts, then the busiest fire cells. |
| `min_magnitude` | number | no | 2.5 |  | Smallest earthquake magnitude, 2.5 to 9.0 (default 2.5). Quakes only. |
| `types` | string[] | no |  |  | Which feeds to include: any of `quake`, `volcano`, `fire`. Default: all three. |

**Output**

| Field | Type | Always present | Description |
|---|---|---|---|
| `acknowledgment` | string | yes | NASA FIRMS acknowledgment. Display it with fire data. |
| `attribution` | map of string | yes | Source credits, keyed by feed. Display them. |
| `count` | integer | yes | Events returned. |
| `coverage` | string | yes | What each feed does and does not cover. Pass it on. |
| `disclaimer` | string | yes | NASA FIRMS "as is" disclaimer. Display it with fire data. |
| `endorsement` | string | yes | No endorsement by or affiliation with USGS, NOAA or NASA is implied. |
| `events` | EventOut[] | yes | The events: newest quakes, then volcano alerts, then fire cells. |
| `feeds` | map of EventsFeedOut | yes | Freshness of each requested feed, keyed `quake`, `volcano`, `fire`. |
| `label` | string | yes | Reported events for display; not a warning service. Pass it on. |
| `summary` | string | yes | One sentence built from the counts. Pass it on with its qualifier. |
| `truncated` | boolean | yes | True when the limit cut the list short. |

**`EventOut`**: One earthquake, volcano alert or fire cell.

| Field | Type | Always present | Description |
|---|---|---|---|
| `id` | string | yes | The event's id (a fire cell id is `fire:<south>,<west>`). |
| `kind` | string | yes | `quake`, `volcano` or `fire`. |
| `lat` | number | yes | Latitude, WGS84 degrees. A fire cell is its 0.5 degree cell centre. |
| `lon` | number | yes | Longitude, WGS84 degrees. |
| `acq_date` | string | no | Fire: the acquisition date (YYYY-MM-DD). |
| `alert_level` | string | no | Volcano: USGS alert level (NORMAL, ADVISORY, WATCH, WARNING). |
| `cell_deg` | number | no | Fire: the cell size in degrees (0.5). |
| `color_code` | string | no | Volcano: USGS aviation colour code as a word (GREEN, YELLOW, ORANGE, RED). |
| `count` | integer | no | Fire: satellite detections inside the cell. |
| `coverage` | string | no | Volcano: US-monitored volcanoes only; an absent alert says nothing about volcanoes elsewhere. |
| `depth_km` | number | no | Quake: depth in kilometres. |
| `derived` | string | no | Fire: says the cell is derived from detections, not a fire. |
| `high_confidence_share` | number | no | Fire: share of detections the satellite flags high confidence, 0 to 1. |
| `label` | string | no | Fire: "Satellite-detected heat, not confirmed fires". Keep it. |
| `mag` | number | no | Quake: magnitude as reported (may be revised). |
| `max_frp` | number | no | Fire: the largest fire radiative power in the cell, megawatts. |
| `name` | string | no | Volcano: name. |
| `notice_url` | string | no | Volcano: the USGS notice page. |
| `observatory` | string | no | Volcano: the monitoring observatory. |
| `place` | string | no | Quake: USGS place description, e.g. "12 km SW of Ridgecrest, CA". |
| `sent_at` | string | no | Volcano: when the notice was sent, RFC 3339 UTC. |
| `time` | string | no | Quake: origin time, RFC 3339 UTC. |
| `url` | string | no | Quake: USGS event page. |

**`EventsFeedOut`**: One feed's freshness, as the gateway reports it.

| Field | Type | Always present | Description |
|---|---|---|---|
| `status` | string | yes | `ok` (fetched within the threshold), `stale` (older; features are still returned, so say they are paused) or `unknown` (no data for this feed). Never present stale or unknown data as current. |
| `age_s` | integer | no | Seconds since `fetched_at` when the gateway assembled the answer. |
| `count_in_file` | integer | no | Events in the feed's file before the box and filters were applied. |
| `detections` | integer | no | Fires only: raw satellite detections behind the thinned cells. |
| `fetched_at` | string | no | When the box last fetched the feed (RFC 3339), if known. |
| `source` | string | no | The feed's source, in words. |
| `threshold_s` | integer | no | The age in seconds after which the feed counts as stale. |

**Example** (called 2026-11-05)

```json
{
  "bbox": [
    -10,
    49,
    2,
    59
  ]
}
```

Result (trimmed):

```json
{
  "acknowledgment": "We acknowledge the use of data and/or imagery from NASA's Fire Information for R…",
  "attribution": {
    "fire": "Fire detections: NASA FIRMS, VIIRS (Suomi NPP). Satellite-detected heat, not con…",
    "quake": "Earthquakes: USGS Earthquake Hazards Program (US public domain). Shown as report…",
    "volcano": "Volcano alerts: USGS Hazard Notification System, with locations from the NOAA NC…"
  },
  "count": 0,
  "coverage": "Earthquakes: worldwide, USGS catalogue. Volcano alerts: US-monitored volcanoes o…",
  "disclaimer": "The information presented through LANCE, GIBS, Worldview, and FIRMS are provided…",
  "endorsement": "No endorsement by or affiliation with USGS, NOAA or NASA is implied.",
  "events": [],
  "feeds": {
    "fire": {
      "age_s": 11497,
      "count_in_file": 0,
      "detections": 0,
      "fetched_at": "2026-11-05T05:45:36Z",
      "source": "NASA FIRMS VIIRS (Suomi NPP) detections, past 24 hours, thinned to 0.5 degree cells",
      "status": "ok",
      "threshold_s": 46800
    },
    "quake": {
      "age_s": 34,
      "count_in_file": 28,
      "fetched_at": "2026-11-05T08:56:39Z",
      "source": "USGS Earthquake Hazards Program real-time feed, magnitude 2.5 and above, past 24 hours",
      "status": "ok",
      "threshold_s": 1800
    },
    "volcano": {
      "age_s": 2259,
      "count_in_file": 3,
      "fetched_at": "2026-11-05T08:19:34Z",
      "source": "USGS Hazard Notification System elevated alerts, placed with NOAA NCEI locations",
      "status": "ok",
      "threshold_s": 10800
    }
  },
  "label": "Reported events for display. Not a warning service and not safety-critical.",
  "summary": "No earthquakes, no volcano alerts, no fire cells in this area. Reported for disp…",
  "truncated": false
}
```

**More from the server's description:**

Requires the MapMap gateway.

### `space_weather_now`

The planetary Kp index and the NOAA SWPC OVATION aurora forecast right now. No input.

**Access:** Keyless. Answers without a key on the hosted endpoint under fair use; present your own key to meter it to you.

**Caveats the server states:**

- Returns `kp` (0 to 9) with its time, the forecast's valid time, the peak aurora probability and `aurora_png_url` (a 360 by 181 probability grid, percent per pixel, column 0 at 180 W, row 0 at 90 N) when the forecast is under an hour old; `status` is `unavailable` when it is older and nothing should be drawn.
- A FORECAST about 30 to 90 minutes ahead, not an observation; never present it as a sighting or a guarantee.
- Display the returned `attribution`; no endorsement by NOAA is implied.

**Inputs**

No inputs: call it with `{}`.

**Output**

| Field | Type | Always present | Description |
|---|---|---|---|
| `attribution` | string | yes | NOAA SWPC credit. Display it. |
| `endorsement` | string | yes | No endorsement by or affiliation with NOAA is implied. |
| `label` | string | yes | "Forecast, not an observation". Pass it on. |
| `status` | string | yes | `ok`, `unavailable` (the forecast is over an hour old: draw nothing) or `unknown` (no data). |
| `summary` | string | yes | One sentence for the answer. Pass it on with its qualifier. |
| `threshold_s` | integer | yes | The age in seconds after which the forecast is unavailable. |
| `age_s` | integer | no | Seconds since the forecast time when the answer was assembled. |
| `aurora_png_url` | string | no | URL of the 360 by 181 probability grid (percent per pixel, column 0 at 180 W, row 0 at 90 N); present only while the forecast is fresh. Needs the caller's own MapMap key. |
| `forecast_time` | string | no | The time the aurora forecast is valid for (RFC 3339 UTC). |
| `kp` | number | no | Planetary Kp index, 0 to 9. |
| `kp_time` | string | no | When the Kp value applies (RFC 3339 UTC). |
| `max_probability` | integer | no | Peak aurora probability anywhere on the grid, percent. |
| `observation_time` | string | no | The observation the forecast was made from (RFC 3339 UTC). |

**Example** (called 2026-11-05)

```json
{}
```

Result (trimmed):

```json
{
  "age_s": 0,
  "attribution": "Aurora and Kp: NOAA Space Weather Prediction Center (public domain). OVATION aur…",
  "aurora_png_url": "https://api.mapmap.ai/v1/space-weather/aurora.png",
  "endorsement": "No endorsement by or affiliation with NOAA is implied.",
  "forecast_time": "2026-11-05T09:58:00Z",
  "kp": 1,
  "kp_time": "2026-11-05T08:51:00",
  "label": "Forecast, not an observation",
  "observation_time": "2026-11-05T08:47:00Z",
  "status": "ok",
  "summary": "Kp 1.0; NOAA SWPC aurora forecast for 09:58 UTC. A forecast about 30 to 90 minut…",
  "threshold_s": 3600
}
```

**More from the server's description:**

Requires the MapMap gateway.

### `camera_incidents`

Road incidents a TRAFFIC CAMERA SAW and a vision model CONFIRMED, in force now: near a point (`near` [lat, lon] with `radius_m`, default 1000, at most 10000), in a `bbox` [min_lon, min_lat, max_lon, max_lat], or along a route `shape` (polyline6 from `route`, corridor `buffer_m` default 60).

**Access:** Not enabled on the hosted endpoint. Registered, but the hosted gateway has not armed camera-verified incidents, so it answers HTTP 501 there today.

**Caveats the server states:**

- Camera records RANK BELOW official closures and never retract one.
- READ `coverage_note` ALOUD: an empty list means no camera-verified incident here, NEVER a clear road.
- Show `attribution` verbatim (Powered by TfL Open Data).
- Off unless the deployment arms camera incidents (SN_CAMERA_INCIDENTS_ENABLED): an unarmed gateway answers 501 and says so.

**Inputs**

| Input | Type | Required | Default | Limits | Description |
|---|---|---|---|---|---|
| `bbox` | number[] | no |  | exactly 4 items | `[min_lon, min_lat, max_lon, max_lat]`. |
| `buffer_m` | number | no |  |  | Corridor half-width for `shape`, metres. |
| `include_recently_expired` | boolean | no | `false` |  | Include records that have expired in the last hour, for the operator's timeline (default false). |
| `min_confidence` | number | no | 0 |  | Drop records below this confidence (default 0). |
| `near` | number[] | no | 1000 | exactly 2 items | `[lat, lon]` plus `radius_m` (default 1000). |
| `radius_m` | number | no |  |  | Radius for `near`, metres, at most 10000. |
| `shape` | string | no | 60 |  | A route shape as polyline6; the corridor rule of `/v1/incidents/along` applies (`buffer_m` default 60). |

**Output**

| Field | Type | Always present | Description |
|---|---|---|---|
| `attribution` | string[] | yes | Attribution lines required by the camera providers, verbatim. |
| `coverage_note` | string | yes | Plain-language statement of which cameras are covered and that camera records rank below official closures. Agents must read it aloud: an empty list means "no camera-verified incident", never "road clear". |
| `incidents` | CameraIncidentOut[] | yes | Records in force (and recently expired, if asked). |

**`CameraIncidentOut`**: One camera-verified incident in force.

| Field | Type | Always present | Description |
|---|---|---|---|
| `camera_name` | string | yes | Camera name. |
| `camera_position` | number[] | yes | Camera position `[lat, lon]`. |
| `confidence` | number | yes | Verifier confidence, `0..=1`. |
| `end` | string | yes | Lifts at, RFC 3339. |
| `id` | string | yes | Override record id. |
| `incident_kind` | IncidentKind | yes | What was seen. |
| `kind` | string | yes | `closed` or `speed_override`. |
| `source` | string | yes | `cam-<provider>-<camera id>`. |
| `start` | string | yes | In force from, RFC 3339. |
| `verifier` | string | yes | Which verifier answered. |
| `ways` | WayOut[] | yes | Roads asserted. |
| `arc_m` | number | no | Distance along the route, metres, when queried by `shape`. |
| `evidence_url` | string | no | The clip the decision was made on. |
| `speed_kph` | integer | no | Speed in force, km/h, for `speed_override`. |

**`IncidentKind`**: What kind of road event an incident describes, as far as a closure decision cares.

One of:

- "collision": Two or more vehicles made contact.
- "stalled-vehicle": A vehicle stopped where it should not have.
- "wrong-way": A vehicle travelling against the carriageway direction.
- an object with `other`: string: Anything else, carrying the raw category for logs.

**`WayOut`**: One road asserted.

| Field | Type | Always present | Description |
|---|---|---|---|
| `direction` | [Direction](#direction) | yes | Carriageway. |
| `way_id` | integer | yes | OSM way id. |
| `name` | string | no | Road name, when known. |

**Example** (called 2026-11-05)

```json
{
  "near": [
    51.5007,
    -0.1246
  ]
}
```

The hosted endpoint answered with this tool error:

```text
gateway /v1/camera-incidents answered HTTP 501: camera-verified incidents are not enabled on this gateway: the operator must set SN_CAMERA_INCIDENTS_ENABLED=true with SN_INCIDENTS_DIR (reads) or SN_OPS_OVERRIDES_FILE (operator closures)
```

**More from the server's description:**

Give exactly one. Each record says what it asserts (`closed` or a `speed_override` in km/h) on which OSM ways and carriageway, what was seen (collision, stalled vehicle, wrong-way vehicle), the camera, the clip URL the verdict was made on (`evidence_url`), the verifier and its `confidence`, and when it lifts (`end`, always within minutes: these records expire on their own). Requires the MapMap gateway (GATEWAY_URL + GATEWAY_API_KEY).

## Clearance and ADR

Dangerous-goods tunnel decisions and measured overhead clearance.

### `check_adr_tunnel`

Check whether a vehicle may pass through a tunnel of a given ADR category ("A"–"E"). Provide `hazmat` and, when known, the load's ADR 8.6.4 tunnel restriction code (e.g.

**Access:** Keyless, local. Local computation: no key, never metered, answers on any transport.

**Caveats the server states:**

- Applies the conservative worst-case reading: conditional clauses are assumed to apply, so a blocked answer may over-restrict but never under-restricts.

**Inputs**

| Input | Type | Required | Default | Limits | Description |
|---|---|---|---|---|---|
| `hazmat` | boolean | yes |  |  | Whether the vehicle carries dangerous goods at all. When false the ADR tunnel matrix does not apply and every tunnel is permitted. |
| `tunnel_category` | string | yes |  |  | ADR category of the tunnel to check: "A", "B", "C", "D" or "E". |
| `tunnel_code` | string | no |  |  | ADR 8.6.4 tunnel restriction code of the load, e.g. "B", "C5000D", "B/D", or "(-)"/"none". Leave unset for a hazmat load of unknown code (conservatively treated as code B). |

**Output**

| Field | Type | Always present | Description |
|---|---|---|---|
| `decision` | DecisionStatus | yes | Whether passage is allowed or blocked. |
| `explanation` | string | yes | Human-readable explanation of how the decision was reached, including the conservative worst-case reading. |
| `forbidden_tunnel_categories` | string[] | yes | ADR tunnel categories this load is forbidden from under the worst-case reading (conditional clauses assumed to apply). Empty when unrestricted. |
| `reason` | string | no | Why passage is forbidden (present only when blocked); cites the ADR 8.6.4 rule that applied. |

**`DecisionStatus`**: The tunnel-entry decision, mirroring `sn_adr::Decision`.

| Value | Meaning |
|---|---|
| `"allowed"` | Passage is permitted. |
| `"blocked"` | Passage is forbidden. |

**Example** (called 2026-11-05)

```json
{
  "hazmat": true,
  "tunnel_category": "E",
  "tunnel_code": "D"
}
```

Result (trimmed):

```json
{
  "decision": "blocked",
  "explanation": "Vehicle with declared tunnel restriction code D: passage through a category-E tu…",
  "forbidden_tunnel_categories": [
    "D",
    "E"
  ],
  "reason": "ADR 8.6.4: tunnel restriction code D forbids passage through tunnels of category…"
}
```

**More from the server's description:**

"B", "C5000D", "B/D", "none"). No network access; answers instantly.

### `check_clearance_on_route`

Measure a vehicle's overhead clearance along a route against surveyed point cloud geometry, wherever survey coverage exists.

**Access:** Keyless. Answers without a key on the hosted endpoint under fair use; present your own key to meter it to you.

**Caveats the server states:**

- Honesty, and it matters here: this measures physical geometry from a dated survey.
- It is not a signed or posted height, `clearance_enforcement.route_certified` is always false, and the caveat is on every answer including the clear one.
- Ground the survey did not cover comes back as `not_surveyed_m` and is never judged, so a `pass` is possible over complete coverage and nowhere else; sparse or stale coverage comes back separately as `insufficient_data_m`.
- Needs the MapMap gateway (GATEWAY_URL + GATEWAY_API_KEY), which holds the surveys; there is no fallback, and it will not answer from the routing step alone.

**Inputs**

| Input | Type | Required | Default | Limits | Description |
|---|---|---|---|---|---|
| `destination` | [LatLon](#latlon) | yes |  |  | Route destination. |
| `height_m` | number | yes | vehicle |  | Vehicle height in metres. Required: there is no default vehicle, because a default vehicle is how somebody gets an answer about a lorry that is not theirs. |
| `origin` | [LatLon](#latlon) | yes |  |  | Route origin. |
| `margin_m` | number | no | 0 |  | Operating margin in metres, added to the height before the verdict is decided (default 0). Your compliance policy, not ours: the measured figure and the safe bound are both reported whatever you set here, and the margin is echoed back. It is applied to the measurement, not to the routing step, where the map's own posted heights already carry a margin of their own. |
| `width_m` | number | no |  |  | Vehicle width in metres. Supplying it asks the width axis as well as the height axis; the width answer is reported separately and is never a headroom. |

**Output**

| Field | Type | Always present | Description |
|---|---|---|---|
| `advisories` | string[] | yes | Notes on stretches of the route: vegetation age, a reading limited by a wire, a seam between surveys. |
| `assessed_m` | number | yes | Metres of route measured against survey data good enough to decide on. |
| `basis` | string | yes | Always `"surveyed_geometry_not_signage"`. |
| `clearance_enforcement` | ClearanceEnforcement | yes | What this answer does and does not prove. Read it. |
| `datasets` | ClearanceDatasetSummary[] | yes | The surveys drawn on, with their capture dates and staleness. |
| `explanation` | string | yes | What was measured, when, with what bound, and what the tool declined to conclude. It may over-restrict; it never under-restricts. |
| `geometry_polyline6` | string | yes | The route as a six-digit-precision encoded polyline, so the same shape can be drawn or re-measured without routing again. |
| `indeterminate_reasons` | string[] | yes | Why an `indeterminate` could not be called, as machine-readable codes (`inside_uncertainty_band`, `vegetation_age_exceeded`, `artefact_stale`, `artefact_freshness_unchecked`). Empty on every other verdict. These are for branching on, not for reading out: the same reasons appear as English in `explanation`, and a person shown `artefact_freshness_unchecked` has been failed by whatever displayed it. |
| `insufficient_data_m` | number | yes | Metres of route a survey covers but too sparsely, or too stale, to decide on. Also an absence, and reported apart from `not_surveyed_m` because the two have different remedies. |
| `not_surveyed_m` | number | yes | Metres of route no survey covers. This is the absence of a measurement, and it is never the same statement as a measured open sky. No verdict is drawn over it. |
| `route_distance_m` | number | yes | Length of the route the vehicle was routed over, metres. |
| `route_duration_s` | number | yes | Estimated driving time for that route, seconds. |
| `verdict` | string | yes | `pass`, `fail`, `indeterminate` or `no_verdict` for the height axis. A `pass` is possible over completely surveyed ground and nowhere else: unsurveyed ground yields `no_verdict`, never a pass. |
| `width` | ClearanceWidthSummary | yes | The width axis, answered separately when `width_m` was given. |
| `limiting` | LimitingPointSummary | no | The limiting point on a `fail`, the tightest point on a `pass`, the worst contested point on an `indeterminate`. Absent on a `no_verdict`, and absent on a `pass` where the survey found nothing at all above the corridor. |
| `resolution_hint` | string | no | What would resolve an `indeterminate`, in one sentence, as the measuring service phrased it. |
| `view_url` | string | no | Deep link to that exact view in the survey viewer, so the reading can be looked at rather than taken on trust. |

**`ClearanceEnforcement`**: How a clearance answer was arrived at, and what it therefore does and does not prove. The `TunnelEnforcement` pattern, applied to the other safety-adjacent answer this server gives, and for the same reason: a headroom figure with a bound on it reads like permission. It is not one. The block is carried on every response including the clear one, because the clear one is the dangerous one. Every field is taken verbatim from the gateway's report rather than rebuilt here. `vertical_datum` is a property of the survey the figures came from, so this server cannot know it; and copying the caveat text into a second crate is exactly the drift the single-source-of-truth rule exists to prevent. When the gateway omits any part of it, the tool refuses the answer rather than emitting a report with a hollow caveat.

| Field | Type | Always present | Description |
|---|---|---|---|
| `basis` | string | yes | How the figures were arrived at. Always `"surveyed_pointcloud_within_survey_difference"`: the difference between two heights measured in the same survey, on the same date, by the same processing. No map tag, no terrain model and no absolute vertical datum enters it. |
| `caveat` | string | yes | What this answer does and does not prove, in one paragraph. |
| `route_certified` | boolean | yes | Whether the route has been certified against a structure inventory. Always `false`, and there is no future in which it is true: this is a measurement with a bound, never a certificate about a route. |
| `vertical_datum` | string | yes | The vertical frame the surveyed figures live in, spelled for a reader. |

**`ClearanceDatasetSummary`**: One survey the report drew on.

| Field | Type | Always present | Description |
|---|---|---|---|
| `dataset` | string | yes | The dataset id. |
| `stale` | boolean | yes | Whether the clearance field lags the survey it was baked from, or has never been checked against it. Either way no route passes on it. |
| `survey_dates` | string | yes | Capture range, `from/to` in ISO dates. |

**`LimitingPointSummary`**: One measured clearance on the route, with the bounds that qualify it. There is no field here for a posted, signed or otherwise legally binding height, and that absence is deliberate. A survey measures the physical gap; a sign records a restriction a road authority posted, set below the physical gap on purpose. They are different quantities and this server never reports one as the other.

| Field | Type | Always present | Description |
|---|---|---|---|
| `dataset` | string | yes | The dataset the measurement came from. |
| `headroom_m` | number | yes | Measured headroom: the surveyed gap between the road surface and the lowest validated surface above it, metres. |
| `lat` | number | yes | Latitude of the point, decimal degrees. |
| `lon` | number | yes | Longitude of the point, decimal degrees. |
| `overhead_class` | string | yes | What the overhead surface is made of: `structure`, `vegetation`, `wire` or `unknown`. Foliage is compressible and seasonal, so an old reading of it stops deciding. |
| `route_distance_m` | number | yes | How far along the route the point sits, metres. |
| `safe_headroom_m` | number | yes | `headroom_m` less the uncertainty terms, metres. This is the number the verdict is decided on, and the number to plan against. |
| `sampling_gap_m` | number | yes | One-sided sampling bias bound, metres: how far below the lowest sample the true low point could hang, given the sample spacing. |
| `sigma_m` | number | yes | One-sigma measurement uncertainty on that figure, metres. |
| `surveyed_on` | string | yes | Last capture date of the survey behind this measurement, ISO. |

**`ClearanceWidthSummary`**: The width axis: how wide the clear corridor is, never how tall. A separate type from `LimitingPointSummary` on purpose, with no headroom field on it. These are different physical quantities, and a shared type is how the reader of a width answer ends up quoting a headroom.

| Field | Type | Always present | Description |
|---|---|---|---|
| `verdict` | string | yes | `pass`, `fail`, `indeterminate`, `no_verdict`, or `not_assessed` where the geometry or the request did not support an answer. |
| `clear_width_m` | number | no | Free width of the narrowest clear corridor found, metres. Present where a corridor edge was actually measured. |
| `reason` | string | no | Why the width axis declined to answer, present for `not_assessed`. |
| `safe_clear_width_m` | number | no | That width less its uncertainty terms, metres: the number the width verdict is decided on. |

**Example** (called 2026-11-05)

```json
{
  "origin": {
    "lat": 51.5007,
    "lon": -0.1246
  },
  "destination": {
    "lat": 51.5308,
    "lon": -0.1238
  },
  "height_m": 4
}
```

Result (trimmed):

```json
{
  "advisories": [],
  "assessed_m": 0,
  "basis": "surveyed_geometry_not_signage",
  "clearance_enforcement": {
    "basis": "surveyed_pointcloud_within_survey_difference",
    "caveat": "Headroom figures are measured from a dated survey's own geometry as a within-sur…",
    "route_certified": false,
    "vertical_datum": "No survey covered this corridor, so no vertical frame applies."
  },
  "datasets": [],
  "explanation": "Routed with truck costing at 4.00 m, so the search avoided the height restrictio…",
  "geometry_polyline6": "gejfaB~zrFDCRGXC^?rCXvD`@|@H`@J`@\\\\b@P^Pf@Nd@Px@Jp@HfA@fAC`Aq@fKeA|OK`ASlAY|@_@r…",
  "indeterminate_reasons": [],
  "insufficient_data_m": 0,
  "not_surveyed_m": 5098.180376212733,
  "route_distance_m": 5106,
  "route_duration_s": 917.918,
  "verdict": "no_verdict",
  "width": {
    "reason": "no vehicle width was supplied, so the corridor was assessed for height alone",
    "verdict": "not_assessed"
  }
}
```

**More from the server's description:**

Routes with truck costing (so the search already avoids the height restrictions the map has tagged), then measures that corridor. Give `origin`, `destination` and `height_m`; optional `width_m` asks the corridor-width axis too, and optional `margin_m` adds your operating margin to the vehicle before the verdict. Returns `pass`, `fail`, `indeterminate` or `no_verdict` with the limiting point, the measured headroom, its uncertainty bound (`safe_headroom_m`, `sigma_m`, `sampling_gap_m`) and a link to that exact view in the survey viewer. An `indeterminate` carries `indeterminate_reasons` as codes to branch on and the same reasons as English inside `explanation`; read out the English. A `pass` may carry no limiting point at all, which means the survey found nothing above that corridor, and the width axis may answer `not_assessed` where the corridor edges are too sparsely surveyed while the height axis still answers. Reach for this when a truck route came back unchanged and you need to know whether that means anything: an unchanged route avoids what the map records, which is a different claim from measured headroom, because a structure nobody tagged is routed through like open road. The mapmap://guide/clearance resource sets out what each answer proves.

## Verify

Check that places an AI named are real, and that a dataset's declared CRS fits its coordinates.

### `verify_places`

Check whether places (and itineraries) an AI mentioned are real, findable and physically possible.

**Access:** Keyless. Answers without a key on the hosted endpoint under fair use; present your own key to meter it to you.

**Caveats the server states:**

- Pass structured `claims` (reliable, and the only path that supports itinerary feasibility) or free `text` (best-effort quoted-phrase extraction).
- Each claim resolves to exactly one of three verdicts, never a boolean: "verified" (matched a real place, with its stable id and the source/date of the evidence), "contradicted" (a specific, dated, sourced fact rules it out, currently only an itinerary leg the routing engine proves cannot be driven in the stated time, with the computed travel time as evidence), or "unverified" (no evidence either way).
- This tool NEVER asserts that a named real business does not exist or has closed, that would be a defamation risk with no upside; a missing match is always "unverified".
- A claim takes only `id`, `name`, `locality`, `sequence` and `claimed_time`; any other field (e.g.
- `near`) is refused naming the accepted ones, so put disambiguating context such as a town in `locality`.

**Inputs**

| Input | Type | Required | Default | Limits | Description |
|---|---|---|---|---|---|
| `claims` | VerifyClaimInput[] | no |  |  | Structured claims, the reliable path, and the only path that supports itinerary feasibility. Mutually exclusive with `text`. Max 20 per request. |
| `costing` | [CostingKind](#costingkind) | no | `"auto"` |  | Costing for feasibility legs: "auto" (default), "truck", "bicycle", "pedestrian" or "motor_scooter". |
| `text` | string | no |  |  | Free text to extract place claims from (best-effort heuristic: quoted phrases and Title Case runs after "at/in/near/to/from/ visiting", not NLP or an LLM call, and it does not attempt itinerary feasibility since there are no explicit times to anchor legs to). Mutually exclusive with `claims`. Max 8,000 characters. |

**`VerifyClaimInput`**: One place claim for the `verify_places` tool: a place an AI mentioned, to be checked for existence and (as part of an itinerary) feasibility. Unknown fields are refused, naming the accepted ones. A dev-lane run (19 Oct 2026) passed `near` instead of `locality`; it was silently dropped, "Roman Baths" matched Ankara, and the itinerary leg became uncomputable with nothing telling the caller why.

| Input | Type | Required | Default | Limits | Description |
|---|---|---|---|---|---|
| `name` | string | yes |  |  | The place name as claimed, e.g. "The Eagle and Child". |
| `claimed_time` | string | no |  |  | ISO 8601 timestamp: when the itinerary claims you are at this place. |
| `id` | string | no |  |  | Caller-chosen id, echoed back on the matching result. Auto-assigned ("claim-1", …) when omitted. |
| `locality` | string | no |  |  | Optional disambiguating context, e.g. "Oxford". Country-level words ("UK", "England", …) are stripped before querying, they add noise, not signal, to name search. |
| `sequence` | integer | no |  |  | Itinerary position. Claims that share increasing `sequence` values and both carry `claimed_time` form legs the feasibility pass checks. |

**Output**

| Field | Type | Always present | Description |
|---|---|---|---|
| `results` | VerifyClaimResult[] | yes |  |
| `summary` | string | yes | Always present: a concise plain-text summary alongside the structured `results`, several MCP clients (notably ChatGPT connectors) drop non-text content blocks, so this must stand on its own. This same string is also returned as the tool call's text content block, not only inside the structured JSON. |

**`VerifyClaimResult`**: The result for one claim.

| Field | Type | Always present | Description |
|---|---|---|---|
| `claim` | VerifyClaimEcho | yes | The claim as echoed back (a subset of the input, for context). |
| `evidence` | VerifyEvidence[] | yes |  |
| `id` | string | yes |  |
| `verdict` | VerifyVerdict | yes | The three-state verdict, see `crate::verify` module docs for why there is no fourth "does not exist" state and never a boolean. |
| `feasibility` | VerifyFeasibility | no | Feasibility of the leg arriving at this stop, when computable. |
| `match` | VerifyPlaceMatch | no | The place a claim matched, when one was found. |

**`VerifyClaimEcho`**: The claim as echoed back (a subset of the input, for context).

| Field | Type | Always present | Description |
|---|---|---|---|
| `name` | string | yes |  |
| `locality` | string | no |  |
| `sequence` | integer | no |  |

**`VerifyEvidence`**: One piece of evidence backing a verdict. Always dated.

| Field | Type | Always present | Description |
|---|---|---|---|
| `checked_at` | string | yes | ISO 8601 timestamp of when this evidence was gathered (a check time, not necessarily the underlying map data's edit date). |
| `detail` | string | yes | Short factual note, always from a fixed template (see `crate::verify`), never free-form text that could assert non-existence or closure. |
| `source` | string | yes | Where the evidence came from, e.g. "mapmap-geocode", "mapmap-matrix". |

**`VerifyFeasibility`**: Feasibility of the leg arriving at this stop, when computable.

| Field | Type | Always present | Description |
|---|---|---|---|
| `available_minutes` | number | yes | Minutes the itinerary claims are available for this leg. |
| `from_id` | string | yes |  |
| `from_name` | string | yes |  |
| `status` | VerifyFeasibilityStatus | yes | Feasibility status of one itinerary leg. |
| `to_id` | string | yes |  |
| `to_name` | string | yes |  |
| `travel_time_minutes` | number | no | Minutes the routing engine computed, or null when it could not compute one at all (e.g. beyond its routable distance). |

**`VerifyFeasibilityStatus`**: Feasibility status of one itinerary leg.

One of:

- "feasible" \\| "impossible"
- "implausible": Tight, flagged, but not asserted as impossible (not certain enough to contradict).

**`VerifyPlaceMatch`**: The place a claim matched, when one was found.

| Field | Type | Always present | Description |
|---|---|---|---|
| `id` | string | yes | Stable identifier when the geocoding backend supplies one, otherwise a coordinate-based fallback. |
| `lat` | number | yes |  |
| `lon` | number | yes |  |
| `name` | string | yes |  |
| `category` | string | no |  |

**`VerifyVerdict`**: The three-state verdict, see `crate::verify` module docs for why there is no fourth "does not exist" state and never a boolean.

| Value | Meaning |
|---|---|
| `"verified"` | Matched a real, findable place in the index. |
| `"contradicted"` | A specific, dated, sourced fact contradicts the claim (currently: an itinerary leg the routing engine proves cannot be driven in the stated time). Never used to assert a business does not exist or has closed. |
| `"unverified"` | No evidence either way: no confident name match, or the check could not run. |

**Example** (called 2026-11-05)

```json
{
  "claims": [
    {
      "name": "British Museum",
      "locality": "London"
    }
  ]
}
```

Result (trimmed):

```json
{
  "results": [
    {
      "claim": {
        "locality": "London",
        "name": "British Museum"
      },
      "evidence": [
        "{…}"
      ],
      "id": "claim-1",
      "match": {
        "category": "house",
        "id": "latlon:51.51931,-0.12671",
        "lat": 51.5193118,
        "lon": -0.1267051,
        "name": "British Museum"
      },
      "verdict": "verified"
    }
  ],
  "summary": "1 claim checked: 1 verified, 0 contradicted, 0 unverified. claim-1 (\"British Mus…"
}
```

**More from the server's description:**

Claims sharing increasing `sequence` values and both carrying `claimed_time` (ISO 8601) form itinerary legs checked for feasibility via `matrix`, catching e.g. "breakfast in Bath, 10am meeting in Edinburgh". Max 20 claims per request. The response's `summary` field is a concise plain-text digest (also returned as this tool result's text content) so clients that drop structured/non-text content blocks still see the verdicts.

### `validate_geodata`

Check whether a dataset's DECLARED coordinate reference system actually describes its own coordinates, before you draw it on a map.

**Access:** Keyless, local. Local computation: no key, never metered, answers on any transport.

**Caveats the server states:**

- "EPSG:4326") and a sample of the raw coordinates as {x, y} in the dataset's OWN units, deliberately not named lon/lat, because whether they are degrees is the question.
- This is a sanity check, not a reprojection: it never transforms coordinates.

**Inputs**

| Input | Type | Required | Default | Limits | Description |
|---|---|---|---|---|---|
| `coordinates` | XY[] | yes |  |  | A sample of the dataset's coordinates. A few dozen is plenty; the check is about ranges and spans, not volume. |
| `declared_crs` | string | yes |  |  | The CRS the dataset claims, e.g. `"EPSG:4326"`, `"EPSG:32610"`, `"EPSG:3857"`, `"EPSG:27700"`. |

**`XY`**: One coordinate from the dataset, in the dataset's own units, NOT necessarily degrees. Named `x`/`y` rather than `lon`/`lat` precisely because whether they are degrees is the thing in question.

| Input | Type | Required | Default | Limits | Description |
|---|---|---|---|---|---|
| `x` | number | yes |  |  |  |
| `y` | number | yes |  |  |  |

**Output**

| Field | Type | Always present | Description |
|---|---|---|---|
| `extent` | Extent | yes | Observed extent of the sample, in the dataset's own units. |
| `interpreted_as` | string | yes | The CRS family the declaration was understood as. |
| `problems` | string[] | yes | Plain-language findings, most important first. Empty when consistent. |
| `suggestions` | string[] | yes | What the numbers look like, when they do not match the declaration. |
| `verdict` | Verdict | yes | How much the declared CRS and the coordinates disagree. |

**`Extent`**

| Field | Type | Always present | Description |
|---|---|---|---|
| `max_x` | number | yes |  |
| `max_y` | number | yes |  |
| `min_x` | number | yes |  |
| `min_y` | number | yes |  |

**`Verdict`**: How much the declared CRS and the coordinates disagree.

| Value | Meaning |
|---|---|
| `"consistent"` | Coordinates are consistent with the declared CRS. |
| `"suspect"` | Consistent only under an assumption worth stating (e.g. the values fit, but the axis order looks swapped). |
| `"impossible"` | The declared CRS cannot describe these coordinates at all. |

**Example** (called 2026-11-05)

```json
{
  "declared_crs": "EPSG:4326",
  "coordinates": [
    {
      "x": 51.5,
      "y": -0.12
    },
    {
      "x": 51.6,
      "y": -0.2
    }
  ]
}
```

Result (trimmed):

```json
{
  "extent": {
    "max_x": 51.6,
    "max_y": -0.12,
    "min_x": 51.5,
    "min_y": -0.2
  },
  "interpreted_as": "geographic degrees",
  "problems": [],
  "suggestions": [],
  "verdict": "consistent"
}
```

**More from the server's description:**

Catches the failures that are otherwise silent: swapped lat/lon axes, degrees labelled as metres, and Web Mercator or another projection mislabelled with a UTM or national-grid code. Pass the declared CRS (e.g. Returns a verdict (consistent / suspect / impossible), what is wrong in plain language, and where the numbers actually point when read another way. Local computation: no network call, no quota.

## Map styling

Read, create and restyle hosted map styles, and audit their contrast.

### `list_style_layers`

List everything a MapMap style theme can style: the first-party named bases a style can start from (pass one as `create_style`'s `base`, or as the static-map API's `style=`), the named palette slots with their light/dark default colours, the skeleton layer ids (paint order) that `set_layer_paint` and `set_layer_layout` accept, what the less obvious layers draw (the footpath layers `road-path` and `road-path-*` for footways, cycleways, steps, bridleways, pedestrian streets and tracks, pedestrian squares `pedestrian-areas`) and the slot that colours each, the OpenMapTiles source-layers extra layers may reference, the insertion slots (`bottom`, `middle`, `top`) an extra layer may name, and the rules for a theme's own `sources` (your data by https URL).

**Access:** Keyless, local. Local computation: no key, never metered, answers on any transport.

**Caveats the server states:**

- Attribution is enforced on every compiled style and cannot be themed away.

**Inputs**

No inputs: call it with `{}`.

**Output**

| Field | Type | Always present | Description |
|---|---|---|---|
| `attribution` | string | yes | The attribution contract: enforced on every compiled style, not themable. |
| `bases` | StyleBaseInfo[] | yes | The first-party named bases a style can start from, in display order: pass one to `create_style`'s `base`, or to the static-map API's `style=`. |
| `layer_ids` | string[] | yes | The skeleton layer ids a theme's `layers` may override, in paint order (first = bottom). |
| `palette_slots` | PaletteSlotInfo[] | yes | The named palette slots a theme's `palette` may override, with their light/dark defaults, in presentation order. |
| `source_layers` | string[] | yes | The OpenMapTiles source-layers the tiles emit; an extra layer on the `territory` source must reference one of these. |
| `layer_descriptions` | StyleLayerDescription[] | no | What a layer draws and the palette slot that colours it, for the layers whose id alone does not say (footpaths and pedestrian squares). |
| `slots` | StyleSlotInfo[] | no | The insertion slots an extra layer may name (`"slot"`), bottom of the stack first, with the layer each sits directly below. A layer without a slot goes in `middle`. |
| `sources` | StyleSourcesInfo | no | The rules for a theme's own `sources` (customer data by URL). |

**`StyleBaseInfo`**: One entry in the named-base catalogue (`sn_style::list_bases`).

| Field | Type | Always present | Description |
|---|---|---|---|
| `description` | string | yes | One line saying what the base is for. This is what to choose on. |
| `group` | string | yes | "base" for the two stock slates (`light`, `dark`), "designed" for a drawn style. |
| `id` | string | yes | Base id: what `create_style`'s `base` and the static-map API's `style=` accept, e.g. "navigator-night". |
| `label` | string | yes | Short display label, e.g. "Navigator Night". |
| `restrictions` | boolean | yes | True when the base is drawn to sit under MapMap's vehicle-restriction overlay (only `fleet`). That overlay is a mapmap.ai runtime layer, not tile data and not part of any compiled style, so it draws in Studio and on /map and nowhere else. Asking for this base anywhere else gets the palette alone; for a real restriction answer use `check_clearance_on_route` or `check_adr_tunnel`, which answer for an actual vehicle. |

**`StyleLayerDescription`**: What one skeleton layer draws.

| Field | Type | Always present | Description |
|---|---|---|---|
| `draws` | string | yes | What it draws, in words. |
| `layer_id` | string | yes | The skeleton layer id, e.g. "road-path". |
| `palette_slot` | string | yes | The palette slot that colours it, e.g. "path". |

**`PaletteSlotInfo`**: One themable palette slot with its built-in light/dark defaults.

| Field | Type | Always present | Description |
|---|---|---|---|
| `dark` | string | yes | Default colour on the dark base theme. |
| `light` | string | yes | Default colour on the light base theme. |
| `slot` | string | yes | Slot name, e.g. "water" or "roadMajor". |

**`StyleSlotInfo`**: One insertion slot for extra layers (Mapbox Standard names).

| Field | Type | Always present | Description |
|---|---|---|---|
| `before` | string | yes | The skeleton layer id the slot sits directly below. |
| `description` | string | yes | Where it sits, in words. |
| `slot` | string | yes | `bottom`, `middle` or `top`. |

**`StyleSourcesInfo`**: What a theme's `sources` block accepts.

| Field | Type | Always present | Description |
|---|---|---|---|
| `max_geojson_sources` | integer | yes | At most this many of them `geojson`. |
| `max_sources` | integer | yes | At most this many sources per theme. |
| `types` | string[] | yes | Accepted source types. |
| `url_rules` | string | yes | The URL rules every source URL must pass. |

**Example** (called 2026-11-05)

```json
{}
```

Result (trimmed):

```json
{
  "attribution": "\"© OpenStreetMap contributors © OpenMapTiles\" is stamped on every compiled style…",
  "bases": [
    {
      "description": "The default light base, a clean slate.",
      "group": "base",
      "id": "light",
      "label": "Light",
      "restrictions": false
    },
    {
      "description": "The default dark base, a clean slate.",
      "group": "base",
      "id": "dark",
      "label": "Dark",
      "restrictions": false
    },
    "… 9 more"
  ],
  "layer_descriptions": [
    {
      "draws": "Pedestrian squares, plazas and footway areas (transportation polygons of class p…",
      "layer_id": "pedestrian-areas",
      "palette_slot": "pedestrianArea"
    },
    {
      "draws": "Non-drivable ways that have no layer of their own (class path with subclass path…",
      "layer_id": "road-path",
      "palette_slot": "path"
    },
    "… 6 more"
  ],
  "layer_ids": [
    "background",
    "landcover",
    "… 60 more"
  ],
  "palette_slots": [
    {
      "dark": "#12161c",
      "light": "#f4f2ee",
      "slot": "background"
    },
    {
      "dark": "#1b2c40",
      "light": "#a8c8e8",
      "slot": "water"
    },
    "… 30 more"
  ],
  "slots": [
    {
      "before": "building",
      "description": "above land, landuse and water; below roads and buildings",
      "slot": "bottom"
    },
    {
      "before": "road-oneway",
      "description": "above roads and 3D buildings; below every label (the default)",
      "slot": "middle"
    },
    "… 1 more"
  ],
  "source_layers": [
    "aerodrome_label",
    "aeroway",
    "… 14 more"
  ],
  "sources": {
    "max_geojson_sources": 8,
    "max_sources": 15,
    "types": [
      "geojson",
      "vector",
      "… 2 more"
    ],
    "url_rules": "https:// only (pmtiles://https:// for a vector or raster archive url); no user n…"
  }
}
```

**More from the server's description:**

Local lookup, no network; always works.

### `get_style`

Fetch a hosted style's latest theme document (the editable source) and the URL of its latest compiled MapLibre style.

**Access:** Keyless. Public style read: no key, never metered.

**Inputs**

| Input | Type | Required | Default | Limits | Description |
|---|---|---|---|---|---|
| `style_id` | string | yes |  |  | Hosted style id, e.g. "midnight-fleet-a1b2c3". |

**Output**

| Field | Type | Always present | Description |
|---|---|---|---|
| `style_id` | string | yes | Hosted style id. |
| `style_url` | string | yes | URL of the latest compiled MapLibre style (point MapLibre GL at it). |
| `theme` | any JSON | yes | The latest theme document, the editable source the next version is published from. |

**Example** (called 2026-11-05)

```json
{
  "style_id": "mapmap-light-76ef8d"
}
```

Result (trimmed):

```json
{
  "style_id": "mapmap-light-76ef8d",
  "style_url": "https://api.mapmap.ai/styles/mapmap-light-76ef8d.json",
  "theme": {
    "base": "light",
    "name": "MapMap Light"
  }
}
```

**More from the server's description:**

Use the theme to inspect current palette and layer overrides before editing.

### `create_style`

Create a hosted map style. Provide a name, optionally a named `base` to start from (light, dark, streets, midnight, navigator-day, navigator-night, fleet, outdoor, dataviz, backdrop, print - call list_style_layers for what each one is for), and optionally a theme document ({base: "light"|"dark", palette: {slot: colour}, layers: {layer_id: overrides}}).

**Access:** Your own key. Metered publish. Refused on the hosted endpoint unless you present your own `snk_` key; a self-hosted server publishes with its own `STUDIO_API_KEY`.

**Caveats the server states:**

- A theme may also set worldview (ISO 3166-1 alpha-2, accepted: AE, KR, SA, US) to display that jurisdiction's official names for a small curated registry of renamed features; omitted, labels keep the OSM on-the-ground names.

**Inputs**

| Input | Type | Required | Default | Limits | Description |
|---|---|---|---|---|---|
| `name` | string | yes |  |  | Human-readable style name; its kebab-case slug seeds the style id. |
| `base` | string | no | light theme as it always did |  | Optional named base to start from, e.g. "streets", "midnight", "navigator-night", "fleet". Call `list_style_layers` for the catalogue, including which bases expect an overlay of their own. The base's whole document is laid down first and `theme` is merged over it, so a base plus two palette entries is a complete style. Omitted, the style starts from the default light theme as it always did. |
| `theme` | any JSON | no | theme outright |  | Optional theme document (palette/layer overrides). Merged over `base` when one is given; on its own, it replaces the default theme outright. |

**Output**

| Field | Type | Always present | Description |
|---|---|---|---|
| `style_id` | string | yes | The generated style id, pass it to `set_palette`, `set_layer_paint` and `get_style`. |
| `style_url` | string | yes | Immutable URL of the compiled style at this version. |
| `theme` | any JSON | yes | The theme document that was stored. |
| `version` | integer | yes | The published version (always 1 on create). |

**Example**

```json
{
  "name": "Streets, darker water",
  "base": "streets",
  "theme": {
    "palette": {
      "water": "#14243a"
    }
  }
}
```

Not called in the live check, because it writes or sends. The result follows the output schema above.

**More from the server's description:**

The named base is laid down first and the theme document is merged over it, so "streets with darker water" is one call; with neither, the style starts from the default theme. Returns the generated style_id (pass it to set_palette / set_layer_paint) and the compiled style URL for MapLibre.

### `set_palette`

Recolour one or more palette slots of a hosted style (e.g. {"water": "#0b2038", "roadMajor": "#8a6d3b"}) and publish the result as a new immutable style version.

**Access:** Your own key. Metered publish. Refused on the hosted endpoint unless you present your own `snk_` key; a self-hosted server publishes with its own `STUDIO_API_KEY`.

**Inputs**

| Input | Type | Required | Default | Limits | Description |
|---|---|---|---|---|---|
| `colours` | map of string | yes |  |  | Palette overrides: slot name → CSS colour (e.g. {"water": "#0b2038"}). Call `list_style_layers` for the slot names. |
| `style_id` | string | yes |  |  | Hosted style id. |

**Output**

| Field | Type | Always present | Description |
|---|---|---|---|
| `style_id` | string | yes | Hosted style id. |
| `style_url` | string | yes | Immutable URL of the compiled style at this version. |
| `version` | integer | yes | The newly published version. |

**Example**

```json
{
  "style_id": "midnight-fleet-a1b2c3",
  "colours": {
    "water": "#0b2038"
  }
}
```

Not called in the live check, because it writes or sends. The result follows the output schema above.

**More from the server's description:**

Slot names come from list_style_layers; colours are CSS (#rgb/#rrggbb/#rrggbbaa/rgb()/hsl()). Returns the new version and style URL.

### `set_layer_paint`

Set one MapLibre paint property on one skeleton layer of a hosted style (e.g. layer_id "road-major", property "line-width", value 4 or an expression array; null removes the property, e.g. line-dasharray null draws the dashed road-path solid) and publish the result as a new immutable style version.

**Access:** Your own key. Metered publish. Refused on the hosted endpoint unless you present your own `snk_` key; a self-hosted server publishes with its own `STUDIO_API_KEY`.

**Inputs**

| Input | Type | Required | Default | Limits | Description |
|---|---|---|---|---|---|
| `layer_id` | string | yes |  |  | Skeleton layer id, e.g. "road-major". Call `list_style_layers` for the accepted ids. |
| `property` | string | yes |  |  | MapLibre paint property name, e.g. "line-width" or "fill-color". |
| `style_id` | string | yes |  |  | Hosted style id. |
| `value` | any JSON | yes |  |  | The paint value (any MapLibre-valid JSON: number, colour string or expression array), or null to remove the property (for example `line-dasharray` null draws a dashed skeleton layer solid). |

**Output**

| Field | Type | Always present | Description |
|---|---|---|---|
| `style_id` | string | yes | Hosted style id. |
| `style_url` | string | yes | Immutable URL of the compiled style at this version. |
| `version` | integer | yes | The newly published version. |

**Example**

```json
{
  "style_id": "midnight-fleet-a1b2c3",
  "layer_id": "road-path",
  "property": "line-dasharray",
  "value": null
}
```

Not called in the live check, because it writes or sends. The result follows the output schema above.

**More from the server's description:**

Layer ids come from list_style_layers. Returns the new version and style URL.

### `set_layer_layout`

Set one MapLibre layout property on one skeleton layer of a hosted style (e.g. layer_id "road-path", property "line-cap", value "round"; null removes the property) and publish the result as a new immutable style version.

**Access:** Your own key. Metered publish. Refused on the hosted endpoint unless you present your own `snk_` key; a self-hosted server publishes with its own `STUDIO_API_KEY`.

**Inputs**

| Input | Type | Required | Default | Limits | Description |
|---|---|---|---|---|---|
| `layer_id` | string | yes |  |  | Skeleton layer id, e.g. "road-path". Call `list_style_layers` for the accepted ids. |
| `property` | string | yes |  |  | MapLibre layout property name, e.g. "line-cap", "line-join" or "visibility". |
| `style_id` | string | yes |  |  | Hosted style id. |
| `value` | any JSON | yes |  |  | The layout value (any MapLibre-valid JSON, e.g. "round"), or null to remove the property and fall back to the skeleton's. |

**Output**

| Field | Type | Always present | Description |
|---|---|---|---|
| `style_id` | string | yes | Hosted style id. |
| `style_url` | string | yes | Immutable URL of the compiled style at this version. |
| `version` | integer | yes | The newly published version. |

**Example**

```json
{
  "style_id": "midnight-fleet-a1b2c3",
  "layer_id": "road-path",
  "property": "line-cap",
  "value": "round"
}
```

Not called in the live check, because it writes or sends. The result follows the output schema above.

**More from the server's description:**

Pair it with set_layer_paint for line patterns: dotted footpaths are road-path line-dasharray [0, 2] plus line-cap round. Layer ids come from list_style_layers. Returns the new version and style URL.

### `check_style_contrast`

Audit a map style's colour contrast against WCAG 2.1 (4.5:1 for label text, 3:1 for graphics like the route line), across both the light and dark palette variants.

**Access:** Keyless, local. Local check, no key, never metered; with a `style_id` it also reads that hosted style (a public read).

**Caveats the server states:**

- Advisory: failing pairs list the palette slots to adjust with set_palette; publishing is never blocked on contrast.

**Inputs**

| Input | Type | Required | Default | Limits | Description |
|---|---|---|---|---|---|
| `style_id` | string | no |  |  | Hosted style id whose latest theme should be checked… |
| `theme` | any JSON | no |  |  | …or an inline theme document (as accepted by `create_style`). Exactly one of `style_id`/`theme` must be given. |

**Output**

| Field | Type | Always present | Description |
|---|---|---|---|
| `findings` | ContrastFindingInfo[] | yes | Every audited pair, failing pairs first. |
| `passes` | boolean | yes | True when every audited pair meets its WCAG threshold. |

**`ContrastFindingInfo`**: One audited colour pair of a contrast report.

| Field | Type | Always present | Description |
|---|---|---|---|
| `background` | string | yes | Effective backing palette slot. |
| `description` | string | yes | Why this pair matters cartographically. |
| `foreground` | string | yes | Foreground palette slot. |
| `kind` | string | yes | Pair kind: "text" (threshold 4.5) or "graphics" (threshold 3.0). |
| `passes` | boolean | yes | Whether the pair meets its threshold. |
| `ratio` | number | yes | Measured WCAG 2.1 contrast ratio. |
| `threshold` | number | yes | The WCAG threshold applied. |
| `variant` | string | yes | Palette variant audited: "light" or "dark". |

**Example** (called 2026-11-05)

```json
{
  "style_id": "mapmap-light-76ef8d"
}
```

Result (trimmed):

```json
{
  "findings": [
    {
      "background": "water",
      "description": "water names on the water fill",
      "foreground": "textSecondary",
      "kind": "text",
      "passes": false,
      "ratio": 3.07,
      "threshold": 4.5,
      "variant": "light"
    },
    {
      "background": "roadMajor",
      "description": "road labels on the major-road fill",
      "foreground": "textSecondary",
      "kind": "text",
      "passes": false,
      "ratio": 3.9,
      "threshold": 4.5,
      "variant": "light"
    },
    "… 14 more"
  ],
  "passes": false
}
```

**More from the server's description:**

Pass a hosted `style_id` OR an inline `theme` document (as accepted by create_style).

## Geo helpers

Local geodesic geometry on WGS84: no network, no quota.

### `geo_distance`

Distance in metres between two coordinates. Computed geodesically on the WGS84 ellipsoid, so it is the straight-line (as-the-crow-flies) distance, NOT a driving distance, use `route` or `matrix` for travel distance and time.

**Access:** Keyless, local. Local computation: no key, never metered, answers on any transport.

**Inputs**

| Input | Type | Required | Default | Limits | Description |
|---|---|---|---|---|---|
| `from` | [LatLon](#latlon) | yes |  |  | Start coordinate. |
| `to` | [LatLon](#latlon) | yes |  |  | End coordinate. |

**Output**

| Field | Type | Always present | Description |
|---|---|---|---|
| `distance_m` | number | yes | Distance in metres along the WGS84 ellipsoid. |

**Example** (called 2026-11-05)

```json
{
  "from": {
    "lat": 51.5007,
    "lon": -0.1246
  },
  "to": {
    "lat": 51.5308,
    "lon": -0.1238
  }
}
```

Result (trimmed):

```json
{
  "distance_m": 3349.3302497533177
}
```

**More from the server's description:**

Local computation: no network call, no quota.

### `geo_bearing`

Initial bearing from one coordinate to another, in degrees clockwise from true north (0-360).

**Access:** Keyless, local. Local computation: no key, never metered, answers on any transport.

**Inputs**

| Input | Type | Required | Default | Limits | Description |
|---|---|---|---|---|---|
| `from` | [LatLon](#latlon) | yes |  |  | Start coordinate. |
| `to` | [LatLon](#latlon) | yes |  |  | End coordinate. |

**Output**

| Field | Type | Always present | Description |
|---|---|---|---|
| `bearing_deg` | number | yes | Initial bearing from `from` to `to`, degrees clockwise from true north, normalised to 0–360. Note this is the bearing at the start of the geodesic: over long distances the bearing changes en route. |

**Example** (called 2026-11-05)

```json
{
  "from": {
    "lat": 51.5007,
    "lon": -0.1246
  },
  "to": {
    "lat": 51.5308,
    "lon": -0.1238
  }
}
```

Result (trimmed):

```json
{
  "bearing_deg": 0.9497178516508029
}
```

**More from the server's description:**

This is the bearing at the START of the geodesic; over long distances the bearing changes en route. Local computation: no network call, no quota.

### `geo_destination`

The coordinate reached by travelling `distance_m` metres from `from` on `bearing_deg` (degrees clockwise from true north).

**Access:** Keyless, local. Local computation: no key, never metered, answers on any transport.

**Inputs**

| Input | Type | Required | Default | Limits | Description |
|---|---|---|---|---|---|
| `bearing_deg` | number | yes |  |  | Bearing in degrees clockwise from true north. |
| `distance_m` | number | yes |  |  | Distance to travel in metres. |
| `from` | [LatLon](#latlon) | yes |  |  | Starting coordinate. |

**Output**

| Field | Type | Always present | Description |
|---|---|---|---|
| `point` | [LatLon](#latlon) | yes | The computed coordinate. |

**Example** (called 2026-11-05)

```json
{
  "from": {
    "lat": 51.5007,
    "lon": -0.1246
  },
  "bearing_deg": 90,
  "distance_m": 1000
}
```

Result (trimmed):

```json
{
  "point": {
    "lat": 51.500699115986265,
    "lon": -0.11019896505598184
  }
}
```

**More from the server's description:**

The inverse of `geo_distance` + `geo_bearing`. Local computation: no network call.

### `geo_point_in_polygon`

Whether a coordinate lies inside a polygon: delivery zones, catchments, congestion or clean-air zones, site boundaries.

**Access:** Keyless, local. Local computation: no key, never metered, answers on any transport.

**Caveats the server states:**

- Provide `point` {lat, lon} and `polygon` as 3+ {lat, lon} coordinates of the outer ring (closed automatically if the last does not repeat the first).

**Inputs**

| Input | Type | Required | Default | Limits | Description |
|---|---|---|---|---|---|
| `point` | [LatLon](#latlon) | yes |  |  | The coordinate to test. |
| `polygon` | [LatLon](#latlon)[] | yes |  |  | The polygon's outer ring, 3 or more coordinates. Closed automatically if the last point does not repeat the first. |

**Output**

| Field | Type | Always present | Description |
|---|---|---|---|
| `inside` | boolean | yes | True when the point is strictly inside the ring. Points exactly on the boundary are **not** counted as inside. |

**Example** (called 2026-11-05)

```json
{
  "point": {
    "lat": 51.5007,
    "lon": -0.1246
  },
  "polygon": [
    [
      -0.13,
      51.49
    ],
    [
      -0.11,
      51.49
    ],
    [
      -0.11,
      51.51
    ],
    [
      -0.13,
      51.51
    ]
  ]
}
```

Result (trimmed):

```json
{
  "inside": true
}
```

**More from the server's description:**

Points exactly on the boundary count as OUTSIDE. Local computation: no network call, no quota.

### `geo_bbox`

The axis-aligned bounding box enclosing 1+ coordinates, as {min_lat, min_lon, max_lat, max_lon}.

**Access:** Keyless, local. Local computation: no key, never metered, answers on any transport.

**Inputs**

| Input | Type | Required | Default | Limits | Description |
|---|---|---|---|---|---|
| `points` | [LatLon](#latlon)[] | yes |  |  | The coordinates to consider. |

**Output**

| Field | Type | Always present | Description |
|---|---|---|---|
| `max_lat` | number | yes | Maximum latitude (north edge). |
| `max_lon` | number | yes | Maximum longitude (east edge). |
| `min_lat` | number | yes | Minimum latitude (south edge). |
| `min_lon` | number | yes | Minimum longitude (west edge). |

**Example** (called 2026-11-05)

```json
{
  "points": [
    {
      "lat": 51.5007,
      "lon": -0.1246
    },
    {
      "lat": 51.5308,
      "lon": -0.1238
    }
  ]
}
```

Result (trimmed):

```json
{
  "max_lat": 51.5308,
  "max_lon": -0.1238,
  "min_lat": 51.5007,
  "min_lon": -0.1246
}
```

**More from the server's description:**

Useful for fitting a map view to a set of stops. Local computation: no network call, no quota.

### `geo_centroid`

The centroid (geometric mean position) of 1+ coordinates, e.g. to pick a depot location or centre a map.

**Access:** Keyless, local. Local computation: no key, never metered, answers on any transport.

**Inputs**

| Input | Type | Required | Default | Limits | Description |
|---|---|---|---|---|---|
| `points` | [LatLon](#latlon)[] | yes |  |  | The coordinates to consider. |

**Output**

| Field | Type | Always present | Description |
|---|---|---|---|
| `point` | [LatLon](#latlon) | yes | The computed coordinate. |

**Example** (called 2026-11-05)

```json
{
  "points": [
    {
      "lat": 51.5007,
      "lon": -0.1246
    },
    {
      "lat": 51.5308,
      "lon": -0.1238
    }
  ]
}
```

Result (trimmed):

```json
{
  "point": {
    "lat": 51.51575,
    "lon": -0.1242
  }
}
```

**More from the server's description:**

Local computation: no network call, no quota.

### `geo_length`

Total length in metres of a polyline through 2+ coordinates, summed geodesically. This measures the line you supply, NOT a driven route , use `route` for that.

**Access:** Keyless, local. Local computation: no key, never metered, answers on any transport.

**Inputs**

| Input | Type | Required | Default | Limits | Description |
|---|---|---|---|---|---|
| `points` | [LatLon](#latlon)[] | yes |  |  | The coordinates to consider. |

**Output**

| Field | Type | Always present | Description |
|---|---|---|---|
| `length_m` | number | yes | Total geodesic length in metres. |

**Example** (called 2026-11-05)

```json
{
  "points": [
    {
      "lat": 51.5007,
      "lon": -0.1246
    },
    {
      "lat": 51.5308,
      "lon": -0.1238
    }
  ]
}
```

Result (trimmed):

```json
{
  "length_m": 3349.3302497533177
}
```

**More from the server's description:**

Local computation: no network call, no quota.

### `geo_area`

Area in square metres enclosed by a ring of 3+ coordinates, computed geodesically. Always positive: the answer does not depend on whether the ring is wound clockwise or anticlockwise.

**Access:** Keyless, local. Local computation: no key, never metered, answers on any transport.

**Caveats the server states:**

- Intended for zones and boundaries, not for polygons covering more than half the globe.

**Inputs**

| Input | Type | Required | Default | Limits | Description |
|---|---|---|---|---|---|
| `points` | [LatLon](#latlon)[] | yes |  |  | The coordinates to consider. |

**Output**

| Field | Type | Always present | Description |
|---|---|---|---|
| `area_sq_m` | number | yes | Enclosed area in square metres, always positive regardless of winding order. |

**Example** (called 2026-11-05)

```json
{
  "points": [
    [
      -0.13,
      51.49
    ],
    [
      -0.11,
      51.49
    ],
    [
      -0.11,
      51.51
    ]
  ]
}
```

Result (trimmed):

```json
{
  "area_sq_m": 1545498.4211921692
}
```

**More from the server's description:**

Local computation: no network call, no quota.

### `geo_simplify`

Reduce the number of coordinates in a polyline while keeping its shape (Douglas-Peucker). `tolerance_deg` is in DEGREES, not metres: about 0.0001 drops detail finer than roughly 10 m at the equator.

**Access:** Keyless, local. Local computation: no key, never metered, answers on any transport.

**Inputs**

| Input | Type | Required | Default | Limits | Description |
|---|---|---|---|---|---|
| `points` | [LatLon](#latlon)[] | yes |  |  | The polyline's coordinates, 2 or more. |
| `tolerance_deg` | number | yes |  |  | Douglas-Peucker tolerance in **degrees**, not metres. Around 0.0001 drops detail finer than roughly 10 m at the equator. |

**Output**

| Field | Type | Always present | Description |
|---|---|---|---|
| `points` | [LatLon](#latlon)[] | yes | The retained coordinates, endpoints always preserved. |
| `removed` | integer | yes | How many coordinates were removed. |

**Example** (called 2026-11-05)

```json
{
  "points": [
    {
      "lat": 51.5007,
      "lon": -0.1246
    },
    {
      "lat": 51.51,
      "lon": -0.1245
    },
    {
      "lat": 51.5308,
      "lon": -0.1238
    }
  ],
  "tolerance_deg": 0.001
}
```

Result (trimmed):

```json
{
  "points": [
    {
      "lat": 51.5007,
      "lon": -0.1246
    },
    {
      "lat": 51.5308,
      "lon": -0.1238
    }
  ],
  "removed": 1
}
```

**More from the server's description:**

Endpoints are always kept. Returns the retained points and how many were removed. Local computation: no network call, no quota.

### `geo_nearest_point_on_line`

The closest position on a polyline to a given coordinate, plus the geodesic distance to it in metres.

**Access:** Keyless, local. Local computation: no key, never metered, answers on any transport.

**Caveats the server states:**

- The answer may lie between vertices, not only on them.

**Inputs**

| Input | Type | Required | Default | Limits | Description |
|---|---|---|---|---|---|
| `line` | [LatLon](#latlon)[] | yes |  |  | The polyline's coordinates, 2 or more. |
| `point` | [LatLon](#latlon) | yes |  |  | The coordinate to measure from. |

**Output**

| Field | Type | Always present | Description |
|---|---|---|---|
| `distance_m` | number | yes | Geodesic distance from the input point to that position, metres. |
| `point` | [LatLon](#latlon) | yes | The closest position on the line, which may lie between vertices. |

**Example** (called 2026-11-05)

```json
{
  "point": {
    "lat": 51.515,
    "lon": -0.12
  },
  "line": [
    {
      "lat": 51.5007,
      "lon": -0.1246
    },
    {
      "lat": 51.5308,
      "lon": -0.1238
    }
  ]
}
```

Result (trimmed):

```json
{
  "distance_m": 292.99648720985147,
  "point": {
    "lat": 51.515112078530855,
    "lon": -0.12421695472343242
  }
}
```

**More from the server's description:**

Useful for 'how far is this address from the route?'. Local computation: no network call, no quota.

## Jobs

The asynchronous lane for problems too large for one request.

### `submit_optimise_job`

Submit a problem too large to solve inside one request to the asynchronous lane, and get a job id back.

**Access:** Keyless. Metered on submission (refunded if the job fails). Not called in the live check, because it queues work.

**Caveats the server states:**

- Set `kind` to "optimise", "replan", "matrix" or "coverage".
- A field that tool's input does not have is REFUSED by name rather than dropped: the HTTP API accepts some the MCP tools have not surfaced yet, and a job queued without a constraint you asked for is worse than one that was never queued.
- "coverage" plans one drive that passes along EVERY street in an area at least once, in the legal direction, split into outings (the Chinese postman problem on the real road network): survey capture, street sweeping, gritting, refuse rounds.
- It has no synchronous tool; its `problem` is the POST /v1/jobs/coverage body: exactly one of `bbox` ([min_lon, min_lat, max_lon, max_lat]) or `polygon` (GeoJSON), and optionally `costing` ("auto" or "truck"), `optimise` ("distance", "time" or "balanced"), `road_classes` {include, exclude}, `include_private`, `turn_penalties` {avoid_left, avoid_right, avoid_u_turn} in metres per turn, `chunk_km` (outing length, default 15), `start` {lat, lon}, `return_to_start`, `chunk_loops`, `loop_radius_m`, `exclude` and `avoid`.
- `loop_radius_m` (0 to 2,000 m, only with `chunk_loops`) lets an outing end within that distance of its start when that saves driving.
- `exclude.way_ids` lists OpenStreetMap way ids not to cover, taken from the `way_id` of a previous plan's `steps`; `exclude.areas` is a GeoJSON polygon, and a street more than half inside it is not covered.
- `avoid` is a hard block, never driven, not even to connect streets or between outings: `avoid.way_ids` closes whole ways both ways; `avoid.locations` [{lat, lon, radius_m?, heading?, heading_tolerance?}] closes the street a point is on (never the cross street; within 3 m of a junction it needs `heading`, else it closes nothing and comes back in `summary.avoid_unmatched`), one direction with `heading`, or every street within `radius_m` (0 to 100).
- After planning, every turn the graph forbids and every U-turn where a street merely continues, on a bridge or on a tertiary or bigger road is replaced by the cheapest legal way round (deadhead); other U-turns not at a dead end are replaced when that costs less than `avoid_u_turn`.
- The result (from `get_job`) has `chunks` (the outings) with ordered `steps` (`way_id`, `name`, `deadhead`) and a ready `trace_route` body each, plus `trace_route_parts`, the same replay cut into pieces that never pass a junction twice (the engine's exact edge walk can refuse a drive that comes back through a junction as one piece; the pieces it walks).
- A client turns one outing or transfer into turn-by-turn guidance with one call to the HTTP API's POST /v1/coverage/guidance (job id and outing id): one trip, one leg per piece, never map matched.
- And a `summary` including dropped and excluded streets and `restricted_turns` (only the restricted turns and refused U-turns no legal way round could replace, with where and why; `summary.turns` counts the ones replaced and the km they added).
- A re-plan is counted on the REMAINING problem, after completed stops are removed, so a shift well through its day may fit where the morning's would not.
- This answers 202-and-a-job-id, NOT a plan: the job is queued and a worker picks it up.
- Polling is free, the gateway meters this submission, not the reads.
- The optional `webhook_url` (https only) posts a SIGNED notification when the job finishes and is for a human wiring infrastructure that must react without a process watching; it carries a pointer, never the result, and needs a webhook signing secret on the key.
- An agent that can poll should not use it.

**Inputs**

| Input | Type | Required | Default | Limits | Description |
|---|---|---|---|---|---|
| `kind` | JobKind | yes |  |  | Which problem this is. It selects both the body shape below and the ceiling the submission is judged against. |
| `problem` | any JSON | yes |  |  | The problem itself, in exactly the shape the synchronous tool takes , `optimise_routes` input for `optimise`, `replan_routes` input for `replan`, `matrix` input for `matrix`. Moving a working synchronous call onto this lane changes nothing but the tool you call it with. `coverage` has no synchronous tool: its problem is the `POST /v1/jobs/coverage` body (`bbox` or `polygon`, `costing`, `optimise`, `road_classes`, `include_private`, `turn_penalties`, `chunk_km`, `start`, `return_to_start`, `chunk_loops`, `loop_radius_m`, `exclude`, `avoid`), without `webhook_url`, which is this tool's own field. |
| `webhook_url` | string | no |  |  | Optional HTTPS URL to POST a signed `{job_id, kind, status, result_url}` notification to when the job finishes. The RESULT is never pushed, the notification says where to fetch it. Requires a webhook signing secret on the key; without one the submission is refused rather than delivered unsigned. An agent that can poll does not need this: polling with `get_job` is free. |

**`JobKind`**: Which asynchronous problem is being submitted.

| Value | Meaning |
|---|---|
| `"optimise"` | A fleet optimisation, the `optimise_routes` problem, at ten times the synchronous location cap. |
| `"replan"` | A mid-shift re-plan, the `replan_routes` problem, counted on the remaining work. |
| `"matrix"` | A many-to-many time/distance matrix, the `matrix` problem, at four times the synchronous element cap. |
| `"coverage"` | A drive-every-street plan (the Chinese postman problem on the road network): one drive along every street in an area, split into outings. There is no synchronous tool for it, so `problem` is the `POST /v1/jobs/coverage` body itself, see `CoverageProblem`. |

**Output**

| Field | Type | Always present | Description |
|---|---|---|---|
| `id` | string | yes | The job id. Pass it to `get_job` to poll. |
| `kind` | string | yes | `optimise`, `replan`, `matrix` or `coverage`. |
| `next` | string | yes | What to do next, in one sentence: poll `get_job`, and how often is reasonable. |
| `result_url` | string | yes | The HTTP URL this job (and its result) can be fetched from. The same URL a webhook carries. `get_job` is the tool that reads it. |
| `status` | string | yes | Always `queued`, no worker has looked at it yet. |
| `units_charged` | integer | yes | Quota units this submission drew. Charged now, handed back in full if the job fails. |

**Example**

```json
{
  "kind": "matrix",
  "problem": {
    "origins": [
      {
        "lat": 51.5007,
        "lon": -0.1246
      }
    ],
    "destinations": [
      {
        "lat": 51.5308,
        "lon": -0.1238
      }
    ],
    "costing": "auto"
  }
}
```

Not called in the live check, because it writes or sends. The result follows the output schema above.

**More from the server's description:**

For the first three, pass `problem` in EXACTLY the shape the matching synchronous tool takes, `optimise_routes` input, `replan_routes` input, or `matrix` input. Moving a working synchronous call onto this lane changes nothing but which tool you call it with. `chunk_loops: true` makes every outing a closed loop that starts and ends at the same junction, with the drive between outings planned as a separate route (each chunk's `transfer_from_previous`, plus `transfer_to_start` with `return_to_start`); `walk_km` excludes those transfers (`summary.transfer_km` has them), and `summary.outings_outside_target` names any outing outside 0.75 to 1.25 times `chunk_km` and why. An excluded street may still be driven to connect others, as a deadhead step. Ids that no longer match the graph are listed back in the result's `summary.exclude_unmatched_way_ids`. Streets to cover that a block closes or cuts off are listed in `summary.dropped_ways` with a reason (`avoided`, `unreachable_after_avoid`, `no_legal_loop`). Billed per started square kilometre: charged on the area's bounding box, settled on success to the polygon's own area or its street floor, whichever is larger (the result's `billing`); the gateway's area, edge and time limits apply. The ceilings are far higher here because there is no request to hold open: 2,000 unique locations for an optimisation or re-plan against the synchronous 200, and 40,000 matrix elements against 10,000 (a deployment may set either lower, in which case its own refusal is the authority). Poll `get_job` with the returned id until it says the status is terminal, then read the result. Units are charged on submission and handed back in full if the job fails. Requires the MapMap gateway.

### `get_job`

Read an asynchronous job submitted with `submit_optimise_job`: its status, and once it has finished, its result inline, exactly the body the synchronous tool would have returned.

**Access:** Keyless. Answers without a key on the hosted endpoint; polling is never metered. Another key's job reads as not found.

**Caveats the server states:**

- POLLING IS FREE: the gateway meters the submission and not the reads, deliberately, because a poll that costs quota is a poll a caller rations, and a rationed poll is how a job that finished in ten seconds gets noticed four minutes later.
- Webhooks are the alternative to polling and exist for humans wiring infrastructure, not for agents in a loop.
- A job belongs to the key that submitted it (or another key of the same identity); anyone else's id answers NOT FOUND rather than forbidden, because confirming an id exists is itself a disclosure.

**Inputs**

| Input | Type | Required | Default | Limits | Description |
|---|---|---|---|---|---|
| `id` | string | yes |  |  | The job id returned by `submit_optimise_job`. |

**Output**

| Field | Type | Always present | Description |
|---|---|---|---|
| `created_at` | string | yes | RFC 3339 UTC submission time. |
| `id` | string | yes | The job id. |
| `kind` | string | yes | `optimise`, `replan`, `matrix` or `coverage`. |
| `refunded` | boolean | yes | Whether a failure refunded the submission's units. |
| `status` | string | yes | `queued`, `running`, `succeeded` or `failed`. Only `succeeded` and `failed` are terminal; keep polling on the other two. |
| `terminal` | boolean | yes | Whether `status` is one this job will never leave. |
| `units_charged` | integer | yes | Metered units the SUBMISSION drew. This is what was charged; `refunded` says whether it came back. |
| `error` | string | no | Why it failed, once `status` is `failed`. |
| `finished_at` | string | no | RFC 3339 UTC time it finished, either way. |
| `result` | any JSON | no | The answer, inline, once `status` is `succeeded`, exactly the body the synchronous tool would have returned (for `coverage`, which has none, the gateway's plan unchanged). A matrix job answers `durations_s` and `distances_m`, as the `matrix` tool does; it also still carries the same numbers as `durations` and `distances`, which are DEPRECATED and will be removed in a future release. |
| `started_at` | string | no | RFC 3339 UTC time a worker picked it up. |
| `webhook_status` | string | no | `delivered` or `delivery_failed`, once a webhook was attempted. |

**Example** (called 2026-11-05)

```json
{
  "id": "job_does_not_exist_docs_audit"
}
```

The hosted endpoint answered with this tool error:

```text
gateway /v1/jobs/job_does_not_exist_docs_audit answered HTTP 404: not found: no job job_does_not_exist_docs_audit
```

**More from the server's description:**

Status is `queued`, `running`, `succeeded` or `failed`; the answer's `terminal` field says whether the job will ever leave the status it is in, so poll while that is false. Check every few seconds rather than guessing at a duration. `units_charged` is what the SUBMISSION drew, and `refunded` says whether a failure handed it back, a failed job shows both, because reporting zero would be a lie about what was charged. Requires the MapMap gateway.

## Account and usage

What your own key has spent.

### `get_usage`

Check what your own API key has spent, so you can decide mid-task whether to keep going. Reading it is free: it costs no quota.

**Access:** Your own key. Needs your own key (`snk_` or an OAuth `sno_` token): it reports on the key that authenticates the call and refuses keyless calls on the hosted endpoint. Reading it costs no quota.

**Caveats the server states:**

- Everything is counted in UNITS, weighted quota units, where a heavier endpoint costs more than one unit per request, so never report these figures as a number of calls.
- When `identity_pooled` is true the quota is shared with the other keys belonging to the same owner, so these figures are not yours alone.
- On the hosted endpoint, connect with your own key (a bearer `snk_` key or OAuth sign-in): an anonymous connection is refused rather than shown the server operator's usage.

**Inputs**

| Input | Type | Required | Default | Limits | Description |
|---|---|---|---|---|---|
| `from` | string | no | the first day of the current month |  | First day to report, `YYYY-MM-DD` (UTC) inclusive. Defaults to the first day of the current month. At most 92 days may separate `from` and `to`. |
| `to` | string | no | today |  | Last day to report, `YYYY-MM-DD` (UTC) inclusive. Defaults to today. |

**Output**

| Field | Type | Always present | Description |
|---|---|---|---|
| `balance_millipence` | integer | yes | Prepaid balance in millipence (thousandths of a penny), for usage beyond the monthly allowance. |
| `days` | UsageDay[] | yes | Per-day breakdown over the range, oldest first. A day with no usage is absent rather than reported as zero. |
| `from` | string | yes | First day covered, `YYYY-MM-DD` inclusive. |
| `identity_pooled` | boolean | yes | Whether quota is pooled across every key belonging to the same identity. When true, these figures are the identity's shared consumption, so another key of the same owner also spends them. |
| `key_id` | string | yes | Identifier of the key this usage belongs to. |
| `month_used_units` | integer | yes | Units counted against the monthly quota right now: the very number the quota check enforces on, independent of `from`/`to`. Quota is monthly, so this (not `total_units`) is what to compare with `monthly_quota_units`. It includes units recorded but not yet written to the daily counters, so over a whole-month range it can exceed `total_units`; that is not a discrepancy. |
| `monthly_quota_units` | integer | yes | The key's monthly allowance in units. |
| `to` | string | yes | Last day covered, `YYYY-MM-DD` inclusive. |
| `total_units` | integer | yes | Total units over the whole range. |
| `totals` | UsageEndpointTotal[] | yes | Units per endpoint over the whole range. |

**`UsageDay`**: One day's usage, in units per endpoint.

| Field | Type | Always present | Description |
|---|---|---|---|
| `day` | string | yes | The day, `YYYY-MM-DD`. |
| `endpoints` | UsageEndpointTotal[] | yes | Units per endpoint on this day, heaviest first. |
| `total_units` | integer | yes | Total units on this day. |

**`UsageEndpointTotal`**: Units attributed to one endpoint.

| Field | Type | Always present | Description |
|---|---|---|---|
| `endpoint` | string | yes | The endpoint label the meter records, e.g. `/route`, `/geocode`, `/tiles`. Labels are collapsed by the meter, so several request shapes can share one. |
| `units` | integer | yes | Weighted quota units, never a count of calls. |

**Example** (called 2026-11-05)

```json
{}
```

Answers with your own key's usage, so no result is shown here.

**More from the server's description:**

Optional `from` and `to` (YYYY-MM-DD UTC, inclusive, at most 92 days apart) bound the report; omitted, it covers the current month to date. Returns `days` (per-day, per-endpoint), `totals` per endpoint over the range, `total_units`, plus `month_used_units` against `monthly_quota_units` and the prepaid `balance_millipence` (thousandths of a penny). The key that authenticates the call is the key reported on: there is no way to read another caller's usage. Needs the MapMap gateway (GATEWAY_URL + GATEWAY_API_KEY).

## Feedback and operator tools

Map corrections, integration retros and the operator-only closure write.

### `report_map_issue`

Report that the live world disagrees with the map, a closed road, a wrong or missing restriction, a bad speed limit, a missing road, a wrong one-way, or changed access.

**Access:** Keyless. Appends to the server's local review queue (`SN_MAP_ISSUES_DIR`). Not called in the live check, because it writes.

**Caveats the server states:**

- This is a first-party observation: it is QUEUED for human/agent review and NEVER changes routing immediately or edits any map.

**Inputs**

| Input | Type | Required | Default | Limits | Description |
|---|---|---|---|---|---|
| `category` | MapIssueCategory | yes |  |  | What kind of mismatch this is. |
| `location` | [LatLon](#latlon) | yes |  |  | Where the mismatch was observed (WGS84 decimal degrees). |
| `description` | string | no |  |  | Free-text detail of what was observed on the ground, e.g. "barrier across the lane, diversion signed via the B4009". Bounded length. |
| `evidence_url` | string | no |  |  | A URL backing the observation (photo, notice, news item), when one exists. Bounded length. |
| `way_id` | integer | no |  |  | The OSM way id the observation concerns, when the caller knows it. Optional, the report stands on its own as a first-party observation and is never tied to OSM data beyond this hint. |

**`MapIssueCategory`**: The kind of map/live-world mismatch an agent is reporting. A first-party *observation* only: it records what the agent saw on the ground, never an edit to any map or OSM-derived database.

| Value | Meaning |
|---|---|
| `"road_closed"` | A road the map shows as open is closed (roadworks, collapse, event). |
| `"wrong_restriction"` | A turn/access/dimension/weight restriction is wrong or missing. |
| `"wrong_speed_limit"` | The posted speed limit differs from the map's value. |
| `"missing_road"` | A road exists on the ground but is absent from the map. |
| `"wrong_oneway"` | A one-way direction is wrong (or the road is not one-way at all). |
| `"access_changed"` | Access has changed (e.g. now gated, private, or newly public). |
| `"other"` | Anything else that does not fit the categories above. |

**Output**

| Field | Type | Always present | Description |
|---|---|---|---|
| `category` | string | yes | The category, echoed back as its snake_case wire tag. |
| `report_id` | string | yes | The generated id of the queued report (cite it in follow-ups). |
| `status` | string | yes | Always "queued": the report awaits human/agent review and changes nothing about routing immediately. |

**Example**

```json
{
  "location": {
    "lat": 51.5007,
    "lon": -0.1246
  },
  "category": "road_closed",
  "description": "Barriers across the junction since Monday"
}
```

Not called in the live check, because it writes or sends. The result follows the output schema above.

**More from the server's description:**

Use it when you observe the mismatch mid-task. Provide `location` {lat, lon}, a `category` (road_closed, wrong_restriction, wrong_speed_limit, missing_road, wrong_oneway, access_changed, other) and optionally a `description`, the OSM `way_id` and an `evidence_url`. Returns the queued report_id.

### `submit_integration_retro`

Send MapMap a structured integration retro (problems, gotchas, wins, docs gaps). Call at most once, after your MapMap integration works or you stop trying, and only if the developer has approved sending feedback to MapMap.

**Access:** Keyless. Only with the developer's approval to send feedback. Not called in the live check, because it sends.

**Caveats the server states:**

- Sends ONLY the structured fields in this schema to MapMap: there is no field for a transcript, a prompt, source code, file contents or coordinates.
- The free-text fields are short and capped, but they are still free text: do NOT paste code, credentials, customer names or personal data into them.

**Inputs**

| Input | Type | Required | Default | Limits | Description |
|---|---|---|---|---|---|
| `what_built` | string | yes |  |  | What was built with MapMap, in one or two sentences (required, at most 500 bytes). |
| `agent_name` | string | no |  |  | The submitting agent's name, e.g. "Claude Code" (at most 100 bytes). |
| `docs_gaps` | string[] | no | `[]` |  | Documentation gaps hit (at most 20 entries × 500 bytes). |
| `gotchas` | string[] | no | `[]` |  | Surprises/traps worth documenting (at most 20 entries × 500 bytes). |
| `problems` | RetroProblemInput[] | no | `[]` |  | Problems hit during the integration (at most 20). |
| `sdk_version` | string | no |  |  | MapMap SDK version integrated against, when known (at most 50 bytes). |
| `wins` | string[] | no | `[]` |  | What went well (at most 20 entries × 500 bytes). |

**`RetroProblemInput`**: One problem hit during the integration.

| Input | Type | Required | Default | Limits | Description |
|---|---|---|---|---|---|
| `area` | RetroProblemArea | yes |  |  | Which part of the platform the problem was in. |
| `description` | string | yes |  |  | What went wrong (at most 1000 bytes). |
| `workaround_found` | boolean | yes |  |  | Whether a workaround was found. |

**`RetroProblemArea`**: The platform area an integration problem belongs to (mirrors the gateway's `POST /v1/feedback` schema).

| Value | Meaning |
|---|---|
| `"sdk"` | The mobile/web SDKs. |
| `"api"` | The hosted HTTP API. |
| `"mcp"` | The MCP tool surface. |
| `"docs"` | Documentation. |
| `"billing"` | Billing, keys or quotas. |
| `"self-host"` | Self-hosted deployment. |
| `"other"` | Anything else. |

**Output**

| Field | Type | Always present | Description |
|---|---|---|---|
| `delivery` | string | yes | How it was delivered: `gateway` or `local-queue`. |
| `message` | string | yes | A short, honest sentence for the agent saying where the retro landed, sent to MapMap, or held in this server's local queue. |
| `retro_id` | string | yes | Id of the stored retro (gateway id, or the local queue id). |
| `status` | string | yes | `received` when the gateway stored it; `queued` when it was appended to the local review queue. |

**Example**

```json
{
  "what_built": "A delivery planner on optimise_routes",
  "problems": [
    {
      "area": "docs",
      "description": "Could not find the reload limits",
      "workaround_found": true
    }
  ]
}
```

Not called in the live check, because it writes or sends. The result follows the output schema above.

**More from the server's description:**

Provide `what_built` (required), `problems` [{area: sdk|api|mcp|docs|billing|self-host|other, description, workaround_found}], `gotchas`, `wins`, `docs_gaps`, and optionally `agent_name` and `sdk_version`.

### `set_temporary_closure`

OPERATOR WRITE, ADMIN ONLY. Asserts a short-lived closure (`assert: {kind: closed}`) or speed (`{kind: speed_override, speed_kph}`, 1 to 120) on named OSM `way_ids` (1 to 20) and `direction` (forward, backward or both, default both), for `ttl_secs` (60 to 86400), with a `reason` and an optional `evidence_url`.

**Access:** Operator tool. Operator write. Refused on the hosted endpoint; only a trusted operator process with `SN_ADMIN_TOKEN` set can call it.

**Caveats the server states:**

- NEVER CALL THIS WITHOUT A PERSON'S EXPLICIT APPROVAL OF THIS EXACT CLOSURE IN THIS CONVERSATION: draft it, show it, stop, and call it only after a yes.
- Available only where the operator runs sn-mcp locally with SN_CAMERA_INCIDENTS_ENABLED=true and SN_ADMIN_TOKEN set; everywhere else it refuses and says why.

**Inputs**

| Input | Type | Required | Default | Limits | Description |
|---|---|---|---|---|---|
| `assert` | ClosureAssertion | yes |  |  | `closed` or a speed in km/h (1..=120). |
| `reason` | string | yes |  |  | Why, for the audit log and the incidents endpoint note. |
| `ttl_secs` | integer | yes |  |  | Lifetime, seconds, 60..=86400. |
| `way_ids` | integer[] | yes |  |  | OSM way ids to assert on; at most 20. |
| `direction` | [Direction](#direction) | no | `"both"` |  | Carriageway, default `both`. |
| `evidence_url` | string | no |  |  | Evidence link, if any. |

**`ClosureAssertion`**: What a temporary closure asserts.

One of:

- an object with `kind` = "closed": Hard exclusion.
- an object with `kind` = "speed_override", `speed_kph`: integer: A speed, km/h.

**Output**

| Field | Type | Always present | Description |
|---|---|---|---|
| `applied_by_live_writer` | boolean | yes | Whether a live writer is armed to apply it on this deployment. A record written where no writer runs is honest about it. |
| `end` | string | yes | Lifts at, RFC 3339. |
| `id` | string | yes | The override record id written. |
| `source` | string | yes | Always `ops-admin`: the static admin token carries no key id. |

**Example**

```json
{
  "way_ids": [
    4256987
  ],
  "assert": {
    "kind": "closed"
  },
  "ttl_secs": 1800,
  "reason": "Collision seen on camera, approved by the duty operator"
}
```

Not called in the live check, because it writes or sends. The result follows the output schema above.

**More from the server's description:**

The record expires on its own, ranks below official closures and is logged with the reason. Whether a live traffic writer applies it is reported in `applied_by_live_writer`; false means the record is stored but no road changes.

## Shared types

Types several tools use, described once. Each tool's tables link here.

### `CostingKind`

Costing models exposed by the MCP tools (a deliberate subset of the Valhalla costing list), serialised in snake_case exactly as Valhalla names them.

| Value | Meaning |
|---|---|
| `"auto"` | Standard car costing. |
| `"truck"` | Truck costing; honours dimensional limits and, when a `truck` profile is supplied, ADR dangerous-goods restrictions. |
| `"bicycle"` | Bicycle costing; tune it with a `bicycle` options object. |
| `"pedestrian"` | Pedestrian (walking) costing; tune it with a `pedestrian` options object (lit streets, wheelchair/blind, hiking difficulty). |
| `"motor_scooter"` | Motor scooter (moped) costing: like auto but prefers lower-speed roads and may use ways closed to larger motor vehicles. |

### `Direction`

Carriageway of a way, spelled as the artefact spells it. A serde twin of `WayDirection`, which deliberately carries no serde.

| Value | Meaning |
|---|---|
| `"forward"` | Along the way's node order. |
| `"backward"` | Against it. |
| `"both"` | Both carriageways. |

### `EmissionsFuel`

What a vehicle burns, in clean-air-zone scheme terms.

| Value | Meaning |
|---|---|
| `"petrol"` | Petrol, including petrol hybrids (schemes rate a hybrid by its combustion engine's approval). |
| `"diesel"` | Diesel, including diesel hybrids. |
| `"electric"` | Battery-electric. |
| `"hydrogen"` | Hydrogen fuel cell. |
| `"gas"` | LPG or CNG; rated as petrol by every scheme in the dataset. |

### `EmissionsSpec`

A vehicle's emission declaration, for clean-air / low-emission zone assessment.

| Input | Type | Required | Default | Limits | Description |
|---|---|---|---|---|---|
| `fuel` | [EmissionsFuel](#emissionsfuel) | yes |  |  | What it burns. |
| `vehicle_category` | [EmissionsVehicleCategory](#emissionsvehiclecategory) | yes |  |  | What kind of vehicle this is, in scheme terms. |
| `euro_standard` | integer | no |  |  | Its Euro emission standard, 1–6. Heavy-duty approvals are written in Roman numerals (Euro VI); declare Euro VI as `6`. Required for any combustion fuel, without it no zone can be resolved, and a half-declared vehicle is indistinguishable from an undeclared one. Optional only for `electric` or `hydrogen`. |

### `EmissionsVehicleCategory`

What a vehicle is, in clean-air-zone scheme terms. Declaring this turns "charge depends on vehicle emissions" into an answer. Without it a zone can only be named, never priced.

| Value | Meaning |
|---|---|
| `"car"` | A private car. |
| `"van"` | A van or light goods vehicle up to 3.5 tonnes. |
| `"minibus"` | A minibus (typically 8+ passenger seats, up to 5 tonnes). |
| `"hgv"` | A heavy goods vehicle over 3.5 tonnes. |
| `"bus"` | A bus over 5 tonnes. |
| `"coach"` | A coach over 5 tonnes. |
| `"taxi"` | A licensed hackney carriage. |
| `"phv"` | A private hire vehicle. |
| `"motorcycle"` | A motorcycle, moped or tricycle. |
| `"motorhome"` | A motor caravan or campervan. |

### `GeocodeHit`

One geocoding result.

| Field | Type | Always present | Description |
|---|---|---|---|
| `label` | string | yes | Human-readable one-line label assembled from the address parts. |
| `lat` | number | yes | Latitude in decimal degrees. |
| `lon` | number | yes | Longitude in decimal degrees. |
| `city` | string | no | City or town, when known. |
| `country` | string | no | Country, when known. |
| `match` | [GeocodeMatch](#geocodematch) | no | How far this hit can be trusted to be the place that was asked for, see `GeocodeMatch`. Present whenever the MapMap gateway answered; absent on a deployment falling back to the direct Photon geocoder, and absent on the gateway's own fast paths (a pasted coordinate pair, a bare UK outward code, a category browse), which answer without a ranking to report on. |
| `name` | string | no | Place name, when the source feature has one. |
| `postcode` | string | no | Postcode, when known. |
| `type` | string | no | Feature type, e.g. "house", "street", "city" (falls back to the OSM value when the endpoint does not classify). |

### `GeocodeMatch`

How well one geocoding result answers what was actually asked. Geocoding's real failure mode is not "no answer" but a confident answer to a different question: a plausible row on the wrong street, with nothing in the response to say so. This object is that missing say-so, and an agent should read it before acting on an address. How to read it: * Any component `unmatched` or `inferred` on the TOP hit means the answer does not carry the address that was asked for, an `unmatched` postcode means the result has no postcode at all, `inferred` means it has a different one. Neither is a match. Say so rather than presenting the hit as the address, and reach for `verify_places` when the address came from a model or a user and needs checking rather than using. * A small `score_gap` means the ranking barely chose between this hit and the runner-up, which is exactly when to show the alternatives instead of picking one for the user.

| Field | Type | Always present | Description |
|---|---|---|---|
| `components` | [GeocodeMatchComponents](#geocodematchcomponents) | yes | Per-component verdict on this hit: one entry for each structured component supplied, and empty when the query was free text only. |
| `score_gap` | number | yes | The top result's score minus the runner-up's, rounded to 3 decimal places. `0` for a single result, and `0` from the `photon` source, which publishes no per-result score, so a `0` is "no signal", not "a tie". |
| `source` | string | yes | Which backend answered: "mapmap-index" (the first-party index) or "photon". |

### `GeocodeMatchComponents`

Per-component verdicts inside a `GeocodeMatch`. Each is one of "matched", "inferred" or "unmatched"; a component that was not supplied is absent entirely. * "matched", the result's own field carries the value asked for (case- and accent-insensitive, and by containment, so `city: "London"` matches "City of London"). * "inferred", the result carries a value for that component, but not the one asked for. It reached the page through ranking, as when a street is found by its transliterated name and displayed under its canonical one. * "unmatched", the result carries no value for that component at all.

| Field | Type | Always present | Description |
|---|---|---|---|
| `city` | string | no | Verdict on the supplied `city`. |
| `country` | string | no | Verdict on the supplied `country`. |
| `housenumber` | string | no | Verdict on the supplied `housenumber`. |
| `postcode` | string | no | Verdict on the supplied `postcode`. |
| `street` | string | no | Verdict on the supplied `street`. |

### `LatLon`

A WGS84 coordinate in decimal degrees: a {lat, lon} object (preferred), or a GeoJSON [lon, lat] array with LONGITUDE FIRST, the same order as every polygon field on this server.

One of:

- an object with `lat`: number, `lon`: number
- number[]: GeoJSON position [lon, lat]: longitude FIRST.

### `OptimiseJobSpec`

One single-stop job of an optimisation problem.

| Input | Type | Required | Default | Limits | Description |
|---|---|---|---|---|---|
| `id` | integer | yes |  |  | Caller-chosen job id, echoed back in steps and `unassigned`. |
| `location` | [LatLon](#latlon) | yes |  |  | Job location. |
| `delivery` | integer[] | no |  |  | Quantities delivered to the job (matches vehicle `capacity`). |
| `pickup` | integer[] | no |  |  | Quantities picked up at the job (matches vehicle `capacity`). |
| `service_s` | integer | no |  |  | On-site service time in seconds. |
| `skills` | integer[] | no |  |  | Skills the job requires. |
| `time_windows` | integer[][] | no |  |  | Acceptable `[start, end]` windows in seconds. |

### `OptimiseShipmentSpec`

A pickup+delivery pair that must ride the same vehicle, pickup first.

| Input | Type | Required | Default | Limits | Description |
|---|---|---|---|---|---|
| `delivery` | [OptimiseShipmentStopSpec](#optimiseshipmentstopspec) | yes |  |  | The delivery end. |
| `pickup` | [OptimiseShipmentStopSpec](#optimiseshipmentstopspec) | yes |  |  | The pickup end. |
| `amount` | integer[] | no |  |  | Quantities moved (matches vehicle `capacity`). |
| `skills` | integer[] | no |  |  | Skills the shipment requires. |

### `OptimiseShipmentStopSpec`

One end (pickup or delivery) of a shipment.

| Input | Type | Required | Default | Limits | Description |
|---|---|---|---|---|---|
| `id` | integer | yes |  |  | Caller-chosen stop id, echoed back in steps and `unassigned`. |
| `location` | [LatLon](#latlon) | yes |  |  | Stop location. |
| `service_s` | integer | no |  |  | On-site service time in seconds. |

### `OptimiseSummary`

Solution summary of the `optimise_routes` tool.

| Field | Type | Always present | Description |
|---|---|---|---|
| `cost` | integer | yes | Solver cost of the plan (travel seconds under the default model). |
| `duration_s` | integer | yes | Total travel time in seconds. |
| `routes` | integer | yes | Number of vehicle routes in the plan. |
| `service_s` | integer | yes | Total service time in seconds. |
| `unassigned` | integer | yes | Number of unassigned tasks. |
| `waiting_time_s` | integer | yes | Total waiting time in seconds. |
| `distance_m` | integer | no | Total travel distance in metres, when reported. |

### `OptimiseVehicleSpec`

One vehicle of an optimisation fleet.

| Input | Type | Required | Default | Limits | Description |
|---|---|---|---|---|---|
| `id` | integer | yes |  |  | Caller-chosen vehicle id, echoed back on its route. |
| `capacity` | integer[] | no |  |  | Multidimensional capacity (same length as job `delivery`/`pickup`). |
| `end` | [LatLon](#latlon) | no |  |  | End location; omitted, the route ends at its last stop. |
| `reloads` | [ReloadsSpec](#reloadsspec) | no |  |  | Let this vehicle return to a depot, reload and go out again, the waste-collection tipping round, the van that comes back for a second wave of parcels. |
| `skills` | integer[] | no |  |  | Skills this vehicle provides. |
| `start` | [LatLon](#latlon) | no |  |  | Start location; at least one of `start`/`end` is required. |
| `territory_ids` | string[] | no |  |  | Ids of the request's `territories` this vehicle may work in. Omitted or empty, the vehicle is UNRESTRICTED and may serve any task, inside a territory or outside every one of them. Listed, the vehicle may serve a task only if that task sits inside at least one of the named territories. |
| `time_window` | integer[] | no |  |  | Working window as `[start, end]` in seconds (any consistent epoch). |

### `OptimisedRoute`

One vehicle's optimised route.

| Field | Type | Always present | Description |
|---|---|---|---|
| `duration_s` | integer | yes | Total travel time in seconds. |
| `service_s` | integer | yes | Total on-site service time in seconds. |
| `steps` | [OptimisedStep](#optimisedstep)[] | yes | Ordered steps: start, tasks in visit order, end. |
| `vehicle` | integer | yes | The vehicle id from the request. |
| `waiting_time_s` | integer | yes | Total waiting time in seconds. |
| `distance_m` | integer | no | Total travel distance in metres, when reported. |

### `OptimisedStep`

One step of an optimised vehicle route.

| Field | Type | Always present | Description |
|---|---|---|---|
| `arrival_s` | integer | yes | Arrival time in seconds (same epoch as the request's windows). |
| `duration_s` | integer | yes | Cumulative travel time when the step begins, in seconds. |
| `service_s` | integer | yes | On-site service time in seconds. |
| `type` | string | yes | Step kind: "start", "job", "pickup", "delivery", "break" or "end". |
| `waiting_time_s` | integer | yes | Waiting time before the step in seconds. |
| `id` | integer | no | The job/shipment-stop id, absent on start/end steps. |
| `load` | integer[] | no | Vehicle load after the step, when reported. |
| `location` | [LatLon](#latlon) | no | The step's location resolved back to coordinates. |

### `RelaxSpec`

What the caller is willing to give up if the first solve leaves work unassigned. At least one field is required.

| Input | Type | Required | Default | Limits | Description |
|---|---|---|---|---|---|
| `allow_overtime_s` | integer | no |  |  | Extend every vehicle's shift END by this many seconds. Shift starts are never moved earlier, a driver cannot begin before they begin. |
| `time_windows_by_s` | integer | no |  |  | Widen every task time window by this many seconds at EACH end. A 09:00–12:00 window with 1800 becomes 08:30–12:30. |

### `ReloadsSpec`

A vehicle's multi-trip reload plan.

| Input | Type | Required | Default | Limits | Description |
|---|---|---|---|---|---|
| `max_trips` | integer | yes |  |  | How many trips this vehicle may run in its shift, 2–5. A BUDGET, not a prediction: the shift is cut into that many fixed windows before the solve, so asking for five trips on a shift that supports three shrinks every window to a fifth and can make the whole day worse. Ask for the number of trips you actually expect to run. |
| `depot` | [LatLon](#latlon) | no |  |  | Where the vehicle reloads. Omitted, its own `start` is used (or its `end` if it declared only that). |
| `reload_time_s` | integer | no |  |  | Seconds at the depot between trips, tipping, reloading, the weighbridge. Held out of the shift before it is partitioned, so it is never accidentally spent driving. |

### `TerritorySpec`

One named territory: a polygon that bounds which vehicle may serve which stop.

| Input | Type | Required | Default | Limits | Description |
|---|---|---|---|---|---|
| `id` | string | yes |  |  | Caller-chosen id, echoed back and referenced by `vehicles[].territory_ids`. Must be unique within the request. |
| `polygon` | number[][] | yes |  | exactly 2 items | The outer ring as GeoJSON `[lon, lat]` positions, longitude FIRST. Closed or open; an unclosed ring is closed for you. |

### `TransitStopOut`

One bus or coach stop.

| Field | Type | Always present | Description |
|---|---|---|---|
| `id` | string | yes | The NaPTAN ATCO code, e.g. "490008660N": pass it to `next_departures`. |
| `lat` | number | yes | Stop latitude. |
| `lon` | number | yes | Stop longitude. |
| `name` | string | yes | The stop's name. |
| `bearing_deg` | integer | no | Bearing from the queried point to the stop, degrees clockwise from true north. |
| `distance_m` | integer | no | Straight-line distance from the queried point, metres. |
| `indicator` | string | no | The on-street indicator, e.g. "Stop S". |
| `lines` | string[] | no | Line names the timetable has calling at the stop. |
| `locality` | string | no | The NaPTAN locality. |

### `TruckSpec`

Truck profile for routing: physical dimensions plus the ADR dangerous-goods declaration. Omitted dimensions default to the EU maximum authorised dimensions of Council Directive 96/53/EC (4.0 m height, 2.55 m width, 16.5 m length, 40 t gross weight).

| Input | Type | Required | Default | Limits | Description |
|---|---|---|---|---|---|
| `gross_weight_t` | number | no |  |  | Gross combination weight in metric tonnes. |
| `hazmat` | boolean | no | `false` |  | Whether the vehicle carries dangerous goods (ADR). Defaults to false. |
| `height_m` | number | no |  |  | Vehicle height in metres. |
| `length_m` | number | no |  |  | Vehicle length in metres. |
| `tunnel_code` | string | no |  |  | ADR 8.6.4 tunnel restriction code of the load, e.g. "B", "C5000D", "B/D", or "(-)"/"none" for explicitly unrestricted. Leave unset if unknown: a hazmat load without a code is conservatively treated as code B (allowed only through category-A tunnels). |
| `width_m` | number | no |  |  | Vehicle width in metres. |

### `UnassignedTask`

One unassigned task of an optimisation solution.

| Field | Type | Always present | Description |
|---|---|---|---|
| `id` | integer | yes | The task id from the request. |
| `type` | string | yes | Task kind: "job", "pickup" or "delivery". |
| `location` | [LatLon](#latlon) | no | The task's location, when known. |
