Streaming con Server-Sent Events
Objetivo
Al terminar sabrás habilitar streaming en /v1/messages, identificar los 7 tipos de eventos del stream, parsearlos a mano con curl para no depender del SDK, y después verás cómo el SDK oficial los ergonomiza. Vas a entender por qué el stream es lo único que hace experiencia de "chat token a token" posible y cómo contar tokens desde los eventos.
Concepto
¿Por qué streaming?
Sin streaming, Claude genera toda la respuesta entera y recién entonces te la manda — tu usuario ve una pantalla en blanco durante 3-20 segundos mientras Opus piensa una respuesta larga. Con streaming, la API te manda cada fragmento de texto apenas se genera, así tu UI empieza a mostrar letras en menos de 1 segundo.
Para chatbots, asistentes y cualquier tarea donde la respuesta sea directa al usuario, streaming no es opcional. La gente no tolera 10 segundos de pantalla muerta.
Además, streaming es la única forma de:
- Mostrar un "typewriter effect" real (no simulado).
- Cortar la generación temprano si el usuario cambia de opinión.
- Empezar a parsear output estructurado antes de que termine (parsing incremental).
¿Cómo activarlo?
Agregás dos cosas al request:
- En el body:
"stream": true. - En los headers:
Accept: text/event-stream.
El header es técnicamente opcional en Anthropic — el stream: true del body es suficiente — pero es buena higiene: le dice a cualquier proxy o CDN intermedio que esto es SSE y no debe bufferizarlo.
La respuesta deja de ser un JSON único y pasa a ser una secuencia de Server-Sent Events (SSE). Formato nativo de HTTP, muy simple, muy soportado.
El formato SSE en 30 segundos
Un stream SSE es texto plano con este shape:
event: <nombre del evento>
data: <JSON con la carga>
event: <otro evento>
data: <otro JSON>2
3
4
5
Cada bloque "evento + data" está separado del siguiente por una línea en blanco. El parser canónico es: leer líneas hasta encontrar una en blanco, juntar las event: y data: acumuladas, emitir ese evento, repetir.
En Node/TS con el SDK oficial esto está abstraído, pero entenderlo a mano te deja construir clientes en cualquier lenguaje — y debuggear cuando algo raro pasa.
Los 7 eventos del stream de Anthropic
En orden cronológico de una respuesta típica:
Gotcha importante: el usage del message_start y el del message_delta no son iguales. El de message_start es preliminar (output_tokens: 1 por protocolo); el de message_delta es el acumulado real al final. Para contar tokens, leé el message_delta, no el message_start.
Secuencia mínima vs secuencia compleja
La secuencia más simple (texto plano, respuesta corta):
message_start
content_block_start (index=0, type=text)
content_block_delta (text_delta)
content_block_delta (text_delta)
...
content_block_stop (index=0)
message_delta (stop_reason=end_turn)
message_stop2
3
4
5
6
7
8
Una respuesta con tool use:
message_start
content_block_start (index=0, type=text)
content_block_delta (text_delta: "Voy a consultar el clima...")
content_block_stop
content_block_start (index=1, type=tool_use)
content_block_delta (input_json_delta: parcial del JSON)
content_block_delta (input_json_delta: siguiente trozo)
content_block_stop
message_delta (stop_reason=tool_use)
message_stop2
3
4
5
6
7
8
9
10
Observá que el index te permite saber a qué bloque pertenece cada delta. Con múltiples bloques en la respuesta (que verás en el Módulo 5), el index es lo que te permite reconstruir content[] correctamente.
Ejecución real
Paso 1 — curl con stream: true y leer los eventos
curl -s -N https://api.anthropic.com/v1/messages \
-H "x-api-key: $ANTHROPIC_API_KEY" \
-H "anthropic-version: 2023-06-01" \
-H "content-type: application/json" \
-H "accept: text/event-stream" \
-d '{
"model": "claude-haiku-4-5",
"max_tokens": 60,
"stream": true,
"messages": [{"role": "user", "content": "Dame 3 palabras separadas por coma y nada más. Sin explicación."}]
}'2
3
4
5
6
7
8
9
10
11
Dos flags nuevos del curl importan:
-N(--no-buffer): sin esto, curl bufferiza la salida y ves el stream de golpe al final. Con-N, ves los eventos a medida que llegan.-H "accept: text/event-stream": higiene para proxies.
Output real al correr esta lección:
event: message_start
data: {"type":"message_start","message":{"model":"claude-haiku-4-5-20251001","id":"msg_01BiHaB6TfwQ4SrYoHQQBE7h","type":"message","role":"assistant","content":[],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":27,"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":0},"output_tokens":1,"service_tier":"standard","inference_geo":"not_available"}}}
event: content_block_start
data: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}
event: ping
data: {"type": "ping"}
event: content_block_delta
data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"g"}}
event: content_block_delta
data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"ato, luna, café"}}
event: content_block_stop
data: {"type":"content_block_stop","index":0}
event: message_delta
data: {"type":"message_delta","delta":{"stop_reason":"end_turn","stop_sequence":null,"stop_details":null},"usage":{"input_tokens":27,"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"output_tokens":9}}
event: message_stop
data: {"type":"message_stop"}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
Observaciones:
input_tokens: 27está fijo desdemessage_start(la API ya los contó antes de generar).output_tokens: 1enmessage_startvsoutput_tokens: 9enmessage_delta. El 9 es el real. El 1 del inicio es protocolo.- El primer
content_block_deltatrajo solo"g"; el segundo trajo"ato, luna, café"entero. Los deltas no son tokens individuales — son "trozos de tamaño arbitrario que el servidor elige enviarte cuando conviene". No asumas que un delta = un token. - Un
pingapareció entrecontent_block_starty el primercontent_block_delta. Tu parser tiene que ignorarlo y seguir. - Al concatenar todos los
text_deltaen orden, reconstruís el texto completo:"g" + "ato, luna, café"="gato, luna, café". Eso es exactamente lo que habrías visto encontent[0].textcon una llamada no-streaming.
Paso 2 — Parser SSE manual en TypeScript (sin SDK)
Para un archivo playground/stream-parser.ts:
import "dotenv/config";
type SSEEvent = { event: string; data: string };
async function* parseSSE(response: Response): AsyncGenerator<SSEEvent> {
const reader = response.body!.getReader();
const decoder = new TextDecoder();
let buffer = "";
while (true) {
const { done, value } = await reader.read();
if (done) return;
buffer += decoder.decode(value, { stream: true });
// Los eventos están separados por \n\n
const events = buffer.split("\n\n");
buffer = events.pop() ?? ""; // el último puede estar incompleto
for (const rawEvent of events) {
let event = "";
let data = "";
for (const line of rawEvent.split("\n")) {
if (line.startsWith("event: ")) event = line.slice(7).trim();
else if (line.startsWith("data: ")) data += line.slice(6);
}
if (event) yield { event, data };
}
}
}
async function main() {
const response = await fetch("https://api.anthropic.com/v1/messages", {
method: "POST",
headers: {
"x-api-key": process.env.ANTHROPIC_API_KEY!,
"anthropic-version": "2023-06-01",
"content-type": "application/json",
accept: "text/event-stream",
},
body: JSON.stringify({
model: "claude-haiku-4-5",
max_tokens: 60,
stream: true,
messages: [
{ role: "user", content: "Dame 3 palabras separadas por coma y nada más. Sin explicación." },
],
}),
});
let fullText = "";
let finalUsage: { input_tokens: number; output_tokens: number } | null = null;
for await (const sse of parseSSE(response)) {
const payload = JSON.parse(sse.data);
switch (sse.event) {
case "content_block_delta":
if (payload.delta.type === "text_delta") {
process.stdout.write(payload.delta.text); // imprimir en vivo
fullText += payload.delta.text;
}
break;
case "message_delta":
finalUsage = {
input_tokens: payload.usage.input_tokens,
output_tokens: payload.usage.output_tokens,
};
break;
case "ping":
case "message_start":
case "content_block_start":
case "content_block_stop":
case "message_stop":
// no-op para este ejemplo
break;
}
}
console.log("\n---");
console.log("Texto completo:", fullText);
console.log("Usage final:", finalUsage);
}
main();2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
Correlo con:
cd ~/Documents/GitHub/curso-claude-console
npx tsx playground/stream-parser.ts2
Vas a ver gato, luna, café aparecer letra por letra (bueno, trozo por trozo), seguido del usage final. Acabás de escribir un cliente streaming desde cero, sin SDK. Eso es lo que un SDK te ahorra, pero lo podés hacer en 50 líneas de código.
Paso 3 — El mismo ejemplo con el SDK oficial
Para comparar, el equivalente con @anthropic-ai/sdk:
import "dotenv/config";
import Anthropic from "@anthropic-ai/sdk";
const client = new Anthropic();
async function main() {
const stream = client.messages.stream({
model: "claude-haiku-4-5",
max_tokens: 60,
messages: [
{ role: "user", content: "Dame 3 palabras separadas por coma y nada más. Sin explicación." },
],
});
stream.on("text", (text) => process.stdout.write(text));
const finalMessage = await stream.finalMessage();
console.log("\n---");
console.log("Texto completo:", finalMessage.content);
console.log("Usage final:", finalMessage.usage);
}
main();2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
Menos código: el SDK te abstrae el parseo SSE y te da un helper .on("text", ...) que solo te entrega el texto a medida que llega, ignorando los eventos que no te importan. Además stream.finalMessage() te devuelve el objeto message "como si hubiera sido una llamada no-streaming", con content[] ya reconstruido y usage completo.
Momento arquitecto: el SDK es azúcar sintáctica sobre lo mismo que hiciste en el Paso 2. Saber el protocolo subyacente te permite:
- Escribir clientes para lenguajes sin SDK oficial.
- Debuggear cuando el SDK hace algo inesperado.
- Construir proxies que transformen el stream en tiempo real (por ejemplo, filtrar PII del texto antes de que llegue al cliente final).
Anti-patterns
- ❌ Asumir que un
content_block_delta= un token. No lo es. Los deltas son chunks de tamaño variable que el servidor decide. Si estás contando tokens contando deltas, estás equivocándote. Para contar, leéusage.output_tokensdelmessage_delta. - ❌ Leer
usage.output_tokensdelmessage_startpensando que es el total. El delmessage_startes siempre 1 (protocolo inicial). El total real está en elmessage_delta. - ❌ Olvidar
-Nen curl. Sin-Nves el stream de golpe al final y pensás que el streaming no funciona. No es el API, es curl bufferizando. - ❌ Ignorar el
indexde los bloques. En respuestas con múltiples bloques (tool use, thinking), los deltas llegan intercalados con distintosindex. Si los pegás todos juntos sin respetarindex, te queda el texto de un bloque mezclado con los inputs JSON de otro — un desastre. - ❌ No manejar el
ping. Si tu switch de eventos no tiene un case paraping, y además tu código falla ante "evento desconocido", vas a crashear aleatoriamente cuando llegue un keepalive. Siempre ignorápingsin romper. - ❌ Concatenar deltas sin filtrar por tipo. Si hay un bloque
thinkingy un bloquetext, un parser que hacefullText += delta.textva a concatenar ambos, mezclando razonamiento interno con output visible al usuario. Filtrá pordelta.type === "text_delta"si solo querés el texto visible. - ❌ Cortar el stream a mitad de camino sin limpiar el reader. Si tu usuario cancela, cerrá la conexión (
reader.cancel()en JS,abort()en fetch con AbortController) o vas a dejar conexiones colgadas. - ❌ Usar streaming cuando no lo necesitás. Para un endpoint server-to-server que procesa un lote de 10k prompts, streaming solo agrega complejidad. Úsalo cuando hay un humano esperando output.
Recap
- Activar streaming:
"stream": trueen el body +Accept: text/event-streamen headers +-Nen curl. Eso es todo. - 7 tipos de eventos:
message_start,content_block_start,content_block_delta,content_block_stop,message_delta,message_stop,ping. El orden es siempre el de arriba (con múltiplescontent_block_*pares si hay varios bloques) ypingpuede aparecer en cualquier momento. - Los deltas no son tokens. Son chunks arbitrarios. Para contar tokens, leé
usage.output_tokensdelmessage_delta(NO delmessage_start). - Parsear SSE a mano es 50 líneas de código. Hacelo una vez (el SDK te lo abstrae pero saberlo te habilita a debuggear y construir clientes custom).
- El SDK oficial te da
.on("text", ...)ystream.finalMessage()como azúcar sobre el mismo protocolo. Mismo contrato, menos boilerplate.
Fuente oficial: platform.claude.com/docs/en/api/messages-streamingEjercicio: