← Back to skills

Domain skill

openstreetmap

Markdown synced from browser-harness domain skills.

Host
openstreetmap
Files
1

Agent prompt

Use this skill

Copy this prompt into your coding agent to make it enable browser-harness domain skills and read this exact domain folder before automating.

Set up https://github.com/browser-use/browser-harness for me if it is not already installed. If setup is needed, read `install.md` first to install and connect it to my real browser. Then read `SKILL.md` for normal usage and always read `helpers.py` because that is where the browser-harness functions are.

Enable domain skills if they are not already enabled by setting `BH_DOMAIN_SKILLS=1` for browser-harness. Use the `openstreetmap` domain skill from `agent-workspace/domain-skills/openstreetmap/`. Read every markdown file for this domain before inventing an approach:
- agent-workspace/domain-skills/openstreetmap/scraping.md

Use those domain-skill notes to complete my task for `openstreetmap` in my real browser. When you open a setup, verification, or task tab, activate it so I can see the active browser tab.

Skill contents

What the agent will read

Nominatim Geocoding + Overpass API

scraping.md

Source
  • Two fully public, no-auth APIs. Everything is a direct HTTP call — never need a browser.
  • Nominatim: geocoding (place name → lat/lon and reverse). Rate limit: 1 req/s.
  • Overpass API: spatial query engine over the full OSM dataset. Rate limit: 2 concurrent slots per IP on the public instance.
  • Do not use httpget without overriding User-Agent — its default Mozilla/5.0 is blocked by both APIs with HTTP 403. Pass headers={"User-Agent": "browser-harness/1.0"} on every call.
Show full markdown

Two fully public, no-auth APIs. Everything is a direct HTTP call — never need a browser.

  • Nominatim: geocoding (place name → lat/lon and reverse). Rate limit: 1 req/s.
  • Overpass API: spatial query engine over the full OSM dataset. Rate limit: 2 concurrent slots per IP on the public instance.

Do not use http_get without overriding User-Agent — its default Mozilla/5.0 is blocked by both APIs with HTTP 403. Pass headers={"User-Agent": "browser-harness/1.0"} on every call.


Fastest path: forward geocode a place

python
import json, urllib.parse
from helpers import http_get

UA = {"User-Agent": "browser-harness/1.0"}

def geocode(query: str, limit: int = 3) -> list[dict]:
    q = urllib.parse.quote(query)
    raw = http_get(
        f"https://nominatim.openstreetmap.org/search?q={q}&format=json&limit={limit}&addressdetails=1",
        headers=UA
    )
    return json.loads(raw)  # [] when nothing found

results = geocode("Eiffel Tower")
# results[0]['display_name']  == 'Tour Eiffel, 5, Avenue Anatole France, ..., 75007, France'
# results[0]['lat']            == '48.8582599'   ← STRING, not float
# results[0]['lon']            == '2.2945006'    ← STRING, not float
# results[0]['type']           == 'tower'
# results[0]['class']          == 'man_made'
# results[0]['importance']     == 0.6205937724353116
# results[0]['osm_type']       == 'way'
# results[0]['osm_id']         == 5013364
# results[0]['boundingbox']    == ['48.8574753', '48.8590453', '2.2933119', '2.2956897']  ← all strings
# results[0]['address']['city']     == 'Paris'
# results[0]['address']['postcode'] == '75007'
# results[0]['address']['country']  == 'France'
# results[0]['address']['country_code'] == 'fr'

Nominatim: all three query modes

1. Forward geocode (free-text)

python
import json, urllib.parse
from helpers import http_get

UA = {"User-Agent": "browser-harness/1.0"}

raw = http_get(
    "https://nominatim.openstreetmap.org/search?q=Eiffel+Tower&format=json&limit=3&addressdetails=1",
    headers=UA
)
results = json.loads(raw)
# Returns [] when nothing found — no exception

