# Streaming (SSE)

> **Note:** the Chat and Outfit endpoints have been retired and now answer `410 Gone`. Use the v2 agent API's [Chat](https://stylor.ai/guides/rest/v2/agent-chat.md) and [Looks](https://stylor.ai/guides/rest/v2/generate-look.md) instead. The rest of v1 is unchanged.

The stylist can take several seconds to write a full reply. Streaming lets you show the reply as it is written and start searching for each garment as soon as the stylist names it, instead of waiting for the whole plan.

Streaming uses Server-Sent Events (SSE) over a normal HTTP response.

## Endpoints that stream

| Endpoint | How to enable | Status |
| --- | --- | --- |
| `POST /v1/chat` | Send `"stream": true` in the body | Available |
| `POST /v1/outfit/compositions` | Send `"stream": true` in the body | Retired. Returns `410` for API callers. Its events are documented below for reference. |

Without `stream: true`, the same endpoint returns one JSON body when generation finishes.

## Frame format

A streaming response has `Content-Type: text/event-stream`. Its body is a sequence of frames, each made of an event name, a JSON payload and a blank line:

```text
event: text_delta
data: {"delta":"For a rooftop dinner "}

```

- Every frame has exactly one `event:` line and one `data:` line.
- `data` is always a single line of JSON.
- Frames are separated by a blank line (`\n\n`).
- The stream sends no `id:` or `retry:` fields and no keep-alive comments.
- The server closes the connection after the final `done` or `error` event.

The response also carries the usual `X-RateLimit-*` headers.

Both streaming endpoints are `POST` requests with an `Authorization` header, so the browser's built-in `EventSource` can't be used. Read the response body with `fetch` or an HTTP client instead, as in the examples below.

## Errors before and during a stream

Where an error happens decides how it reaches you.

Before the stream starts, you get an ordinary JSON error with a real status code. This covers authentication, quota and rate limits, request validation, an unknown `chatId`, and a catalogue with no active datasets:

```http
HTTP/1.1 422 Unprocessable Entity
Content-Type: application/json

{ "error": "Messages array cannot be empty." }
```

After the stream starts, the status has already been sent as `200`. A failure then arrives as an `error` event, and the stream closes without a `done` event:

```text
event: error
data: {"error":"Looks like I ran into some trouble generating your outfit. Let's try that again."}

```

Always check the status and `Content-Type` before parsing a stream, and treat an `error` event as a failed request even though the status was `200`. A stream that closes with neither `done` nor `error` was interrupted, so treat it as failed too.

A request that fails mid-stream still counts against your quota. See [Rate limits and quotas](rate-limits).

## Chat events

`POST /v1/chat` with `stream: true` emits these events in this order:

| Event | When | Payload |
| --- | --- | --- |
| `chat_id` | Always first | `{ "chatId": "…" }`, the chat the reply is written to. A new id when you didn't send `chatId`. |
| `text_delta` | Zero or more times, as the reply is written | `{ "delta": "…" }`, the next piece of reply text. Append deltas in order. |
| `slot_complete` | Once per slot, interleaved with `text_delta` | `{ "slot": { … } }`, a slot the stylist has finished describing. |
| `done` | Last event on success | `{ "output": { … }, "chatId": "…", "warnings": [ … ] }` |
| `error` | Last event on failure, in place of `done` | `{ "error": "…" }` |

Details:

- `slot_complete` slots are saved to the chat before the event is sent, so you can call Search with the `chatId` and the slot's `id` immediately. `results` is always `[]` here.
- A `search` slot's `filters` may still be missing `pattern` and `material` in `slot_complete`. The copy inside `done` has every field.
- `done.output` has the same shape as the non-streaming JSON response: `status`, `message` and `slots`, with the same slot ids as the `slot_complete` events. It can also contain slots that never had their own `slot_complete` event. `warnings` appears only when there is something to report.
- `error.error` is either `Invalid AI response format.` or `Looks like I ran into some trouble generating your outfit. Let's try that again.`

A full stream looks like this:

```text
event: chat_id
data: {"chatId":"3f0c2a4e-8b1d-4c6a-9e2f-7a5b1c0d9e88"}

event: text_delta
data: {"delta":"For a rooftop dinner "}

event: text_delta
data: {"delta":"I'd pair a silk slip dress with a cropped leather jacket."}

event: slot_complete
data: {"slot":{"id":"8066a68a-a39e-4d3e-993a-3887f6245032","type":"search","slot":"dress","query":"black silk slip dress","filters":{"fit":["slim"],"color_family":["black"],"color_rgb":[20,20,20],"family":["dress"],"exclude_terms":[],"boost_terms":["silk"],"minPrice":null,"maxPrice":180},"results":[]}}

event: done
data: {"output":{"status":"resolved","message":"For a rooftop dinner I'd pair a silk slip dress with a cropped leather jacket.","slots":[...]},"chatId":"3f0c2a4e-8b1d-4c6a-9e2f-7a5b1c0d9e88"}

```

## Outfit composition events

`POST /v1/outfit/compositions` is retired and returns `410 Gone` with `This endpoint has been retired (v1/outfit/compositions).` These events are listed only so existing readers can be understood.

