Todos los recursos

Hacer streaming con herramientas es un bucle agéntico, no una sola llamada: vas emitiendo tokens para que la respuesta se sienta ágil, pausas cuando Claude pide una herramienta, la ejecutas, le devuelves el resultado y sigues con el streaming hasta que el turno se cierra. Esta guía recorre el bucle completo en TypeScript con eventos reales del SDK, las reglas de forma del mensaje que la API exige y los fallos que más duelen en producción.

Hacer streaming de llamadas a herramientas de la API de Claude dentro de un bucle

En resumen

  • La unidad de trabajo es el bucle, no la llamada: streaming → detectar tool_use → ejecutar la herramienta → agregar el tool_result → volver al streaming.
  • Mete siempre el contenido completo del asistente (con el bloque tool_use adentro) antes del tool_result, y haz coincidir cada tool_use_id.
  • Un mismo turno puede traer varios bloques tool_use: respóndelos todos en un único mensaje de usuario con los tool_results.
  • Ponle tope a las iteraciones, parsea el input con JSON.parse y maneja pause_turn para que una herramienta que se porta mal no se quede dando vueltas sin fin.
  • Apóyate en el helper de streaming del SDK (stream.on("text"), finalMessage()): él se encarga de armar los bloques y de cerrar el stream por ti.

Hacer streaming de una respuesta de chat es fácil. Hacer streaming de una respuesta que además llama a tus herramientas es donde la mayoría se equivoca en la forma del mensaje, pierde el bloque tool_use del asistente o termina con un bucle que nunca corta. El problema de fondo no es el streaming: es que usar herramientas convierte una sola petición en un bucle agéntico con reglas estrictas sobre la forma del turno, y la capa de streaming facilita saltárselas sin darte cuenta. Al terminar esta guía vas a tener un bucle funcional en TypeScript que emite tokens al usuario, pausa de forma limpia para ejecutar una herramienta, le devuelve el resultado con la forma exacta que la API espera y se detiene justo cuando debe.

01 · Requisitos previos y el modelo mental

Antes de escribir código, deja claras dos cosas.

Importante

Necesitas una clave de API de Anthropic en tu entorno como ANTHROPIC_API_KEY y el SDK instalado con npm install @anthropic-ai/sdk. Nunca expongas esta clave en el navegador: una clave que vive en el código del cliente es una clave que cualquiera puede leer y gastar a tu costa. Si llamas a Claude desde un frontend, mete un servidor delgado (un route handler de Next.js o una Edge Function de Supabase) entre el navegador y Anthropic, y guarda la clave ahí.

El modelo mental pesa más que la sintaxis. Una petición normal es un solo viaje de ida y vuelta: mandas messages, Claude te devuelve texto por streaming y listo. Una petición con herramientas es un bucle:

  1. Haces streaming de una respuesta. Claude o bien responde en texto, o bien decide que necesita una herramienta y se detiene con un stop_reason igual a «tool_use».
  2. Ante «tool_use», pausas, lees el nombre y el input de la herramienta en la respuesta, y la ejecutas en tu propio código.
  3. Agregas dos cosas a la conversación: la respuesta completa del asistente (la que contiene el bloque tool_use) y un mensaje de usuario que lleva el tool_result; luego vuelves a llamar a la API.
  4. Repites hasta que Claude se detiene con «end_turn».

La API no guarda estado: no recuerda nada entre llamadas, así que el historial de mensajes y el bucle corren por tu cuenta. Las herramientas son una función del único endpoint /v1/messages, no una API aparte: tú las describes, Claude las pide, tú las ejecutas. Esa repartición (Claude decide, tu harness ejecuta) es la clave de todo.

02 · Define la herramienta y el ejecutor

Una definición de herramienta consta de un nombre, una description que Claude lee para decidir cuándo llamarla y un JSON Schema para su input. Sé bien específico en la description: di en qué momento llamarla, no solo qué hace. Los modelos Opus recientes recurren a las herramientas con más cautela, así que una condición de disparo clara sube de forma medible la probabilidad de que decida usarla cuando toca.