# Useful optional params:
# &addressdetails=1   → adds 'address' dict to each result (city, postcode, road, etc.)
# &extratags=1        → adds 'extratags' dict (website, wikidata, phone, etc.)
# &namedetails=1      → adds 'namedetails' dict (name:en, name:fr, etc.)
# &countrycodes=fr,de → restrict to countries (comma-separated ISO 3166-1 alpha-2)
# &viewbox=2.2,48.8,2.4,48.9 &bounded=1  → restrict to bounding box (lon_min,lat_min,lon_max,lat_max)

2. Reverse geocode (lat/lon → address)

python
raw = http_get(
    "https://nominatim.openstreetmap.org/reverse?lat=48.8584&lon=2.2945&format=json",
    headers=UA
)
result = json.loads(raw)
# result['display_name']  == 'Avenue Gustave Eiffel, Quartier du Gros-Caillou, ..., France'
# result['address']['road']         == 'Avenue Gustave Eiffel'
# result['address']['city']         == 'Paris'
# result['address']['postcode']     == '75007'
# result['address']['country']      == 'France'
# result['address']['country_code'] == 'fr'
# result['address']['state']        == 'Île-de-France'
# result['lat'], result['lon']  → strings (not floats)

# Optional: &zoom=N (0-18) controls granularity of the returned address
# zoom=3 → country, zoom=10 → city, zoom=18 → street/building (default)

3. Structured search (field-based)

python
raw = http_get(
    "https://nominatim.openstreetmap.org/search?city=Paris&country=France&format=json&limit=1",
    headers=UA
)
result = json.loads(raw)[0]
# result['name']          == 'Paris'
# result['lat']           == '48.8534951'
# result['lon']           == '2.3483915'
# result['type']          == 'administrative'
# result['place_rank']    == 12  (lower = broader: 4=country, 8=state, 12=city, 30=POI)
# result['addresstype']   == 'city'
# result['boundingbox']   == ['48.8155755', '48.9021560', '2.2241220', '2.4697602']

# Supported structured params: street, city, county, state, country, postalcode

4. Lookup by OSM ID

python
# Prefix: N=node, W=way, R=relation
raw = http_get(
    "https://nominatim.openstreetmap.org/lookup?osm_ids=W5013364&format=json",
    headers=UA
)
result = json.loads(raw)
# Returns list. Eiffel Tower way: result[0]['name'] == 'Tour Eiffel'
# Supports up to 50 IDs: osm_ids=W5013364,N123456,R789

Nominatim response field reference

FieldTypeNotes
place_idintInternal Nominatim ID — do not cache long-term, can change
osm_typestr"node", "way", or "relation"
osm_idintThe OSM element ID
latstrLatitude as string — convert with float(r['lat'])
lonstrLongitude as string — convert with float(r['lon'])
display_namestrFull human-readable address string
namestrShort name of the place
typestrOSM type tag value: "tower", "administrative", "restaurant", etc.
classstrOSM key: "man_made", "boundary", "amenity", "highway", etc.
addresstypestrSemantic category: "city", "road", "man_made", etc.
place_rankintHierarchy rank: 4=country, 8=state, 12=city, 16=suburb, 30=POI
importancefloat0–1 relevance score (higher = more notable)
boundingboxlist[str][south_lat, north_lat, west_lon, east_lon] — all strings, note unusual order
licencestrODbL attribution string — include in user-facing output
addressdictOnly present with &addressdetails=1 or in reverse results

address dict common keys: road, house_number, quarter, suburb, city_district, city, state, postcode, country, country_code, ISO3166-2-lvl4/6.


Overpass API: query OSM data by tags

Overpass is a read-only query engine over the full OSM planet. It supports finding POIs by tag, radius, bounding box, and combinations.

Endpoint: https://overpass-api.de/api/interpreter Backup instances (use when main is overloaded, which happens often):

  • https://overpass.openstreetmap.fr/api/interpreter — requires non-Mozilla User-Agent

