io.github.Unselfisheologism/supercarto
# supercarto
mcp-name: io.github.Unselfisheologism/supercarto
[](https://skills.sh/Unselfisheologism/supercarto)
**LLM-native spatial middleware.** Compiles geospatial data into token-budgeted
topological graphs that an AI agent can reason over directly.
Give an agent a map without paying the token tax. One file in, both numbers
out: examples/synthetic-dense.geojson is 272,993 bytes (~68,000 tokens) raw
and compiles at a 1024-token budget to about 890 tokens - 26 nodes
(10 named) on 34 edges (streets plus access links), ~99% smaller.
Rerun it yourself: `supercarto compile examples/synthetic-dense.geojson
--budget 1024`. Token counts are character-based estimates on a fixed test
file; the sun and timestamp lines shift them by a few tokens run to run.
```bash
npm install supercarto
pip install supercarto # Python client, talks MCP over stdio
```
---
## Give it coordinates
This is the whole product:
```ts
import { SuperCarto } from 'supercarto';
const carto = new SuperCarto();
const result = await carto.maplet({
lat: 35.6833,
lon: 139.7620,
radiusM: 400,
budget: 1500,
});
console.log(result.yaml);
```
The same file from the headline, compiled below (truncated output;
warnings travel inside the map, where an agent reading only the map sees them):
```yaml
map:
center: "37.7749, -122.4194"
source: geojson
note: Simplified for reasoning; call expand_feature for exact geometry.
nodes:
n530: {type: poi, name: Bank 0, tags: [bank]}
n531: {type: poi, name: Cafe 1, tags: [cafe]}
n673: {type: transit, name: Station 0, tags: [subway_entrance]}
edges:
n515->n516: {dist: 80m, dir: east, path: road}
n516->n517: {dist: 80m, dir: east, path: road}
omitted:
- {layer: building, count: 259}
- {layer: poi, count: 141}
- {layer: road, count: 441}
tools:
- expand_feature(id)
```
That is about 890 tokens for the whole extract at a 1024 budget: every drop named
by layer, nothing silently vanished.
---
## The problem
You want an agent to know where things are and how to get between them. Three
options, all bad:
- **Paste GeoJSON into the prompt.** Thousands of tokens per block. The model
reasons badly about raw coordinates and will hallucinate them.
- **Call a map API.** You get back the same GeoJSON. The token cost is
unchanged; only the HTTP round trip differs.
- **Invent a compact wire format and teach the model to read it.** A novel
grammar in a context window produces hallucinated coordinates and tokens
burned inferring the grammar. A model was not trained on your format.
supercarto takes the third option seriously and solves it the only way it can be
solved: **the model never reads the wire format.**
```
coordinates live sources reasoning format
35.68, 139.76 ──▶ Overpass / PMTiles ──▶ topological YAML graph
distances in metres,
compass directions,
built for models
```
The wire format is a spec you own, optimized for machines. The reasoning format
is standard YAML in flow style, using only constructs a model has read billions
of times. A deterministic compiler in the middleware does the translation.
---
## An agent works for a person
The person is standing outside. Rain and traffic change whether the trip happens
at all, so those are first-class layers here, not an afterthought bolted onto a
routing engine.
### Weather
No API key. No account. No billing.
```ts
const w = await carto.weatherAt(35.6833, 139.7620, 12);
w.now.summary; // "heavy rain"
w.now.precipitation; // "rain" | "snow" | "sleet" | "hail" | "drizzle"
w.now.feelsLikeC; // what they actually feel
```
```
$ supercarto weather --lat 35.6833 --lon 139.762
now clear night, 23.2C (feels 26.9C), wind 4.3km/h, precip none
2026-10-01T13:00:00Z 22.7C none 3.5km/h
2026-10-01T14:00:00Z 22.1C none 3.1km/h
```
The MCP tool returns prose rather than fields, because a model repeats "it is
raining, take the bus" reliably and re-derives it from twelve numbers
unreliably.
When the source is unreachable it returns `NaN` for every reading and sets
`degraded`, and the tool raises. It does not return zeroes. An agent handed
`precipitationMm: 0` concludes the weather is dry and sends someone cycling into
a storm.
### Traffic
```ts
const t = await carto.routeWithTraffic({ from, to, mode: 'drive' });
t.freeFlowOnly; // true when no flow credential is configured
t.worstLevel; // "severe" - the segment that makes someone late
t.delayS;
```
Traffic needs a credential, so `get_traffic` is **absent from the tool list**
until you set one. With no key the routing path reports free-flow and labels it:
```
$ supercarto traffic --from 37.7749,-122.4194 --to 37.7849,-122.4194
traffic: unknown (no flow source configured)
free-flow estimate: 244s, ignores congestion
note: set TOMTOM_API_KEY or HERE_API_KEY for live conditions
```
A free-flow estimate for a congested route is worse than no answer, because the
person leaves on the schedule the agent gave them. Presenting one as live is the
single most damaging thing this library could do, so it does not.
Set `TOMTOM_API_KEY` or `HERE_API_KEY` and it becomes live, reporting the worst
segment rather than an average.
### Indoor floors
Nobody else publishes this. Google builds indoor maps for its own users and has
no reason to hand a third-party agent a clean floor-by-floor graph; Mapbox has
nothing comparable; every MCP wrapper returns the same flat GeoJSON.
```yaml
indoor:
storeys: 0,1,2
multi_storey: true
0: {label: ground, rooms: 12}
L0n1: {type: corridor}
L0n2: {type: shop, name: "Blue Bottle"}
L0n1->L0n2: {dist: 11m}
v1: {type: lift, levels: [0,1,2], wheelchair: true}
missing: [1]
```
Lifts are collapsed across storeys into one vertical link. Rooms only connect to
corridors **on the same floor** — an edge through a concrete slab would send
someone through a floor. A room more than 40m from any corridor is left
unattached, because a straight line through intervening walls is worse than no
edge. Untagged storeys are reported as `missing` rather than invented.
### Transit schedules
Set `SUPERCARTO_GTFS` to any static GTFS feed (an unzipped directory or a .zip)
and `get_transit` appears: stops, lines, and a service window for the queried
day. It is the one agent map tool that answers "can you be somewhere by 9"
from an actual schedule, and every agency's failure mode is covered by an
honest state rather than a guess:
```yaml
transit:
center: "36.913, -116.77"
radius: 3.0km
feed: sample-feed
note: Stop distances are straight-line. Schedule from GTFS feed for 2007-01-11 in America/Los_Angeles.
nodes:
STAGECOACH: {type: stop, name: "Stagecoach Hotel & Casino", tags: [bus], routes: ["30", "40"], first: 06:00, last: 06:58}
NADAV: {type: stop, name: "North Ave / D Ave N", tags: [bus], routes: ["40"], first: 06:14, last: 06:44}
edges:
NADAV->DADAN: {dist: 601m, dir: south, path: bus 40}
```
- **Three service states, never two.** Running today shows routes and
first/last departures. A date with nothing running keeps the stops with
`routes: []` — no transit is not no stops — and the note says so. A feed
without a calendar shows the whole network and says the service window is
unknown instead of presenting whole-feed data as today's schedule.
- **Times past midnight stay past midnight.** GTFS expresses a departure
after the service day's midnight as hours beyond 24 (`25:10`). Rendering
that as `01:10` fits the eye and misleads the decision, so it renders as
recorded, in the feed's own timezone.
- **Edges are structure; schedules attach to nodes.** The adjacency survives
a no-service day because an agent walking between stops still wants it.
- **Live delays when you point GTFS-Realtime at it.** Set `SUPERCARTO_GTFS_RT`
to the agency's TripUpdates URL and predictions join onto the schedule:
node lines gain a `live:` attribute such as `bus 16 +300s` or
`bus 16 cancelled`, and the note says how fresh the feed is. Spec
precedence holds — an explicit stop update beats the trip-level delay;
absolute-time predictions convert through the feed's timezone. The
honesty rule that matters most: **a stop with no live entry is unknown,
not on time**, and the note says so in words. A feed that will not read
(404, timeout, malformed data) degrades to the pure schedule answer with
a one-sentence explanation, never a silent drop.
- **Next departures, not just the day's window.** A `next:` attribute on
each node lists the upcoming departures at that stop, in feed time order
and in the feed's clock (`tram 9 25:10` stays past midnight), capped at
three with the hidden rest counted (`+2 more`). With a realtime feed
configured the board speaks the prediction inline — `bus 16 07:05 +300s`
or `cancelled` — and the `live:` list omits anything the board already
said, so nothing prints twice. When the day's last departure has run, the
note says the day is over instead of letting an empty board read as a
broken feed.
- **A certified ride plan between two coordinates.** `get_transit_route`
walks to a stop, rides one line that serves both catchments, and walks it
off — all straight-line legs labelled as such, never an interchange the
feed cannot prove. Plans ride on the departure board's honesty: certified
against the service day, predictions decorate their trips, cancelled
trips drop out with a counted note, and when nothing can be certified
the note names the leg that failed.
- **The signal is explicit.** Departure times, the schedule date, and the
feed's timezone travel in the `note`, where a model reading only the map
sees them.
---
## The budget is a real budget
Most map-for-agents code truncates and hopes. supercarto treats truncation as a
correctness problem:
- **Ranking.** Named landmarks survive; anonymous intersections are cut first.
- **Connectivity.** Cut vertices are never removed, so the surviving network
stays connected. A map the agent can traverse but not connect is worse than a
smaller map it can use.
- **No stranded names.** A landmark the agent can see but cannot route to is
removed. It would confidently try to walk to it.
- **Verification.** `emitAndVerify` re-measures the actual emitted YAML and cuts
again until it fits. The reported budget is the budget delivered, not an
estimate.
- **Honesty.** Every drop is recorded in `omitted:`, aggregated per layer, so
the model can tell "not there" from "not shown".
---
## The benchmark
Lives in its own repo now:
[supercarto-bench](https://github.com/Unselfisheologism/supercarto-bench).
Same question, same model, same map — the only variable is what sits in
the context window: compiled graph vs raw GeoJSON vs nothing. Raw runs,
frozen test maps, and the scoring code all live there, so anyone building
a rival map tool can run the same test and try to beat the score.
Current headline there: 9/9 for the compact graph against ~4/9 raw and 0/9
with no map, on two AI models. Route questions are unmeasurable (the public
test router gives impossible answers) — so `route` is demoted to a
**feasibility** answer: straight-line distance, compass direction, then
"verify the approach on arrival". Turn-by-turn requires a router you host and
have verified; OSRM is opt-in, not default. Stated, not hidden.
The score now ships with its own reproduction recipe (`scoreC certificate`,
re-derived from raw answer archives without model calls), a per-model
leaderboard, and adapter arms: a rival tool registers itself with a single
`{ name, prepare }` module and gets the same tasks, models and pass rules.
The current certificate: supercarto 38/47 · geojson 15/44 · none 0/47
across every archived run.
- [Live certificate](https://github.com/Unselfisheologism/supercarto-bench/blob/master/bench-results/certificate.md)
— every number, re-derivable from committed archives with no model calls.
- [Live leaderboard](https://github.com/Unselfisheologism/supercarto-bench/blob/master/bench-results/leaderboard.md)
— per model and per arm, including the free models you can run yourself.
What this repo keeps is a quick token-cost check:
```bash
npx supercarto bench <file.geojson>
```
---
## Data sources
| Source | Status | Use |
|---|---|---|
| **Overpass** | default | Development. 15-40s per call, rate limited. |
| **PMTiles / Protomaps** | `SUPERCARTO_PMTILES` | **Production.** Range requests against object storage, milliseconds, no rate limit. |
| **OSRM** | default | Turn-by-turn routing, walk/bike/drive. |
| **Open-Meteo** | default | Weather. No key. |
| **TomTom / HERE** | key required | Live traffic. |
| **Terrarium tiles** | default | Elevation and slope. No key. |
| **GTFS feeds** | `SUPERCARTO_GTFS` | Transit stops, lines, and schedules. Any static feed, no key. Live delays join from a GTFS-Realtime TripUpdates URL via `SUPERCARTO_GTFS_RT`. |
```bash
export SUPERCARTO_PMTILES=https://your-bucket/planet.pmtiles
```
PMTiles is the answer at production volume. The reader is included — no `pmtiles`
dependency, no `mapbox-gl`, zero transitive packages.
Everything is read from the environment by one factory, so the CLI and the MCP
server cannot disagree about where data came from. They did once, and nothing
reported it.
```
SUPERCARTO_PMTILES PMTiles archive URL; primary source when set
OVERPASS_ENDPOINT your own Overpass instance
TERRARIUM_ENDPOINT elevation tile template
TOMTOM_API_KEY enables get_traffic
HERE_API_KEY enables get_traffic
SUPERCARTO_NO_TERRAIN disable elevation
SUPERCARTO_NO_WEATHER disable weather
SUPERCARTO_GTFS path to a GTFS feed (dir or .zip); enables get_transit
SUPERCARTO_GTFS_RT TripUpdates URL; adds live delay lines to get_transit
```
Whole-metro feeds load entirely into memory. Since 0.6.1 the load is
columnar — stop_times go straight into per-trip arrays, no row objects — so
a feed the size of Translink SEQ (35 MB zip) fits Node's default heap and
the answer is byte-identical to the pre-0.6.1 build. Verified live at that
size: schedule, departure boards, and 750+ realtime predictions join in one
answer with no `NODE_OPTIONS` at all. Bigger feeds load in proportion to
their own inflated size; give such a run `NODE_OPTIONS=--max-old-space-size=4096`.
---
## Agent integration
### MCP
```json
{
"mcpServers": {
"supercarto": { "command": "npx", "args": ["supercarto", "mcp"] }
}
}
```
Seven tools: `get_maplet`, `route`, `search_places`, `expand_feature`,
`get_daylight`, `get_terrain`, `get_weather`, plus `get_traffic` when a key is
present, `get_transit` and `get_transit_route` when a GTFS feed is
configured. Each maplet lists exactly the tools that deployment can answer,
so an agent never calls one that cannot work. `get_daylight` is always
available: sun position is arithmetic, needing no source and no credential.
### What a maplet says about a place
Beyond topology and distance, a node can carry the facts that decide whether a
trip is worth making:
```yaml
n3: {type: poi, name: Blue Bottle, tags: [cafe], hours: 07:00-18:00, brand: Blue Bottle}
n4: {type: poi, name: Central Rx, tags: [pharmacy], hours: 09:00-17:00}
map:
sun: up, elevation: 43.4deg, twilight: day, azimuth: 22deg, sunrise: "..."
```
Both shops above are open at 12:00 local. At 04:00 the same two read
`hours: closed`, because their hours are known and they are shut. A bench with
no recorded hours emits no `hours` field at all.
`hours` is evaluated against an instant and evaluated in the **map's** timezone,
not the server's. Three states, never two: a time range, `closed`, or absent
because nobody recorded it. Absent is deliberately not `closed` — those lead to
opposite advice, one means go and find out, the other means go elsewhere.
`twilight` is `day`, `civil`, `nautical`, or `night` rather than a boolean,
because the three lead to different decisions: walking is fine at civil
twilight and unwise at nautical. Above the Arctic circles in summer and winter
the map reports `polar: day` or `polar: night` instead of inventing a sunrise.
Pass `compile.utcOffsetMinutes` and `compile.now` to fix both to a known place
and instant, which is what the tests do.
### Python
```python
import asyncio
from supercarto import SuperCarto
async def main():
async with SuperCarto() as carto:
m = await carto.maplet(lat=35.6833, lon=139.7620, radius_m=400, budget=1500)
print(m.yaml)
w = await carto.weather(lat=35.6833, lon=139.7620)
asyncio.run(main())
```
Zero runtime dependencies. It speaks JSON-RPC to the Node server over stdio
rather than importing an SDK, because the `mcp` package pulls in httpx and
pydantic and an agent framework that already depends on one of them should not
get a second copy.
### HTTP
```bash
curl 'localhost:8787/maplet?lat=35.6833&lon=139.762&radiusM=400&budget=1500&format=yaml'
```
### Hosted mode (deployed)
The compile path is Workers-compatible (fetch-based sources, no Node built-ins),
and `cloud/worker/` packages it as a Cloudflare Worker: the same `/maplet`,
`/scr`, weather, daylight and terrain surface as the local HTTP API above, with
per-IP rate limiting and ODbL attribution on every map response. It is
deliberately a subset — transit and traffic are not served, because a Worker has
no filesystem for a GTFS feed and hosted mode ships no flow-provider keys, and
`GET /` states that openly. See `cloud/worker/README.md`.
Deployed at https://supercarto-compile.jeffrinjames99.workers.dev. Data source
chain for `/maplet` and `/scr`: live Overpass first, then a hosted PMTiles
planet build of the OpenStreetMap basemap — every known public Overpass
instance refuses the shared Cloudflare egress range (HTTP 521), so the
fallback is what actually serves a hosted maplet. The archive's detail level
is coarse beyond z10-ish, and the deployment caps a request's tile and
feature fetches so a planet tile stays inside a Worker CPU budget;
truncation travels in the document as warnings, and the `source:` line says
`protomaps` when the fallback served it. Weather, daylight and rate limiting
are live; terrain answers an honest 503 (Terrarium tiles are PNG-encoded and
the bundled zero-dependency decoder reads raw Terrarium buffers only); transit
and traffic are absent for the reasons `GET /` lists. `GET /healthz` probes
Overpass only, so a 503 there means the Overpass leg is unreachable — not that
the PMTiles fallback is down.
---
## CLI
```bash
supercarto fetch --lat 35.6833 --lon 139.762 --radiusM 400 --budget 1500 --metrics
supercarto weather --lat 35.6833 --lon 139.762 --hours 12
supercarto traffic --from 37.7749,-122.4194 --to 37.7849,-122.4194
supercarto transit --feed city-gtfs.zip --lat 36.913 --lon -116.77 --radiusM 3000
supercarto route --from 37.7749,-122.4194 --to 37.7849,-122.4194 --mode walk
supercarto mcp
supercarto serve --port 8787
supercarto compile city.geojson --budget 1024 --metrics
supercarto encode city.geojson --out city.scr
supercarto decode city.scr
supercarto bench city.geojson
```
`fetch` layers accept `road` and `poi` alongside raw OSM tags; anything
else is rejected rather than silently returning nothing. A request that
would yield named places with no streets fails with a clear error instead
of a misleading map — pass `--layers poi` for a POI-only map. Warnings
travel inside the map output, not just stderr, so agents see them.
---
## Accuracy
Distances are verified against haversine ground truth at mid and high latitude:
- 0.11% error at San Francisco (37.77°N)
- 0.11% error at Oslo (59.91°N)
This required getting the Web Mercator scale correction right: mercator distances
exceed ground distance by `1/cos(latitude)` on both axes, and getting the sign
wrong produces a 26% error at mid latitude that compounds toward the poles. A
mean isotropic scale is also wrong for a non-square bbox, so the scale is applied
per axis.
The benchmark task set spans 64°N to 33°S deliberately. A benchmark run only at
37°N measures one case and hides every sign error toward the poles.
---
## Status
461 tests passing: 328 unit (including 42 for the GTFS transit layer, 14 for the
protobuf wire and TripUpdates schema, 5 for the real-time overlay join, and 7
for the PNG elevation decoder), 121 against live sources, and 12 MCP
end-to-end.
Outputs are byte-identical to the Python port - the parity engine runs on
every push. Verified against live OpenStreetMap, live Open-Meteo, and the
live MCP server driven from Python.
Implemented: SCR codec (text and binary, delta geometry, ring groups, three heat
encodings, omissions, routes, refs), Web Mercator with per-axis scale correction,
Visvalingam-Whyatt simplification, GeoJSON ingestion with OSM classification,
topology compilation with junction welding and landmark attachment, token
budgeting with connectivity guarantees, YAML emission, **indoor floor graphs
with vertical circulation**, **place-density heatmaps**, live Overpass, **PMTiles
with a built-in MVT decoder**, **routing demoted to feasibility** (straight-line
distance and direction by default; OSRM turn-by-turn is opt-in for an engine you
have verified — the public demo router returns impossible distances), **keyless weather with
a degraded path that refuses to invent readings**, **traffic with an honest
free-flow fallback**, terrain elevation and slope, **GTFS transit schedules
with three honest service states**, **GTFS-Realtime trip updates joined onto
those schedules with unknown-not-on-time semantics**, MCP over stdio, HTTP API, CLI,
a Python client, and a **task-level benchmark with OSRM ground truth**.
Not implemented: hosted vector tiles with style delivery, and full indoor room
geometry beyond centroids.
---
## License
Apache-2.0TDQS
Scored across 8 tools
Most tools have clearly distinct purposes, but route and get_maplet both mention answering 'how do I get from A to B', which could cause misselection. search_places also overlaps with get_maplet's near-coordinate lookup, though the descriptions clarify cost and output shape.
Most names follow a verb_noun pattern (get_maplet, search_places, expand_feature, get_terrain, get_weather, get_daylight), while route and localize are bare verbs. The convention is still readable and mostly predictable.
Eight tools is well-scoped for a geo/spatial reasoning server. Each tool covers a distinct capability area: routing, map graph retrieval, session localization, place search, geometry expansion, terrain, weather, and daylight.
The surface covers map data, place search, geometry, terrain, weather, daylight, and session-based movement, which is strong lifecycle coverage for agent navigation. Minor gaps remain: no address/place-name geocoding to coordinates and routing is explicitly limited to feasibility rather than turn-by-turn.