Streaming (SSE)

Note: the Chat and Outfit endpoints have been retired and now answer 410 Gone. Use the v2 agent API's Chat and Looks 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.

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.