http_get works for GET requests — pass headers={"User-Agent": "browser-harness/1.0"}. For POST, use urllib directly (see example below).

GET query (simplest for http_get)

python
import json, urllib.parse
from helpers import http_get

UA = {"User-Agent": "browser-harness/1.0"}
OVERPASS = "https://overpass.openstreetmap.fr/api/interpreter"

def overpass_get(query: str) -> dict:
    url = f"{OVERPASS}?data={urllib.parse.quote(query)}"
    raw = http_get(url, headers=UA)
    if not raw.startswith("{"):
        raise RuntimeError(f"Overpass error (HTML returned): {raw[:200]}")
    return json.loads(raw)

# Find cafes in central Paris (bbox: south_lat, west_lon, north_lat, east_lon)
r = overpass_get('[out:json][timeout:25];node["amenity"="cafe"](48.855,2.295,48.862,2.308);out 10;')
# r['version']    == 0.6
# r['generator']  == 'Overpass API 0.7.62.7 375dc00a'
# r['elements']   → list of matching OSM elements

for cafe in r['elements']:
    print(cafe['tags'].get('name'), cafe['lat'], cafe['lon'])
# 'Café de l\'Alma' 48.8609068 2.3015143
# 'Le Campanella'   48.8585847 2.3032822
# 'Kozy Bosquet'    48.855445  2.3054013

# Find restaurants within 500m radius of a point (around filter)
r = overpass_get(
    '[out:json][timeout:25];node["amenity"="restaurant"](around:500,37.7749,-122.4194);out 10;'
)
for rest in r['elements']:
    print(rest['tags'].get('name'), rest['tags'].get('cuisine',''))
# 'Nepalese Indian Cusine' 'indian;nepali'
# 'Local Diner' 'coffee_shop;italian;burger;seafood'
# 'Moya Cafe' ''

POST query (for complex QL, avoids URL length limits)

python
import json, urllib.parse, urllib.request, gzip
from helpers import http_get  # http_get is GET-only; use urllib for POST

OVERPASS = "https://overpass.openstreetmap.fr/api/interpreter"

def overpass_post(query: str) -> dict:
    """POST to Overpass — no URL length limits, preferred for multi-statement QL."""
    data = urllib.parse.urlencode({"data": query}).encode()
    req = urllib.request.Request(
        OVERPASS, data=data, method="POST",
        headers={
            "User-Agent": "browser-harness/1.0",
            "Content-Type": "application/x-www-form-urlencoded",
            "Accept-Encoding": "gzip",
        }
    )
    with urllib.request.urlopen(req, timeout=30) as r:
        body = r.read()
        if r.headers.get("Content-Encoding") == "gzip":
            body = gzip.decompress(body)
    body = body.decode()
    if not body.startswith("{"):
        raise RuntimeError(f"Overpass error (HTML): {body[:300]}")
    return json.loads(body)

# Example: cafes in Paris bbox
r = overpass_post('[out:json][timeout:25];node["amenity"="cafe"](48.855,2.295,48.862,2.308);out 5;')
print(len(r['elements']))  # 5 (or up to 5)

Overpass element structure

Every element in r['elements'] is a dict with at minimum:

python
{
    "type": "node",          # "node", "way", or "relation"
    "id": 308684349,         # int — OSM element ID (stable, use for dedup)
    "lat": 48.8609068,       # float — ONLY present for node type
    "lon": 2.3015143,        # float — ONLY present for node type
    "tags": {                # dict — all OSM tags on this element
        "amenity": "cafe",
        "name": "Café de l'Alma",
        "name:fr": "Café de l'Alma",
        "outdoor_seating": "yes",
        "payment:credit_cards": "yes",
        "phone": "+33 1 45 51 56 74",
        "opening_hours": "Mo-Sa 08:00-23:00; Su 09:00-19:00",  # optional
        "website": "https://...",                               # optional
        "wheelchair": "yes"                                     # optional
    }
}

