Documentation
Stream events
Every SSE event type, its payload and what to do with it.
These are the events emitted by POST /chats/{chat_id}/messages/stream. They are a public contract and not the platform's internal events: what changes inside does not change here.
| Event | When | Terminal |
|---|---|---|
message.start | Always, and always first. | No |
message.delta | New text of the answer. | No |
message.step | The turn progressed: reasoning or tools. | No |
message.action_required | The turn needs an answer from you. | No |
ping | Every 15 s of silence. | No |
message.completed | The turn finished successfully. | Yes |
message.cancelled | The turn was cancelled. | Yes |
message.error | The turn failed. | Yes |
Note
Exactly one terminal arrives per turn, and nothing is emitted afterwards. If your client sees anything after a terminal, that is a client bug.
message.start
Confirms the turn started and gives you its message_id, which is how you will later find the turn in 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 }| Field | What it is |
|---|---|
text | The new suffix with replace: false; the full text with replace: true. |
replace | true means the answer was rewritten (provider failover): discard what you accumulated. |
message.step
The state of reasoning and tools. It is informational: useful to show progress, not to decide anything. The shape of each step can grow over time, so read the fields you care about and ignore the rest.
{
"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
The turn stopped and is waiting. It only appears with permission_mode: "manual", except for auth_required, which can appear in any mode when a tool needs an unconnected connector.
{
"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` | Answered with |
|---|---|
tool_approval | `POST …/actions/approve-tool` |
plan_approval | `POST …/actions/respond-plan` |
elicitation | `POST …/actions/respond-elicitation` |
auth_required | `POST …/actions/cancel-auth` — cancel only |
Note
With kind: "auth_required" the event also includes resolution: "connect_in_ui_or_cancel", explicitly stating that the connection cannot be completed from the API.
Heads up
The action_id is opaque and ours: it is not the turn's internal identifier. Use it verbatim and do not try to derive anything from it.
ping
{ "ts": 1767222015.42 }Keeps the connection alive across proxies. Ignore it, except to reset your own 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" }
}Note
content carries the complete answer, so you can ignore the deltas if you do not care about progressive rendering. sources lists the library chunks used as context, metadata returns exactly what you sent when opening the turn, and usage has the same shape and the same types here, in the synchronous response, in the replay and in the message list: integers and snake_case.
message.cancelled
Same body as message.completed. content carries whatever was generated before the cut, which may be empty.
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 is present when the cause is an outage, and it has the same shape as `meta.error_details`. A cut decided on our side, like turn_timeout, arrives without it.
| `code` | What happened |
|---|---|
turn_timeout | The turn exceeded its hard deadline. |
upstream_error | The conversation service or the model provider failed. The turn may have had effects: retryable is false. |
persistence_failed | The turn finished but its save could not be confirmed. Check its state with the same key before retrying. |
outcome_unknown | The turn's close failed unexpectedly and it is unknown what was stored. Automatic recovery resolves it; check the state later. |
idempotency_outcome_unknown | Another execution closed this turn, or —on a replay— the original turn never wrote its result. Repeat with the same key to read the stored one. |
What retryable means
retryable: true means “another operation with a NEW key may work”, not “retry with the same key”. Retrying with the same key is a replay: it returns this very error. Errors from a turn that was already dispatched arrive with retryable: false, because it is unknown which effects it had: look at its messages before repeating it.
Typical order
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