import Anthropic from "@anthropic-ai/sdk"

const client = new Anthropic()

const tools: Anthropic.Tool[] = [
  {
    name: "get_weather",
    description:
      "Obtiene el clima actual de una ciudad. Llama a esta herramienta cuando " +
      "el usuario pregunte por las condiciones actuales, la temperatura, o si " +
      "debe llevar abrigo.",
    input_schema: {
      type: "object",
      properties: {
        city: { type: "string", description: "Nombre de ciudad, ej. Santo Domingo" },
        unit: { type: "string", enum: ["celsius", "fahrenheit"] },
      },
      required: ["city"],
    },
  },
]

// Tu lógica real de la herramienta. Devuelve un string (o JSON.stringify de un objeto).
async function runTool(name: string, input: unknown): Promise<string> {
  if (name === "get_weather") {
    const { city } = input as { city: string }
    // ...aquí llamas a una API de clima real...
    return JSON.stringify({ city, tempC: 29, sky: "húmedo, nubes dispersas" })
  }
  return JSON.stringify({ error: `herramienta desconocida: ${name}` })
}

Dos detalles que ahorran horas de debug. Primero, mantén el esquema plano y con nombres de propiedad descriptivos: los campos muy anidados o ambiguos disparan la tasa de error. Segundo, trata el input que manda Claude como datos sin tipar que tienes que validar, no como verdad absoluta. Un esquema restringe la forma, no la corrección de negocio.

Por qué un resultado en string es suficiente

El contenido del tool_result puede ser un string plano o bloques estructurados. Un string es lo más simple que funciona; usa contenido estructurado solo cuando devuelves imágenes o necesitas citas. Si tu herramienta falla, devuelve el error como resultado con is_error en true, para que Claude se adapte en vez de andar adivinando.

03 · Haz streaming del primer turno

Usa el helper de streaming del SDK. Él va juntando los deltas que llegan en bloques de contenido completos, así que no tienes que reducir a mano los eventos content_block_delta hasta armar el mensaje.

let messages: Anthropic.MessageParam[] = [
  { role: "user", content: "¿Qué me pongo hoy en Santo Domingo?" },
]

const stream = client.messages.stream({
  model: "claude-opus-4-8",
  max_tokens: 1024,
  tools,
  messages,
})

// Muestra el texto visible al usuario conforme va llegando.
stream.on("text", (delta) => process.stdout.write(delta))

const final = await stream.finalMessage()

El evento text te entrega solo el delta como string: más simple que filtrar a mano los eventos de delta crudos. Usa finalMessage() para obtener el Anthropic.Message completo y ya armado cuando el stream termina; maneja por dentro los estados de cierre, error y aborto, así que no envuelvas los handlers .on() en un Promise hecho a mano solo para juntar el resultado.

Consejo

Si quieres mostrarle al usuario que se está invocando una herramienta, escucha los eventos input_json_delta: son los argumentos de input de la herramienta llegando token por token. Casi nunca necesitas parsear esos fragmentos parciales; deja que finalMessage() te dé el input ya armado. Los deltas te sirven para un spinner del tipo "llamando a get_weather…", no para la lógica.

04 · Detecta la llamada, ejecútala y devuelve el resultado

Este es el meollo del asunto, y es donde las reglas de forma del mensaje no perdonan.

if (final.stop_reason === "tool_use") {
  // 1. Mete el contenido COMPLETO del asistente, incluido el bloque tool_use.
  messages.push({ role: "assistant", content: final.content })

  // 2. Un mismo turno puede traer VARIOS bloques tool_use. Ejecútalos todos.
  const toolUses = final.content.filter(
    (b): b is Anthropic.ToolUseBlock => b.type === "tool_use",
  )

  const toolResults: Anthropic.ToolResultBlockParam[] = []
  for (const call of toolUses) {
    const result = await runTool(call.name, call.input) // el input ya viene parseado
    toolResults.push({
      type: "tool_result",
      tool_use_id: call.id, // debe coincidir con el bloque tool_use de origen
      content: result,
    })
  }

  // 3. TODOS los resultados vuelven en UN solo mensaje de usuario, y luego el bucle.
  messages.push({ role: "user", content: toolResults })
}

