# Send a message

`POST https://stylor.ai/api/v1/chat`

- Authentication: public API key, sent as `Authorization: Bearer sgpt-pk-…`
- Rate-limit resource: `api.v1.chat.message`
- Demo key: accepted
- Streaming: Server-Sent Events when the body has `"stream": true`
- Deprecated: yes
- Web page: https://stylor.ai/guides/rest/v1/send-chat-message

> **Retired.** This endpoint answers `410 Gone` and no longer works.
> It is documented for reference only; use the v2 agent API's
> [Chat](https://stylor.ai/guides/rest/v2/agent-chat.md) instead.

Send a shopper message to the stylist and receive a structured plan:
a natural-language reply, a list of **slots** (one per garment the
stylist wants to find, each with a semantic query and filters), and a
`status` telling you whether the request was `resolved` or the stylist
`needs_clarification`.

The stylist only **plans** the outfit. Slots come back with an empty
`results` array; call the **Search** endpoint with the `chatId` and the
slot's `id` to fill each slot with products from your catalogue.

## Starting vs. continuing a conversation

- **Omit `chatId`** to start a new conversation. Every message in
  `messages` is stored in the new chat (so you can seed it with a prior
  exchange), and the new id is returned as `chatId`.
- **Pass `chatId`** to continue an existing conversation. The stored
  history is loaded from the server and **only the last entry of
  `messages` is appended**; any earlier entries in the request are
  ignored. Public keys can continue any chat that belongs to the same
  organization.

## Message normalization

Messages are normalized before validation, so several shapes are
accepted:

- A `user` message whose `content` is a plain string becomes a single
  `input_text` part.
- An `assistant` message whose `content` is a plain string is parsed as
  JSON (`{ "message", "slots", "status" }` or `{ "output": {...} }`);
  if it is not JSON it is treated as the reply text. Assistant messages
  are always rewritten into the three-part `output_text` /
  `output_slots` / `output_status` form.
- An `output_status` value that is not `resolved` or
  `needs_clarification` (for example a stale `streaming` marker replayed
  from a client) is coerced to `resolved` rather than rejected, and any
  `generating` flag is dropped.
- `system` messages are forwarded to the model unchanged.

Anything that still fails validation after normalization is rejected
with `422` and a message naming the offending index.

## Images

A `user` message may include `input_image` parts. `image_url` can be a
public `https://` URL, a `data:` URI, or a Stylor image-delivery URL
(`/api/v1/image/...`) from a previous upload — the latter is converted
to a short-lived signed CDN URL before it reaches the model.

## Catalogue scope

`datasetIds` restricts which datasets the stylist can plan against.
When omitted or empty, every **active** dataset in the organization is
used. Archived datasets are skipped (with a warning); draft datasets are
used but flagged in `warnings`. `filters.gender` narrows the catalogue to
that gender's items plus unisex items.

## Streaming

Set `stream: true` to receive the same result as a Server-Sent Events
stream: the reply text arrives as it is written and each slot is
delivered the moment it is complete, so you can start searching before
the stylist has finished talking. See the **Streaming** guide and the
event list below.

The non-streaming response is produced by consuming that same stream
server-side and returning its final payload as one JSON body.

## Example request

```bash
curl -X POST "https://stylor.ai/api/v1/chat" \
  -H "Authorization: Bearer $STYLOR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "messages": [
    {
      "role": "user",
      "content": "I need a smart-casual outfit for a rooftop dinner, budget around $300."
    }
  ],
  "filters": {
    "gender": "womens"
  }
}'
```

## Request body

Content type `application/json`, required.

- `messages` (array<ChatMessage>, required): The conversation. With no `chatId` every entry is stored and used; with a `chatId` only the **last** entry is appended to the stored history. [min items 1]
  - `role` (string, required): Who wrote the message. `system` messages are passed to the model verbatim. [one of `"user"`, `"assistant"`, `"system"`; example `"user"`]
  - `content` (string | array<ChatMessageContentPart>, required): Plain text, or an array of content parts.
    - Option 1: string
    - Option 2: array<ChatMessageContentPart>
      - `type` (string, required): The kind of part. `input_*` parts are written by the shopper; `output_*` parts by the stylist. [one of `"input_text"`, `"input_image"`, `"output_text"`, `"output_slots"`, `"output_status"`]
      - `text` (string): The text. Required for `input_text` and `output_text`. [example `"I need a smart-casual outfit for a rooftop dinner."`]
      - `image_url` (string): Image reference for `input_image`. A public URL, a `data:` URI, or a Stylor image-delivery URL. [example `"https://example.com/inspiration.jpg"`]
      - `slots` (array<ChatSearchSlot | ChatOutfitCompositionsSlot>): The stylist's slots. Required for `output_slots`.
      - `status` (string): Outcome of the turn. Required for `output_status`. `needs_clarification` means the stylist asked a follow-up question and `slots` is empty. [one of `"resolved"`, `"needs_clarification"`]
      - `generating` (boolean): Server-managed, read-only. Present as `true` on the `output_status` part of an assistant message whose streamed reply is still in progress. Dropped from any message you send back in.
- `chatId` (string (uuid)): Id of an existing chat to continue. Omit to start a new chat. [example `"3f0c2a4e-8b1d-4c6a-9e2f-7a5b1c0d9e88"`]
- `datasetIds` (array<string (uuid)>): Dataset ids to plan against. Omit or pass `[]` to use every active dataset. Ids are trimmed and de-duplicated; archived ids are dropped with a warning and unknown ids are rejected. [default `[]`]
- `filters` (ChatFilters): Catalogue-level filters for the whole request. Keys other than `gender` are ignored.
  - `gender` (string): Restrict the catalogue to this gender's items plus unisex items. Omit (or pass `unisex`) to use unisex items only. Any other string is not rejected but matches only unisex items. [one of `"mens"`, `"womens"`, `"unisex"`; example `"womens"`]
- `stream` (boolean): When true the response is a `text/event-stream` (see the stream events below) instead of JSON. [default `false`]

### Examples

#### New chat

Start a conversation with a gender filter.

```json
{
  "messages": [
    {
      "role": "user",
      "content": "I need a smart-casual outfit for a rooftop dinner, budget around $300."
    }
  ],
  "filters": {
    "gender": "womens"
  }
}
```

#### Continue

Send a follow-up to an existing chat by its id.

```json
{
  "chatId": "3f0c2a4e-8b1d-4c6a-9e2f-7a5b1c0d9e88",
  "messages": [
    {
      "role": "user",
      "content": "Swap the heels for something flat."
    }
  ]
}
```

#### With an image

Ask for a look based on an inspiration photo.

```json
{
  "messages": [
    {
      "role": "user",
      "content": [
        {
          "type": "input_text",
          "text": "Build me a look in this style."
        },
        {
          "type": "input_image",
          "image_url": "https://example.com/inspiration.jpg"
        }
      ]
    }
  ],
  "datasetIds": [
    "9c1a7d4e-2b5f-4a8c-b6e1-0d3f5a7c9e21"
  ]
}
```

#### Streaming

Get the reply as Server-Sent Events.

```json
{
  "stream": true,
  "messages": [
    {
      "role": "user",
      "content": "Show me a complete gym-to-brunch outfit."
    }
  ],
  "filters": {
    "gender": "mens"
  }
}
```

## Responses

### 200

The stylist's plan. Returned as JSON when `stream` is omitted or false, or as `text/event-stream` when `stream` is true. Both carry the rate-limit headers.

Content type `application/json`:

- `output` (ChatOutput, required): The stylist's plan for one turn.
  - `status` (string, required): `resolved` when an outfit was planned; `needs_clarification` when the stylist asked a follow-up question instead (and `slots` is empty). [one of `"resolved"`, `"needs_clarification"`; example `"resolved"`]
  - `message` (string, required): The stylist's reply to show the shopper. [example `"For a rooftop dinner I'd pair a silk slip dress with a cropped leather jacket and strappy heels."`]
  - `slots` (array<ChatSearchSlot | ChatOutfitCompositionsSlot>, required): The items to find, in outfit order, plus an optional `outfit_compositions` slot.
- `chatId` (string (uuid), required): The chat the turn was written to. Equal to the request's `chatId`, or the newly created id. [example `"3f0c2a4e-8b1d-4c6a-9e2f-7a5b1c0d9e88"`]
- `warnings` (array<string | object>): Non-fatal notices. Present only when there is at least one.

Example:

```json
{
  "chatId": "3f0c2a4e-8b1d-4c6a-9e2f-7a5b1c0d9e88",
  "output": {
    "status": "resolved",
    "message": "For a rooftop dinner I'd pair a silk slip dress with a cropped leather jacket and strappy heels.",
    "slots": [
      {
        "id": "8066a68a-a39e-4d3e-993a-3887f6245032",
        "type": "search",
        "slot": "dress",
        "query": "black silk slip dress",
        "filters": {
          "fit": [
            "slim"
          ],
          "pattern": [
            "solid"
          ],
          "material": [
            "silk"
          ],
          "color_family": [
            "black"
          ],
          "color_rgb": [
            20,
            20,
            20
          ],
          "family": [
            "dress"
          ],
          "exclude_terms": [
            "maxi"
          ],
          "boost_terms": [
            "silk",
            "slip",
            "cowl"
          ],
          "minPrice": null,
          "maxPrice": 180
        },
        "results": []
      },
      {
        "id": "1d7c3b52-6e0f-4d2a-8a9b-2c4e6f8a0b13",
        "type": "search",
        "slot": "outerwear",
        "query": "cropped black leather jacket",
        "filters": {
          "fit": [
            "regular"
          ],
          "pattern": null,
          "material": [
            "leather"
          ],
          "color_family": [
            "black"
          ],
          "color_rgb": null,
          "family": [
            "jacket"
          ],
          "exclude_terms": [],
          "boost_terms": [
            "cropped",
            "moto"
          ],
          "minPrice": null,
          "maxPrice": 120
        },
        "results": []
      },
      {
        "id": "5b2e9f40-3c7d-4e1a-9f6b-8d0c2a4e6f17",
        "type": "outfit_compositions",
        "results": []
      }
    ]
  },
  "warnings": [
    {
      "message": "Dataset \"9c1a7d4e-2b5f-4a8c-b6e1-0d3f5a7c9e21\" is still in draft mode and may not have complete or production-ready data."
    }
  ]
}
```

Content type `text/event-stream`:

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

event: text_delta
data: {"delta":"For a rooftop dinner I'd pair "}

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":["maxi"],"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 and strappy heels.","slots":[{"id":"8066a68a-a39e-4d3e-993a-3887f6245032","type":"search","slot":"dress","query":"black silk slip dress","filters":{"fit":["slim"],"pattern":["solid"],"material":["silk"],"color_family":["black"],"color_rgb":[20,20,20],"family":["dress"],"exclude_terms":["maxi"],"boost_terms":["silk"],"minPrice":null,"maxPrice":180},"results":[]},{"id":"5b2e9f40-3c7d-4e1a-9f6b-8d0c2a4e6f17","type":"outfit_compositions","results":[]}]},"chatId":"3f0c2a4e-8b1d-4c6a-9e2f-7a5b1c0d9e88","warnings":["Failed to save chat messages."]}

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

## Stream events

Each frame is `event: <name>`, a newline, `data: <json>`, and a blank line. Events are listed in the order they normally arrive.

### `chat_id`

Always the first frame. Carries the id of the chat the reply is being written to (the new id when `chatId` was omitted), so you can issue Search calls against it before generation finishes.

```json
{
  "chatId": "3f0c2a4e-8b1d-4c6a-9e2f-7a5b1c0d9e88"
}
```

### `text_delta`

A new chunk of the stylist's reply text. Concatenate every `delta`, in order, to rebuild `output.message`. Zero or more of these arrive, interleaved with `slot_complete`.

```json
{
  "delta": "For a rooftop dinner I'd pair "
}
```

### `slot_complete`

A slot the stylist has finished describing. `search` slots are emitted once `slot`, `query` and the core filter keys (`fit`, `color_family`, `color_rgb`, `family`, `exclude_terms`, `boost_terms`, `minPrice`, `maxPrice`) are present; `pattern` and `material` may still be missing at this point and are filled in on the copy inside `done`. `outfit_compositions` slots are emitted as soon as their `type` appears. The slot is persisted to the chat before this frame is sent, so its `id` is immediately valid for the Search endpoint. `results` is always `[]` here.

```json
{
  "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": [
        "maxi"
      ],
      "boost_terms": [
        "silk"
      ],
      "minPrice": null,
      "maxPrice": 180
    },
    "results": []
  }
}
```

### `done`

Final frame on success; the stream closes after it. `output` is the complete plan — the same shape as the JSON response — with every slot fully merged (same `id`s as the `slot_complete` frames, plus any slot that only appeared in the final parse). `warnings` is present only when non-empty.

```json
{
  "output": {
    "status": "resolved",
    "message": "For a rooftop dinner I'd pair a silk slip dress with a cropped leather jacket and strappy heels.",
    "slots": [
      {
        "id": "8066a68a-a39e-4d3e-993a-3887f6245032",
        "type": "search",
        "slot": "dress",
        "query": "black silk slip dress",
        "filters": {
          "fit": [
            "slim"
          ],
          "pattern": [
            "solid"
          ],
          "material": [
            "silk"
          ],
          "color_family": [
            "black"
          ],
          "color_rgb": [
            20,
            20,
            20
          ],
          "family": [
            "dress"
          ],
          "exclude_terms": [
            "maxi"
          ],
          "boost_terms": [
            "silk"
          ],
          "minPrice": null,
          "maxPrice": 180
        },
        "results": []
      },
      {
        "id": "5b2e9f40-3c7d-4e1a-9f6b-8d0c2a4e6f17",
        "type": "outfit_compositions",
        "results": []
      }
    ]
  },
  "chatId": "3f0c2a4e-8b1d-4c6a-9e2f-7a5b1c0d9e88",
  "warnings": [
    "Failed to save chat messages."
  ]
}
```

### `error`

Final frame on failure, sent **instead of** `done`; the stream closes after it. The HTTP status is still `200` because headers were already sent. `error` is either `Invalid AI response format.` (the model's output could not be parsed) or `Looks like I ran into some trouble generating your outfit. Let's try that again.` (the model call failed). The chat's placeholder assistant message is finalized with that text so the conversation can continue.

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

## Errors

Every error is a JSON object with an `error` string. Match on the status code; the message is written for people.

| Status | Message | When |
| --- | --- | --- |
| 400 | Request body must be valid JSON. | The request body could not be parsed as JSON. |
| 400 | Request body must be a JSON object. | The request body is empty, or parsed to something other than a JSON object (for example an array or a string). |
| 404 | Chat not found or you do not have permission to access it. | `chatId` was provided but no chat with that id belongs to the organization. |
| 404 | No active datasets found for this organization. Activate one via the dashboard (Dataset → Settings) or through the REST API before proceeding. | `datasetIds` was omitted or empty and the organization has datasets, but none is active. |
| 404 | All requested datasets are archived. Reactivate at least one via the dashboard or REST API. | Every id in `datasetIds` refers to an archived dataset. |
| 404 | No datasets found for this organization. Create one via the dashboard or REST API, or pass datasetIds to use draft/archived datasets before proceeding. | The organization has no datasets at all. |
| 404 | No products found in selected datasets. | The resolved datasets contain no fashion items for the requested gender (non-fashion categories such as phone cases are ignored). |
| 410 | This endpoint has been retired (v1/chat). | Always, for API callers. This check runs before authentication and consumes no quota. |
| 422 | The 'messages' field must be an array. | `messages` is present but not an array (including `null`). |
| 422 | A valid chatId is required. | `chatId` was provided but is not a string (for example a number) or is only whitespace. |
| 422 | Message at index <n> must be an object. | An entry of `messages` is not an object. |
| 422 | Message at index <n> must have a 'role' field. | A message has no string `role`. |
| 422 | Message at index <n> has invalid role '<role>'. Must be one of: user, assistant, system. | `role` is anything other than `user`, `assistant` or `system` (`developer` is not accepted). |
| 422 | Message at index <n> must have a 'content' field. | `content` is missing or null. |
| 422 | Message at index <n> has empty content array. Must have at least one content part. | `content` is an empty array. |
| 422 | Message at index <n> has invalid content. Must be either a string or an array of content parts. | `content` is neither a string nor an array. |
| 422 | Message at index <n>: Content part at index <i> must be an object. | A content part is not an object. |
| 422 | Message at index <n>: Content part at index <i> must have a 'type' field. | A content part has no string `type`. |
| 422 | Message at index <n>: Content part at index <i> has invalid type '<type>'. Must be one of: input_text, output_text, input_image, output_slots, output_status. | `type` is not one of the five content part types. |
| 422 | Message at index <n>: Content part at index <i> with type '<type>' must have a 'text' property. | An `input_text` or `output_text` part has no string `text`. |
| 422 | Message at index <n>: Content part at index <i> with type '<type>' should not have '<field>' property. | A part carries a field that belongs to another type (for example `image_url` on an `input_text` part, or `text` on an `output_slots` part). |
| 422 | Message at index <n>: Content part at index <i> with type 'input_image' must have an 'image_url' string property. | An `input_image` part has no string `image_url`. |
| 422 | Message at index <n>: Content part at index <i> with type 'output_slots' must have a 'slots' array property. | An `output_slots` part has no `slots` array. |
| 422 | Message at index <n>: Content part at index <i>: slot at index <j> must be an object. | An entry of `slots` is not an object. |
| 422 | Message at index <n>: Content part at index <i>: slot at index <j> must have an 'id' string property. | A slot has no string `id`. |
| 422 | Message at index <n>: Content part at index <i> with type 'output_status' must have a 'status' string property. | An `output_status` part has no string `status`. Only reachable for parts that normalization did not rewrite. |
| 422 | Messages array cannot be empty. | `messages` is omitted or `[]`. |
| 422 | datasetIds must be an array. | `datasetIds` is present but not an array (including `null`). |
| 422 | filters must be an object. | `filters` is present but is a string, number or boolean. |
| 422 | gender filter must be a string. | `filters.gender` is present but not a string. |
| 422 | Invalid datasetIds for this organization: <id>, <id> | One or more ids in `datasetIds` do not belong to the organization. Ids are trimmed and de-duplicated before the check. |
| 502 | Invalid AI response format. | Non-streaming only. The model finished but its output could not be parsed. In streaming mode the same text arrives as an `error` event. |
| 502 | Looks like I ran into some trouble generating your outfit. Let's try that again. | Non-streaming only. The model call failed mid-generation. In streaming mode the same text arrives as an `error` event. The placeholder assistant message stored in the chat is finalized with this text so the conversation stays valid. |
| 503 | Inventory index unavailable. Try again in a moment. | The catalogue facet index for the selected datasets has not been built yet or is being rebuilt. A rebuild is requested automatically; retry after a short delay. |

### Shared authentication, quota and rate-limit errors

| Status | Message | When |
| --- | --- | --- |
| 401 | API key missing. | The `Authorization` header is absent, is not `Bearer <key>`, or the key does not have three dash-separated segments. |
| 401 | Authentication failed: public key is invalid or not recognized. | A public-key endpoint was called with a key that does not exist. |
| 401 | Authentication failed: private key provided instead of a public key. | A public-key endpoint was called with an `sgpt-sk-…` key. |
| 401 | Authentication failed: token is not recognized. | A private-key endpoint was called with a key whose lookup segment does not exist. |
| 401 | Authentication failed: private key is invalid. | A private-key endpoint was called with a key whose secret does not match. |
| 401 | Authentication failed: public key provided instead of a private key. | A private-key endpoint was called with an `sgpt-pk-…` key. |
| 401 | Authentication failed: bearer token format is invalid. | A private key was sent without its `::` lookup segment. |
| 401 | Authentication failed: API key could not be read. Regenerate the key. | The key's embedded organization data could not be decrypted or parsed. |
| 403 | Demo mode is not available for private-key routes. Demos are only supported with public API keys. | The demo key was sent to a private-key endpoint. |
| 403 | Demo mode is not supported on this API route. Check the documentation for available demo endpoints. | The demo key was sent to a public-key endpoint that opts out of demo mode. |
| 403 | No active subscription for this organization. | The organization that owns the key has no active subscription. |
| 403 | No quota found for organization "<organizationId>". | The organization has no quota record for the current billing period. |
| 403 | No entitlement for "<resource>". | The plan's quota template has no entry for this endpoint. |
| 403 | Access denied to "<resource>". | The plan's quota template turns this endpoint off. |
| 429 | Monthly quota exceeded for "<resource>" (<quota>/<quota> used). | The billing-period quota for this endpoint is exhausted. `Retry-After` gives the seconds until the period resets. |
| 429 | RPM limit exceeded for "<resource>" (<used>/<limit> used). | More than the allowed requests per minute were sent. `Retry-After` is 60. |
| 500 | Internal server error. | An unexpected failure on the server. The message is always this string; details are logged, never returned. Quota consumed by the request is refunded, as it is for every 5xx response. |
