# topoloop API Reference

- **Base URL:** `https://api.topoloop.com`
- **Auth:** `X-API-Key: tl_live_…` header on every request ([get a key](https://dash.topoloop.com/signup))
- **Format:** JSON in, JSON out (`Content-Type: application/json`)
- **Endpoints:** `POST /places/search` · `POST /places/nearest` · `POST /places/details` · `POST /web/search` · `POST /web/page`

This page is also served as plain Markdown at
[`/docs/api-reference.md`](https://topoloop.com/docs/api-reference.md) — see
[llms.txt](https://topoloop.com/llms.txt).

## Authentication

Send your key as the `X-API-Key` header. Missing or invalid keys return:

```json
// HTTP 401
{
  "error": "unauthorized",
  "message": "Missing or invalid API key. Send it as the X-API-Key header. Get one at https://dash.topoloop.com"
}
```

Keys are managed at [dash.topoloop.com](https://dash.topoloop.com) (create up to 5,
revoke any time — revocation takes effect within ~15 seconds).

## Rate limits & quotas

| Tier | Rate | Monthly quota |
|------|------|---------------|
| Free | 2 requests/s, burst 10 | 10,000 requests |

Exceeding either returns **HTTP 429**. The burst allowance means a small agent fan-out
(several parallel tool calls) won't instantly 429; sustained traffic should stay under
the per-second rate. Usage is visible per-key in the dashboard.

---

## POST /places/search

Semantic + lexical place search near a coordinate. This is the agent-facing workhorse:
approximate names, translations, misspellings, and category-flavored queries
("ramen near the station") all resolve. Results are ranked by a learned reranker over
semantic similarity, lexical match, distance, and name overlap — **not** by distance
alone: an exact name match 2 km away outranks a vaguely-related place 100 m away.

### Request

```json
{
  "query": "kinkakuji golden pavilion",
  "lat": 35.03,
  "lng": 135.72,
  "limit": 5
}
```

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `query` | string | yes | Natural-language place query (min 1 char). |
| `lat` | float | yes | Latitude of the search center (−90..90). Use 3–4 decimals of precision — 1 decimal (~11 km) is too coarse and pulls the search to the wrong neighborhood. |
| `lng` | float | yes | Longitude of the search center (−180..180). |
| `radius` | float | no | Search radius in km (0–500). **Omit for a dynamic radius** picked from local landmark density: ~5 km in dense cities up to ~100 km in remote areas. Values below 1 clamp to 1. |
| `limit` | int | no | Max results (1–100, default 10). For agent context, 3–5 is usually plenty. |
| `include_admin` | bool | no | Include administrative-boundary entities (default false). A geocoding agent almost never wants the boundary polygon's centroid; turn on only for gazetteer/region-resolution callers. |

### Response

```json
{
  "results": [
    {
      "osm_id": "3402555795",
      "osm_type": "node",
      "name": "金閣寺",
      "name_en": "Kinkaku-ji",
      "category": "buddhist place of worship",
      "lat": 35.0394,
      "lng": 135.7292,
      "distance_meters": 1230.4,
      "wikipedia": null,
      "wikidata": null
    }
  ],
  "count": 5,
  "radius_km": 5.0
}
```

| Field | Type | Description |
|-------|------|-------------|
| `results` | array | Matching places, best first. |
| `count` | int | Number of results returned. |
| `radius_km` | float | The radius actually used — when `radius` was omitted, this is the dynamically chosen one. |

**Place object:**

| Field | Type | Description |
|-------|------|-------------|
| `osm_id` | string | OpenStreetMap object ID. |
| `osm_type` | string | `node`, `way`, or `relation`. Join `osm_type[0].upper() + osm_id` (e.g. `"N3402555795"`) to get the ID that `/places/details` accepts. |
| `name` | string | Primary name (typically the local language). |
| `name_en` | string \| null | English name when it exists and differs from `name`. |
| `category` | string \| null | Human-readable category ("cafe", "mountain peak", "railway station", …). |
| `lat`, `lng` | float | Coordinates. |
| `distance_meters` | float | Distance from the search center. |
| `wikipedia`, `wikidata` | null | Always null in search responses — fetch via `/places/details`. |

---

## POST /places/nearest

The N most *relevant* places around a coordinate — no query. Ranking blends distance
with fame (a normalized encyclopedia + travel-writing signal), so a famous landmark
outranks equally-near trivia. Use it for "what's around here" grounding: give your agent
spatial awareness of a location it's working in.

### Request

```json
{
  "lat": 35.71, "lng": 139.796,
  "limit": 10,
  "max_radius_km": 5
}
```

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `lat`, `lng` | float | yes | The query point. |
| `limit` | int | no | Places to return (1–100, default 10). |
| `max_radius_km` | float | no | Hard distance cap in km (0–500, default 50). |
| `prominence_weight` | float | no | Fame blend strength (0–10, default 2.0). Candidates are ranked by `distance / (1 + prominence_weight × fame)`. 0 = pure nearest-by-distance. |
| `prominent_slots` | int | no | Guaranteed spots for the most famous places within the radius (0–20, default 3), regardless of distance. This is what surfaces the landmark temple from a side street when the blend alone is crowded out by very-close trivia. 0 disables. |
| `include_admin` | bool | no | Include administrative boundaries (default false). |

### Response

Same shape as `/places/search`, ordered by blended effective distance. Each result
additionally carries `fame` (0..1 normalized score — the ranking signal) and `prom_pct`
(prominence percentile) when the place has any signal. `radius_km` is the distance to
the furthest returned place.

---

## POST /places/details

OSM tag details for up to 50 places by ID. Use it to enrich search results —
`/places/search` intentionally omits these fields to keep agent context lean.

### Request

```json
{ "osm_ids": ["N6960525765", "W12903132"] }
```

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `osm_ids` | string[] | yes | 1–50 IDs in `N`/`W`/`R` + digits format — the joined form of `osm_type` + `osm_id` from search results. |

### Response

An object keyed by the requested IDs. IDs that don't exist are simply absent. Only
fields present in the data are included per place:

```json
{
  "W12903132": {
    "name": "Space Needle",
    "name_en": "Space Needle",
    "website": "https://www.spaceneedle.com/",
    "phone": "+1-206-905-2100",
    "opening_hours": "Mo-Th 09:00-22:00; Fr-Su 08:00-00:00",
    "wikipedia": "en:Space Needle",
    "wikidata": "Q5317",
    "wheelchair": "yes",
    "fee": "yes",
    "_sources": {"website": "osm", "phone": "osm"}
  }
}
```

Possible fields: `name`, `name_en`, `website`, `phone`, `email`, `opening_hours`,
`wikipedia`, `wikidata`, `cuisine`, `wheelchair`, `fee`, `stars`, `description`,
`image`, `address`, `socials`, plus `extra_websites` / `extra_phones` / `extra_emails`
arrays when multiple values exist.

`opening_hours` uses [OSM opening-hours syntax](https://wiki.openstreetmap.org/wiki/Key:opening_hours)
(`Mo-Th 09:00-22:00; Fr-Su 08:00-00:00`) — compact, machine-parseable, and LLMs read it
natively.

Contact fields fall back to the **Overture** places dataset when OSM lacks them;
`_sources` records where each field came from (`"osm"` or `"overture"`).

**Errors:** HTTP 400 with code `INVALID_OSM_ID` if any ID isn't `N`/`W`/`R` + digits.

---

## POST /web/search

Search a curated corpus of travel blogs and trip reports — the written evidence behind
places. Same key, same rate limit and quota as the places endpoints; each call counts as
one request. Use it when the agent needs *why* / *how* (which hut to book, whether the
trail is open in May, what a day in Chamonix actually looks like), and `/places/*` when
it needs *where*.

### Request

```json
{ "query": "best day hikes in the Dolomites", "limit": 3 }
```

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `query` | string | yes | Natural-language query. Hybrid semantic + BM25 retrieval, so both concept phrasing and exact place names work. |
| `limit` | int | no | Max results (1–20, default 5). |

### Response

```json
{
  "query": "best day hikes in the Dolomites",
  "limit": 3,
  "count": 3,
  "results": [
    {
      "title": "29 Best Day Hikes In The Dolomites Rated From Easy To Hard",
      "url": "https://inafarawayland.com/top-day-hikes-in-the-italian-dolomites",
      "blog": "inafarawayland.com",
      "score": 0.812,
      "context": "One-line summary of what this passage covers",
      "content": "The matched passage, truncated to ~500 characters…",
      "semantic_score": 0.741,
      "bm25_score": 18.52
    }
  ]
}
```

`content` is the retrieved chunk, not the whole article — cite the `url` and feed the
passage to the model. `blog` is the source domain for attribution.

---

## POST /web/page

Full text of one article from the corpus by URL (the `url` of a `/web/search` result).
Only URLs already in the corpus resolve — this is not a live fetcher.

### Request

```json
{ "url": "https://inafarawayland.com/top-day-hikes-in-the-italian-dolomites" }
```

### Response

```json
{ "url": "…", "title": "…", "blog": "inafarawayland.com", "content": "…", "content_length": 12345 }
```

**Errors:** HTTP 404 if the URL is not in the corpus.

---

## Errors

| Status | Meaning |
|--------|---------|
| 400 | Invalid parameters (JSON body includes `error`, `message`, `code`). |
| 401 | Missing or invalid API key. |
| 422 | Validation error (field out of range — body lists the offending fields). |
| 404 | Unknown route under `/web/`, or `/web/page` URL not in the corpus. |
| 429 | Rate limit (2 r/s burst 10) or monthly quota exceeded. |
| 5xx | Server error — safe to retry with backoff. |

**In an agent loop**, prefer catching errors in your dispatch layer and returning an
empty result list (optionally with a reason) to the model, rather than surfacing raw
HTTP errors — models handle "no results found" far more gracefully than stack traces.
See [Building a places harness](/docs/building-a-harness).

## Data licensing

Web results (`/web/*`) return short excerpts of third-party blog posts with their source URL; link back and attribute the source blog. The excerpts are for retrieval and citation, not republication.

Place data © [OpenStreetMap contributors](https://www.openstreetmap.org/copyright),
available under the Open Database License (ODbL). You may **cache, store, and train on**
API responses. Attribute "© OpenStreetMap contributors" where you use the data. Contact
fields marked `"overture"` in `_sources` come from the
[Overture Maps Foundation](https://overturemaps.org/) dataset (CDLA-Permissive-2.0).