Tres reglas que la API impone, y cada una falla de forma escandalosa si la rompes:

  1. Mete el contenido completo del asistente antes del tool_result. Si agregas un tool_result armado a mano sin el turno previo del asistente que contiene el bloque tool_use, la API rechaza la petición: un tool_result sin su tool_use correspondiente está malformado.
  2. Haz coincidir cada tool_use_id. Cada tool_result debe traer el tool_use_id del bloque al que responde. Si no coincide o lo omites, el turno se rechaza.
  3. Responde todas las llamadas en un mismo mensaje de usuario. Cuando un turno trae varios bloques tool_use, junta todos los tool_results en un solo mensaje de usuario: ni uno por llamada, ni uno por vuelta del bucle. El SDK ya expone call.input como objeto parseado; no compares strings crudos sobre el JSON serializado, porque el escapado puede variar entre versiones del modelo.

05 · Cierra el bucle y detente a tiempo

El fragmento de arriba maneja una sola ronda de herramienta. Un agente de verdad lo envuelve en un bucle acotado que vuelve a hacer streaming tras cada ronda hasta que Claude termina.

async function runConversation(userText: string, maxTurns = 8) {
  let messages: Anthropic.MessageParam[] = [
    { role: "user", content: userText },
  ]

  for (let turn = 0; turn < maxTurns; turn++) {
    const stream = client.messages.stream({
      model: "claude-opus-4-8",
      max_tokens: 1024,
      tools,
      messages,
    })
    stream.on("text", (d) => process.stdout.write(d))
    const final = await stream.finalMessage()

    if (final.stop_reason === "end_turn") return final
    if (final.stop_reason === "pause_turn") {
      // Trabajo del lado del servidor pausado a mitad de turno. Reenvía para reanudar; no agregues ningún mensaje.
      messages.push({ role: "assistant", content: final.content })
      continue
    }
    if (final.stop_reason !== "tool_use") return final

    messages.push({ role: "assistant", content: final.content })
    const results: Anthropic.ToolResultBlockParam[] = []
    for (const b of final.content) {
      if (b.type === "tool_use") {
        results.push({
          type: "tool_result",
          tool_use_id: b.id,
          content: await runTool(b.name, b.input),
        })
      }
    }
    messages.push({ role: "user", content: results })
  }
  throw new Error("el bucle de herramientas superó el máximo de turnos permitido")
}

Atención

Ponle tope a las iteraciones, sin excepción. Una herramienta que devuelve resultados ambiguos, o un prompt que hace que Claude vuelva a llamar la misma herramienta una y otra vez, puede quedarse dando vueltas sin parar y quemar tokens. Un techo duro (8 turnos es un default razonable) convierte un descontrol en un error limpio y fácil de depurar. Acompáñalo con un timeout por herramienta para que una API externa lenta no deje colgado el turno entero.

Fíjate en la rama pause_turn. Cuando usas herramientas del lado del servidor (búsqueda web, ejecución de código) la API corre su propio bucle de sampling; si alcanza su límite interno a mitad de turno, devuelve pause_turn. La solución es agregar el contenido del asistente y reenviar: el servidor reanuda por su cuenta. No le inyectes un mensaje de "continúa"; el bloque de herramienta del servidor que queda al final le dice a la API que retome donde se quedó.

06 · Ejemplo resuelto y errores comunes

Sigamos el ejemplo del clima de principio a fin. Turno 0: mandas "¿qué me pongo hoy en Santo Domingo?". Claude transmite un breve preámbulo y luego se detiene con stop_reason «tool_use», pidiendo get_weather con input { city: "Santo Domingo" }. Agregas el turno del asistente, ejecutas la herramienta, recibes el JSON y lo metes como tool_result. Turno 1: vuelves a hacer streaming; ahora Claude ya tiene el clima y transmite la respuesta final ("Está húmedo y rondando los 29 °C: ropa ligera y transpirable, y una sombrilla por las nubes dispersas"), deteniéndose con «end_turn». El bucle termina. Dos llamadas a la API, una ejecución de herramienta, y todo con streaming en ambas.