For way elements, use out center; to get a center dict with lat/lon instead of a node list:

python
# way element with out center:
{
    "type": "way",
    "id": 338411946,
    "center": {"lat": 48.8660087, "lon": 2.3153233},  # centroid of the polygon
    "nodes": [3454913623, 3454913707, ...],            # node IDs forming the boundary
    "tags": {"amenity": "cafe", "name": "Café 1902", ...}
}

# Query to get both nodes and ways with lat/lon:
query = '[out:json][timeout:25];(node["amenity"="cafe"](48.85,2.29,48.87,2.32);way["amenity"="cafe"](48.85,2.29,48.87,2.32););out center 20;'
r = overpass_get(query)
for el in r['elements']:
    if el['type'] == 'node':
        lat, lon = el['lat'], el['lon']
    else:  # way
        lat, lon = el['center']['lat'], el['center']['lon']
    print(el['tags'].get('name'), lat, lon)

Overpass QL quick reference

code
[out:json][timeout:25]      # Required header: JSON output, 25s timeout
[maxsize:52428800]          # Optional: 50MB max result size (default is server limit)

node["amenity"="cafe"](south,west,north,east);out N;
#  ↑ bbox order: south_lat, west_lon, north_lat, east_lon
#  Note: DIFFERENT from Nominatim's boundingbox field which is [south,north,west,east]

node["amenity"="cafe"](around:RADIUS_METERS,LAT,LON);out N;

node["amenity"~"cafe|restaurant"](bbox);out N;    # regex match on tag value
node[!"name"](bbox);out N;                        # elements WITHOUT the 'name' tag
node["name"~"Star",i](bbox);out N;               # case-insensitive regex

# Union of types:
(node["amenity"="cafe"](bbox); way["amenity"="cafe"](bbox););out center N;

# Multiple tags (AND logic):
node["amenity"="cafe"]["outdoor_seating"="yes"](bbox);out N;

OSM tile server (reference only, no scraping)

code
https://{a,b,c}.tile.openstreetmap.org/{z}/{x}/{y}.png
  • Subdomains a, b, c for load balancing
  • z = zoom level 0–19, x/y = tile coordinates
  • Returns 256×256 PNG tiles
  • Policy: max 2 req/s per IP, non-commercial use, must display OSM attribution
  • Tile coordinate calculator: https://wiki.openstreetmap.org/wiki/Slippy_map_tilenames
  • Bulk tile downloading is prohibited — use Overpass or data extracts instead
python
# Convert lat/lon to tile coordinates
import math

def lat_lon_to_tile(lat, lon, zoom):
    n = 2 ** zoom
    x = int((lon + 180) / 360 * n)
    y = int((1 - math.log(math.tan(math.radians(lat)) + 1 / math.cos(math.radians(lat))) / math.pi) / 2 * n)
    return x, y

x, y = lat_lon_to_tile(48.8582, 2.2945, 14)
url = f"https://a.tile.openstreetmap.org/14/{x}/{y}.png"
# url == 'https://a.tile.openstreetmap.org/14/8281/5646.png'

Rate limits

APILimitEnforcement429 behavior
Nominatim1 req/sSoft — rapid requests work but you get delayed/droppedReturns HTTP 403 if your IP is banned (not 429)
Overpass (main)2 concurrent slots per IPHard — 3rd concurrent req returns HTML error immediatelyHTML error page with rate_limited in body
Overpass (main)Also: query complexity quotaResets over time (~per hour)HTML error page with rate_limited
Tile server2 req/s per IPSoft/hardIP block

Check your Overpass quota:

python
raw = http_get("https://overpass-api.de/api/status", headers={"User-Agent": "browser-harness/1.0"})
print(raw)
# Connected as: 1728118854
# Rate limit: 2
# 2 slots available now.
# Slot available after: 2026-04-18T11:00:00Z, in 30 seconds.

