# External API tools

Web page: https://academy.memberpress.ai/courses/tool-use/l5/

Lesson 5 of 9 in [Tool Use — From Calculator to Agent](https://academy.memberpress.ai/courses/tool-use/), 18 min.

Video: https://www.youtube.com/watch?v=bl3Vc_wT9pk

Your handler calls an API the model has never seen. The model passes a city name; your code geocodes it, fetches weather, and returns three fields. The model does not see the geocoding step, the HTTP headers, or the full upstream response. It sees what you choose to return. That boundary — what you expose versus what you hide — is the entire design problem.

**The adapter contract**

An external API tool is a thin adapter between the model and an upstream service. The model sees: parameter names, descriptions, and the return structure you choose to expose. The model does not see: your API key, your retry logic, HTTP headers, or the full upstream response envelope. Your job is to decide exactly where that boundary sits and keep it narrow.

**A complete example: Open-Meteo weather**

Open-Meteo is a free, no-auth weather API — useful here because it removes the credential step and lets you focus on the adapter structure.

```python
# scripts/weather_tool.py
import anthropic, httpx, json, os

client = anthropic.Anthropic()

tools = [{
    "name": "get_current_weather",
    "description": (
        "Return current weather conditions for a city. "
        "Use when the user asks about temperature, wind, precipitation, or "
        "general weather. Returns temperature_c, wind_kph, and condition. "
        "Do not use for historical data or forecasts beyond today."
    ),
    "input_schema": {
        "type": "object",
        "properties": {
            "city": {
                "type": "string",
                "description": "City name in English, e.g. 'Oslo', 'Tokyo', 'Chicago'"
            }
        },
        "required": ["city"]
    }
}]
```

**The handler with real error handling**

```python
GEOCODE_URL = "https://geocoding-api.open-meteo.com/v1/search"
WEATHER_URL = "https://api.open-meteo.com/v1/forecast"

def get_current_weather(city: str) -> dict:
    # Step 1: geocode the city name to coordinates
    try:
        geo = httpx.get(GEOCODE_URL, params={"name": city, "count": 1}, timeout=8)
        geo.raise_for_status()
        results = geo.json().get("results", [])
        if not results:
            return {"error": f"City not found: {city}"}
        lat = results[0]["latitude"]
        lon = results[0]["longitude"]
    except httpx.TimeoutException:
        return {"error": "Geocoding timed out. Try again."}
    except httpx.HTTPStatusError as e:
        return {"error": f"Geocoding failed: {e.response.status_code}"}

    # Step 2: fetch weather
    try:
        params = {
            "latitude": lat, "longitude": lon,
            "current": "temperature_2m,wind_speed_10m,weathercode",
            "timezone": "auto"
        }
        weather = httpx.get(WEATHER_URL, params=params, timeout=8)
        weather.raise_for_status()
        current = weather.json()["current"]

        # Map WMO weather code to human-readable condition
        code = current["weathercode"]
        condition = {0: "clear", 1: "mainly clear", 2: "partly cloudy",
                     3: "overcast", 45: "foggy", 61: "light rain",
                     63: "moderate rain", 80: "rain showers"}.get(code, f"code {code}")

        # Return only the three fields the model needs
        return {
            "temperature_c": current["temperature_2m"],
            "wind_kph": current["wind_speed_10m"],
            "condition": condition
        }
    except httpx.TimeoutException:
        return {"error": "Weather fetch timed out. Try again."}
    except httpx.HTTPStatusError as e:
        return {"error": f"Weather API returned {e.response.status_code}"}
```

Three things worth noting. First: every error path returns a dict, not a raised exception. The model receives the dict and can decide whether to retry or report. Second: the return value is three fields stripped from a deeply nested response — the model does not need to parse nested JSON; you flatten it. Third: timeout handling is explicit. Eight seconds is generous for a geocoding call; do not let httpx default to infinity, or a slow upstream will stall your loop indefinitely.

**Authentication — when you need it**

For APIs that require a key:

```python
API_KEY = os.environ["WEATHER_API_KEY"]   # read from environment, never from the tool call

headers = {"Authorization": f"Bearer {API_KEY}"}
response = httpx.get(url, headers=headers, timeout=8)
```

Never include the key in the tool definition, the tool description, or the `tool_result`. The model has no need to see it. If you log `tool_result` content for debugging, scrub credentials before logging. If the key appears in a tool description, it will appear in the conversation history returned by the API and may be stored in logs you do not control.

**Rate limits**

If the upstream API rate-limits (HTTP 429), return a structured error that includes a `retry_after` field. This gives the model information it can act on — it may decide to wait and retry, or to tell the user the data is temporarily unavailable.

```python
if response.status_code == 429:
    retry_after = response.headers.get("Retry-After", "60")
    return {"error": "Rate limited", "retry_after_seconds": int(retry_after)}
```

In an agent loop (l7), the model may issue multiple weather calls in rapid succession. If the upstream API limits at ten requests per minute, build a simple call counter in the handler and sleep before the request when the counter approaches the ceiling.

**If you get stuck**

*Model calls the tool with a city name in a non-English script:* Add "City name in English" to the parameter description. The geocoding API may handle it anyway, but making the expectation explicit reduces malformed calls and gives you a clear reason when it fails.

*Handler returns the full upstream JSON and the model hallucinates field names:* You returned too much. The model treats every key in the result dict as a signal. Flatten to the three or four fields the model's downstream reasoning needs and nothing else.

*`httpx.ConnectError` propagates as an uncaught exception:* Catch it explicitly. `ConnectError` is not a subclass of `HTTPStatusError`. Add a bare `except Exception as e: return {"error": str(e)}` as a final catch after all specific exception handlers.

**Cross-reference**

Rate-limit handling within a multi-turn loop is covered in l7. The pattern of returning a structured error that the model reads and acts on is the same whether the error comes from an upstream API or from your own database handler in l4. The handler interface is identical — return a dict, never raise.

**Deliverable**

`scripts/weather_tool.py` — a complete script with the tool definition, the Open-Meteo handler, and an agent loop. Run it with a city that exists, a city that does not exist, and a misspelled city name. Confirm the model handles all three cases and produces a coherent response without crashing the loop.

**Further reading**

- ["What Agents Actually Need From Your API"](https://sethshoultes.com/blog/what-agents-need.html) — when the caller is a model, the response shape that worked for human developers does not. The essay names what changes.

## Try these prompts

- Write the adapter contract for a weather tool that calls Open-Meteo — what three fields it returns to the model, what error dict it returns on timeout, and what it never exposes.
- Write the Python handler block for an HTTP 429 rate-limit response that returns a structured error dict with a retry_after_seconds field.
- Explain in two sentences why returning the full upstream JSON response to the model degrades reasoning quality, and what to do instead.

Next: [Filesystem tools and safety](https://academy.memberpress.ai/courses/tool-use/l6/)