| Event | Payload |
| --- | --- |
| `preparing` | `{ "count": n }`, sent first. |
| `combination` | `{ "id", "index", "combination" }`, one per outfit chosen. Arrives in order. |
| `visualization` | `{ "id", "index", "imageUrl", "imageSource", "reasoning", "combination" }` when an image is ready. Can arrive in any order. |
| `visualization_error` | `{ "id", "index", "error" }` when one image fails. The stream continues. |
| `done` | `{ "metadata": { … } }` once every image has settled. |
| `error` | `{ "error": "…" }` on a fatal failure, in place of `done`. |

## Reading a stream in JavaScript

This works in browsers and in Node.js 18 or later. It buffers partial chunks and dispatches each complete frame:

```javascript
async function streamChat(body, handlers) {
  const res = await fetch("https://stylor.ai/api/v1/chat", {
    method: "POST",
    headers: {
      Authorization: `Bearer ${STYLOR_PUBLIC_KEY}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({ ...body, stream: true }),
  });

  // Errors before the stream starts are plain JSON with a real status.
  const contentType = res.headers.get("Content-Type") || "";
  if (!res.ok || !contentType.includes("text/event-stream")) {
    const err = await res.json().catch(() => ({ error: res.statusText }));
    throw new Error(`${res.status}: ${err.error}`);
  }

  const reader = res.body.getReader();
  const decoder = new TextDecoder();
  let buffer = "";
  let finished = false;

  while (true) {
    const { value, done } = await reader.read();
    if (done) break;
    buffer += decoder.decode(value, { stream: true });

    let boundary;
    while ((boundary = buffer.indexOf("\n\n")) !== -1) {
      const frame = buffer.slice(0, boundary);
      buffer = buffer.slice(boundary + 2);

      let event = "message";
      let data = "";
      for (const line of frame.split("\n")) {
        if (line.startsWith("event:")) event = line.slice(6).trim();
        else if (line.startsWith("data:")) data += line.slice(5).trim();
      }
      if (!data) continue;

      const payload = JSON.parse(data);
      if (event === "error") throw new Error(payload.error);
      if (event === "done") finished = true;
      handlers[event]?.(payload);
    }
  }

  if (!finished) throw new Error("Stream ended before a done event.");
}

let reply = "";
await streamChat(
  { messages: [{ role: "user", content: "A smart-casual outfit for a rooftop dinner." }] },
  {
    chat_id: ({ chatId }) => console.log("chat", chatId),
    text_delta: ({ delta }) => {
      reply += delta;
    },
    slot_complete: ({ slot }) => {
      if (slot.type === "search") console.log("search for", slot.query, "in slot", slot.id);
    },
    done: ({ output }) => console.log("final:", output.status, output.message),
  }
);
```

## Reading a stream in Python

With `requests`, use `stream=True` and read line by line:

```python
import json
import os
import requests


def stream_chat(body):
    res = requests.post(
        "https://stylor.ai/api/v1/chat",
        headers={"Authorization": f"Bearer {os.environ['STYLOR_API_KEY']}"},
        json={**body, "stream": True},
        stream=True,
        timeout=(10, 300),
    )

    # Errors before the stream starts are plain JSON with a real status.
    if not res.ok or "text/event-stream" not in res.headers.get("Content-Type", ""):
        raise RuntimeError(f"{res.status_code}: {res.json().get('error')}")

    event, data = None, ""
    finished = False
    for raw in res.iter_lines(decode_unicode=True):
        if raw is None:
            continue
        if raw == "":
            if data:
                payload = json.loads(data)
                if event == "error":
                    raise RuntimeError(payload["error"])
                if event == "done":
                    finished = True
                yield event, payload
            event, data = None, ""
        elif raw.startswith("event:"):
            event = raw[len("event:"):].strip()
        elif raw.startswith("data:"):
            data += raw[len("data:"):].strip()

    if not finished:
        raise RuntimeError("Stream ended before a done event.")


reply = []
for event, payload in stream_chat({"messages": [{"role": "user", "content": "A gym-to-brunch outfit."}]}):
    if event == "text_delta":
        reply.append(payload["delta"])
    elif event == "slot_complete":
        print("slot ready:", payload["slot"]["id"])
    elif event == "done":
        print("final:", payload["output"]["message"])
```

## Watching a stream with cURL

Pass `-N` to turn off output buffering so frames print as they arrive:

```bash
curl -N -X POST https://stylor.ai/api/v1/chat \
  -H "Authorization: Bearer $STYLOR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "stream": true,
    "messages": [{ "role": "user", "content": "Show me a complete gym-to-brunch outfit." }],
    "filters": { "gender": "mens" }
  }'
```

Add `-i` to see the status line and rate-limit headers before the frames.

## Tips

- Set a generous read timeout. A streamed reply can take tens of seconds from start to `done`.
- Treat `done.output` as the source of truth for the final plan, and the earlier events as progress.
- Start Search calls from `slot_complete` to cut time to first product. The slot id is already valid.
- If a stream fails, the chat keeps a finished assistant message containing the error text, so the conversation can continue with the same `chatId`.
