[ blog · tutorial ]9 min read

Crea un agente de research con Web Search + URL Extract (tool use de Claude, de principio a fin)

Sarah ChoyPublicado el 3 de mayo de 20269 min de lectura
Crea un agente de research con Web Search + URL Extract (tool use de Claude, de principio a fin)

La mayoría de los tutoriales de 'agente de research IA' se quedan en 'aquí tienes una definición de herramienta'. Este envía un agente funcional: entra una pregunta, sale una respuesta con citas. Buscar, extraer, razonar, citar — todo en menos de 120 líneas de Python.

Resumen

  • El bucle de agente de research más pequeño que sirve es: buscar → elegir enlaces → extraer cuerpos → pedir al modelo que responda con citas en línea.
  • Dos herramientas — Web Search (15 créditos) y URL Extract (2 créditos por URL) — cubren el 95% de los casos de respuesta fundamentada.
  • El tool use de Claude se encarga de la orquestación; tú solo trasiegas bloques tool_use → llamadas a la API → bloques tool_result hasta que el modelo se detiene.
  • Coste de principio a fin: ~25 créditos + ~$0.02 en tokens del LLM por pregunta a profundidad típica.

Qué vamos a construir

Un agente de research que toma una entrada — una pregunta en lenguaje natural — y devuelve una respuesta con fuentes. Arquitectónicamente:

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

Unas 120 líneas de Python. Dos herramientas — search y extract — y un bucle de agente que gestiona lo que sea que Claude decida hacer. Lo construiremos desde cero en 4 pasos.

1Trae los esquemas de herramienta (nada de JSON a mano)

Ambos endpoints publican una ruta de tool-schema que devuelve una definición de herramienta de Claude con la forma exacta que espera messages.create.

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.

Dos cosas importantes que esto te da gratis: el esquema de parámetros (para que Claude sepa que aceptas query, country_code, start_date, etc.) y una descripción clara y amigable para el modelo de lo que hace la herramienta.

2Escribe el handler de la herramienta

Cuando Claude devuelve un bloque tool_use, tu trabajo es llamar a la API real y devolver un bloque tool_result. Una función por herramienta, un dispatcher que enruta según el nombre que eligió 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,
    }

Tres cosas a destacar. tool_use_id es como Claude correlaciona el resultado con su llamada anterior — debes devolverlo tal cual. is_error: True le dice a Claude que se recupere con elegancia (a menudo probando otra consulta). Y pasamos el texto de respuesta en crudo — Claude está cómodo con la forma JSON que devuelve API Pick, así que no hace falta transformación.

3El bucle del agente

El bucle es corto: envía la conversación, mira cada bloque de la respuesta, ejecuta las herramientas, añade a la conversación tanto el tool_use de Claude como tu tool_result, y repite hasta que stop_reason sea 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}")

Fíjate en que el modelo puede disparar varias llamadas de herramienta en un mismo turno — por ejemplo, podría emitir una única llamada a extract_urls con cinco URLs para procesarlas en lote. Lo gestionamos recogiendo todos los bloques tool_use del turno del asistente y devolviendo todos los bloques tool_result en el siguiente turno de usuario.

4Ejecútalo

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

Verás algo como:

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/...].

Eso es un agente de research. ~120 líneas, dos herramientas, salida con fuentes.

Lo que cuesta

Una llamada de research típica:

  • 1 llamada de búsqueda — 15 créditos ($0.015)
  • 1 llamada de extracción cubriendo 3–5 URLs — 6–10 créditos ($0.006–$0.010)
  • ~3,000 tokens de entrada + 800 de salida de Claude — ~$0.02

Cifra redonda: ~3 centavos por respuesta investigada. A 1,000 llamadas de research al día son ~$30/día — más o menos el presupuesto del café matutino de un analista. Presupuesta el LLM, no las APIs de búsqueda.

Tres refinamientos para producción

1. Cachea por pregunta

Si tu producto hace preguntas similares, calcula el hash de (question, today's date) y cachea la respuesta final durante una hora. La mayoría del research impulsado por usuarios tiene tasas de acierto de cola larga por encima del 30%.

2. Restringe cuando importa la frescura

Para preguntas de 'esta semana' / 'hoy', fija start_date en la llamada de búsqueda a la fecha actual o a ayer. Sin ella, la búsqueda puede devolver artículos del año pasado. La versión más simple: una regla de system prompt como 'incluye siempre start_date cuando la pregunta contenga hoy, esta semana, reciente o último'.

3. Salvaguardas contra la alucinación

La regla de mayor apalancamiento individual: 'Si no tienes suficiente contenido extraído para responder, devuelve: No pude encontrar una fuente fiable para esto. No adivines.' Añadir esto al system prompt reduce las respuestas inventadas en un orden de magnitud en nuestras pruebas.

Hacia dónde llevarlo después

Tres extensiones naturales:

  • Verticalízalo: cambia Web Search por Academic Search para un agente de revisión de literatura, SEC Filings Search para due diligence, o Clinical Search para investigación médica. El handler no cambia — solo el system prompt.
  • Añade salida estructurada: pide al modelo que devuelva JSON con campos answer y citations[] para poder renderizarlos como una tarjeta de UI.
  • Transmite tokens: cambia a client.messages.stream() para salida incremental — útil cuando la respuesta es larga. El bucle de tool-call es el mismo.

Preguntas frecuentes

¿Por qué dos herramientas en lugar de un único endpoint 'answer'?

Separar búsqueda y extracción te da control sobre cuántas URLs leer, cuándo parar y cuáles citar. Un único endpoint alojado de 'answer' oculta esto — no puedes cambiar la estrategia con facilidad sin rearquitectar. Con dos herramientas, el mismo código se convierte en un agente de briefing matutino, un revisor de literatura o un scraper de inteligencia competitiva con solo ajustar el prompt.

¿Funciona también con OpenAI Assistants / Responses API?

Sí. La arquitectura es idéntica — lo único que cambia es cómo parseas los bloques de tool-call y envías resultados. La forma del handler pasa a ser submit_tool_outputs(...) en lugar de un bloque de contenido tool_result, pero el bucle del agente y las definiciones de herramienta son el mismo JSON.

¿Cómo decide el agente cuándo parar?

Claude devuelve stop_reason: end_turn cuando tiene contexto suficiente para responder, y tool_use cuando quiere llamar a una herramienta. El bucle sigue hasta end_turn. En la práctica el modelo suele hacer entre 1 y 3 ciclos de búsqueda/extracción antes de responder.

¿Cómo me aseguro de que realmente cite las fuentes?

Dos palancas. Primera: en el system prompt, exige explícitamente '[source: URL]' en línea tras cada afirmación. Segunda: la respuesta de URL Extract incluye la URL, así que cuando el modelo la tiene en contexto tiende a citar de forma natural. Si se le escapan las citas, añade un paso final de formato que rechace la respuesta y pida al modelo que agregue las citas.

¿Y la latencia?

Cada ida y vuelta de búsqueda es de ~1s; cada lote de extracción es de 1–4s según el número de URLs y el renderizado de JS. Una llamada típica de 'investiga una pregunta' aterriza en 5–15s de reloj. Si lo necesitas más rápido, paraleliza el paso de extracción (extract ya toma una lista de URLs en una sola llamada) y limita al modelo a una ronda de herramientas por pregunta mediante instrucciones en el system prompt.

APIs usadas en este artículo

Sarah Choy
Escrito por
Sarah Choy
CEO, API Pick

Sarah Choy es la CEO de API Pick. Escribe sobre cómo construir APIs listas para producción para agentes de IA y flujos de trabajo con LLMs.