Clean a spreadsheet of addresses with an agent

About 20 minutes. Updated 11 September 2026.

A complete Python script: read a CSV of addresses as people typed them, let Claude resolve each one through the Locio API, fall back to fuzzy matching for typos, and write a CSV with the G-NAF id, coordinates and a note for every row it could not settle.

A file of addresses as people typed them is the ordinary case: some exact, some with a suburb misspelled, some with a street type missing, a few that were never real. A script that calls resolve on each row handles the first kind. An agent handles the rest, because the judgement in "is 1 Gorge Rd Sydenham the same address as 1 gorge rd sydenhum" is the part that was hard to code.

This is one file, a hundred lines, using the SDK's tool runner with three tools: two that call the API and one that writes the answer for a row. The third is the trick. It gives the agent one exact thing to do per row, so nothing depends on parsing prose out of the final message. It builds on the Anthropic API guide.

01The script

Twenty rows a batch: enough that the agent can see a pattern in a file, small enough that one request stays quick. The instruction says when a fuzzy match counts and when to leave a row alone, and the note column records which happened, so a person can review only the rows that needed a decision.

clean.pypython
# clean.py
# pip install anthropic requests
import csv
import json
import os
import sys

import anthropic
import requests
from anthropic import beta_tool

API = "https://api.locio.com.au"
HEADERS = {"Authorization": f"Bearer {os.environ['LOCIO_KEY']}"}


@beta_tool
def resolve_address(q: str) -> str:
    """Validate, geocode and parse one Australian address in one call.

    Args:
        q: The whole address as written.
    Returns matched (true or false) and, when matched, the address with its
    gnaf_pid, formatted text, lat, lng and components. One unit.
    """
    return requests.get(f"{API}/v1/addresses/resolve", params={"q": q}, headers=HEADERS, timeout=10).text


@beta_tool
def similar_addresses(q: str) -> str:
    """The nearest real addresses to one that did not resolve. Three units,
    so only after resolve_address answered matched: false.

    Args:
        q: The address as written.
    """
    return requests.get(f"{API}/v1/addresses/similar", params={"q": q, "limit": 3}, headers=HEADERS, timeout=10).text


@beta_tool
def record(row: int, gnaf_pid: str, formatted: str, lat: float, lng: float, note: str) -> str:
    """Write the result for one row of the file. Call it exactly once per row.

    Args:
        row: The row number given in the input.
        gnaf_pid: The G-NAF id, or an empty string when nothing resolved.
        formatted: The resolved address as text, or an empty string.
        lat: Latitude, or 0 when nothing resolved.
        lng: Longitude, or 0 when nothing resolved.
        note: Empty when the address resolved as written; otherwise what you did
              ("used the nearest match", "two candidates, left unmatched").
    """
    results[row] = {"gnaf_pid": gnaf_pid, "formatted": formatted, "lat": lat, "lng": lng, "note": note}
    return "recorded"


results: dict[int, dict] = {}

with open(sys.argv[1], newline="") as f:
    rows = list(csv.DictReader(f))

client = anthropic.Anthropic()
BATCH = 20

for start in range(0, len(rows), BATCH):
    batch = rows[start : start + BATCH]
    listing = "\n".join(f"{start + i}: {r['address']}" for i, r in enumerate(batch))
    runner = client.beta.messages.tool_runner(
        model="claude-opus-5",
        max_tokens=16000,
        betas=["server-side-fallback-2026-07-01"],
        fallbacks="default",
        tools=[resolve_address, similar_addresses, record],
        messages=[{
            "role": "user",
            "content": (
                "Resolve each address below with resolve_address. If it does not match, try "
                "similar_addresses once; use the nearest result only when it is clearly the same "
                "address (same number and street, a suburb or spelling corrected), otherwise leave the "
                "row unmatched and say why. Call record exactly once for every row.\n\n" + listing
            ),
        }],
    )
    for _ in runner:
        pass

with open(sys.argv[2], "w", newline="") as f:
    out = csv.DictWriter(f, fieldnames=[*rows[0].keys(), "gnaf_pid", "formatted", "lat", "lng", "note"])
    out.writeheader()
    for i, r in enumerate(rows):
        out.writerow({**r, **results.get(i, {"note": "not recorded"})})

print(f"{sum(1 for r in results.values() if r['gnaf_pid'])} of {len(rows)} resolved")

02Run it

The input needs an address column. Every other column comes through untouched, with the five new ones on the end.

shell
LOCIO_KEY=lc_live_... ANTHROPIC_API_KEY=sk-ant-... python clean.py customers.csv customers-resolved.csv

What a thousand rows cost

On the Locio side: one unit per row for resolve, plus three for each row that needed a fuzzy match. A file where a tenth of the rows have a typo is about 1,300 units, which the free tier covers seven times over. On the Anthropic side, the model reads each batch's listing and the tool results, so cost scales with rows; keep the batch at twenty or so and the context stays small. The rows to look at afterwards are the ones with a note.

Related