# Building a places harness

The API gives your agent search; the **harness** — your tool definition, dispatch code,
and response shaping — decides what the model actually sees. Every serious agent app
ends up with its own opinions here, and they matter: the same API behind a vague tool
description and an unfiltered response dump performs measurably worse than behind a
tuned one.

This page is the harness we'd build — the tool definitions, the dispatch code, and the
operational lessons from running this exact search engine behind a production travel
planning agent for months. Copy it, then make it yours.

## The tool definition

The description text below isn't boilerplate — each line prevents a real failure mode we
hit in production (annotated after the code).

**Anthropic Messages API:**

```json
{
  "name": "search_places",
  "description": "Search for a real-world place by name or description. Returns candidate places with coordinates and stable IDs. IMPORTANT: always provide lat/lng to bias the search to the geographic area you are working in — use coordinates from your current context (the city being discussed, the user's location, a previous result) with 3-4 decimal places of precision. The search radius is chosen automatically from local density (tight in cities, wide in wilderness); only pass radius to override it. Queries can be approximate: partial names, translations, and descriptions all work.",
  "input_schema": {
    "type": "object",
    "properties": {
      "query": {
        "type": "string",
        "description": "The place name or description to search for, e.g. 'kinkakuji golden pavilion' or 'the old lantern street'."
      },
      "lat": {
        "type": "number",
        "description": "Latitude to center the search on (REQUIRED). At least 3-4 decimals - 1 decimal (~11km) is too coarse and pulls results to the wrong neighborhood."
      },
      "lng": {
        "type": "number",
        "description": "Longitude to center the search on (REQUIRED). At least 3-4 decimals."
      },
      "radius": {
        "type": "number",
        "description": "Optional radius override in km (min 1). Normally omit. Pass a tight value (1-2) to reject distant noise in a dense city, or a wide one (30-50) when the auto-radius misses a remote feature."
      }
    },
    "required": ["query", "lat", "lng"],
    "additionalProperties": false
  }
}
```

**OpenAI-format APIs** — same schema, wrapped as a function tool:

```json
{
  "type": "function",
  "function": {
    "name": "search_places",
    "description": "…same description text as above…",
    "parameters": { "…same schema object as input_schema above…": {} }
  }
}
```

Why each piece is there:

