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:
{
"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:
{
"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/lngrequired, 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):
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/lngfrom 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
idfield, piped from search into details limitset 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.