Streaming con Tool Use en Next.js: el agente que habla
Tu agente de soporte arranca la respuesta: "Estoy revisando tu pedido...". Los tokens llegan uno a uno. Todo fluye. Entonces el agente necesita consultar la base de datos del cliente. El stream se para. El cursor parpadea. El usuario espera mirando una pantalla que no hace nada durante tres segundos. Cierra la pestaña.
Este no es un bug de Vercel ni un problema de timeouts. Es un problema de arquitectura de streaming. La mayoría de tutoriales de IA muestran cómo hacer stream de texto. Ninguno explica qué pasa cuando el agente llama a una herramienta en mitad del stream, y cómo mostrarle al usuario que algo está ocurriendo mientras tanto.
Por qué el streaming rompe con tool calls
El SDK de Anthropic emite eventos de distintos tipos cuando usas messages.stream(). La mayoría de implementaciones solo escuchan uno:
| Evento | Qué significa |
|---|---|
content_block_delta + text_delta | Token de texto generado |
content_block_start + tool_use | El agente quiere llamar a una herramienta |
content_block_delta + input_json_delta | Parámetros de la herramienta (incremental) |
message_stop | El agente terminó su turno |
El error habitual: el servidor solo envía los deltas de texto, ejecuta la herramienta en silencio y luego reanuda el stream. El cliente ve texto, silencio, más texto. No sabe que el agente está trabajando activamente.
La solución es que el servidor emita un evento por cada fase del ciclo — incluyendo cuándo empieza a llamar a una herramienta y cuál es su nombre — para que el cliente pueda mostrar un indicador de progreso concreto.
El servidor: Route Handler en Next.js 16
El patrón correcto usa un bucle agentico explícito: el servidor sigue iterando mientras el modelo devuelva stop_reason: "tool_use". Cada iteración envía eventos SSE al cliente.
// app/api/agent/stream/route.ts
import Anthropic from "@anthropic-ai/sdk";
const anthropic = new Anthropic();
const TOOLS: Anthropic.Messages.Tool[] = [
{
name: "buscar_cliente",
description: "Obtiene historial y pedidos del cliente por ID",
input_schema: {
type: "object" as const,
properties: { cliente_id: { type: "string", description: "ID del cliente" } },
required: ["cliente_id"],
},
},
];
export async function POST(req: Request) {
const { message } = await req.json();
const encoder = new TextEncoder();
const stream = new ReadableStream({
async start(controller) {
const send = (data: object) =>
controller.enqueue(
encoder.encode(`data: ${JSON.stringify(data)}\n\n`)
);
const messages: Anthropic.Messages.MessageParam[] = [
{ role: "user", content: message },
];
// Bucle agentico: el modelo puede llamar N herramientas antes de responder
while (true) {
const agentStream = anthropic.messages.stream({
model: "claude-sonnet-4-6",
max_tokens: 1024,
tools: TOOLS,
messages,
});
let activeToolBlock: Anthropic.Messages.ToolUseBlock | null = null;
for await (const event of agentStream) {
if (
event.type === "content_block_delta" &&
event.delta.type === "text_delta"
) {
send({ type: "text", text: event.delta.text });
} else if (
event.type === "content_block_start" &&
event.content_block.type === "tool_use"
) {
// Avisar al cliente: el agente está llamando a esta herramienta
activeToolBlock = event.content_block;
send({ type: "tool_start", name: event.content_block.name });
}
}
const finalMsg = await agentStream.finalMessage();
if (finalMsg.stop_reason !== "tool_use" || !activeToolBlock) {
send({ type: "done" });
controller.close();
break;
}
// Ejecutar herramienta y notificar al cliente del resultado
const toolResult = await executeToolCall(activeToolBlock);
send({ type: "tool_result", name: activeToolBlock.name });
// Construir el historial para la siguiente iteración
messages.push({ role: "assistant", content: finalMsg.content });
messages.push({
role: "user",
content: [
{
type: "tool_result",
tool_use_id: activeToolBlock.id,
content: JSON.stringify(toolResult),
},
],
});
}
},
});
return new Response(stream, {
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
Connection: "keep-alive",
},
});
}
async function executeToolCall(
block: Anthropic.Messages.ToolUseBlock
): Promise<unknown> {
const input = block.input as { cliente_id?: string };
// Aquí iría la llamada real a tu base de datos o CRM
return {
nombre: "García López",
pedidos: 3,
último_pedido: "2026-09-08",
estado: "activo",
};
}
El detalle que más falla en implementaciones apresuradas: el historial debe incluir el bloque tool_use del asistente y el tool_result del usuario. Sin los dos, el modelo no tiene contexto para continuar.
El cliente: hook de React con estado de agente
El hook gestiona cuatro estados: idle, streaming, calling-tool y done. Esto permite renderizar feedback específico en cada fase.
// hooks/useAgentStream.ts
import { useState } from "react";
type AgentStatus = "idle" | "streaming" | "calling-tool" | "done";
export function useAgentStream() {
const [text, setText] = useState("");
const [status, setStatus] = useState<AgentStatus>("idle");
const [activeTool, setActiveTool] = useState<string | null>(null);
const send = async (message: string) => {
setText("");
setStatus("streaming");
const response = await fetch("/api/agent/stream", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ message }),
});
const reader = response.body!.getReader();
const decoder = new TextDecoder();
while (true) {
const { done, value } = await reader.read();
if (done) break;
for (const line of decoder.decode(value).split("\n")) {
if (!line.startsWith("data: ")) continue;
try {
const event = JSON.parse(line.slice(6));
switch (event.type) {
case "text":
setText((prev) => prev + event.text);
break;
case "tool_start":
setStatus("calling-tool");
setActiveTool(event.name);
break;
case "tool_result":
setStatus("streaming");
setActiveTool(null);
break;
case "done":
setStatus("done");
break;
}
} catch {
// Ignorar líneas malformadas del stream
}
}
}
};
return { text, status, activeTool, send };
}
En la UI, el estado calling-tool permite mostrar exactamente qué está haciendo el agente:
// Uso en componente
const { text, status, activeTool, send } = useAgentStream();
<div>
{text && <p>{text}</p>}
{status === "calling-tool" && (
<span className="text-yellow-400 text-sm animate-pulse">
Consultando {activeTool?.replace(/_/g, " ")}...
</span>
)}
</div>
Lo que cambia en producción
Sin este patrón: texto generado → silencio de 2-3 segundos → más texto. El usuario no sabe si la app se colgó.
Con este patrón: texto generado → "Consultando historial de cliente..." → texto con datos reales. El usuario ve un proceso coherente.
El impacto no es solo perceptual. Los agentes con herramientas tienen latencias reales que el usuario interpreta como errores si no reciben feedback. Un indicador correcto convierte una espera de 3 segundos en algo que el usuario tolera sin ansiedad.
Este es el patrón estándar en los proyectos de integración IA que hacemos con clientes: el agente explica en tiempo real lo que está haciendo antes de dar la respuesta final.
Tres gotchas que destrozan la implementación
1. No incluir el tool_use block en el historial del asistente
Si envías solo el tool_result sin el bloque tool_use previo del asistente, el modelo recibe una respuesta de herramienta sin contexto. El error aparece como un comportamiento extraño o una excepción del SDK.
2. Asumir que solo hay una llamada a herramienta
Un agente real puede encadenar múltiples llamadas. El patrón con while (true) es deliberado: el bucle solo termina cuando stop_reason no es "tool_use". Si lo implementas con una sola iteración, el agente trunca su respuesta cuando necesita más de una herramienta.
3. No manejar errores en la ejecución de herramientas
Si la llamada a tu base de datos falla, debes enviar un tool_result con el error igualmente — el modelo necesita saber que la herramienta respondió, aunque sea con un error. Si no envías nada, el bucle cuelga.
try {
const result = await executeToolCall(activeToolBlock);
messages.push({ role: "user", content: [{ type: "tool_result", tool_use_id: activeToolBlock.id, content: JSON.stringify(result) }] });
} catch (err) {
messages.push({ role: "user", content: [{ type: "tool_result", tool_use_id: activeToolBlock.id, content: `Error: ${(err as Error).message}`, is_error: true }] });
}
El tiempo que cuesta hacerlo bien
Implementar este patrón desde cero lleva entre 3 y 8 días dependiendo de la complejidad del agente. El bucle agentico, el manejo correcto del historial, los errores en herramientas, la cancelación con AbortController, el reconectar si cae la conexión... cada pieza tiene sus edge cases.
En los proyectos que construimos con AI Driven Development, esto es infraestructura que entregamos desde el primer sprint, no algo que hay que descubrir después de que el agente esté en producción y los usuarios se quejen de pantallas congeladas.
Si estás construyendo un agente de producción con tool use y no quieres perder ese tiempo en infraestructura, escríbeme por WhatsApp. Lo que acabas de leer es exactamente cómo arrancamos.