Los errores que de verdad te complican:

  • Olvidar el turno del asistente. El bug más común, por mucho: agregar el tool_result sin agregar primero el mensaje del asistente que contiene el bloque tool_use. El resultado es un 400 por un tool_result huérfano. Mete el contenido del asistente primero, siempre.
  • Manejar solo el primer bloque tool_use. Claude puede disparar varias llamadas a herramientas en un mismo turno (el clima de dos ciudades, por ejemplo). Si manejas solo la primera, la API rechaza el siguiente turno por un tool_result faltante. Recórrelas todas.
  • El buffering del proxy mata el stream. Si haces streaming a través de un reverse proxy (Traefik, nginx) o un host que almacena las respuestas en buffer, el usuario no ve nada hasta el final: el streaming queda anulado en silencio. Confirma que la ruta no esté bufferizada y nunca acumules el stream en un solo string antes de reenviarlo.
  • Quedarte corto con max_tokens. Si el modelo alcanza max_tokens a mitad de una llamada a herramienta, el input de la herramienta queda truncado y la llamada es inservible. Dale aire; 1024 está bien para herramientas pequeñas, y más para respuestas largas.
  • Forzar la herramienta equivocada. Deja tool_choice en su default («auto») para que Claude también pueda simplemente responder. Usa tool_choice con { type: "tool", name } solo cuando de verdad necesites forzar una herramienta específica (por ejemplo, para garantizar una extracción estructurada) y acompáñalo con disable_parallel_tool_use si quieres como mucho una llamada por turno.

Ese es todo el patrón: un bucle acotado alrededor del helper de streaming, el turno del asistente metido antes de cada tool_result, cada tool_use_id haciendo coincidencia, y todas las llamadas respondidas en un mismo mensaje. Acierta esos cuatro y el streaming con herramientas deja de ser frágil para convertirse en la columna confiable de un agente. Todo lo más elaborado (herramientas en paralelo, herramientas del lado del servidor, una UI de chat que lee el stream por SSE) es el mismo bucle con más eventos que escuchar.

Puntos clave

  • Usar herramientas es un bucle agéntico, no una sola llamada: haz streaming, detecta tool_use, ejecuta la herramienta, devuelve el resultado y repite hasta end_turn.
  • Mete el contenido completo del asistente (con el bloque tool_use adentro) antes de cualquier tool_result y haz coincidir cada tool_use_id, o la API te devuelve un 400.
  • Maneja todos los bloques tool_use de un turno en un único mensaje de usuario con los tool_results; usa call.input como objeto parseado y nunca compares strings crudos.
  • Acota el bucle y agrega timeouts por herramienta para que una herramienta que se porta mal no se quede dando vueltas sin fin ni queme tokens.
  • Apóyate en el helper de streaming del SDK (stream.on("text"), finalMessage()) y mantén tu clave de Anthropic en el servidor, detrás de un proxy que no bufferice el stream.

Preguntas frecuentes

¿Tengo que hacer streaming para usar herramientas, o me sirve messages.create?

Puedes usar messages.create sin streaming y aplica la misma lógica de bucle: detectar tool_use, ejecutar la herramienta, agregar el resultado y volver a llamar. El streaming te da agilidad (el usuario ve el texto mientras se genera) y te evita timeouts de HTTP en salidas largas. Para una UI de chat, hazle streaming; para un trabajo por lotes en segundo plano que nadie está mirando, create a secas es más simple.

¿Qué pasa si mi herramienta lanza un error?

Atrápalo y devuelve el error como contenido del tool_result con is_error en true, en vez de dejar que tumbe el bucle. Claude lee el error y puede reintentar con otro input, probar otro enfoque o avisarle al usuario que no pudo completar la tarea. Una excepción que mata tu bucle solo deja una conversación muerta; un error que devuelves bien mantiene al agente con capacidad de recuperarse.

¿Cómo manejo varias llamadas a herramientas en un solo turno?

