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:
event: text_delta
data: {"delta":"For a rooftop dinner "}
- Every frame has exactly one
event:line and onedata:line. datais always a single line of JSON.- Frames are separated by a blank line (
\n\n). - The stream sends no
id:orretry:fields and no keep-alive comments. - The server closes the connection after the final
doneorerrorevent.
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/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:
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_completeslots are saved to the chat before the event is sent, so you can call Search with thechatIdand the slot'sidimmediately.resultsis always[]here.- A
searchslot'sfiltersmay still be missingpatternandmaterialinslot_complete. The copy insidedonehas every field. done.outputhas the same shape as the non-streaming JSON response:status,messageandslots, with the same slot ids as theslot_completeevents. It can also contain slots that never had their ownslot_completeevent.warningsappears only when there is something to report.error.erroris eitherInvalid AI response format.orLooks like I ran into some trouble generating your outfit. Let's try that again.
A full stream looks like this:
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:
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:
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:
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.outputas the source of truth for the final plan, and the earlier events as progress. - Start Search calls from
slot_completeto 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.