Handle rate limiting in production:

python
import time

def overpass_get_with_retry(query: str, max_retries: int = 3) -> dict:
    for attempt in range(max_retries):
        url = f"https://overpass.openstreetmap.fr/api/interpreter?data={urllib.parse.quote(query)}"
        raw = http_get(url, headers={"User-Agent": "browser-harness/1.0"})
        if raw.startswith("{"):
            return json.loads(raw)
        if "rate_limited" in raw or "too busy" in raw:
            wait = 2 ** attempt * 10  # 10s, 20s, 40s
            time.sleep(wait)
            continue
        raise RuntimeError(f"Overpass error: {raw[:200]}")
    raise RuntimeError("Overpass: too many retries")

Complete working example

python
import json, time, urllib.parse, urllib.request, gzip
from helpers import http_get

UA = {"User-Agent": "browser-harness/1.0"}
NOMINATIM = "https://nominatim.openstreetmap.org"
OVERPASS   = "https://overpass.openstreetmap.fr/api/interpreter"

def geocode(query: str, limit: int = 1) -> list[dict]:
    """Forward geocode — returns [] if nothing found."""
    q = urllib.parse.quote(query)
    raw = http_get(f"{NOMINATIM}/search?q={q}&format=json&limit={limit}&addressdetails=1", headers=UA)
    return json.loads(raw)

def reverse_geocode(lat: float, lon: float) -> dict:
    """Reverse geocode — always returns a result (nearest road/place)."""
    raw = http_get(f"{NOMINATIM}/reverse?lat={lat}&lon={lon}&format=json", headers=UA)
    return json.loads(raw)

def overpass_get(query: str) -> list[dict]:
    """Run an Overpass QL query, return elements list."""
    url = f"{OVERPASS}?data={urllib.parse.quote(query)}"
    raw = http_get(url, headers=UA)
    if not raw.startswith("{"):
        raise RuntimeError(f"Overpass error: {raw[:200]}")
    return json.loads(raw)["elements"]

def overpass_post(query: str) -> list[dict]:
    """POST variant — avoids URL length limits for complex queries."""
    data = urllib.parse.urlencode({"data": query}).encode()
    req = urllib.request.Request(
        OVERPASS, data=data, method="POST",
        headers={"User-Agent": "browser-harness/1.0",
                 "Content-Type": "application/x-www-form-urlencoded",
                 "Accept-Encoding": "gzip"}
    )
    with urllib.request.urlopen(req, timeout=30) as r:
        body = r.read()
        if r.headers.get("Content-Encoding") == "gzip":
            body = gzip.decompress(body)
    body = body.decode()
    if not body.startswith("{"):
        raise RuntimeError(f"Overpass error: {body[:300]}")
    return json.loads(body)["elements"]

# --- Usage examples (validated 2026-04-18) ---

# 1. Geocode a landmark
places = geocode("Eiffel Tower", limit=3)
# places[0]['lat']  == '48.8582599'  (string)
# places[0]['lon']  == '2.2945006'   (string)
# places[0]['display_name'] == 'Tour Eiffel, 5, Avenue Anatole France, ..., 75007, France'
# places[0]['address']['city'] == 'Paris'
lat = float(places[0]['lat'])
lon = float(places[0]['lon'])

# 2. Reverse geocode the coordinates
addr = reverse_geocode(lat, lon)
# addr['address']['road']    == 'Avenue Gustave Eiffel'
# addr['address']['city']    == 'Paris'
# addr['address']['postcode']== '75007'
# addr['address']['country'] == 'France'

# 3. Find nearby cafes (wait 1s between nominatim and overpass if same script)
time.sleep(1)
cafes = overpass_get(
    f"[out:json][timeout:25];node[\"amenity\"=\"cafe\"](around:500,{lat},{lon});out 10;"
)
for cafe in cafes:
    print(f"{cafe['tags'].get('name','?'):30s}  {cafe['lat']:.4f}, {cafe['lon']:.4f}")
