Documentación
Eventos del stream
Cada tipo de evento SSE, su payload y qué hacer con él.
Estos son los eventos que emite POST /chats/{chat_id}/messages/stream. Son un contrato público y no los eventos internos de la plataforma: lo que cambie adentro no cambia acá.
| Evento | Cuándo | Terminal |
|---|---|---|
message.start | Siempre, y siempre primero. | No |
message.delta | Texto nuevo de la respuesta. | No |
message.step | El turno avanzó: razonamiento o herramientas. | No |
message.action_required | El turno necesita una respuesta tuya. | No |
ping | Cada 15 s de silencio. | No |
message.completed | El turno terminó bien. | Sí |
message.cancelled | El turno se canceló. | Sí |
message.error | El turno falló. | Sí |
Nota
Llega exactamente un terminal por turno, y después no se emite nada más. Si tu cliente ve algo posterior a un terminal, es un bug del cliente.
message.start
Confirma que el turno arrancó y te da su message_id, que es con el que después vas a encontrar el turno en GET /chats/{id}/messages.
{
"chat_id": "3f6b1a90-6c2d-4d84-a0b1-9d0f5e7c2ab3",
"message_id": 90213,
"request_id": "9f1c0a3e-7b2d-4f18-9a55-2c6e0d1b4a77"
}message.delta
{ "text": " se despachó el 28 de agosto.", "replace": false }| Campo | Qué es |
|---|---|
text | El sufijo nuevo con replace: false; el texto completo con replace: true. |
replace | true significa que la respuesta se reescribió (failover de proveedor): descartá lo acumulado. |
message.step
El estado del razonamiento y de las herramientas. Es informativo: sirve para mostrar progreso, no para decidir nada. La forma de cada paso puede crecer con el tiempo, así que leé los campos que te importan e ignorá el resto.
{
"steps": [
{
"key": "search_documents",
"label": "Buscando en Procedimientos de soporte",
"label_code": "step.tool.running",
"status": "completed",
"timestamp": "2026-09-01T14:09:41Z",
"duration": 1240,
"tool_calls": [
{
"tool_use_id": "toolu_01A9f",
"name": "search_documents",
"server": "niucore",
"input": { "query": "pedido 8842" }
}
]
}
]
}message.action_required
El turno se detuvo y espera. Solo aparece con permission_mode: "manual", salvo auth_required, que puede aparecer en cualquier modo cuando una herramienta necesita un conector sin conectar.
{
"kind": "tool_approval",
"action_id": "act_9c2f1e7a4b6d",
"payload": {
"tools": [
{
"tool_use_id": "toolu_01A9f",
"name": "gmail_send",
"server": "gmail",
"input": { "to": "cliente@example.com", "subject": "Pedido 8842" }
}
]
}
}| `kind` | Se responde con |
|---|---|
tool_approval | `POST …/actions/approve-tool` |
plan_approval | `POST …/actions/respond-plan` |
elicitation | `POST …/actions/respond-elicitation` |
auth_required | `POST …/actions/cancel-auth` — solo cancelar |
Nota
Con kind: "auth_required" el evento incluye además resolution: "connect_in_ui_or_cancel", que dice explícitamente que desde la API no hay forma de completar la conexión.
Atención
El action_id es opaco y nuestro: no es el identificador interno del turno. Usalo tal cual y no intentes derivar nada de él.
ping
{ "ts": 1767222015.42 }Mantiene viva la conexión a través de proxies. Ignoralo, salvo para reiniciar tu propio watchdog.
message.completed
{
"id": 90213,
"chat_id": "3f6b1a90-6c2d-4d84-a0b1-9d0f5e7c2ab3",
"role": "assistant",
"content": "El pedido 8842 se despachó el 28 de agosto y llegó el 1 de septiembre.",
"usage": {
"prompt_tokens": 2410,
"completion_tokens": 188,
"total_tokens": 2598,
"reasoning_tokens": 0,
"provider": "OPENAI",
"model": "gpt-4o"
},
"steps": [],
"sources": [{ "workspace_id": 31, "file_id": 902, "score": 0.83 }],
"anonymization": null,
"metadata": { "ticket": "8842" }
}Nota
content trae la respuesta completa, así que podés ignorar los deltas si no te interesa el progresivo. sources lista los fragmentos de biblioteca que se usaron como contexto, metadata devuelve tal cual lo que mandaste al abrir el turno, y usage tiene la misma forma y los mismos tipos acá, en la respuesta sincrónica, en el replay y en el listado de mensajes: enteros y snake_case.
message.cancelled
Mismo cuerpo que message.completed. content trae lo que se alcanzó a generar antes de cortar, que puede ser vacío.
message.error
{
"code": "upstream_error",
"message": "Stream ended unexpectedly",
"retryable": false,
"details": {
"schema_version": 1,
"service": "core_ai",
"service_code": "NC-SVC-01",
"dependency": "core_chat",
"operation": "chat.turn.stream",
"failure_kind": "stream_interrupted",
"operation_outcome": "unknown",
"retryable": false,
"retry_action": "check_status",
"request_id": "9f1c0a3e-7b2d-4f18-9a55-2c6e0d1b4a77"
}
}details viene cuando la causa es una indisponibilidad y tiene la misma forma que `meta.error_details`. Un corte decidido de nuestro lado, como turn_timeout, llega sin él.
| `code` | Qué pasó |
|---|---|
turn_timeout | El turno superó su plazo máximo. |
upstream_error | El servicio de conversación o el proveedor del modelo falló. El turno pudo tener efectos: retryable es false. |
persistence_failed | El turno terminó pero no se pudo confirmar su guardado. Consultá el estado con la misma key antes de reintentar. |
outcome_unknown | El cierre del turno falló de forma inesperada y no se sabe qué quedó guardado. Lo resuelve la recuperación automática; consultá el estado más tarde. |
idempotency_outcome_unknown | Otra ejecución cerró este turno, o —en un replay— el turno original nunca llegó a escribir su resultado. Repetí con la misma key para leer el que quedó. |
Qué significa retryable
retryable: true significa «otra operación con una key NUEVA puede funcionar», no «reintentá con la misma key». Reintentar con la misma key es un replay: te devuelve este mismo error. Los errores de un turno que ya se despachó llegan con retryable: false, porque no se sabe qué efectos alcanzó a tener: mirá sus mensajes antes de repetirlo.
Orden típico
message.start
├─ message.step (opcional, varias veces)
├─ message.delta (muchas veces)
├─ message.action_required ──▶ respondés por POST …/actions/…
│ └─ message.delta (el turno sigue)
├─ ping (cada 15 s de silencio)
└─ message.completed | message.cancelled | message.error ← exactamente uno,
después de guardarse