[ blog · tutorial ]9 min read

Crea un agente di research con Web Search + URL Extract (tool use di Claude, dall'inizio alla fine)

Sarah ChoyPubblicato il 3 maggio 20269 min di lettura
Crea un agente di research con Web Search + URL Extract (tool use di Claude, dall'inizio alla fine)

La maggior parte dei tutorial sugli 'agenti di research IA' si ferma a 'ecco una definizione di tool'. Questo consegna un agente funzionante: entra una domanda, esce una risposta con citazioni. Cercare, estrarre, ragionare, citare — tutto in meno di 120 righe di Python.

In breve

  • Il più piccolo loop di agente di research che serva davvero è: cercare → scegliere i link → estrarre i corpi → chiedere al modello di rispondere con citazioni inline.
  • Due tool — Web Search (15 crediti) e URL Extract (2 crediti per URL) — coprono il 95% dei casi di risposta fondata.
  • Il tool use di Claude si occupa dell'orchestrazione; tu ti limiti a far transitare blocchi tool_use → chiamate API → blocchi tool_result finché il modello non si ferma.
  • Costo dall'inizio alla fine: ~25 crediti + ~$0.02 in token del LLM per domanda, a profondità tipica.

Cosa stiamo costruendo

Un agente di research che prende un input — una domanda in linguaggio naturale — e restituisce una risposta con fonti. Dal punto di vista architetturale:

question → [Claude]
              ↓ tool_use(search)
            [API Pick Web Search] → ranked URLs
              ↓ tool_use(extract)
            [API Pick URL Extract] → cleaned bodies
              ↓ Claude reads, decides if more is needed
              ↓ end_turn
          answer with inline [source: URL] citations

Circa 120 righe di Python. Due tool — search ed extract — e un loop di agente che gestisce qualunque cosa Claude decida di fare. Lo costruiremo da zero in 4 passi.

1Recupera gli schema dei tool (niente JSON a mano)

Entrambi gli endpoint pubblicano una rotta tool-schema che restituisce una definizione di tool di Claude nella forma esatta che messages.create si aspetta.

import requests

API_KEY = "pk_yourkey"

def fetch_tool(tool_path: str) -> dict:
    """Fetch a Claude tool definition from API Pick's tool-schema endpoint."""
    schema = requests.get(f"https://www.apipick.com{tool_path}/tool-schema").json()
    return schema["claude"]

WEB_SEARCH_TOOL = fetch_tool("/api/search/web")
URL_EXTRACT_TOOL = fetch_tool("/api/extract")

# Cache these once at module load. They don't change between requests.

Due cose importanti che questo ti dà gratis: lo schema dei parametri (così Claude sa che accetti query, country_code, start_date, ecc.) e una descrizione chiara e adatta al modello di ciò che fa il tool.

2Scrivi l'handler del tool

Quando Claude restituisce un blocco tool_use, il tuo compito è chiamare l'API reale e restituire un blocco tool_result. Una funzione per tool, un dispatcher che instrada in base al nome scelto da Claude.

def call_tool(tool_use_block) -> dict:
    """Execute the tool Claude asked for and return a tool_result block."""
    name = tool_use_block.name
    args = tool_use_block.input

    if name == "web_search":
        endpoint = "https://www.apipick.com/api/search/web"
    elif name == "extract_urls":
        endpoint = "https://www.apipick.com/api/extract"
    else:
        return {
            "type": "tool_result",
            "tool_use_id": tool_use_block.id,
            "content": f"Unknown tool: {name}",
            "is_error": True,
        }

    resp = requests.post(
        endpoint,
        headers={"x-api-key": API_KEY},
        json=args,
        timeout=30,
    )

    if resp.status_code != 200:
        return {
            "type": "tool_result",
            "tool_use_id": tool_use_block.id,
            "content": f"HTTP {resp.status_code}: {resp.text[:500]}",
            "is_error": True,
        }

    return {
        "type": "tool_result",
        "tool_use_id": tool_use_block.id,
        "content": resp.text,
    }

Tre cose degne di nota. tool_use_id è il modo in cui Claude correla il risultato con la sua chiamata precedente — devi restituirlo tale e quale. is_error: True dice a Claude di recuperare con eleganza (spesso provando una query diversa). E passiamo il testo della risposta grezzo — Claude si trova a suo agio con la forma JSON che API Pick restituisce, quindi non serve alcuna trasformazione.

3Il loop dell'agente

Il loop è breve: invia la conversazione, guarda ogni blocco della risposta, esegui i tool, aggiungi alla conversazione sia il tool_use di Claude sia il tuo tool_result, e ripeti finché stop_reason non è end_turn.

import anthropic

client = anthropic.Anthropic()

SYSTEM_PROMPT = """You are a research assistant. When the user asks a question:

1. Use web_search to find relevant sources. Prefer recent (set start_date) for time-sensitive queries.
2. Use extract_urls to read the body of the top 3-5 most relevant URLs from the search results.
3. Answer the question concisely. After every factual claim, include an inline citation in the form [source: URL].
4. If you don't have enough information, say so — don't fabricate.

Be concise. Aim for 3-5 sentence answers unless the user asks for depth."""

def research(question: str) -> str:
    messages = [{"role": "user", "content": question}]

    while True:
        response = client.messages.create(
            model="claude-sonnet-4-6",
            max_tokens=2048,
            system=SYSTEM_PROMPT,
            tools=[WEB_SEARCH_TOOL, URL_EXTRACT_TOOL],
            messages=messages,
        )

        # Append the assistant's response (may contain tool_use blocks)
        messages.append({"role": "assistant", "content": response.content})

        if response.stop_reason == "end_turn":
            # Pull out the text answer
            return "\n".join(b.text for b in response.content if b.type == "text")

        if response.stop_reason == "tool_use":
            # Run every tool the model asked for in this turn
            tool_results = [
                call_tool(b)
                for b in response.content if b.type == "tool_use"
            ]
            messages.append({"role": "user", "content": tool_results})
            continue

        raise RuntimeError(f"Unexpected stop_reason: {response.stop_reason}")

Nota che il modello può lanciare più chiamate di tool in un unico turno — per esempio, potrebbe emettere una sola chiamata a extract_urls con cinque URL per processarle in lotto. Lo gestiamo raccogliendo tutti i blocchi tool_use del turno dell'assistente e restituendo tutti i blocchi tool_result nel turno utente successivo.

4Eseguilo

if __name__ == "__main__":
    answer = research("What were the major announcements at OpenAI DevDay this year?")
    print(answer)

Vedrai qualcosa come:

OpenAI's DevDay focused on three themes: cheaper inference (a new gpt-mini
tier at roughly half the prior cost) [source: https://openai.com/devday-2026],
agent infrastructure (a managed agent runtime with persistent memory and
tool sandboxing) [source: https://techcrunch.com/...], and developer
tooling (a new Responses API replacing the legacy Assistants API)
[source: https://platform.openai.com/docs/...].

Questo è un agente di research. ~120 righe, due tool, output con fonti.

Quanto costa

Una tipica chiamata di research:

  • 1 chiamata di ricerca — 15 crediti ($0.015)
  • 1 chiamata di estrazione che copre 3–5 URL — 6–10 crediti ($0.006–$0.010)
  • ~3,000 token di input + 800 di output di Claude — ~$0.02

Cifra tonda: ~3 centesimi per risposta indagata. A 1,000 chiamate di research al giorno sono ~$30/giorno — più o meno il budget del caffè mattutino di un analista. Metti a budget il LLM, non le API di ricerca.

Tre rifiniture per la produzione

1. Fai cache per domanda

Se il tuo prodotto pone domande simili, calcola l'hash di (question, today's date) e metti in cache la risposta finale per un'ora. La maggior parte del research guidato dagli utenti ha tassi di hit a coda lunga superiori al 30%.

2. Vincola quando conta la freschezza

Per le domande di 'questa settimana' / 'oggi', imposta start_date nella chiamata di ricerca alla data corrente o a ieri. Senza, la ricerca può restituire articoli dell'anno scorso. La versione più semplice: una regola di system prompt come 'includi sempre start_date quando la domanda contiene oggi, questa settimana, recente o ultimo'.

3. Salvaguardie contro le allucinazioni

La regola con la leva più alta in assoluto: 'Se non hai abbastanza contenuto estratto per rispondere, restituisci: Non sono riuscito a trovare una fonte affidabile per questo. Non tirare a indovinare.' Aggiungere questo al system prompt riduce le risposte inventate di un ordine di grandezza nei nostri test.

Dove portarlo dopo

Tre estensioni naturali:

  • Verticalizzalo: scambia Web Search con Academic Search per un agente di revisione della letteratura, SEC Filings Search per la due diligence, o Clinical Search per la ricerca medica. L'handler non cambia — solo il system prompt.
  • Aggiungi output strutturato: chiedi al modello di restituire JSON con i campi answer e citations[] così da poterli renderizzare come una card di UI.
  • Fai streaming dei token: passa a client.messages.stream() per un output incrementale — utile quando la risposta è lunga. Il loop di tool-call è lo stesso.

Domande frequenti

Perché due tool invece di un unico endpoint 'answer'?

Separare ricerca ed estrazione ti dà il controllo su quante URL leggere, quando fermarti e quali citare. Un unico endpoint hosted di 'answer' nasconde tutto questo — non puoi cambiare facilmente la strategia senza riprogettare l'architettura. Con due tool, lo stesso codice diventa un agente di briefing mattutino, un revisore della letteratura o uno scraper di intelligence competitiva semplicemente ritoccando il prompt.

Funziona anche con OpenAI Assistants / Responses API?

Sì. L'architettura è identica — l'unica cosa che cambia è come fai il parsing dei blocchi di tool-call e invii i risultati. La forma dell'handler diventa submit_tool_outputs(...) invece di un blocco di contenuto tool_result, ma il loop dell'agente e le definizioni dei tool sono lo stesso JSON.

Come decide l'agente quando fermarsi?

Claude restituisce stop_reason: end_turn quando ha contesto sufficiente per rispondere, e tool_use quando vuole chiamare un tool. Il loop va avanti finché non arriva end_turn. In pratica il modello di solito fa da 1 a 3 cicli di ricerca/estrazione prima di rispondere.

Come faccio a essere sicuro che citi davvero le fonti?

Due leve. Prima: nel system prompt, richiedi esplicitamente '[source: URL]' inline dopo ogni affermazione. Seconda: la risposta di URL Extract include la URL, quindi quando il modello ce l'ha nel contesto tende a citare in modo naturale. Se le citazioni sfuggono, aggiungi un passaggio finale di formattazione che rifiuta la risposta e chiede al modello di aggiungere le citazioni.

E la latenza?

Ogni andata e ritorno di ricerca è di ~1s; ogni lotto di estrazione è di 1–4s a seconda del numero di URL e del rendering JS. Una tipica chiamata di 'indaga una domanda' si attesta sui 5–15s di orologio. Se ti serve più veloce, parallelizza il passaggio di estrazione (extract accetta già una lista di URL in una sola chiamata) e limita il modello a un solo giro di tool per domanda con istruzioni nel system prompt.

API usate in questo articolo

Sarah Choy
Scritto da
Sarah Choy
CEO, API Pick

Sarah Choy è la CEO di API Pick. Scrive sulla creazione di API pronte per la produzione per agenti IA e flussi di lavoro con LLM.