Inspiration

We had one weekend of viewings, in a city none of us had lived in, and a spreadsheet.

The question was simple and nothing could answer it: where can all four of us actually live?

Property sites filter by price and bedroom count. But nobody is unhappy about a bedroom count. They're unhappy because the school run takes forty minutes, and because that got decided once, in a hurry, from three timezones away, and then paid for every morning for three years.

So we guessed. Everybody guesses.

It's a constraint satisfaction problem and it deserved a solver, so we wrote one. Every requirement becomes a comparison over a 250-metre grid of the real city: real schools from Google Places with their real ratings, real gyms, real parks, and real terrain, so a hill costs the minutes it actually costs. Where everyone's requirements overlap, a volume lights up over the photorealistic map.

Then it goes to zero. Six honest requirements from four people usually don't fit in one city.

Every tool we'd used treats that as a dead end. Empty list, try removing a filter. But "nowhere works" is genuinely useful information, as long as you also say which two requirements are fighting and how much one has to give. So the page tests dropping each requirement, then every pair, then sweeps the blocking thresholds to find the smallest concession that reopens the map. It draws both options at once, in the colours of the two people involved.

That's the point where WebMCP stopped being a technology choice.

The solver can tell you bending one limit reopens more ground than bending the other. It can't tell you that one of those limits is a bad knee and the other is a fourteen-year-old walking to school by herself. It can't know that the cheaper concession is the harder one to live with. Somebody has to say "hers is cheaper, but hers is the medical one" — and a slider is never going to say that.

But it doesn't work from a server either. The agent offers two futures, the page draws both over the real city, and the person picks by looking at them. What's being compared is a shape on a map, not a number in a response. The state and the solver have to sit where the person's eyes already are.

The loop runs both ways too, which is the part we didn't expect to matter as much as it does. People don't wait their turn. They drag a threshold while the agent is still talking, delete something, accept an option on their own. Those edits are real and they aren't the agent's. So the page logs who changed what and hands the agent everything a human did since its last turn.

What it does

Relocates.io finds the part of a city where everyone in a household gets what they need at the same time, draws it on a photorealistic 3D map, and when that region doesn't exist, tells you exactly which requirements collide and how far one has to move.

You can describe your household to an agent or build it by hand. Both drive the same state.

Pick a city. It builds a 250 m grid and loads the terrain.

Add people, then their requirements. Not filters — requirements that belong to someone, each carrying their own reason in their own words. "She walks alone, it's her independence." "It's his knee, not a preference." That sentence matters later.

Watch where they overlap. The intersection is contoured and extruded over the real buildings. The area in km² is the number on screen.

When it hits zero, you get options, not an error. The page finds what's blocking, works out the smallest movement that reopens the map, and draws both candidate futures at once with the area each one buys.

You choose. Click one and it resolves. Both numbers were already on screen — what the agent adds is which concession is cheaper, which is easier, and why those aren't the same.

Then check a real listing. Paste an address, get pass or fail on every requirement with the margin, plus the nearest spots that would have worked. Or search homes restricted to inside the feasible region, so it can't return somewhere that fails one of your family.

The 3D isn't decoration. If you can't fly out for a third viewing, the two things you most need to see are what a street looks like and how steep it is, and a flat map shows neither. Walking times run through Tobler's hiking function over real elevation, so a kilometre uphill costs about 40% more than a flat one. The hill you can see is the hill that makes the school walk eleven minutes instead of seven. Same dataset drives the picture and the arithmetic.

How we built it

Next.js 16, React 19, TypeScript, Google Maps Platform, WebMCP. About 4,600 lines. No server, no database, no API routes.

Browser tab
├── Grid       250 m lattice over the search area
├── Layers     one Float32Array per question — walk time, commute, grade
├── Solver     masks · marching squares · diagnosis · relaxation sweep
├── Map3D      photorealistic tiles + pooled extruded volumes
├── WebMCP     18 tools on document.modelContext
└── Cache      IndexedDB — a city is learned once

Everything runs in the browser through the Maps JS SDK and caches locally. Nothing about the household leaves the page. That started as a constraint and turned out to be the reason the whole thing works: an agent can reach into live state and get an answer in the same tick.

Places and Elevation are real. Commute times are modelled from distance and mode rather than routed, and we say so on screen. We skipped the Routes API on purpose. Real routed commutes meant thousands of billed matrix elements per origin and several seconds of latency on every threshold change, which would have killed the thing that makes the sweep feel like an instrument.

Two functions carry the project. diagnose.ts answers why there's nowhere to live. With a dozen requirements over a few thousand cells there's no need to be clever, so it tests every leave-one-out combination, then every pair if none of those work. Exhaustive and exact. frontier.ts answers how far a requirement has to bend, sweeping its threshold across the grid for the smallest movement that opens any cell, then the area gained at every step after. It returns a curve, not a point, so the agent can describe what a concession costs.