# Café de l'Alma                  48.8609, 2.3015
# Le Campanella                   48.8586, 2.3033

# 4. Structured city lookup + find restaurants in bounding box
time.sleep(1)
paris = geocode("Paris, France")[0]
bb = paris['boundingbox']  # [south_lat, north_lat, west_lon, east_lon] ← Nominatim order!
# For Overpass: need (south_lat, west_lon, north_lat, east_lon) ← DIFFERENT order
south, north, west, east = bb[0], bb[1], bb[2], bb[3]
# Restrict to center slice to avoid massive result set
center_bbox = f"48.855,2.295,48.865,2.315"
rests = overpass_post(
    f"[out:json][timeout:25];node[\"amenity\"=\"restaurant\"]({center_bbox});out 5;"
)
print(f"Found {len(rests)} restaurants near Paris center")

Gotchas

http_get default UA (Mozilla/5.0) is blocked by both APIs. Always pass headers={"User-Agent": "browser-harness/1.0"}. The headers kwarg in http_get does a .update() so it properly overrides the default. Confirmed: Mozilla/5.0 → 403 on Nominatim; browser-harness/1.0 → 200.

Blocked User-Agent patterns on Nominatim: Mozilla/5.0, python-requests/*, Wget/*. Accepted: any non-generic app-style UA like browser-harness/1.0, MyApp/2.0, curl/7.x. Nominatim policy requires a descriptive UA with contact info, but in practice any non-library string passes.

Nominatim lat/lon are strings, Overpass lat/lon are floats. Always convert Nominatim coordinates: float(result['lat']). Overpass element lat/lon are native Python floats — no conversion needed.

Nominatim boundingbox field order is [south_lat, north_lat, west_lon, east_lon] — NOT [south, west, north, east]. Overpass bbox uses (south_lat, west_lon, north_lat, east_lon). When feeding a Nominatim bounding box into Overpass, you must reorder: f"({bb[0]},{bb[2]},{bb[1]},{bb[3]})".

overpass-api.de main instance is frequently overloaded. Returns HTTP 504 (timeout) or an HTML error page with rate_limited when busy. The FR mirror (overpass.openstreetmap.fr) is usually more responsive but also blocks Mozilla/5.0. Always detect non-JSON responses: if not raw.startswith("{").

Overpass error responses are HTML, not JSON. The API returns HTTP 200 with an HTML error page when rate-limited or when the server is too busy. Always check raw.startswith("{") before parsing.

Overpass rate limit: 2 concurrent slots, NOT 2 requests/s. You can run 2 queries simultaneously. A 3rd concurrent query immediately returns an error. Sequential queries with no sleep between them work fine as long as each completes before the next starts.

out N; limits results to N elements — use it. Without a limit, large bounding boxes can return thousands of elements and hit the 512MB memory limit, returning a maxsize error. Default safe limit: out 50; for exploration, out 500; for bulk collection.

Overpass QL bbox order is (south, west, north, east) — latitude FIRST. This is the opposite of the standard GeoJSON convention [west, south, east, north]. The around: filter uses (around:METERS,LAT,LON) — note lat before lon.

name tag in Overpass is the local-language name. For Paris cafes this is French. English names may appear under name:en but are often absent. Never assume name is in English.

Nominatim /reverse always returns the nearest result — it never returns an empty response (unlike /search). If the coordinates are in the ocean, it still returns the nearest coastline or country.

place_id is internal and ephemeral — do not store it for long-term use. Use osm_type + osm_id for stable references (e.g., way/5013364 for the Eiffel Tower).

Overpass http_get POST workaround: http_get only supports GET. For POST requests (needed to avoid URL length limits for complex multi-statement QL), use urllib.request.Request directly as shown in the overpass_post() example above.