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.

EventWhenTerminal
message.startAlways, and always first.No
message.deltaNew text of the answer.No
message.stepThe turn progressed: reasoning or tools.No
message.action_requiredThe turn needs an answer from you.No
pingEvery 15 s of silence.No
message.completedThe turn finished successfully.Yes
message.cancelledThe turn was cancelled.Yes
message.errorThe 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.

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 }
FieldWhat it is
textThe new suffix with replace: false; the full text with replace: true.
replacetrue 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.

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

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.

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" }
      }
    ]
  }
}

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

JSON
{ "ts": 1767222015.42 }

Keeps the connection alive across proxies. Ignore it, except to reset your own 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" }
}

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

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 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_timeoutThe turn exceeded its hard deadline.
upstream_errorThe conversation service or the model provider failed. The turn may have had effects: retryable is false.
persistence_failedThe turn finished but its save could not be confirmed. Check its state with the same key before retrying.
outcome_unknownThe turn's close failed unexpectedly and it is unknown what was stored. Automatic recovery resolves it; check the state later.
idempotency_outcome_unknownAnother 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