- **`lat`/`lng` required, from context.** The single biggest quality lever. The
  coordinate comes from what the agent already knows — the destination it's planning,
  the user's stated location — not from a recursive "geocode the city first" call. This
  is the honest tool contract (it's also why Google separates query from location bias).
- **"3–4 decimals of precision."** Without this, models round to 1 decimal (~11 km) and
  searches drift to the wrong neighborhood. This one sentence fixed a whole class of
  failures in our traces.
- **"Normally omit radius."** The server's density-based radius beats model guesses.
  Left unguided, models pass arbitrary radii that either drown results in noise or miss
  the target. Keep the override for the two cases that need it.

## The dispatch layer

A complete, runnable agent loop (Python, Anthropic SDK — the same shape works with any
tool-calling API):

```python
import os, requests, anthropic

TOPOLOOP = "https://api.topoloop.com"
KEY = os.environ["TOPOLOOP_KEY"]

def search_places(query: str, lat: float, lng: float, radius: float | None = None) -> list[dict]:
    payload = {"query": query, "lat": lat, "lng": lng, "limit": 5}
    if radius is not None:
        payload["radius"] = radius
    try:
        r = requests.post(f"{TOPOLOOP}/places/search", json=payload,
                          headers={"X-API-Key": KEY}, timeout=20)
        r.raise_for_status()
    except requests.RequestException:
        return []  # empty results, not an exception - models recover gracefully
    out = []
    for p in r.json()["results"]:
        out.append({
            # one joined id the model can pipe straight into get_place_details
            "id": p["osm_type"][0].upper() + p["osm_id"],
            # collapse name/name_en/category into one label
            "name": (p.get("name_en") or p["name"])
                    + (f" ({p['category']})" if p.get("category") else ""),
            "lat": p["lat"], "lng": p["lng"],
            "distance_m": round(p["distance_meters"]),
        })
    return out

def get_place_details(ids: list[str]) -> dict:
    r = requests.post(f"{TOPOLOOP}/places/details", json={"osm_ids": ids},
                      headers={"X-API-Key": KEY}, timeout=20)
    r.raise_for_status()
    return r.json()

def search_web(query: str) -> list[dict]:
    # travel-blog evidence: passages + source URLs, same key and quota
    try:
        r = requests.post(f"{TOPOLOOP}/web/search", json={"query": query, "limit": 3},
                          headers={"X-API-Key": KEY}, timeout=20)
        r.raise_for_status()
    except requests.RequestException:
        return []
    return [{"url": h["url"], "blog": h["blog"], "passage": h["content"]}
            for h in r.json()["results"]]

TOOLS = [...]  # the search_places definition from above, plus get_place_details and search_web

client = anthropic.Anthropic()
messages = [{"role": "user", "content":
             "Plan an afternoon around Jiufen, Taiwan - the old mining town with the lantern street."}]

while True:
    response = client.messages.create(
        model="claude-opus-5", max_tokens=16000,
        tools=TOOLS, messages=messages,
    )
    if response.stop_reason != "tool_use":
        break
    messages.append({"role": "assistant", "content": response.content})
    results = []
    for block in response.content:
        if block.type == "tool_use":
            if block.name == "search_places":
                out = search_places(**block.input)
            elif block.name == "get_place_details":
                out = get_place_details(**block.input)
            elif block.name == "search_web":
                out = search_web(**block.input)
            results.append({"type": "tool_result", "tool_use_id": block.id,
                            "content": str(out)})
    messages.append({"role": "user", "content": results})

print(next(b.text for b in response.content if b.type == "text"))
```

## The lessons baked into that code

**Project the response — the model doesn't need every field.** The dispatch above keeps
five fields per result. Search responses land in your model's context window on every
call, and in a loop that's most of your places-related token spend. `wikipedia`/
`wikidata` nulls, the raw `osm_type`/`osm_id` pair, the envelope — none of it changes
what the model does next.

**One ID, piped end-to-end.** `osm_type[0].upper() + osm_id` gives you the exact string
`/places/details` accepts. Exposing it as a single `id` field means the model never has
to reassemble it (and never gets it wrong).

**Compose the display name.** `name_en` over `name` when present, category appended —
`"Jiufen Old Street (attraction)"`. Three fields become one label the model can reason
about and echo to users directly.

**Keep details out of the loop when you can.** If rich fields (website, hours, photos)
are destined for your *UI* rather than the model's reasoning, fetch them app-side by ID
after the loop finishes — don't route them through the context window. Give the model
`get_place_details` for the cases where it genuinely needs hours or a website to answer,
and keep `limit` small (3–5) everywhere.

**Errors become empty results.** The `except` branch returns `[]` instead of raising.
A model that sees "no results" retries with a different query or widens the radius; a
model that sees a stack trace often derails. Log the error on your side — just don't
make the model parse it.

**Filter categories your product can't use.** A point on a linear or diffuse feature
(a street, a river, an administrative area) may not be a usable result for your app —
a travel app can't make "Main Street" an itinerary stop. If that's you, drop those
categories in dispatch before the model sees them. `include_admin` already defaults
administrative boundaries off server-side.

## Checklist

- [ ] Tool description requires `lat`/`lng` from agent context, 3–4 decimals
- [ ] Radius omitted by default; override documented for the two edge cases
- [ ] Response projected to the fields your agent actually uses
- [ ] Single joined `id` field, piped from search into details
- [ ] `limit` set to 3–5 for agent calls
- [ ] Rich fields fetched app-side where possible, not through the model
- [ ] Errors returned to the model as empty results
- [ ] Category filtering matched to your product's semantics

Questions, or built a harness for a stack we should document?
[summit@topoloop.com](mailto:summit@topoloop.com).
