Place results phrased from where the user is standing
MapMap's nearby_places and reverse_geocode MCP tools take an optional heading_deg, and every result then carries a direction string written to be spoken: "ahead and slightly to your right, about 80 metres". A voice assistant or a pair of smart glasses can read that out. Neither can read out a latitude and longitude, and until we added this, every voice, wearable or robot client had to do the trigonometry itself.
Why is a coordinate the wrong answer for a voice client?
Because a client with no screen has to turn the coordinate into a sentence before it can say anything, and that is geometry rather than language. A map-centric answer is a point, a radius and a distance in metres: the right shape for a pin on a screen, where the user supplies the frame of reference by looking at it. Strip the screen away and the frame of reference has to come from the API, or the client invents one.
Both tools already returned distance_m. Distance alone gets you "there is a cafe 80 metres away", which is most of the way to useless at a junction. What the user needs is which way to turn.
What do the tools return when you pass a heading?
Every hit gains three fields, and the request gains two optional parameters. heading_deg is which way the user is facing, in degrees clockwise from true north: 0 north, 90 east, 180 south, 270 west, the same convention as geo_bearing. fov_deg is a field of view, covered below.
| Field | When it is present | What it is |
|---|---|---|
bearing_deg | always | Geodesic bearing from the queried point to the place, degrees clockwise from true north |
relative_bearing_deg | only with heading_deg | Signed angle from the way the user is facing, negative left, positive right, in the range -180 to 180 |
direction | always | The phrase to speak, direction first and then distance |
A nearby_places call from Trafalgar Square, facing due north, looking for a cafe:
{
"lat": 51.5074,
"lon": -0.1278,
"category": "cafe",
"heading_deg": 0,
"fov_deg": 90,
"limit": 2
}
The structured response, abridged to the fields in question:
{
"results": [
{
"name": "North Cafe",
"lat": 51.5083,
"lon": -0.1278,
"distance_m": 100.0,
"categories": ["cafe"],
"bearing_deg": 0.0,
"relative_bearing_deg": 0.0,
"direction": "directly ahead, about 100 metres"
}
],
"out_of_view": 1
}
Nothing here is a new tool. The inputs are optional and the fields are additive, so a client that ignores all of it keeps working.
How does a bearing become a phrase?
Through five coarse bands on the absolute value of the relative bearing, chosen because the underlying positions rarely justify anything finer.
| Relative bearing, ignoring sign | Phrase |
|---|---|
| under 15° | directly ahead |
| 15° to under 50° | ahead and slightly to your left / right |
| 50° to under 100° | to your left / right |
| 100° to under 150° | behind you and to your left / right |
| 150° and over | directly behind you |
The side is left when relative_bearing_deg is negative and right when it is positive. Distance is rounded on the same principle:
| Straight-line distance | Phrase |
|---|---|
| under 25 m | a few steps away |
| 25 m to under 1,000 m | about N metres, rounded to the nearest 10 |
| 1,000 m and over | about N.N km |
"Slightly to your right" rather than "22 degrees to your right" is deliberate. A place derived from an OpenStreetMap node, reached from a phone's position fix, described using a phone's compass, does not support a one-degree claim, and a voice that makes one is lying with precision. Rounding under 25 m to "a few steps away" is the same argument at the other end.
All of it lives in one pure module with no network and no clock, crates/sn-mcp/src/egocentric.rs, so the strings that get read aloud are reviewable in one place and unit-testable without a server. There are 18 unit tests over the phrasing, the bands, the cardinals and the wrap-around across north, plus four integration tests over the wiring.
Why does the phrasing without a heading avoid the word "you"?
Because the queried point is very often not the user, and a phrase that says "north-east of you" asserts a location the caller never claimed. Without a heading the phrase is "to the north-east, about 80 metres", using an eight-point compass, and it is true whoever is asking and wherever they are.
That distinction came out of shipping it. Our own voice copilot calls reverse_geocode on a point the user tapped, and nearby_places on whatever place is under discussion. Its instructions say that heading-aware answers come only from tools, never from guessing which way the driver is facing, and a test holds that rule in place. A cardinal phrase reading "north-east of you" would have walked the copilot through its own guardrail on a string we handed it. Supplying heading_deg is a claim that the queried point is where the user stands and which way they face, so second person is earned in that branch and only that branch.
What does fov_deg do, and why is it refused without a heading?
fov_deg is the full width in degrees of a cone centred on the heading, and results outside it are dropped: pass 90 and you keep what lies within 45° either side of dead ahead. It accepts 1 to 360, and without a heading_deg it is rejected rather than quietly ignored, with the error "fov_deg needs heading_deg: a field of view is a cone around the direction you are facing, so there is nothing to centre it on without a heading". Silently dropping the filter would hand back results the caller believes it excluded, which is worse than an error.
Two details matter more than the parameter itself.
The cone is applied before the page is cut to limit, and the tool over-fetches to do it. Filter after truncating and "the nearest cafe ahead of me" answers "none" whenever the five nearest cafes sit behind the user while a sixth stands in front. That is a wrong answer produced by correct code, and the kind a voice client repeats with total confidence.
Whatever the cone drops is counted in out_of_view. A non-zero count means there are matching places nearby that are simply not in front of the user, so an agent can say "there is one behind you" instead of "nothing nearby". Those are different answers and only one is true. The field is omitted when it is zero, so it never adds noise to an unfiltered call.
What this does not do
It cannot tell whether your heading is real. We validate that heading_deg is a finite number between 0 and 360, and reject anything else rather than wrapping it, but we cannot validate the truth of it. A client feeding magnetic north instead of true north, or a counter-clockwise angle, gets confidently wrong phrasing with no warning. A phone lying flat on a passenger seat has a compass heading that means nothing, and the tool cannot know that. Our own browser copilot therefore never sends heading_deg: a browser session has no compass, its instructions forbid guessing which way the driver is facing, and the honest answer is the cardinal one.
The bearing is a straight line, not a walking route. bearing_deg is the geodesic bearing from the queried point, and distance_m is straight-line distance. "Directly ahead, about 100 metres" can mean 100 m of walking, or a building, a railway cutting or a river in the way and 400 m round the block. If the answer has to be walkable, route to the place and use the route's distance.
The bands have hard edges and no hysteresis. A place at 14° is "directly ahead" and the same place at 16° is "ahead and slightly to your right", so a user turning slowly on the spot, with an app polling as they turn, will hear the phrase flip. Coarse bands make that rarer than fine ones would, but do not remove it.
out_of_view is a floor, not a census. The over-fetch asks the backend for at most 10 results, so the count reflects that page rather than everything matching within the radius. Treat a count of three as "at least three behind you".
These fields are added in the MCP handler. The gateway's REST endpoint for reverse geocoding, documented in the HTTP API reference, is unchanged. nearby_places also needs the MapMap gateway, since the filtered browse runs on the first-party index rather than the raw geocoder.
Try it
Connect the hosted MCP endpoint and ask for something with a heading. No key is needed to connect, under fair use:
claude mcp add --transport http mapmap https://mcp.mapmap.ai/mcp
Then call nearby_places with lat, lon, a category and a heading_deg, and read the direction field back rather than the coordinates. The full parameter list for both tools is in the MCP tool reference, the argument for building a geo API whose customer is a language model is in maps for AI agents, the connection guide and the rest of the tool surface are in the MCP server post, and the modules that teach a coding agent to use them are in agent skills.
The places themselves come from OpenStreetMap, so credit © OpenStreetMap contributors wherever you show them.
