# ATlas Developer Guide

ATlas (https://atls.city) maps Bluesky accounts to real-world places. This guide covers every way to use that data: the read-only **Location API**, the **labeler**, the **city lists**, the **location lexicon**, and the public **post/stats** endpoints.

This file is plain markdown and meant to be pasted directly into an LLM or read by a script. Canonical URL: `https://atls.city/developers.md`

Contact: [@atlas.bsky.social](https://bsky.app/profile/atlas.bsky.social) for API keys, questions, or corrections.

---

## TL;DR

```bash
# Where is this account? (DID or handle)
curl https://nearyou.atls.city/api/v1/users/pfrazee.com/location

# Not in the index yet? Infer it on the spot from their bio and follow graph (API key required)
curl -H "Authorization: Bearer $ATLAS_KEY" \
  "https://nearyou.atls.city/api/v1/users/someone.bsky.social/location?live=true"

# Who is in Oregon? (all cities in the state, paginated)
curl "https://nearyou.atls.city/api/v1/locations/US-OR/members?include_children=true&limit=100"

# Who is near Salem, OR, weighted by distance?
curl "https://nearyou.atls.city/api/v1/locations/US-OR-Salem/members?radius_km=100"

# Which state/city does this person's follow graph point to? (API key required)
curl -H "Authorization: Bearer $ATLAS_KEY" \
  "https://nearyou.atls.city/api/v1/users/pfrazee.com/affinity?level=region"

# Batch: up to 500 accounts per location lookup, 100 per affinity call
curl -X POST -H "Content-Type: application/json" -d '{"actors":["pfrazee.com","did:plc:..."]}' \
  https://nearyou.atls.city/api/v1/users/locations
```

Base URL: `https://nearyou.atls.city/api/v1`. All responses are JSON (exports are NDJSON). CORS is open. Batch endpoints take a JSON body (`Content-Type: application/json`).

---

## 1. Where the location data comes from

### How it works

1. **Ground truth.** People tell ATlas where they live (atls.city or the `city.atls.actor.location` record). Those locations are treated as correct, at confidence 1.0.
2. **Signals.** For everyone else we look for evidence of where they live:
   - what their profile bio says;
   - who follows them, meaning whether people known to live in one city follow them disproportionately;
   - who they follow, meaning whether they follow an unusual share of one city's locals;
   - which city hashtags they post with;
   - which city lists other users put them on.
   Each piece of evidence is a **placement**: one account, one location, one source.
3. **Locals grow the graph.** "Known locals" starts with ATlas users and also includes accounts whose bio clearly names their city. More locals means the follow signals reach more people, and the accounts those locals follow get their bios checked too.
4. **Calibration.** Every source's confidence is measured, not guessed. We check each group against people whose location we already know (ATlas users and clear bio matches) and set its confidence to how often it was right. **0.8 means right about 80% of the time.** The follow signals use a fitted curve instead of fixed levels, because their accuracy rises smoothly with how many locals back the city and how concentrated those locals are; the curves are refit weekly.
5. **Combination.** When an account has several placements, they're weighed together into `combined_confidence`. Agreement raises it and conflict lowers it. `best_location` and `best_region` are the results.
6. **Freshness.** Bio matches expire after 30 days unless they're re-confirmed (they are re-checked automatically, and live when someone edits their profile). Follow signals are rebuilt on daily and weekly cycles. The API's index reloads every 10 minutes.

**Which threshold to use.** For most products, use `best_region` or `best_location` with confidence **0.7 or higher**. Use 0.85+ when a wrong guess is costly, and 0.5+ for soft signals like ranking or suggestions. Measured on accounts whose city we know from a strict bio match, scored with their bio hidden (so only follow and hashtag signals count): at 0.7+ the city was right 85% of the time and the state 90%; at 0.85+, 90% and 94%; at 0.9+, 92% and 96%. Our own feeds use these scores: Near You Live only shows authors at 0.85+, and the ranked Near You feed admits authors from 0.7 and scales each post's ranking by its author's confidence. Live counts are in `GET /meta` under `coverage` (`located` uses the 0.7 cut).

**What it can't do.** Locations are where someone appears to live, inferred from public signals. They can be stale if someone moves, they're weaker outside the US, and they're never interests (see affinity for that). Treat anything below 1.0 as a probability, not a fact.

### Sources

Every located account has one or more **placements**. A placement is `(account, location, source, confidence)`. Sources, from most to least trustworthy:

| Source | How it's determined | Confidence | Approx. accounts |
|---|---|---|---|
| `atlas` | The person tagged their own location on atls.city (or wrote a `city.atls.actor.location` record). Up to 3 per person, one primary. | 1.0 | ~2,000 |
| `follow_graph` | **Followed by** locals: accounts that confirmed ATlas users in one city disproportionately follow. Only accounts followed by 2+ locals are included. See `evidence.method` below. | 0.05 to 0.93, from a curve (see below) | ~70,000 |
| `bio` | Location text in the profile bio (for example "Portland, OR", city hashtags, admin-curated local terms), or a clearly stated US state when no city is named ("living in Michigan", "📍 New Mexico"). Renewed before it expires while the bio still matches, and re-checked live whenever anyone on Bluesky edits their profile; a match is removed when the place is edited out of the bio. Strict matches named the exact city for 89% of ATlas users we checked (97% the right state); state-only matches named the right state for 29 of 31. `evidence.match_strength` is `strict`, `state`, `loose`, or `ambiguous`. An `ambiguous` match is a city name shared by several places ("Portland"): it is placed when another source points at one of the same-named cities (`evidence.resolved_by` names that source; 85-97% right when tested), or at the largest of them when the bio clearly states it as a location, for example "📍 Portland" (`resolved_by: "largest_city"`, 32 of 40 ATlas users). | 0.85 strict, 0.7 state-only, 0.5 loose, 0.85 ambiguous confirmed by another source, 0.6 ambiguous alone | ~97,000 |
| `hashtag_author` | Account repeatedly posts with a city's hashtags (for example `#pdx`) in a rolling 30-day window. Drops out if they stop. Lower when their city hashtags are split across several cities. | up to 0.85 | ~5,000 |
| `crowdsource` | Account appears on multiple users' city lists inside Near You. | up to 0.6 | small |
| `follows_locals` | **Follows** locals: accounts whose follows include an unusual number and share of confirmed ATlas users from one city (at least 3, and 60%+ of the ATlas users they follow). The opposite direction from `follow_graph`. City confidence comes from a curve over the share of followed ATlas users in that city (about 0.63 at 60%, 0.82 at 95%). When no city qualifies but the ATlas users they follow are concentrated in one state, they are placed in that state instead (`evidence.level: "state"`, not yet measured). A second method, `evidence.method: "anchored"`, uses accounts with a strict bio match as the locals instead of ATlas users (3+ followed, 60%+ in one city or state); both its city and state confidence come from curves fit against strict bios (about 87% agreement overall). | 0.63 to 0.93 city (curve), 0.5 state (curve for `anchored`) | ~19,000 plus `anchored` (growing weekly) |

Notes:
- **Confirmed ATlas users always win.** If someone is an ATlas user, their inferred placements are dropped.
- **`follow_graph` evidence** includes `local_followers`, `concentration` (the share of their located followers in that city), `meets_ingestion_threshold` (the same bar the Near You feed uses), and `method`:
  - `direct`: followed by confirmed ATlas users. Confidence follows a curve (below): for example 0.17 with 2 local followers at 30% concentration, 0.61 with 2 at 95%, 0.86 with 5-9 at 90%+, up to 0.93.
  - `anchored`: the same idea, but the "locals" also include accounts with a strict bio match, not just ATlas users. This finds far more accounts. 60%+ concentration is required. Curve examples: 0.59 with 2 local followers at 60-75%, 0.74 with 2 at 95%, 0.84 with 3-4 at 95%, 0.92 with 5-9 at 95%, 0.93 with 10+. Evidence adds `atlas_followers` and `bio_anchor_followers`. Refreshed on a weekly rotation.
  - `direct_state`: no single city has 60% of their local followers, but 5+ of them and 60%+ are in one state. Placed in the state at 0.8 (agreed with strict bio on the state 86% of the time). Evidence has `local_followers_in_state` and `cities_in_state`.
- **Confidence curves.** For `follow_graph` (`direct`, `anchored`) and `follows_locals` city placements (plus `anchored` state placements), confidence is `1 / (1 + e^-(a + b·ln(local followers) + c·concentration))`, clamped to 0.05-0.93. `a`, `b` and `c` are fit per method against strict bio matches and refit weekly, so the same evidence can move slightly from week to week as the data grows. In testing the curve matched observed agreement within a few points across every follower-count and concentration bucket. More locals and a higher concentration always mean higher confidence.
- **What confidence means.** Every inferred level is set from how often that group agrees with strict bio matches (which are themselves about 89% right at city level). Read 0.8 as "right about 80% of the time".
- **`follows_locals` evidence** includes `atlas_locals_followed`, `total_atlas_users_followed`, `concentration`, and `first_seen_at`. Results refresh with the daily follow sync. `anchored` placements instead carry `method: "anchored"`, `level`, `anchors_followed`, `total_anchors_followed` and `concentration`, and refresh on the same weekly rotation as `follow_graph` `anchored`.
- **State-level placements** have a state location (`location_type: "state"`, for example `US-MI`) and appear for accounts we can tell are in a state but not which city. A city placement inside that state with equal or higher confidence replaces them. Every city placement already carries its state in `region_key`, and `/locations/US-MI/members?include_children=true` returns both city-placed and state-placed accounts.
- **Combined confidence.** Many accounts have several placements. Each placement's `confidence` describes that one source on its own; `combined_confidence` is our estimate for that location after weighing all of the account's placements together:
  - Sources that agree raise it. For example, bio (0.85) plus `follows_locals` (0.8) on the same city gives about 0.91. When two sources agreed, the city was right 86% of the time, which is about as high as we can measure.
  - Placements elsewhere lower it, more so for a different state than for another city in the same state. When sources disagreed, the strongest was right only 63% of the time.
  - City evidence also counts toward that city's state, so state-level combined confidence is usually higher than city-level.
  - Confirmed ATlas locations are always 1.
  - Without a `raw` key, combined confidence only uses the placements you can see.
  - `/users/:actor/location` returns `best_location` (the city with the highest combined confidence) and `best_region` (the best state). These are the simplest fields to use.
- **Placement is where someone lives, not what they care about.** A placement means "this account is probably located here." Interest in a place (who you follow) is a separate signal, exposed only through the affinity endpoints and never stored as a location.
- **Coverage is global but strongest in the US**, especially large metros. Location types are `country`, `state` (any first-level region), `county`, and `city`.
- **Location keys** look like `US-OR-Portland` (city), `US-OR` (state), `US` (country). Keys are case-insensitive in the API.
- **Refresh rate:** the index refreshes every 10 minutes.

### Access levels

| | No key | API key with `raw` scope |
|---|---|---|
| `atlas` placements | yes | yes |
| Inferred placements whose account carries the matching public ATlas label (see section 5) | yes | yes |
| All other inferred placements (unlabeled, lower confidence) | no | yes |
| `evidence` object on each placement | no | yes |
| Rate limit | 60 req/min per IP | 600 req/min per key |

Additional scopes: `affinity` (the follow-graph affinity endpoint) and `export` (bulk NDJSON downloads). Send the key as `Authorization: Bearer atls_...`.

---

## 2. Location API reference

### `GET /meta`
Index freshness, source list, proximity scoring parameters, what your key can access, and live `coverage`: `atlas_users`, `inferred_accounts`, `located` / `accounts_placed` (accounts with a city or state at combined confidence 0.7+), `located_city_level`, `all_accounts`, `inferred_state_only`, and `inferred_by_confidence` bands.

### `GET /meta/coverage`
Same live coverage fields plus a `daily` array of end-of-day snapshots (`day`, `located` / `accounts_placed`, `atlas_users`, `inferred_accounts`, `all_accounts`, `inferred_by_confidence`) for growth charts. Cached ~60s.

### `GET /meta/scoring`
```json
{
  "decay_k": 50,
  "decay_exponent": 2,
  "default_cutoff_radius_km": 250,
  "max_precomputed_radius_km": 250,
  "formula": "proximity_weight = 1 / (1 + (distance_km / decay_k) ^ decay_exponent)"
}
```
This is the same distance decay the Near You feed uses to rank posts: 0 km gives 1.0, 50 km gives 0.5, 100 km gives 0.2.

### `GET /locations`
Browse location keys with member counts.

Query params: `country` (ISO code, for example `US`), `region` (`OR` or `US-OR`), `type` (`city|county|state|country`, `region` is an alias for `state`), `q` (name search), `has_members=true`, `limit` (default 500, max 10000).

```json
{
  "count": 1, "total_matches": 1,
  "locations": [{
    "key": "US-OR-Salem", "name": "Salem", "type": "city", "country_code": "US",
    "region_key": "US-OR", "region_name": "Oregon",
    "latitude": 44.939, "longitude": -123.033, "population": 155469, "osm_id": "150956539",
    "members": { "total": 22, "bio": 21, "hashtag_author": 1 }
  }]
}
```
`members` counts reflect your access level.

### `GET /locations/:key`
One location, plus `child_cities` for states.

### `GET /locations/:key/nearby?radius_km=250`
Cities near a city, sorted by distance, with the proximity weight and member counts. Max radius is 250 km.

```json
{
  "origin": { "key": "US-OR-Salem", "...": "..." },
  "radius_km": 80,
  "nearby": [
    { "key": "US-OR-Albany", "distance_km": 34.14, "proximity_weight": 0.682, "members": { "total": 3 } },
    { "key": "US-OR-Corvallis", "distance_km": 45.41, "proximity_weight": 0.548, "members": { "total": 12 } },
    { "key": "US-OR-Portland", "distance_km": 70.46, "proximity_weight": 0.335, "members": { "total": 2594 } }
  ]
}
```

### `GET /locations/:key/members`
Accounts placed in a location, ranked by `combined_weight` (highest first).

| Param | Meaning |
|---|---|
| `include_children=true` | For a state or country: include every city under it. **Use this for state-level feeds.** |
| `radius_km=N` | For a city: also include cities within N km (max 250), down-weighted by distance. |
| `sources=atlas,bio` | Only these sources. |
| `min_confidence=0.5` | Drop placements below this confidence. |
| `min_combined_confidence=0.7` | Drop accounts whose combined confidence for this location is below this. Usually the better filter. |
| `rank_by=combined_confidence` | Rank by `combined_confidence * proximity_weight` instead of `confidence * proximity_weight`. |
| `limit` | Default 100, max 1000. |
| `cursor` | Pass the `cursor` from the previous response. |

Each row:
```json
{
  "did": "did:plc:...",
  "location_key": "US-OR-Portland", "location_name": "Portland", "location_type": "city", "region_key": "US-OR",
  "source": "bio", "confidence": 0.85, "combined_confidence": 0.91, "public": true,
  "evidence": { "match_strength": "strict", "matched_term": "portland, or" },
  "distance_km": 70.46, "proximity_weight": 0.335, "combined_weight": 0.285
}
```
- `combined_weight = confidence * proximity_weight` (or `combined_confidence * proximity_weight` with `rank_by=combined_confidence`).
- `distance_km` is `0` for the requested city and `null` for cities pulled in via `include_children` (in that case `proximity_weight` is 1).
- If an account has placements in several matching locations, only its best one is returned.

This reproduces the Near You fallback behavior. A Salem query with `radius_km=100` returns Salem accounts first, then Albany, Corvallis, Portland and so on at reduced weight. You pick where to cut off.

### `GET /users/:actor/location`
`:actor` is a DID or handle.

```json
{
  "did": "did:plc:...",
  "access": "raw",
  "located": true,
  "best_location": { "location_key": "US-MA-Boston", "location_type": "city", "region_key": "US-MA", "confidence": 0.91, "sources": ["bio", "follows_locals"] },
  "best_region": { "location_key": "US-MA", "location_type": "state", "confidence": 0.91, "sources": ["bio", "follows_locals"] },
  "primary": { "location_key": "US-MA-Boston", "source": "bio", "confidence": 0.85, "combined_confidence": 0.91, "...": "..." },
  "placements": [ ... ]
}
```
`best_location` and `best_region` use combined confidence (see section 1). `best_location` is `null` for accounts we only know at state level. Placements are sorted by combined confidence, then confidence. `located: false` means we have nothing visible at your access level. `computed` is `"live"` when this request ran a live lookup (below), otherwise `"index"`.

#### Live lookups (`?live=true`, API key required)
The index covers accounts our crawlers have seen. For an account it can't place, add `live=true` and the server computes a location on the spot from public Bluesky data and **adds the account to the index**. It uses the same signals, thresholds and calibrated confidence curves as the index:
- **Bio:** the profile description run through the bio parser.
- **Who they follow:** how many of the accounts they follow are known locals (ATlas users and strict bio matches), and how concentrated those are in one city or state. Reads up to 3,000 follows.
- **Who follows them:** the same for their followers. Reads up to 1,000 followers; very large accounts are sampled.

```json
{
  "did": "did:plc:...",
  "access": "raw",
  "located": true,
  "best_location": { "location_key": "US-IL-Chicago", "location_type": "city", "region_key": "US-IL", "confidence": 0.82, "sources": ["follow_graph"] },
  "best_region":   { "location_key": "US-IL", "location_type": "state", "confidence": 0.82, "sources": ["follow_graph"] },
  "primary": { "location_key": "US-IL-Chicago", "source": "follow_graph", "confidence": 0.82, "combined_confidence": 0.82, "public": false, "evidence": { "live": true, "method": "anchored", "...": "..." } },
  "placements": [ ... ],
  "computed": "live",
  "live": { "computed_at": "2026-10-06T17:37:30Z", "cached": false, "profile_found": true, "follows_scanned": 412, "followers_scanned": 230 }
}
```
- Accounts the index already places are returned from the index as usual; `live=true` only kicks in when there's nothing to show.
- After a live lookup the account is an ordinary index entry: plain lookups (no `live`), `/locations/:key/members`, the export and affinity all include it, with the same confidence and visibility rules as any other inferred placement. Placements from a live lookup carry `evidence.live: true` (visible with `raw` scope). They're kept for 30 days, the same as bio matches.
- If a live lookup found nothing, asking again within 30 days returns `located: false` with `live.cached: true` instead of recomputing (free and instant).
- A live computation typically takes 0.2 to 3 seconds and counts as 1 extra request toward your rate limit.
- Like other inferred placements, live ones are visible only to keys with `raw` scope unless the account has the matching ATlas label.
- `located: false` with `computed: "live"` means we looked and found nothing usable. Most accounts the index can't place don't follow enough locals or name a place in their bio; expect a live lookup to place roughly 5 to 10% of them. `live.profile_found: false` means the account doesn't exist or is deactivated.
- Accuracy, measured on ATlas users with their own location hidden: at confidence 0.85 or higher the state was right 94% of the time and the city 91%; at 0.5 to 0.84, about 75 to 80%. Treat anything under 0.5 as a guess.

### `GET /users/:actor/affinity` (scope: `affinity`)
Reads who the account follows (via the public Bluesky AppView, up to 5,000 follows) and maps each followed account through the index. Each located follow contributes its best placement's combined confidence as weight.

Params: `level=region|city` (default `region`), `limit` (default 25).

```json
{
  "did": "did:plc:...",
  "level": "region",
  "follows_scanned": 677, "follows_truncated": false, "follows_located": 469, "coverage": 0.693,
  "top_city":   { "key": "US-WA-Seattle", "weight": 35.242, "share": 0.39, "located_follows": 244 },
  "top_region": { "key": "US-WA", "weight": 35.242, "share": 0.394, "located_follows": 244 },
  "own_placements": [],
  "distribution": [
    { "key": "US-WA", "name": "Washington", "type": "state", "weight": 35.242, "share": 0.394, "located_follows": 244,
      "by_source": { "follow_graph": 235, "atlas": 8, "bio": 1 } },
    { "key": "US-OR", "weight": 7.528, "share": 0.084, "located_follows": 41 }
  ]
}
```
- Results are cached per account for 6 hours.
- `share` is the fraction of total located weight. A person who follows lots of Oregon accounts shows `US-OR` with a high share.
- `own_placements` is where we think the person themselves is, which is often a stronger signal than their follows when present.
- Accounts that follow mostly national figures produce a flat distribution with a low top `share`. Treat that as "unknown" rather than forcing a pick.

### `POST /users/locations` (batch)
Look up to 500 accounts at once. Same access rules as the single lookup (a key with `raw` scope returns inferred placements and evidence).

```bash
curl -X POST https://nearyou.atls.city/api/v1/users/locations \
  -H "Authorization: Bearer $ATLAS_KEY" -H "Content-Type: application/json" \
  -d '{"actors": ["did:plc:2sfzopxqkih7jt4mh37tuuow", "pfrazee.com"]}'
```
```json
{
  "access": "raw",
  "requested": 2, "located": 2, "unresolved": [],
  "results": {
    "did:plc:2sfzopxqkih7jt4mh37tuuow": { "actor": "did:plc:2sfz...", "did": "did:plc:2sfz...", "located": true, "primary": { "location_key": "US-OR-Portland", "source": "atlas", "...": "..." }, "placements": [ ... ] },
    "did:plc:ragtjsm2j2vknwkz3zp4oxrd":  { "actor": "pfrazee.com", "located": true, "primary": { "location_key": "US-WA-Seattle", "source": "follow_graph", "...": "..." }, "placements": [ ... ] }
  }
}
```
- `results` is keyed by DID; `actor` echoes what you sent.
- Handles that can't be resolved are listed in `unresolved`. **Send DIDs when you have them**; it's much faster.
- Counts as 1 request per 50 actors toward your rate limit.
- Add `"live": true` to the body to run live lookups (API key required, see above) for accounts the index can't place. Up to 25 accounts per request are computed live. Any more come back unplaced with `"live_skipped": "batch_limit"`; send them again in the next request. Each computed account counts as 1 extra request. The response then includes `live_computed` (how many were computed this time), and computed accounts have `computed: "live"` and a `live` object.

### `POST /users/affinity` (batch, scope: `affinity`)
Follow-graph affinity for up to 100 accounts at once.

```bash
curl -X POST https://nearyou.atls.city/api/v1/users/affinity \
  -H "Authorization: Bearer $ATLAS_KEY" -H "Content-Type: application/json" \
  -d '{"actors": ["pfrazee.com", "jay.bsky.team"], "level": "region", "limit": 3}'
```
- Body: `actors` (required), `level` (`region` default, or `city`), `limit` (distribution entries per account, default 5, max 50).
- `results` is keyed by DID; each entry has the same shape as the single affinity response. Accounts whose follows couldn't be fetched are listed in `failed`.
- Each uncached account counts as 1 request toward your rate limit. Cached accounts (computed in the last 6 hours) are free and instant.
- Expect a few seconds per batch. Accounts with thousands of follows take longest, and the server caps how many Bluesky calls it makes at once. For large viewer sets, send batches sequentially and cache results on your side.

### `GET /export/placements.ndjson` (scope: `export`)
Every placement, one JSON object per line (about 190k lines, about 35 MB). Params: `sources`, `min_confidence`, `min_combined_confidence`, `public_only=true`.

```json
{"did":"did:plc:...","location_key":"US-WA-Seattle","location_type":"city","region_key":"US-WA","country_code":"US","source":"atlas","confidence":1,"combined_confidence":1,"public":true,"is_primary":true}
```

### `GET /export/nearby-cities.ndjson` (scope: `export`)
About 159k city pairs within 250 km: `{"city_a":"US-OR-Salem","city_b":"US-OR-Albany","distance_km":34.14}`. Combine with `/meta/scoring` to apply the same distance decay offline.

### Errors
`400` bad params, `401` missing/invalid key (`ApiKeyRequired`, `InvalidApiKey`), `403` key lacks scope, `404` unknown location/actor, `429` rate limited, `502` Bluesky AppView unavailable, `503` `WarmingUp` for the first ~15 seconds after a server restart (honor `Retry-After` and retry). Body: `{ "error": "Code", "message": "..." }`.

---

## 3. Recipe: recommend a state/district election feed

Goal: for each viewer, pick the most relevant state feed (for example "Oregon election results") and surface it (sticky post, recommendation, and so on).

1. **The person's own location first:** call `GET /users/:did/location?live=true` (with a key; without one, drop `live`). If `best_region` exists with confidence of 0.7 or more, use `best_region.location_key`.
2. **Otherwise use their follow graph:** call `GET /users/:did/affinity?level=region`. If `top_region.share >= 0.35` and `follows_located >= 10`, use `top_region.key`.
3. **Otherwise** show a generic national feed, or ask the user.
4. **Building the feed's author pool**, for example Oregon voices:
   - `GET /locations/US-OR/members?include_children=true&min_combined_confidence=0.6` (paginate), or
   - download `/export/placements.ndjson` once and filter `region_key == "US-OR"`.

For many viewers at once, use `POST /users/locations` for step 1 and `POST /users/affinity` only for the accounts that step 1 couldn't place.

Tune the thresholds to taste. These are starting points, not validated cutoffs.

### What stays on your side
- **Topic filtering.** ATlas gives you people and places, not politics classification.
- **Congressional districts.** ATlas resolves to city, county and state. Mapping a city to a district needs a separate crosswalk; large cities span several districts.
- **Thresholds.** What confidence, share or proximity weight is "enough" is your call.

---

## 4. City and interest lists (did:web lists)

ATlas serves AT Protocol lists of **confirmed ATlas users** per location from `did:web:lists.fema.monster`. These work anywhere a list URI is accepted, for example Graze `list_member` filters.

```bash
curl "https://lists.fema.monster/xrpc/app.bsky.graph.getList?list=at://did:web:lists.fema.monster/app.bsky.graph.list/US-OR-Portland&limit=100"
```
- The list key is the ATlas location key (`US-OR-Portland`, `US-OR`, ...).
- Interest lists use the interest key instead (no dashes).
- Paginate with `cursor`.
- Lists contain only `atlas` (self-reported) members, not inferred accounts. For inferred accounts use the Location API.

---

## 5. The ATlas labeler

Labeler DID: `did:plc:l37i5se642dgeb7kmrdwoqv4` (Ozone at `https://ozone.atls.city`).

| Label | Applied to | Meaning |
|---|---|---|
| `us-or-portland` style city/state/country labels (lowercased location key) | ATlas users | Self-reported location |
| `atlas-user` | ATlas users | Member of the ATlas directory |
| `follow-graph` | Inferred accounts that pass the follow-graph ingestion threshold | Placed by follower network |
| `bio-location` | Accounts with an active bio match (not already follow-graph) | Placed by bio text |
| `hashtag-author` | Accounts with 5+ city-hashtag posts in 30 days | Placed by hashtag use |
| `crowdsource` | Accounts on enough users' city lists | Placed by community lists |

`follows_locals` placements have no label, so they are visible only with a `raw` key.

Source labels do **not** name the city; use the Location API for that. Query labels directly:

```bash
curl "https://ozone.atls.city/xrpc/com.atproto.label.queryLabels?uriPatterns=did:plc:2sfzopxqkih7jt4mh37tuuow"
```

Or subscribe via `com.atproto.label.subscribeLabels` on the same host.

---

## 6. The location lexicon

ATlas users' locations are also written to their own PDS as `city.atls.actor.location` (rkey `self`), wrapping `community.lexicon.location.address`. Anyone can read them without auth, and any app can write them. ATlas watches the firehose and picks up writes automatically.

Full guide: https://atls.city/location-lexicon.md

Resolve a place name to an ATlas key:
```bash
curl "https://atls.city/api/locations/lookup?q=salem&country=US"
```

---

## 7. Other public endpoints

| Endpoint | What it returns |
|---|---|
| `https://atls.city/api/locations/lookup?q=` | Resolve city names to ATlas keys (CORS open) |
| `https://atls.city/api/map-locations` | All locations with coordinates, user counts, and 1h/6h/24h/7d local post counts |
| `https://atls.city/api/heatmap-data?window=24h` | Post volume per location |
| `https://atls.city/api/map-posts/:locationId` | Recent local posts for a location (used by the ATlas map) |
| Near You feed | `at://did:plc:l37i5se642dgeb7kmrdwoqv4/app.bsky.feed.generator/near-you`: personalized local feed |

---

## Getting an API key

Message [@atlas.bsky.social](https://bsky.app/profile/atlas.bsky.social) with who you are and what you're building. Keys are free for community projects.

Please don't republish raw inferred placements as a public directory: those people didn't opt in the way ATlas users did. Aggregates, recommendations and feeds are what this data is for.