Filtra todos los bloques tool_use del contenido del asistente, ejecuta cada uno y junta todos los resultados en un solo mensaje de usuario con los tool_results: un tool_result por cada tool_use_id. No mandes un mensaje por llamada. Si las llamadas son independientes, puedes correrlas en paralelo con Promise.all; si una depende de otra, córrelas en secuencia. En cualquier caso, todos los resultados vuelven en un mismo turno de usuario.

¿Por qué la API rechaza mi tool_result con un 400?

Casi siempre es una de tres cosas: agregaste el tool_result sin agregar primero el mensaje del asistente que contiene el bloque tool_use (resultado huérfano); el tool_use_id de tu resultado no coincide con ningún tool_use del turno previo; o respondiste solo algunos bloques tool_use del turno y dejaste uno sin resultado. Mete primero el contenido completo del asistente, haz coincidir cada id y responde todas las llamadas.

¿Puedo forzar a Claude a usar una herramienta específica?

Sí: pon tool_choice en { type: "tool", name: "tu_herramienta" } y el modelo queda obligado a usar esa herramienta, que es la forma confiable de sacar JSON estructurado (define una herramienta cuyo input_schema sea la forma que quieres). Usa { type: "any" } para forzar alguna herramienta, o déjalo en el default { type: "auto" } para que Claude también pueda responder en texto. Agrega disable_parallel_tool_use: true para limitarlo a una sola llamada por turno.

¿Qué es pause_turn y necesito manejarlo?

pause_turn aparece cuando usas herramientas del lado del servidor (búsqueda web, ejecución de código): la API corre su propio bucle de sampling y, si alcanza su límite interno de iteraciones a mitad de turno, devuelve pause_turn en lugar de terminar. Para reanudar, agrega el contenido del asistente a messages y reenvía la petición: el servidor retoma donde se quedó. No agregues un mensaje de usuario de 'continúa'. Si solo usas tus propias herramientas del lado del cliente, nunca vas a ver pause_turn.

¿Prefieres que lo hagamos por ti?

Esto mismo lo construimos para negocios como el tuyo. La primera conversación es gratis y sin compromiso.

Escríbenos por WhatsApp

Escríbenos por WhatsApp

Escanéalo con tu teléfono para escribirnos por WhatsApp.

Escanéalo con tu teléfono para escribirnos por WhatsApp.

¿Estás desde el teléfono y no puedes escanear? Escríbenos a info@ilustrari.com

Primera conversación gratis. Te responde el fundador.

Recursos relacionados

GuíaClaude

Ingeniería de prompts para agentes que usan herramientas

Cuando un agente llama a la herramienta equivocada, el bug casi siempre está en la descripción de la herramienta, no en el system prompt. El modelo lee esas descripciones como si fueran documentación de API y enruta con ellas, así que escríbelas igual que documentación de API. Esta guía recorre toda la superficie: schemas, condiciones de disparo, el system prompt corto que amarra todo y un loop de eval para que un cambio de una sola palabra no te dañe el ruteo sin que te enteres.

28 abr 202611 min de lectura
GuíaNext.js

Transmite la salida del modelo con Server-Sent Events desde un route handler de Next.js

Una UI de chat que espera la respuesta completa se siente trabada; una que va pintando los tokens a medida que llegan se siente viva. Esta guía arma todo el flujo en el App Router de Next.js: un route handler que devuelve un ReadableStream, el formato SSE que aguanta el paso por los proxies, un cliente que lee el stream y va pintando los deltas, y los detalles de producción (buffering, cancelación, qué runtime elegir) que matan el streaming sin hacer ruido, cuando nadie está mirando.

30 abr 202612 min de lectura
GuíaTypeScript

Arma evals para tus agentes antes de confiar en ellos

Cada cambio de prompt en un agente que no puedes medir es una apuesta a ciegas. Una eval no es más que un test para sistemas no deterministas: un dataset pequeño y honesto, graders que corren en cada cambio y un pass rate que te dice al instante si tu "ajustito" rompió algo que ya funcionaba.

28 abr 202612 min de lectura