# Rich messages

Page: https://docs.sonecentral.com/en/zengin-mesajlar
Markdown: https://docs.sonecentral.com/en/zengin-mesajlar.md

> Product and service cards, buttons, quick replies and files arrive as SoneMessage objects, each with a plain text fallback.

The assistant can share the same cards, buttons and documents as on WhatsApp. The API returns structured data you can render in your own UI. This structure is `SoneMessage`. Staff shares from Messages use the same format with `role: "staff"`: [Staff shares](https://docs.sonecentral.com/en/insan-devri.md#ekibin-paylastiklari).

## Where messages appear

| Format | Location |
| --- | --- |
| Chat Completions | Non-streamed: `choices[0].message.sone.messages`. Streamed: top-level `sone.message` on each message's final chunk. |
| Responses | `sone` on each `message` output item. |
| Vercel AI SDK | `data-sone-catalog`, `data-sone-actions`, `data-sone-file`, `data-sone-staff`. Assistant plain text uses `text` parts. |
| Conversation messages | `data` from `GET /conversations/{id}/messages`. |

AI SDK part `data` contains rendering fields rather than the complete `SoneMessage`: `text`, `role`, and `catalog`, `actions` or `file`. `data-sone-staff` is only for staff plain text. Staff cards, buttons and files use their respective part types with `data.role: "staff"`. See [Vercel AI SDK](https://docs.sonecentral.com/en/vercel-ai-sdk.md#tipli-mesajlar).

## Plain text is always available

Every `SoneMessage` has populated `text`. Catalog text lists names, prices and links, so text-only clients can display it directly. Long catalog fallback text may be shortened while `catalog.items` remains complete: [Catalog](#katalog). OpenAI responses also include this text in `content`.

## `SoneMessage`

```ts title="sone-message.ts"
export type SoneMessage = {
  id: string; // "msg_…"
  role: 'assistant' | 'staff' | 'user'; // staff: a team member wrote this
  type: 'text' | 'catalog' | 'actions' | 'file';
  text: string; // plain text fallback
  created_at: number; // Unix saniyesi
  catalog?: SoneCatalog;
  actions?: SoneActions;
  file?: SoneFile;
};

export type SoneCatalog = {
  // Business-selected presentation: "card", "card_button", "image_text", "link_button", "text", "carousel" or "list"
  format: string;
  items: SoneCatalogItem[];
};

export type SoneCatalogItem = {
  id: string; // Sonecentral record ID
  kind: 'inventory' | 'offering'; // product or service
  title: string;
  description?: string;
  price?: string; // formatted price: "1.290,00 TL"
  detail?: string; // short detail such as stock or duration
  attributes?: string; // attributes such as color or size, as plain text
  image_url?: string;
  url?: string; // product or service page
  button_title?: string; // URL button label; present when url exists
};

export type SoneActions = {
  format: 'buttons' | 'quick_replies' | 'card' | 'link_button';
  title?: string;
  text?: string;
  image_url?: string;
  buttons?: { title: string; url: string }[];
  choices?: string[];
};

export type SoneFile = {
  id: string; // "file_…"
  name: string;
  mime_type: string;
  size: number; // bayt
  url: string; // signed download URL
};
```

`role: "user"` appears only in conversation message lists and always has `type: "text"`. Turn responses contain business-side messages with role `assistant` or `staff`.

Empty optional fields such as `description`, `price` and `image_url` are omitted.

### Catalog

Each share contains 1–10 records in the assistant's selected order.

```json
{
  "id": "msg_c4t2l9g7x1p3h8q",
  "role": "assistant",
  "type": "catalog",
  "text": "Linen shirt, stone\n1.290,00 TL\nM and L in stock\nhttps://www.ornek.com/urun/keten-gomlek-tas\n\nLinen shirt, navy\n1.290,00 TL\nOnly M left\nhttps://www.ornek.com/urun/keten-gomlek-lacivert",
  "created_at": 1760866872,
  "catalog": {
    "format": "carousel",
    "items": [
      {
        "id": "r8k2m9p4q7w1x3z",
        "kind": "inventory",
        "title": "Linen shirt, stone",
        "price": "1.290,00 TL",
        "detail": "M and L in stock",
        "image_url": "https://cdn.ornek.com/urun/keten-tas.jpg",
        "url": "https://www.ornek.com/urun/keten-gomlek-tas",
        "button_title": "View product"
      },
      {
        "id": "r4q7w1x8k2m9a5z",
        "kind": "inventory",
        "title": "Linen shirt, navy",
        "price": "1.290,00 TL",
        "detail": "Only M left",
        "url": "https://www.ornek.com/urun/keten-gomlek-lacivert",
        "button_title": "View product"
      }
    ]
  }
}
```

`format` reflects the business's chosen presentation. A single record uses its own format: `card`, `card_button`, `image_text`, `link_button` or `text`. Several records use `carousel`, or `list` if all were configured as lists. This is a hint; you can render a list as cards. Services use the same fields with `kind: "offering"`; `detail` usually carries duration.

`text` lists each record's name, description, price, short detail, attributes and URL on separate lines, with a blank line between records.

Fallback text is limited to 4,000 characters. Space is shared among records. Records that fit are included in full. Otherwise name, price and detail take priority, the URL is appended if it fits, and attributes and complete description sentences fill the remaining space. A field is included whole or omitted, never cut midway. Always render complete cards from `catalog.items`; use `text` as the fallback.

A listed size, color or model is not proof of stock. An untracked inventory card reports that current stock was not verified. Descriptions omit the known legacy assistant-only stock instruction; configured private attributes stay out of customer output. Generated rich-message text and file captions are checked before delivery when they claim an operation or follow a catalog read. A `completesReply` value does not bypass that check.

### Buttons and quick replies

```json
{
  "id": "msg_a7c1t9n2k4q8z3w",
  "role": "assistant",
  "type": "actions",
  "text": "Which day works for your appointment?\nThursday\nFriday\nSaturday",
  "created_at": 1760866923,
  "actions": {
    "format": "quick_replies",
    "text": "Which day works for your appointment?",
    "choices": ["Thursday", "Friday", "Saturday"]
  }
}
```

| `format` | Contents |
| --- | --- |
| `buttons` | `text` and 1–3 options in `choices`. |
| `quick_replies` | `text` and 1–13 options in `choices`. |
| `link_button` | `text` and one link button in `buttons`. |
| `card` | `title`, short `text`, optional `image_url`, and up to 3 link buttons in `buttons`. |

Choice and button titles are limited to 20 characters. Fallback `text` includes the title, body, link buttons as `Title: URL`, and choices on separate lines.

- Each link button carries a `url` to open in a new tab.
- Clicking a choice sends its title as a new visitor message. The assistant treats it as typed text.
- Choices remain relevant until the visitor sends their next message. An assistant may send text after its choices; keep the choices until the visitor writes, then hide them.

### Files

```json
{
  "id": "msg_f1l3d0c5x9w2m6r",
  "role": "assistant",
  "type": "file",
  "text": "Fiyat listesi",
  "created_at": 1760867010,
  "file": {
    "id": "file_p9x2k4m7q1w8e3t",
    "name": "fiyat-listesi-2026.pdf",
    "mime_type": "application/pdf",
    "size": 188416,
    "url": "https://pb.sonecentral.com/api/v1/files/file_p9x2k4m7q1w8e3t?exp=1761471810&sig=…"
  }
}
```

`text` is the file description, or its filename if no description exists. Render a link using `<a download>`. Files can be up to 32 MB.

Sharing the same file again in the same conversation preserves `file.id`. Another conversation gets its own ID and URL. Storage contains one copy regardless of how many conversations share it.

## Downloading files

`file.url` is a signed `GET /files/{id}` URL:

```http
GET https://pb.sonecentral.com/api/v1/files/{id}?exp={expiry}&sig={signature}
```

- No API key is required; the URL signature is sufficient. Use the URL unchanged.
- URLs are valid for **seven days**. Reading conversation messages generates fresh URLs valid for another seven days.
- Downloads use `Content-Disposition: attachment` and preserve the filename, using `filename*` for Turkish characters. The browser does not render the file inside your page.
- Invalid or expired URLs return `404 file_not_found`. Temporarily unreadable files return `503 file_unavailable`.
- Each file ID permits 10 downloads/hour, then `429 file_download_limit`. Workspace daily egress defaults to 2 GiB per UTC day, then `429 file_egress_limit`. Both include `Retry-After` and apply regardless of request address.
- More than 120 download requests/minute from one address also returns `429`; this body does not use OpenAI's error format.

## Rendering

This function converts a `SoneMessage` to DOM. It uses `textContent` throughout, never `innerHTML`, to prevent fields from injecting code into your page.

```js title="render-sone-message.js"
function el(tag, props = {}, ...children) {
  const node = document.createElement(tag);
  for (const [key, value] of Object.entries(props)) {
    if (value == null) continue;
    if (key === 'class') node.className = value;
    else if (key.startsWith('on')) node.addEventListener(key.slice(2), value);
    else node.setAttribute(key, value);
  }
  for (const child of children.flat()) {
    if (child == null || child === false) continue;
    node.append(typeof child === 'string' ? document.createTextNode(child) : child);
  }
  return node;
}

export function renderSoneMessage(message, { onChoose }) {
  if (message.type === 'catalog' && message.catalog) {
    return el(
      'div',
      { class: 'sone-catalog' },
      message.catalog.items.map((item) =>
        el(
          'article',
          { class: 'sone-item' },
          item.image_url ? el('img', { src: item.image_url, alt: '', loading: 'lazy' }) : null,
          el('strong', {}, item.title),
          item.detail ? el('span', {}, item.detail) : null,
          item.price ? el('span', { class: 'sone-price' }, item.price) : null,
          item.url ? el('a', { href: item.url, target: '_blank', rel: 'noopener' }, item.button_title || 'View') : null,
        ),
      ),
    );
  }

  if (message.type === 'actions' && message.actions) {
    const { title, text, buttons = [], choices = [] } = message.actions;
    return el(
      'div',
      { class: 'sone-actions' },
      title ? el('strong', {}, title) : null,
      text ? el('p', {}, text) : null,
      buttons.map((button) => el('a', { href: button.url, target: '_blank', rel: 'noopener' }, button.title)),
      choices.map((choice) => el('button', { type: 'button', onclick: () => onChoose(choice) }, choice)),
    );
  }

  if (message.type === 'file' && message.file?.url) {
    return el('a', { class: 'sone-file', href: message.file.url, download: message.file.name }, message.file.name);
  }

  return el('p', { class: message.role === 'staff' ? 'sone-text sone-staff' : 'sone-text' }, message.text);
}
```

Complete widget: [Embedded chat widget](https://docs.sonecentral.com/en/sohbet-penceresi.md).

TikTok’s native image/text behavior is documented in [Channels](https://docs.sonecentral.com/en/kanallar.md); this page describes structured messages returned by the developer API.
