Quickstart API reference Building a harness ⌘ view as markdown

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:

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

Questions, or built a harness for a stack we should document? summit@topoloop.com.