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á.

EventoCuándoTerminal
message.startSiempre, y siempre primero.No
message.deltaTexto nuevo de la respuesta.No
message.stepEl turno avanzó: razonamiento o herramientas.No
message.action_requiredEl turno necesita una respuesta tuya.No
pingCada 15 s de silencio.No
message.completedEl turno terminó bien.
message.cancelledEl turno se canceló.
message.errorEl turno falló.

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.

JSON
{
  "chat_id": "3f6b1a90-6c2d-4d84-a0b1-9d0f5e7c2ab3",
  "message_id": 90213,
  "request_id": "9f1c0a3e-7b2d-4f18-9a55-2c6e0d1b4a77"
}

message.delta

JSON
{ "text": " se despachó el 28 de agosto.", "replace": false }
CampoQué es
textEl sufijo nuevo con replace: false; el texto completo con replace: true.
replacetrue 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.

JSON
{
  "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.

JSON
{
  "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

JSON
{ "ts": 1767222015.42 }

Mantiene viva la conexión a través de proxies. Ignoralo, salvo para reiniciar tu propio watchdog.

message.completed

JSON
{
  "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

JSON
{
  "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_timeoutEl turno superó su plazo máximo.
upstream_errorEl servicio de conversación o el proveedor del modelo falló. El turno pudo tener efectos: retryable es false.
persistence_failedEl turno terminó pero no se pudo confirmar su guardado. Consultá el estado con la misma key antes de reintentar.
outcome_unknownEl 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_unknownOtra 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