Documentation

Streaming

Receiving a turn as Server-Sent Events, as it happens.

POST /chats/{chat_id}/messages/stream returns text/event-stream instead of waiting for the end. It serves two distinct purposes: showing text as it is generated, and being able to answer the turn when it asks for permission.

SynchronousStream
Modesauto onlyauto and manual
ResponseThe full Message resourceEvents, with the terminal at the end
InteractionsImpossible: there is no channelmessage.action_required
When to use itBatch jobs, queues, headless integrations.Chat interfaces, agents with human approval.

The format

Each event is two lines plus a blank one. data is always a single-line JSON payload.

Server-Sent Events
event: message.delta
data: {"text":"El pedido 8842","replace":false}

The stream always starts with message.start and always ends with exactly one of message.completed, message.cancelled or message.error. A provider failure followed by an error is one terminal, not two. And the terminal is sent only once the outcome is saved: if you read message.completed, GET /chats/{chat_id}/messages already returns it.

Deltas are suffixes

message.delta carries the new text, not the accumulated one. You concatenate what you receive and you have the answer. This differs from the platform's internal stream, which emits the accumulation and would force a replace on every event.

The exception is replace: true, which appears when the answer is rewritten — it happens on a provider failover. There text is the full text and whatever you accumulated must be discarded.

JavaScript
let answer = "";

function onDelta({ text, replace }) {
  // replace es raro, pero ignorarlo deja el texto duplicado tras un failover.
  answer = replace ? text : answer + text;
}

Keepalive and disconnection

Every 15 seconds of silence a ping with a timestamp is emitted. It keeps the connection alive across proxies; ignore it in your logic, but use it as a signal that the turn is still alive.

If you disconnect, the turn continues

The turn keeps running to its terminal (or to the timeout) and its outcome is persisted under your Idempotency-Key. Reconnect with the same key and you get the real terminal, not a disconnection error. To actually abort, use POST /chats/{chat_id}/actions/cancel: that is an explicit intent, not a dropped socket.

The turn also has a hard deadline of 30 minutes. On expiry you get message.error with code: "turn_timeout" and retryable: false.

A complete client

import json
import uuid

import requests


def stream_turn(chat_id: str, content: str, token: str):
    """Envía un turno y produce (evento, payload) hasta el terminal."""
    with requests.post(
        f"https://api.niucore.com/api/v1/chats/{chat_id}/messages/stream",
        headers={
            "Authorization": f"Bearer {token}",
            "Accept": "text/event-stream",
            "Idempotency-Key": str(uuid.uuid4()),
        },
        json={"content": content, "permission_mode": "manual"},
        stream=True,
        # Sin timeout de lectura: un turno legítimo puede callarse hasta 15 s
        # entre pings, y mucho más entre un paso y el siguiente.
        timeout=(10, None),
    ) as response:
        response.raise_for_status()

        event = None
        for line in response.iter_lines(decode_unicode=True):
            if not line:
                continue
            if line.startswith("event:"):
                event = line[6:].strip()
            elif line.startswith("data:"):
                yield event, json.loads(line[5:].strip())


answer = ""
for event, payload in stream_turn(chat_id, "¿Qué pasó con el pedido 8842?", token):
    if event == "ping":
        continue
    if event == "message.delta":
        answer = payload["text"] if payload["replace"] else answer + payload["text"]
    elif event == "message.action_required":
        handle_interaction(payload)          # ver la guía de interacciones
    elif event == "message.completed":
        print(payload["usage"])
    elif event in ("message.cancelled", "message.error"):
        print(event, payload)

Proxies and buffering

The response travels with Cache-Control: no-cache, no-transform and X-Accel-Buffering: no. If you still see the stream arrive all at once at the end, the culprit is usually on your side:

  • An HTTP client that does not expose the body until it is complete (stream=True in requests, never response.text).
  • A reverse proxy compressing on the fly. no-transform asks it not to, but not all honor it.
  • A serverless runtime with buffered responses: many environments do not support output streaming.

Note

A single event above 1 MB is dropped — that event, not the turn. In practice this only happens with anomalous tool payloads.