Both measure off the grid. Neither estimates. That's the whole technical claim, so it lives in the code rather than in a paragraph.

The map uses Map3DElement with regions as extruded Polygon3DElement volumes and drawsOccludedSegments on, so buildings never hide the answer. Those are DOM nodes, not batched geometry, so they're pooled at startup and mutated in place.

Three things about the tools mattered more than the rest. Descriptions say when to call something, not just what it does: "call this whenever the area is 0, before telling the user anything." Every requirement carries a required rationale field. And get_state returns humanChanges, so the agent finds out what a person did while it wasn't looking.

Challenges we ran into

deck.gl doesn't work over photorealistic tiles. The obvious stack was a dead end. Overlays float above the terrain instead of sitting on it. We dropped deck.gl for native Polygon3DElement, which Google renders against the same terrain as the tiles. Found it with a throwaway HTML file before writing any real code, which saved a day.

Google has no ratings for schools. Gyms, parks and supermarkets all return them. Secondary schools returned zero out of eight. Our "4★ or better" filter emptied the pool, so every school requirement became permanently unsatisfiable and the map went permanently blank. Saying "nowhere qualifies" would have been a lie with a straight face, so the filter now drops itself, says so in a banner, and reports the caveat to the agent.

Nobody agrees where WebMCP lives. Drafts disagree on navigator.modelContext versus document.modelContext. We built against navigator, and an agent looking at the live page told us exactly what that produces: "exposes WebMCP capability, but currently provides no WebMCP tools." It now checks document first, awaits each registration, and tears down with AbortSignal.

Photorealistic tiles take no style array. No saturation: -100, so the grey-out when there's no answer had to be a CSS filter on the container. It greys the regions too, but during a conflict there are no regions to keep coloured, so that worked out. The numbers sit in an unfiltered layer above.

Cutting things. Routes API, air quality, saved sessions, multi-city. All had to go to keep one sequence working reliably: requirements, conflict, two futures, a choice.

Accomplishments that we're proud of

The empty state is the product. Treating "nowhere works" as the interesting case is the idea everything is built around, and it held up against real data from a real city.

Every number is real. Every km² and every minute comes off the grid. The agent is told never to state a figure it didn't get from a tool, and the tools are shaped so it never needs to.

Terrain that means something. Tobler over real elevation ties the picture to the arithmetic. The visible hill and the expensive walk are the same hill, and that's what makes the 3D an argument instead of a flourish.

The loop runs both ways. get_state reporting what a human changed is the bit we like most. It treats the person as an actor in the session, not an audience for it.

Being honest where the data is thin. Commute times are modelled and the app says so. The rating filter announces when it can't apply. A stated limitation reads as judgement; an unstated one that a judge finds reads as something worse.

What we learned

Test the unstable thing on day one, on its own. Two of our three worst problems — deck.gl over photorealistic tiles, and where WebMCP actually lives — weren't answerable from documentation and took ten minutes each to settle empirically. Both would have cost a day if we'd hit them late.

For an agent, the tool descriptions are the product. Our highest-leverage change was rewriting them to say when to call something. "Diagnoses infeasibility" never fires on its own. "Call this whenever the area is 0" fires every time.

An agent will do arithmetic unless you give it something better. Left alone it says "this option gives more area", which is a max() the page already ran. The rationale field is what lets it weigh a knee against a teenager's independence. Without it the agent only has numbers, so numbers are all you get back.

Empty results are a design surface. Everything we looked at treats zero results as the search failing. Treating it as the start of a negotiation turned out to be the most interesting thing we built.

What's next for Relocates.io

Real routed commutes. Routes API drops in behind one function. It's a latency problem, not a hard one: precompute per workplace, cache it, and the sweep stays instant.

Air quality. Already working against our key. "Clean air for a kid with asthma" is a real reason people move, and it's about thirty lines away.

Shareable sessions. A household is more than one person. The state should survive being sent to the rest of it, so the negotiation happens between the people it concerns.

Beyond households. Diagnose, relax, explain fits clinic siting, depot placement, office selection. Any site with a constrained search over client-side state can expose those three and get an agent that negotiates instead of filtering. That pattern is more useful than the map.

Built With

  • 3d-maps
  • ai-agents
  • codex
  • constraint-satisfaction
  • d3-contour
  • geospatial
  • google-elevation-api
  • google-geocoding-api
  • google-maps-platform
  • google-places
  • indexeddb
  • javascript
  • marching-squares
  • model-context-protocol
  • netlify
  • next.js
  • node.js
  • photorealistic-3d-tiles
  • react
  • tailwindcss
  • turf.js
  • typescript
  • vitest
  • webmcp
  • zustand
Share this project:

Updates