This is the second of three posts about building the Urbanist Atlas with Claude Code. The first covered the code: the Go API, the React frontend, and the moment I turned down Claude’s first architectural recommendation.
It ended on a warning. AI rarely hands you something obviously wrong. It hands you something plausible, and if you don’t know the subject well enough to argue, you take it.
I expected the data to be the easy half. I’ve lived in three of the cities in the Atlas and spent years in the advocacy world in two of them. Knowing the places turned out to be enough to notice a wrong answer but nowhere near enough to explain one.
The region graph
Cities are complicated and their boundaries move, and all of that has to fit into a data model that turns a postal code into the advocacy groups covering it. This was my favorite part of the project, because it’s the subject of the career I’m moving into — after thirteen years as a backend engineer, I’m starting a master’s in sustainable urban mobility in Lisbon.
I modeled the United States first, where I could check the results against places I know. ZIP codes were the obvious anchor, since that’s what a visitor types in, but a ZIP code is a postal delivery route rather than a place anyone organizes around, so it can’t be the unit the whole model hangs off of.
The Census publishes a ready-made alternative: the Metropolitan Statistical Area (MSA), a city plus the counties that commute into it. It’s a functional geography — drawn from where people actually travel, rather than from an administrative line — and for a while I assumed it was my answer. It isn’t, because advocacy is more local than that. The 11217 ZIP code is Brooklyn, but it’s also Park Slope, which has no border you can look up anywhere. And 11217 sits inside the New York-Newark-Jersey City metro area, which the Census draws across two states — while Stamford, Connecticut commutes into Manhattan from outside that boundary, and Hartford, two hours further up I-91, isn’t part of the New York region at all.
None of that is strictly hierarchical, so I started a brainstorming session with Claude: “let’s start by modeling New York City, Chicago, and San Francisco.” I’ve lived in or visited each city enough to know when an answer was wrong.
I went in with two rules for myself. No hard-coding ZIP codes to organizations, because that doesn’t scale, and no national organizations. That second one was an editorial call rather than a technical one: a national group has a communications budget and will find you on its own, while the point of the Atlas is to discover the chapter meeting eight blocks away that you’d otherwise never hear about.
The region-graph design doc has the thorough version. Claude’s first proposal was a stack of tiers: a ZIP code sits inside a city (or a borough, in New York’s case), and a city sits inside a state. That’s a reasonable place to start — it’s how an envelope is addressed, and it handles Brooklyn fine.
It breaks as soon as you push on it. What about an organization that covers a hundred ZIP codes? I wasn’t going to build something as brute-forced as:
- Riders Alliance: 10001, 10002, …
Or with ZIP code as the key:
- 10036: Riders Alliance, Transportation Alternatives, etc.
I needed something that could hold all the layers a real place stacks up in — neighborhood, borough, city, county, transit district, metro — without enumerating every combination by hand. What Claude and I landed on was a directed acyclic graph (DAG): every one of those layers is a node, and each node declares its parents. A postal code anchors to the smallest region I’ve curated for it, and a lookup then walks the graph upward from there, collecting the advocacy orgs attached to every region it passes through.
Take my own ZIP code. With no Park Slope border to curate against, 11217 anchors at Brooklyn, the smallest unit around it with a real border. Brooklyn declares two parents: New York City and New York State. New York City’s parent is the New York metro, and above that sits the Tri-State Region. New York State has no parent at all — so Brooklyn reaches the state by its own direct edge instead of climbing through the metro, and that asymmetry is the whole trick.
A lookup for 11217 returns Brooklyn groups, then citywide New York City groups, then New York State groups, and the regional organizations above them — TransitCenter at the metro tier, and the Tri-State Transportation Campaign above that.
That last part is correct, not a bug that quietly widens the search. The Tri-State Transportation Campaign’s remit also covers New Jersey and Connecticut, so from Brooklyn it’s a group you should know about. What a Brooklyn search doesn’t return is New Jersey’s own groups: Bike JC, the New Jersey Bike & Walk Coalition and Trenton Cycling Revolution hang off the New Jersey state node, which isn’t an ancestor of Brooklyn, so they never surface.
Tri-State in, Bike JC out. If I’d parented New York State under the metro instead, both would come back, and you only catch that by naming real organizations and asking whether you’d actually want them in the results.
The same shape handles messier cases without special-casing any of them. Chicago is the clearest example: Chicagoland spills across the state line into northwest Indiana, so the Census metro area is officially an Illinois-Indiana one.
The RTA, the transit district that funds the trains and buses there, stops dead at the Illinois border: Cook County plus five collar counties and nothing else. That’s a third kind of boundary — not functional, not administrative, but the reach of a service and of the body that governs it. I only knew it existed because I spent years in Chicago’s advocacy world. Two different shapes over roughly the same place, so they became two different nodes instead of one blurred tier.
The RTA is itself mid-transition: Illinois’s new Northern Illinois Transit Authority formally takes over on September 1st, a month after this post goes up. The Atlas data says RTA, which is right today but won’t be for long.
A search from downtown Chicago picks up both nodes. A search from Gary, Indiana picks up the metro and Indiana’s groups but never the RTA and never Illinois — this is right, because Gary sits inside the metro but outside the RTA’s service area and its governance.
None of it is hard-coded to the US. Canada gets the same graph with no county layer to lean on, so postal codes anchor at the FSA (Forward Sortation Area, the smallest postal geography Canada publishes boundaries for) and match to the CMA (census metropolitan area, Canada’s equivalent of an MSA) whose boundary they overlap most — unless I’ve curated a city underneath, which takes precedence. Ottawa-Gatineau, straddling Ontario and Quebec, reuses the cross-border shape from NYC and Chicago.
I modeled Portugal because I was about to move to Lisbon and wanted to understand where I was going, and because nobody designed Portuguese administrative geography with North America in mind. The Área Metropolitana de Lisboa is a real authority of eighteen municipalities, but for EU statistical reporting Portugal split that same ground in two at the Tejo in 2024: Grande Lisboa to the north, Península de Setúbal to the south. So the metro has two parents, for the same reason Brooklyn does. Madeira needs the reverse — it’s an autonomous region sitting parallel to that tier, with no parent at all. None of it is live, though: the seed sits in the repo next to the US and Canadian ones, but the running API won’t load it until the editorial coverage is there.
I’m proud of the region graph. But designing the shape was only half the job — it still had to be built, out of real government data rather than a few hand-picked cities I happened to know.
Generating the graph
The region graph isn’t hand-written. A pipeline reads six staged upstream files and generates every data file the API ships with. Four are American: the US Census Bureau’s metro delineation list, two crosswalks from the 2020 census tying ZCTAs (ZIP Code Tabulation Areas, the Census’s stand-in for a ZIP code) to counties and places, and the Department of Housing and Urban Development’s ZIP-to-county file. Two are Canadian, both Statistics Canada boundary shapefiles. One command per country rebuilds all of it: 393 US metros and 41 Canadian ones, 530 rows once the cross-border portions are counted separately, plus the anchors for over 40,000 postal codes.
I was insistent on one property here. The generator had to be deterministic — same source data in, byte-identical files out — so that when a regenerated file comes back with a diff (a line-by-line comparison of what it used to say against what it says now), the upstream data really changed. A check fails the build if a regenerated file doesn’t match what’s committed, so nobody, me included, can quietly hand-edit generated data and have it survive.
This is how I ended up staring at Connecticut. At lunch with some of my wife’s extended family, who live in Stamford, CT, I asked for their ZIP code and tried it. It rolled up to the State of Connecticut and stopped, when I’d have expected Tri-State orgs too. Stamford is 45 minutes from Grand Central and you’re trying to tell me this isn’t part of the NYC metro? Something was wrong, so I went digging.
Two of the federal datasets I was combining turned out to be different vintages of the same geography — snapshots of the same map taken years apart. The Census Bureau had swapped Connecticut’s eight counties for nine new planning regions in 2022, and only one of the two files had caught up. That swap wasn’t housekeeping, either: the planning regions are the state’s councils of governments, and Connecticut abolished county government in 1960, so its counties had been empty statistical containers for 60 years. Neither file was wrong. They just disagreed about what year it was.
The join between them returned nothing at all, which is the worst possible outcome, because the model still looked like it was working — it just returned the state. Hundreds of ZIP codes sat at the state line instead of in their actual metro, and they’d have stayed there if I hadn’t gone looking. Reconciling the vintages didn’t take very long once I understood the problem, but no amount of AI review was ever going to catch it.
That was only half of it. The Stamford ZIPs now landed in their own metro, Bridgeport-Stamford-Danbury, and still surfaced no Tri-State orgs, because nothing in that metro’s record pointed up at the Tri-State region. I gave it that parent, and it’s the only Connecticut metro that has one — Hartford, New Haven, Waterbury and Norwich all stop at the state. Fairfield County commutes into Manhattan; the rest of Connecticut, quite reasonably, does not consider itself part of the New York region.
The Canadian side broke differently. Matching each FSA to the CMA it overlaps with most sounds like a lookup but is really a measurement: clip the FSA’s boundary against the metro’s, then measure how much shape is left over. A library does the clipping. Nothing in it measures the area, so that part I had to write.
A clipped shape isn’t one outline. It’s several — an outer boundary, anything punched out of the middle like a lake or a river, and any islands sitting inside those. So something has to decide which outlines are holes, because those come off the total rather than onto it.
The tempting shortcut is to read the direction each outline was drawn in, since a hole is conventionally drawn the opposite way round from the boundary it sits in. Do that and the holes subtract themselves. That’s the assumption that breaks, though: the clipping library hands back outlines in whatever direction the algorithm happened to trace them, so a hole can come back drawn the same way round as the boundary around it, and its area gets added instead of subtracted.
Nothing crashes. Nothing throws an error. The number just comes back too big, and that number is what decides which metro an FSA belongs to.
I found it by hand, and fairly close to accidentally. I didn’t really know all that many Canadian postal codes, so I was just typing in ones I’d found online for cities I knew, like Windsor, Ontario, which I used to visit when I still lived in Detroit. One of those runs came back wrong. Nothing had actually shipped wrong up to that point, but nothing would have told me if it had, and that is a much worse feeling than a failing test.
Claude and I fixed it by throwing out the drawing direction entirely and reading the nesting instead. The code counts how many of the other outlines each outline sits inside: an odd number means it’s a hole, so its area comes off the total; an even number, including zero, means it’s solid, so it gets added. Which way it was drawn never enters the calculation.
The picture below uses a shape the Canadian data really produces — an FSA along the Ottawa River, where the river is a hole to subtract and an island in it is solid ground to add.
And then that fix had its own plausible-looking bug.
To ask whether one outline sits inside another, you need a point that’s definitely inside the first. The obvious candidate is one of its corners, and that is exactly the wrong choice, because two outlines in a clipped shape can meet at a corner. A code-review pass by a fresh agent caught it on a test polygon: true area 9,200, measured 10,800. The hole had been counted as solid. I was already struggling to hold all of this in my head, so seeing that had me throw my hands up a bit.
The AI wrote plausible geometry code very fast, then wrote a plausible fix for it, and I signed off on both. Plausible is not the same thing as correct, and the only thing standing between the two was someone taking a deliberate second look: me on the first bug, a fresh review agent on the second.
Cataloging the organizations
Cataloging 236 organizations across two countries could easily have taken months on its own. AI let me do it, along with the rest of this project, in a little over a month. The data still had to be verified, though.
First I had to decide what belonged, so I defined a set of criteria that went into every request to AI (the published list is on the About page).
- Geographic focus — a defined region, because the best advocacy happens locally
- Active advocacy — visible public-facing work in the last 12 months, including campaigns, hearings, testimony, organizing
- On-topic — transit or safe-streets advocacy, the two scopes the Atlas exists to index
- No government agencies — DOTs, transit boards, and their subsidiaries
- No consultancies — advocacy has to be the primary product
- No housing or YIMBY — a neighboring movement, but a tighter scope keeps local search useful
Those six rules look like an implementation detail. They’re closer to the definition of the Atlas itself — what belongs, what doesn’t, and therefore what the whole thing is for. Every one is a call I had to make before any AI research was worth anything.
Frankly, having to cut any organization doing good work from this list was a difficult choice. Housing and YIMBY groups are the ones I keep second-guessing. More housing brings down living costs and can reduce car dependency, and those groups often show up right alongside safe streets advocates arguing for the same sustainable land use. Open New York is the sharpest case: a group I really like, whose whole argument is for building more homes where transit access is best. Leaving them out is the call I went back and forth on the longest, and it’s one I could see being challenged on.
I still think it’s the right call. There are a lot of organizations whose focus on safe streets and transit is explicit, and they’re what the Atlas is for. A directory that indexes everything adjacent stops being useful for the thing you came to it for. Losing sight of that, even when the call is a hard one, is what would actually weaken it.
In practice I hold “active” to a harder line: the evidence has to be on the organization’s own site or social presence, not in external press. A group other people still write about but that hasn’t posted in two years isn’t one I can confidently send someone to.
That test has a bias worth naming, though. It measures visible activity, which isn’t quite the same as activity: a volunteer-run group doing real work with a neglected website reads as dead to me. So the Atlas skews toward organizations with the capacity to keep a web presence up — bigger, newer, better resourced, English-language. It’s a proxy, and I haven’t found a better one that a single person can actually check.
PedSafe Vegas is the one I found hardest. It’s a pedestrian-safety program run out of UNLV’s transportation research center, and it clears all six of the rules above: it’s local, it’s active, it’s squarely on topic. UNLV is a public university, but rule four is about transportation agencies — DOTs, transit boards — not every organization with public money behind it. I excluded PedSafe anyway.
The reason isn’t in the six. Bike Walk Chattanooga sits under Bike Walk Tennessee without incorporating separately, so it doesn’t get its own entry, and once I’d decided that, I had to decide the same way about a program housed inside a university. Underneath those six rules is a pile of precedent I built one organization at a time, and I only find out what the next rule is when something like PedSafe forces it.
The Southern Nevada Bicycle Coalition was admitted for Vegas instead, so the city still has coverage and nobody looking up a Vegas postal code would ever know PedSafe was considered. That’s the part that nags at me — the cost of an editorial line is invisible to the people it lands on. These are hard calls to be making alone, and where I’d like this to end up is somewhere more democratic than one person’s judgment. I don’t yet know what that looks like in practice. The submission desk I describe below is a small start, and it’s still me approving.
So, using the rules above, here’s the typical workflow for adding new organizations.
- Prompt: “Please do a deep dive into organizations that are focused on advocating for safe, sustainable, and human-focused city design and transportation. Ensure that organizations follow the editorial rules defined. Review social media presence to determine activity. Ask me if you’re unsure about a specific org.”
- Prompt (new agent): “Review all of the orgs found in the recent pass. Ensure they meet the criteria. Check the websites and social media presence for activity”. Then I’d review every org myself and run a custom-built
just verify-org-urls, which spins up an Alpine Linux container routed through a WireGuard VPN tunnel, hits every URL in the dataset, and flags anything dead, redirected off-domain, or parked. Anything I couldn’t verify came out. This is deliberately boring, deterministic tooling: rather than asking an AI whether a URL looks valid, I ask a machine whether it actually resolves. - A third pass looks for orgs I’d missed in a given region, or falsely excluded, and then there’s another review step.
AI was genuinely good at the first half of that loop, casting a far wider net than I could have alone. It was never the authority on whether an organization actually belonged. That call, and the verification behind it, will always be mine.
I also added a submission desk. When I approve a visitor-submitted org, a worker opens a GitHub pull request adding it to the dataset, so I review a diff instead of a raw report. Because the data is baked into the binary, it goes live on the next deployment.
Did Claude hallucinate?
Yes, occasionally. I didn’t encounter organizations that appeared to be entirely fabricated, but it guessed at URLs that didn’t exist and handed me metadata that wasn’t accurate. Human review and the later agent passes caught those — which is the distinction that mattered through the whole effort: AI-assisted research isn’t AI-verified research.
Closing thoughts
Every one of those 236 entries is a room full of people somewhere, arguing about a bus route. I spent a month building a machine to find those rooms, and it’s genuinely good at it. I found Riders Alliance the slow way, in an online thread full of New York transit obsessives, which is exactly the errand the Atlas now runs for you in about a second.
Finding these groups is only the first step, and it’s the one I could automate. Joining them is harder: turning up to meetings, going to the gala, giving them money. No directory does that step for you, and it’s the only step that has ever changed a bus route.
So use AI, and use it aggressively where it makes you faster. It made me faster. On the two occasions in this post that actually mattered — a join that silently returned nothing, a polygon that measured too big — faster is precisely what would have shipped the error.
Cities are made and remade every day by the people living in them, in ways no dataset fully captures, and the parts that matter most are usually the parts somebody has to tell you about.
The last post in this series steps back from the Atlas itself: where AI fell short, what the month cost me in money and energy, and the five lessons I keep coming back to.