Documentación
Streaming
Recibir un turno como Server-Sent Events, a medida que ocurre.
POST /chats/{chat_id}/messages/stream devuelve text/event-stream en vez de esperar al final. Sirve para dos cosas distintas: mostrar el texto mientras se genera, y poder contestarle al turno cuando pide permiso.
| Sincrónico | Stream | |
|---|---|---|
| Modos | Solo auto | auto y manual |
| Respuesta | El recurso Message completo | Eventos, y el terminal al final |
| Interacciones | Imposible: no hay canal | message.action_required |
| Cuándo usarlo | Trabajos por lotes, colas, integraciones sin interfaz. | Interfaces de chat, agentes con aprobación humana. |
El formato
Cada evento son dos líneas y una en blanco. data es siempre un JSON en una sola línea.
event: message.delta
data: {"text":"El pedido 8842","replace":false}
El stream siempre empieza con message.start y siempre termina con exactamente uno de message.completed, message.cancelled o message.error. Un fallo del proveedor seguido de un error es un terminal, no dos. Y el terminal sale recién cuando el resultado quedó guardado: si leíste message.completed, GET /chats/{chat_id}/messages ya lo devuelve.
Los deltas son sufijos
message.delta trae el texto nuevo, no el acumulado. Concatenás lo que recibís y tenés la respuesta. Es la diferencia con el stream interno de la plataforma, que emite el acumulado y obligaría a reemplazar en cada evento.
La excepción es replace: true, que aparece cuando la respuesta se reescribe —pasa en un failover de proveedor—. Ahí text es el texto completo y hay que descartar lo acumulado hasta ese momento.
let answer = "";
function onDelta({ text, replace }) {
// replace es raro, pero ignorarlo deja el texto duplicado tras un failover.
answer = replace ? text : answer + text;
}Keepalive y desconexión
Cada 15 segundos de silencio se emite un ping con un timestamp. Sirve para mantener viva la conexión a través de proxies; ignoralo en tu lógica, pero úsalo como señal de que el turno sigue vivo.
Si te desconectás, el turno sigue
El turno sigue corriendo hasta su terminal (o hasta el timeout) y su resultado se persiste bajo tu Idempotency-Key. Reconectá enviando la misma key y recibís el terminal real, no un error de desconexión. Si querés abortar de verdad, usá POST /chats/{chat_id}/actions/cancel: es una intención explícita, no un socket que se cayó.
El turno también tiene un plazo máximo de 30 minutos. Al vencer recibís message.error con code: "turn_timeout" y retryable: false.
Un cliente completo
import json
import uuid
import requests
def stream_turn(chat_id: str, content: str, token: str):
"""Envía un turno y produce (evento, payload) hasta el terminal."""
with requests.post(
f"https://api.niucore.com/api/v1/chats/{chat_id}/messages/stream",
headers={
"Authorization": f"Bearer {token}",
"Accept": "text/event-stream",
"Idempotency-Key": str(uuid.uuid4()),
},
json={"content": content, "permission_mode": "manual"},
stream=True,
# Sin timeout de lectura: un turno legítimo puede callarse hasta 15 s
# entre pings, y mucho más entre un paso y el siguiente.
timeout=(10, None),
) as response:
response.raise_for_status()
event = None
for line in response.iter_lines(decode_unicode=True):
if not line:
continue
if line.startswith("event:"):
event = line[6:].strip()
elif line.startswith("data:"):
yield event, json.loads(line[5:].strip())
answer = ""
for event, payload in stream_turn(chat_id, "¿Qué pasó con el pedido 8842?", token):
if event == "ping":
continue
if event == "message.delta":
answer = payload["text"] if payload["replace"] else answer + payload["text"]
elif event == "message.action_required":
handle_interaction(payload) # ver la guía de interacciones
elif event == "message.completed":
print(payload["usage"])
elif event in ("message.cancelled", "message.error"):
print(event, payload)Proxies y buffering
La respuesta viaja con Cache-Control: no-cache, no-transform y X-Accel-Buffering: no. Si igual ves el stream llegar de golpe al final, el culpable suele estar en tu lado:
- Un cliente HTTP que no expone el cuerpo hasta completarlo (
stream=Trueenrequests, no leerresponse.text). - Un proxy inverso que comprime al vuelo.
no-transformlo pide, pero no todos lo respetan. - Un serverless con respuesta bufferizada: muchos entornos no soportan streaming de salida.
Nota
Un evento individual que supere 1 MB se descarta —ese evento, no el turno—. En la práctica solo pasa con payloads anómalos de herramientas.