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.
Agentes
Cuando el turno delega (con agents:use), message.step suma pasos con forma propia. Ningún id interno viaja: task_id es una correlación opaca dentro del mensaje, la misma en los pasos, las interacciones, el terminal y el historial.
| `key` | Qué es |
|---|---|
agents_delegated | El punto donde el turno delegó. agent_task_ids nombra las tareas que arrancaron ahí; no dice que hayan terminado. |
agent | Una tarea. agent trae task_id, agent_id (null si es temporal), name, kind, tag, state, reason, waiting_on, model, iteration, tool_calls, tokens, current_step y summary — solo los que tenga. |
otro key con agent | Un paso dentro de una tarea (por ejemplo, una herramienta). agent: { task_id, name } dice de cuál. |
agents_unavailable | El turno no pudo delegar (por ejemplo, el modelo no usa herramientas). El motivo va en label/detail. |
{
"steps": [
{
"key": "agents_delegated",
"label": "Delegó en agentes",
"status": "completed",
"agent_task_ids": ["task_6f0d2a9b41c8e7735a10bd42"]
},
{
"key": "agent",
"label": "Trabajando",
"status": "running",
"agent": {
"task_id": "task_6f0d2a9b41c8e7735a10bd42",
"agent_id": 4,
"name": "Contador",
"kind": "user",
"state": "working",
"model": "gpt-4o",
"iteration": 2,
"tool_calls": 1,
"tokens": { "input_tokens": 1840, "output_tokens": 312, "total_tokens": 2152 }
}
}
]
}Si una tarea necesita una aprobación, llega un message.action_required normal con un campo más, agent: { task_id, name }. Dos agentes pidiendo permiso a la vez generan dos eventos con action_id distintos; cada uno se resuelve por separado y solo con su action_id — cambiar el task_id no cambia el destino.
Nota
El terminal (message.completed, message.cancelled, y message.error si alguna tarea llegó a correr) trae agent_runs con el resumen de cada tarea, y agents: { supported, reason } si el turno llevó agentes. Para el detalle —encargo, pasos, resultado— usá `…/agent-runs`.
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" },
"agent_runs": []
}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