# Sonecentral text assistant API: full documentation > Call your Sonecentral text assistant from your own app or website. Supports OpenAI Chat Completions, OpenAI Responses and Vercel AI SDK formats, with the same assistant that replies on WhatsApp. Source: https://docs.sonecentral.com/en. Published at https://docs.sonecentral.com/en/llms-full.txt and regenerated on each build. Pages follow the sidebar order; each starts with its title and address. --- # Introduction Page: https://docs.sonecentral.com/en Markdown: https://docs.sonecentral.com/en/index.md > Call your Sonecentral text assistant from your own app or website using familiar OpenAI and Vercel AI SDK formats. > **Give the docs to your agent.** The full documentation is one Markdown file: https://docs.sonecentral.com/en/llms-full.txt. Paste this URL into your coding agent. For one page, use its .md address, for example https://docs.sonecentral.com/en/hizli-baslangic.md. Page index: https://docs.sonecentral.com/en/llms.txt ## What this API does The text assistant you configure in Sonecentral replies to customers on WhatsApp, Instagram, Messenger and TikTok. This API brings that same assistant into your app, website or backend. **Your assistant** replies, with the instructions and workspace configuration you already use: services, products, stock, catalog items, team, departments, business hours, business information, campaigns, shipping rules, price lists, discount authority, Knowledge Base documents and business data extracted from them, order and appointment requests, and team handoff. Requests run through the same runtime that handles your WhatsApp messages. The difference is that API visitors are anonymous. The assistant does not access their personal records or confirm appointments itself. Every order and appointment is a request awaiting your team's approval. ## Base URL and formats All requests use this base URL: ```txt https://pb.sonecentral.com/api/v1 ``` Choose one of three formats. All use the same assistant and conversation record. | Endpoint | Format and use case | | --- | --- | | `POST /chat/completions` | OpenAI Chat Completions, for an existing OpenAI SDK or compatible client. | | `POST /responses` | OpenAI Responses, to continue with `previous_response_id`. | | `POST /chat` | Vercel AI SDK, for a React or Next.js chat built with `useChat`. | Set `model` to your assistant's ID. `GET /models` lists IDs: [Assistants and models](https://docs.sonecentral.com/en/asistanlar-ve-modeller.md). If your key allows one assistant, use `"default"`. ## Things to know - **Visitors are anonymous.** The API does not verify a visitor's identity. The assistant helps with public information such as products, prices, stock and appointment availability, and does not disclose previous orders or existing appointments. See [Orders, appointments and privacy](https://docs.sonecentral.com/en/siparis-randevu-gizlilik.md). - **Streaming delivers messages.** Business safety checks inspect the complete reply before text is sent. Streaming progresses message by message. See [Streaming](https://docs.sonecentral.com/en/akis.md). - **Rich messages include plain text.** Product cards, buttons and files arrive as structured data, with a plain text fallback for each. See [Rich messages](https://docs.sonecentral.com/en/zengin-mesajlar.md). - **Conversations appear in Messages.** Every API conversation appears with source **API** and visitor name **API ziyaretçisi** (API visitor). The assistant can hand it to your team; staff reply there and your client reads their replies through the API. See [Human handoff and team replies](https://docs.sonecentral.com/en/insan-devri.md). ## Endpoints | Endpoint | Description | | --- | --- | | `GET /models` | Assistants available to the key. [Assistants and models](https://docs.sonecentral.com/en/asistanlar-ve-modeller.md) | | `GET /models/{id}` | One assistant. | | `POST /chat/completions` | OpenAI Chat Completions, streamed or non-streamed. [Chat Completions](https://docs.sonecentral.com/en/chat-completions.md) | | `POST /responses` | OpenAI Responses, streamed or non-streamed. [Responses API](https://docs.sonecentral.com/en/responses.md) | | `GET /responses/{id}` | A stored response. | | `DELETE /responses/{id}` | Delete a stored response. | | `GET /responses/{id}/input_items` | A response's input items. | | `POST /responses/{id}/cancel` | Always returns `400 not_cancellable` for an existing response, or `404 response_not_found`; responses complete within the request. | | `POST /chat` | Vercel AI SDK UI message stream for `useChat`. [Vercel AI SDK](https://docs.sonecentral.com/en/vercel-ai-sdk.md) | | `GET /chat/{id}/stream` | For `useChat({ resume: true })`. Always returns `204`. [Disconnections](https://docs.sonecentral.com/en/vercel-ai-sdk.md#baglanti-koparsa) | | `GET /conversations/{id}` | Whether the assistant or team owns the conversation. [Conversation state](https://docs.sonecentral.com/en/insan-devri.md#sohbetin-durumu) | | `GET /conversations/{id}/messages` | Conversation messages, including team replies. [Human handoff](https://docs.sonecentral.com/en/insan-devri.md) | | `GET /files/{id}` | Signed download URL for a file shared by the assistant. No API key required. [Downloading files](https://docs.sonecentral.com/en/zengin-mesajlar.md#dosya-indirme) | | `GET /key` | Summary of the current key. [Authentication](https://docs.sonecentral.com/en/kimlik-dogrulama.md#anahtari-denetlemek) | All endpoints answer `OPTIONS` preflight requests, so a browser can call them with a publishable key. Every endpoint except `GET /files/{id}` requires a key and counts toward its [limits](https://docs.sonecentral.com/en/limitler.md). ## Next step Create your key and send your first request in the [Quickstart](https://docs.sonecentral.com/en/hizli-baslangic.md). --- # Quickstart Page: https://docs.sonecentral.com/en/hizli-baslangic Markdown: https://docs.sonecentral.com/en/hizli-baslangic.md > Create a key, find your assistant's ID and send your first message. > **Give the docs to your agent.** The full documentation is one Markdown file: https://docs.sonecentral.com/en/llms-full.txt. Paste this URL into your coding agent. For one page, use its .md address, for example https://docs.sonecentral.com/en/hizli-baslangic.md. Page index: https://docs.sonecentral.com/en/llms.txt Working with a coding agent? Give it the URL above and describe what you want to build. To implement it yourself, follow these four steps. ### Create a key In Sonecentral, open **Metin asistanı** (Text assistant) and create a key under **API anahtarları** (API keys). Only workspace administrators see this section. **Tür** (Type) offers two choices: - **Server key** starts with `sk-sone-v1-` and belongs in your backend. Never put it in a browser, mobile app bundle or public repository. - **Publishable key** starts with `pk-sone-v1-` and is for your website's chat widget. It works only from your allowed origins. The key is shown once, when created. Save it immediately as an environment variable: ```bash export SONE_API_KEY="sk-sone-v1-..." ``` Details: [Authentication and keys](https://docs.sonecentral.com/en/kimlik-dogrulama.md). ### Find your assistant's ID ```bash curl https://pb.sonecentral.com/api/v1/models \ -H "Authorization: Bearer $SONE_API_KEY" ``` Use the listed `id` as `model`. If the key allows only one assistant, `"default"` is sufficient. ### Send the first message **curl** ```bash curl https://pb.sonecentral.com/api/v1/chat/completions \ -H "Authorization: Bearer $SONE_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "model": "default", "messages": [ { "role": "user", "content": "Can I book an appointment tomorrow afternoon?" } ] }' ``` **OpenAI JS** ```ts import OpenAI from 'openai'; const client = new OpenAI({ baseURL: 'https://pb.sonecentral.com/api/v1', apiKey: process.env.SONE_API_KEY, }); const completion = await client.chat.completions.create({ model: 'default', messages: [{ role: 'user', content: 'Can I book an appointment tomorrow afternoon?' }], }); console.log(completion.choices[0].message.content); ``` **OpenAI Python** ```python import os from openai import OpenAI client = OpenAI( base_url="https://pb.sonecentral.com/api/v1", api_key=os.environ["SONE_API_KEY"], ) completion = client.chat.completions.create( model="default", messages=[{"role": "user", "content": "Can I book an appointment tomorrow afternoon?"}], ) print(completion.choices[0].message.content) ``` **Vercel AI SDK** ```ts import { createOpenAI } from '@ai-sdk/openai'; import { generateText } from 'ai'; const sone = createOpenAI({ baseURL: 'https://pb.sonecentral.com/api/v1', apiKey: process.env.SONE_API_KEY, }); const { text } = await generateText({ model: sone.chat('default'), prompt: 'Can I book an appointment tomorrow afternoon?', }); console.log(text); ``` Install with `npm i openai`, `pip install openai` or `npm i ai @ai-sdk/openai`. Examples were tested with `openai` 7 (Node), `openai` 2 (Python), `ai` 7 and `@ai-sdk/openai` 4. ### Continue the conversation The response includes `choices[0].message.sone.conversation_id`. Send it back in `metadata` on the next request so the assistant remembers earlier messages. When sending only the new message, add an `Idempotency-Key` to each message: retrying after a network failure with the same key prevents duplicate storage. ```ts // OpenAI types omit `sone`; see the Chat Completions page for its type. const { sone } = completion.choices[0].message as unknown as { sone: { conversation_id: string }; }; await client.chat.completions.create( { model: 'default', metadata: { conversation_id: sone.conversation_id }, messages: [{ role: 'user', content: 'Is four oclock available?' }], }, { headers: { 'Idempotency-Key': crypto.randomUUID() } }, ); ``` Other continuation methods, retries and browser persistence: [Conversation continuity](https://docs.sonecentral.com/en/sohbet-surekliligi.md). ## Where to go next - Add a chat to your website: [Embedded chat widget](https://docs.sonecentral.com/en/sohbet-penceresi.md) - React and Next.js: [Vercel AI SDK](https://docs.sonecentral.com/en/vercel-ai-sdk.md) - Product cards and buttons: [Rich messages](https://docs.sonecentral.com/en/zengin-mesajlar.md) --- # Authentication and keys Page: https://docs.sonecentral.com/en/kimlik-dogrulama Markdown: https://docs.sonecentral.com/en/kimlik-dogrulama.md > Server and publishable keys, authentication headers, allowed origins and revocation. ## Two key types | | Server key | Publishable key | | --- | --- | --- | | Format | `sk-sone-v1-` followed by 48 letters or digits | `pk-sone-v1-` followed by 48 letters or digits | | Use | Backend, serverless function or script | Website chat widget | | From a browser | Rejected (`403 secret_key_in_browser`) | Allowed origins only | | Find conversations with `user` | Supported | Ignored | | Developer context | Supported | Ignored | | History imported into a new conversation | Visitor and assistant messages | Visitor messages only | | Request limit | 120 per minute per key | 30 per minute per visitor address; 1,200 requests and 20,000 reads per hour per key | | Automatic reply limit | Workspace total: 60 turns/hour, 300/day | Shared across all publishable keys within that total: 40 turns/hour, 200/day | Create, edit and revoke keys under **Metin asistanı → API anahtarları** (Text assistant → API keys) in Sonecentral. Only workspace administrators see this section. A workspace can have up to 50 active keys; revoked keys do not count. ## Send the key Use the `Authorization` header: ```http Authorization: Bearer sk-sone-v1-... ``` `x-api-key` is also accepted. If both are supplied, `Authorization` takes precedence. ```http x-api-key: sk-sone-v1-... ``` OpenAI SDKs and Vercel AI SDK send their `apiKey` option as `Authorization: Bearer` automatically. ## When the key is shown The complete key is shown once, at creation. Sonecentral stores only its hash. The list displays its prefix and final four characters. Request logs do not retain the key, whether sent through `Authorization` or `x-api-key`. If lost, create a new key and revoke the old one. ## Rotate a key Open [Text assistant → API keys](https://app.sonecentral.com/text-agent#api-anahtarlari) as a workspace administrator. Create a replacement with the required assistant scope, allowed origins and request permission. Copy it once, update your integration and verify it with `GET /key`, then revoke the old key. Rotation uses create-and-revoke; there is no separate rotate button. If a key is exposed, revoke it immediately. Key-management responses use `Cache-Control: no-store`. Requests carrying `Origin: null` (for example from an opaque or sandboxed browser context) are refused; server requests omit `Origin`. ## Key settings - **Name.** Up to 80 characters to identify the key. Also returned by `GET /key`; do not include secrets. - **Type.** Server or publishable. Cannot be changed after creation. - **Assistants.** Which assistants the key can call. Selecting all includes every published workspace assistant, including future ones. You can select an unpublished assistant; the key starts working with it once published. - **Allowed origins.** Publishable keys only, from 1 to 20 origins. - **Can receive order and appointment requests.** Enabled by default. The assistant can create requests awaiting your team's approval. When disabled, it passes the details to your team instead. See [Orders, appointments and privacy](https://docs.sonecentral.com/en/siparis-randevu-gizlilik.md). All settings except type can be edited later. Changes apply on the next request. ## Allowed origins Browsers send `Origin` automatically on cross-origin requests. A publishable key is accepted only when `Origin` matches its allowlist. It cannot be used without an `Origin` header. - Use a scheme and hostname, such as `https://www.example.com`. The hostname must contain at least one dot. For a non-443 port, use `https://www.example.com:8443`. - Paths, query strings, `*` wildcards, IP addresses and `https://localhost` are not accepted. - `https://example.com` and `https://www.example.com` are separate origins. Add both if needed. - For development, only `http://localhost` and `http://127.0.0.1` are allowed over HTTP, with or without a port, such as `http://localhost:3000` or `http://127.0.0.1:5173`. Hostnames are compared in lowercase. An origin outside the allowlist returns `403 origin_not_allowed`. ## Server keys do not belong in browsers Anyone can read a key embedded in website code and make requests on your behalf. Sonecentral rejects server-key requests carrying an origin not listed on that key with `403 secret_key_in_browser`. The dashboard does not add origins to server keys, so browser-origin requests are rejected. Always use a publishable key in browsers. This check is a safety net. Pages opened from a local file or a sandboxed frame can send `Origin: null`; Sonecentral rejects these requests too. A client outside the browser can omit or forge `Origin`, so this check does not protect an exposed key. Never include a server key in a browser, mobile app bundle or public repository. > **Warning** > > The OpenAI SDK's `dangerouslyAllowBrowser` option does not bypass this rule. Use a publishable key in a browser. Browser code can read these response headers: `x-request-id`, `retry-after`, `x-should-retry` and `x-sone-conversation-id`. ## Developer context With a server key, `system` and `developer` messages, Responses `instructions`, and Vercel AI SDK `context` are passed to the assistant **as information only**. For example, identify the product page the visitor is viewing or their app language preference. This text cannot override business instructions or rules; instructions embedded in it are not followed. Multiple contexts are joined and limited to 2,000 characters. Responses rejects `instructions` above 2,000 characters or 4 KB of JSON with `400 instructions_too_long`. Context applies only to the current turn; send it again if needed on the next request. Context sent with a publishable key is ignored and not stored, because visitors can change it. ## Revocation Revoking a key in **API anahtarları** disables it immediately. Subsequent requests return `401 revoked_api_key`. Revocation cannot be undone and revoked keys cannot be edited. Existing conversations remain in Messages. Stored responses created by a revoked key expire after 30 days. A new key that can call the same assistant can read them and continue with `previous_response_id`, but cannot delete them or see `instructions`, `safety_identifier` or `user`. See [Stored responses](https://docs.sonecentral.com/en/responses.md#saklanan-yanitlar). ## Inspect the key `GET /key` returns the current key's summary. Use it to check the workspace and available assistants during setup. ```bash curl https://pb.sonecentral.com/api/v1/key \ -H "Authorization: Bearer $SONE_API_KEY" ``` ```json { "data": { "label": "pk-sone-v1-Ab3…", "name": "Web sitesi sohbeti", "kind": "publishable", "assistants": ["k3m9x2p7q1w8e4r"], "requests": true, "allowed_origins": ["https://www.ornek.com"], "limits": { "requests_per_minute_per_visitor": 30, "requests_per_hour": 1200, "read_requests_per_hour": 20000, "assistant_turns_per_hour": 60, "assistant_turns_per_day": 300, "browser_assistant_turns_per_hour": 40, "browser_assistant_turns_per_day": 200 } } } ``` | Field | Type | Description | | --- | --- | --- | | `label` | string | First 14 characters followed by an ellipsis. No response includes the complete key. | | `name` | string | Name assigned in Sonecentral. | | `kind` | "secret" \| "publishable" | secret is a server key; publishable is a browser key. | | `assistants` | string[] | Currently published, allowed assistant IDs, sorted by assistant name. | | `requests` | boolean | Whether order and appointment requests are enabled. | | `allowed_origins` | string[] | Allowed origins. Empty for server keys. | | `limits` | object | Server keys include requests_per_minute. Publishable keys include requests_per_minute_per_visitor, requests_per_hour (non-read requests), read_requests_per_hour, browser_assistant_turns_per_hour and browser_assistant_turns_per_day. Both include the workspace's assistant_turns_per_hour and assistant_turns_per_day. | Server-key `limits`: ```json { "requests_per_minute": 120, "assistant_turns_per_hour": 60, "assistant_turns_per_day": 300 } ``` `GET /key` is a read and counts toward the key's [limits](https://docs.sonecentral.com/en/limitler.md). ## Errors | Status | `code` | When | | --- | --- | --- | | `401` | `missing_api_key` | No key supplied. | | `401` | `invalid_api_key` | Unknown or malformed key, or prefix does not match its type. | | `401` | `revoked_api_key` | Key has been revoked. | | `403` | `origin_not_allowed` | Publishable key used outside its allowlist or without `Origin`. | | `403` | `secret_key_in_browser` | Server key used from an origin not listed on the key. | | `429` | `rate_limit_exceeded` | Request or read limit exceeded. Wait for `Retry-After`. | All codes: [Errors](https://docs.sonecentral.com/en/hatalar.md). --- # Assistants and models Page: https://docs.sonecentral.com/en/asistanlar-ve-modeller Markdown: https://docs.sonecentral.com/en/asistanlar-ve-modeller.md > What to put in model, how to list assistants and what "default" means. In OpenAI's format, `model` names a language model. Here it is **your assistant's ID** and selects which assistant replies. Its Sonecentral settings determine the language model, instructions and tools; requests cannot change them. ## What the assistant uses The API assistant is the same assistant that replies on WhatsApp, Instagram, Messenger and TikTok. It uses your workspace configuration in the same way: - Services, with prices, durations, images and links. - Products and stock, with prices, images and links. - Catalog items and packages. - Team, departments and business hours. - Business name, address and phone number. - Your connected store's shipping rules and campaigns. - Price lists and any configured discount authority. - Knowledge Base documents, documents marked as shareable, and business data extracted from your documents. - Order and appointment requests and appointment availability, if the key enables requests. - Notes for the team and conversation handoff. TikTok is an officially supported messaging channel. Its native message formats differ from the API and Meta channels: [Channels](https://docs.sonecentral.com/en/kanallar.md). Product and service cards, buttons and files arrive through this API as structured data: [Rich messages](https://docs.sonecentral.com/en/zengin-mesajlar.md). Because API visitors are not authenticated, the assistant does not read or change their personal records (previous orders, existing appointments, customer profile, remembered information or connected panel account), and does not confirm appointments itself. See [Orders, appointments and privacy](https://docs.sonecentral.com/en/siparis-randevu-gizlilik.md). ## List assistants ```bash curl https://pb.sonecentral.com/api/v1/models \ -H "Authorization: Bearer $SONE_API_KEY" ``` ```json { "object": "list", "data": [ { "id": "k3m9x2p7q1w8e4r", "object": "model", "created": 1760889600, "owned_by": "sonecentral", "name": "Store assistant" } ] } ``` The list contains only **published assistants the key can call**. Drafts and assistants with incomplete setup are excluded. Results are sorted by assistant name. | Field | Type | Description | | --- | --- | --- | | `id` | string | Assistant ID. Use this as model. | | `object` | "model" | Always model. | | `created` | integer | Assistant creation time in Unix seconds. | | `owned_by` | "sonecentral" | Always sonecentral. | | `name` | string | Assistant name in the dashboard. | `GET /models/{id}` returns the same object for one assistant. ```bash curl https://pb.sonecentral.com/api/v1/models/k3m9x2p7q1w8e4r \ -H "Authorization: Bearer $SONE_API_KEY" ``` In OpenAI SDKs, use `client.models.list()` and `client.models.retrieve(id)`. Everything before the final `/` in `model` is ignored. Tools that prefix model names with a provider can therefore use `sonecentral/k3m9x2p7q1w8e4r` to select the same assistant. ## `"default"` When `model` is omitted or set to `"default"`, selection follows this order: 1. The key's only allowed assistant, if there is exactly one. 2. Otherwise, the workspace's default text assistant, if the key allows it. 3. Otherwise, the first allowed assistant in alphabetical name order. If your key allows several assistants and the choice matters, send an explicit ID. ## Conversation and assistant A conversation is bound to the assistant that started it. Continuing it with another `model` returns `400 conversation_model_mismatch`. Use the same assistant or start a new conversation. ## Errors | Status | `code` | When | | --- | --- | --- | | `404` | `model_not_found` | No published assistant with this ID is available to the key. Also returned for drafts and incomplete setup. | | `400` | `conversation_model_mismatch` | The conversation belongs to another assistant. | --- # Chat Completions Page: https://docs.sonecentral.com/en/chat-completions Markdown: https://docs.sonecentral.com/en/chat-completions.md > Send messages using OpenAI Chat Completions. The sone fields carry conversation state and rich messages. ```http POST https://pb.sonecentral.com/api/v1/chat/completions ``` Uses OpenAI Chat Completions request and response formats. OpenAI SDKs, LangChain and compatible clients work by changing `baseURL`. ## Request ```json { "model": "default", "messages": [ { "role": "user", "content": "Are you open this weekend?" } ], "metadata": { "conversation_id": "conv_x8k2m9p4q7w1r3t" }, "stream": false } ``` | Field | Type | Description | | --- | --- | --- | | `model` | string | Assistant ID or "default". See Assistants and models for IDs. Required. | | `messages` | array | Conversation messages. User messages after the final assistant message are new, up to 12. Earlier messages are used to find the conversation or initialize a new one. Required. | | `stream` | boolean | Stream the reply using server-sent events (SSE). Default: false. | | `stream_options` | object | include_usage: true adds usage at the end of the stream. | | `conversation_id` | string | Sonecentral extension: ID of the conversation to continue. | | `metadata` | object | Only metadata.conversation_id is read, as an alternative to conversation_id. Other metadata is ignored and not stored. | | `user` | string | Server keys only. An internal visitor identifier; the same value finds the same conversation. | | `safety_identifier` | string | Same function as user; takes precedence when both are supplied. | The first non-empty conversation ID is used in this order: 1. Body `conversation_id`. 2. Body `sone.conversation_id`. 3. `X-Sone-Conversation-Id` header. 4. `metadata.conversation_id`. 5. `sone.conversation_id` from an assistant message returned unchanged in history, using the most recent if several exist. Without an ID, other lookup methods apply: [Conversation continuity](https://docs.sonecentral.com/en/sohbet-surekliligi.md#sohbet-nasil-bulunur). ### Message roles | Role | Behavior | | --- | --- | | `user` | Visitor message. `content` can be a string or an array of `text` parts. | | `assistant` | Previous reply. Used for conversation lookup; with a server key, also imported when initializing a new conversation. | | `system`, `developer` | With a server key, informational context, up to 2,000 characters. Ignored with publishable keys. See [Developer context](https://docs.sonecentral.com/en/kimlik-dogrulama.md#gelistirici-baglami). | | `tool`, `function` | Ignored. | Other roles return `400 invalid_role`. The final message must be a text-bearing `user` message, otherwise `400 missing_user_message`. A new message longer than 4,000 characters returns `400 message_too_long`. Images, audio and files in new messages return `400 unsupported_content`. Such parts in previous messages are skipped, retaining only their text. ### Ignored and unsupported fields Sonecentral's assistant configuration determines the model, tone and tools. These fields are **ignored** without an error: `temperature`, `top_p`, `max_tokens`, `max_completion_tokens`, `presence_penalty`, `frequency_penalty`, `seed`, `stop`, `logit_bias`, `logprobs`, `top_logprobs`, `reasoning_effort`, `verbosity`, `service_tier`, `store`, `parallel_tool_calls`, `tool_choice`, `function_call`, `audio`, `modalities`. Unknown fields are also ignored. These fields are **rejected**: | Field | Error | | --- | --- | | `tools`, `functions` | `400 tools_not_supported`; the assistant uses the business's own tools. | | `n` other than 1 | `400 unsupported_parameter` | | `response_format` other than `text` | `400 unsupported_parameter` | ## Response ```json { "id": "chatcmpl-9f2c4a7e1b8d3f6a0c5e2b9d", "object": "chat.completion", "created": 1760866000, "model": "k3m9x2p7q1w8e4r", "choices": [ { "index": 0, "message": { "role": "assistant", "content": "Yes, we are open Saturday from 10:00 to 18:00. We are closed on Sunday.", "refusal": null, "annotations": [], "sone": { "conversation_id": "conv_x8k2m9p4q7w1r3t", "status": "answered", "messages": [ { "id": "msg_p4q7w1x8k2m9a5z", "role": "assistant", "type": "text", "text": "Yes, we are open Saturday from 10:00 to 18:00. We are closed on Sunday.", "created_at": 1760866000 } ] } }, "logprobs": null, "finish_reason": "stop" } ], "usage": { "prompt_tokens": 7, "completion_tokens": 16, "total_tokens": 23 }, "system_fingerprint": null, "sone": { "conversation_id": "conv_x8k2m9p4q7w1r3t", "status": "answered" } } ``` `model` is the replying assistant's ID, even when the request uses `"default"`. The conversation ID is also in `X-Sone-Conversation-Id`. `content` is the plain text of all messages in the turn, separated by blank lines (`\n\n`). A text-only client remains fully functional. `usage` is informational, estimated from new input and output text at roughly four characters per token. Billing is not based on these values. ### The `sone` fields These are additions to OpenAI's format. The complete object is at `choices[0].message.sone`. A summary without `messages` is also present at the response's top level. | Field | Type | Description | | --- | --- | --- | | `conversation_id` | string | Conversation ID, prefixed with conv_. Send it on subsequent requests. | | `status` | "answered" \| "handoff" \| "human" \| "silent" | Turn outcome, described below. | | `messages` | SoneMessage[] | Messages sent in this turn, in order. See Rich messages for the format. | | `status` | Meaning | `content` | | --- | --- | --- | | `answered` | The assistant replied. | Reply text | | `handoff` | The conversation moved to the team this turn and a message was sent, usually explaining the handoff. | Handoff message | | `human` | The team owns the conversation. The assistant did not reply; the visitor message reached the team. | Empty | | `silent` | No reply needed, for example after “thanks”. | Empty | For `handoff` and `human`, read [Human handoff and team replies](https://docs.sonecentral.com/en/insan-devri.md). Define these fields in TypeScript: ```ts title="sone.ts" import type { SoneMessage } from './sone-message'; // definition from the Rich messages page export type SoneStatus = 'answered' | 'handoff' | 'human' | 'silent'; // Non-streamed response: choices[0].message.sone export type SoneTurn = { conversation_id: string; status: SoneStatus; messages: SoneMessage[]; }; // Top-level sone on a stream chunk; only one field is populated per chunk export type SoneChunk = { message?: SoneMessage; // a message completed activity?: string; // current assistant activity: "Ürünlere bakılıyor" conversation_id?: string; // on the final chunk status?: SoneStatus; // on the final chunk }; export const soneOfMessage = (message: unknown) => (message as { sone?: SoneTurn }).sone; export const soneOfChunk = (chunk: unknown) => (chunk as { sone?: SoneChunk }).sone; ``` OpenAI's Python models preserve unknown fields: ```python sone = completion.choices[0].message.to_dict().get("sone", {}) print(sone.get("conversation_id"), sone.get("status")) ``` ## Streaming With `"stream": true`, the response is `text/event-stream`. Chunks arrive in this order: 1. **First chunk:** after key, limit and plan checks pass and the assistant starts, `delta` carries the role. Rejected requests return normal HTTP errors instead. 2. **Activity chunk:** while a tool runs, `delta` is empty and top-level `sone.activity` carries a short label, such as the Turkish “Ürünlere bakılıyor” (Checking products). 3. Text streams in word groups through `delta.content`. Second and later messages begin with `\n\n`. Each message's **final chunk** also includes the full `SoneMessage` in top-level `sone.message`. A message with empty text uses one chunk with an empty `delta`. 4. **Final chunk:** `finish_reason` is `"stop"`, `delta` is empty, and top-level `sone` contains `conversation_id` and `status`. 5. With `stream_options.include_usage`, an extra chunk has empty `choices` and populated `usage`; preceding chunks contain `"usage": null`. 6. The stream ends with `data: [DONE]`. `sone` is always top-level on the chunk, never inside `choices[0].delta`. The conversation ID is available in `X-Sone-Conversation-Id` when streaming begins. ```txt data: {"id":"chatcmpl-9f2c","object":"chat.completion.chunk","created":1760866000,"model":"k3m9x2p7q1w8e4r","choices":[{"index":0,"delta":{"role":"assistant","content":""},"logprobs":null,"finish_reason":null}]} data: {"id":"chatcmpl-9f2c","object":"chat.completion.chunk","created":1760866000,"model":"k3m9x2p7q1w8e4r","choices":[{"index":0,"delta":{},"logprobs":null,"finish_reason":null}],"sone":{"activity":"Çalışma saatlerine bakılıyor"}} : ping data: {"id":"chatcmpl-9f2c","object":"chat.completion.chunk","created":1760866000,"model":"k3m9x2p7q1w8e4r","choices":[{"index":0,"delta":{"content":"Yes, we are open Saturday "},"logprobs":null,"finish_reason":null}]} data: {"id":"chatcmpl-9f2c","object":"chat.completion.chunk","created":1760866000,"model":"k3m9x2p7q1w8e4r","choices":[{"index":0,"delta":{"content":"from 10:00 to 18:00. We are "},"logprobs":null,"finish_reason":null}]} data: {"id":"chatcmpl-9f2c","object":"chat.completion.chunk","created":1760866000,"model":"k3m9x2p7q1w8e4r","choices":[{"index":0,"delta":{"content":"closed on Sunday."},"logprobs":null,"finish_reason":null}],"sone":{"message":{"id":"msg_p4q7w1x8k2m9a5z","role":"assistant","type":"text","text":"Yes, we are open Saturday from 10:00 to 18:00. We are closed on Sunday.","created_at":1760866000}}} data: {"id":"chatcmpl-9f2c","object":"chat.completion.chunk","created":1760866000,"model":"k3m9x2p7q1w8e4r","choices":[{"index":0,"delta":{},"logprobs":null,"finish_reason":"stop"}],"sone":{"conversation_id":"conv_x8k2m9p4q7w1r3t","status":"answered"}} data: [DONE] ``` IDs in this example are shortened. **OpenAI JS** ```ts import type { SoneMessage } from './sone-message'; import { soneOfChunk } from './sone'; const stream = await client.chat.completions.create({ model: 'default', messages: [{ role: 'user', content: 'Are you open this weekend?' }], stream: true, }); const messages: SoneMessage[] = []; for await (const chunk of stream) { process.stdout.write(chunk.choices[0]?.delta.content ?? ''); const sone = soneOfChunk(chunk); if (sone?.message) messages.push(sone.message); // render the rich message here if (sone?.activity) console.log(`\n${sone.activity}…`); if (sone?.status) console.log('\nsohbet:', sone.conversation_id, sone.status); } ``` **OpenAI Python** ```python stream = client.chat.completions.create( model="default", messages=[{"role": "user", "content": "Are you open this weekend?"}], stream=True, ) messages = [] for chunk in stream: if chunk.choices: print(chunk.choices[0].delta.content or "", end="", flush=True) sone = chunk.to_dict().get("sone") or {} if "message" in sone: messages.append(sone["message"]) # render the rich message here elif "activity" in sone: print(f"\n{sone['activity']}…") elif "status" in sone: print("\nsohbet:", sone["conversation_id"], sone["status"]) ``` An error after streaming begins arrives as `data: {"error": {…}}`, followed by `data: [DONE]`. OpenAI SDKs throw it as an error. See [Errors](https://docs.sonecentral.com/en/hatalar.md#akis-sirasinda-hata). For message-level delivery and keepalives, see [Streaming](https://docs.sonecentral.com/en/akis.md). ## Retries Send a new `Idempotency-Key`, up to 200 characters, for every new message. Reuse it when retrying that message after a network failure. The message is stored once and the same reply is returned if already answered. Different text with the same key returns `422 idempotency_key_reused`. With a publishable key, a retry without a conversation ID from a different network address, such as a first-message retry, opens a new conversation: [Retries](https://docs.sonecentral.com/en/sohbet-surekliligi.md#tekrar-denemek). OpenAI SDKs do not add this header automatically. Pass it in request options so SDK retries reuse it: **OpenAI JS** ```ts const key = crypto.randomUUID(); // generate once per message and reuse on retries const completion = await client.chat.completions.create( { model: 'default', metadata: { conversation_id: 'conv_x8k2m9p4q7w1r3t' }, messages: [{ role: 'user', content: 'Are you open this weekend?' }], }, { headers: { 'Idempotency-Key': key } }, ); ``` **OpenAI Python** ```python import uuid key = str(uuid.uuid4()) # generate once per message and reuse on retries completion = client.chat.completions.create( model="default", metadata={"conversation_id": "conv_x8k2m9p4q7w1r3t"}, messages=[{"role": "user", "content": "Are you open this weekend?"}], extra_headers={"Idempotency-Key": key}, ) ``` When every request includes the complete history and conversation ID, Sonecentral can also identify retries from the history tail after the first message. History alone without an ID or message identity cannot find an unanswered retry: it starts another conversation and replies again. An idempotency key is the safe path for first messages and clients sending only new text without saving an ID. Send one on every message. See [Retries](https://docs.sonecentral.com/en/sohbet-surekliligi.md#tekrar-denemek). ## Errors | Status | `code` | When | | --- | --- | --- | | `400` | `missing_user_message` | The final message must be a user message containing text. | | `400` | `invalid_role` | Unknown message role, or a Responses message item without a role. | | `400` | `invalid_content` | Message content is not a string or an array of parts. | | `400` | `unsupported_content` | A new message contains an image, audio or file. Send text only. | | `400` | `message_too_long` | A new message exceeds 4,000 characters. Shorten it. | | `400` | `too_many_new_messages` | More than 12 new visitor messages in one request. Include the assistant reply in history before continuing, or start a new conversation. | | `400` | `tools_not_supported` | Remove tools, functions or function-call items. The assistant uses the business tools configured in Sonecentral. | | `400` | `unsupported_parameter` | n is not 1, response_format or text.format is not text, or background is true. param identifies the field. | | `400` | `conversation_model_mismatch` | The conversation belongs to another assistant. Use the same model or start a new conversation. | | `404` | `model_not_found` | No published assistant with this ID is available to the key. GET /models lists available assistants. | | `404` | `conversation_not_found` | Not an API conversation in this workspace, or its assistant is unavailable to the key. Clear the saved ID and start a new conversation. | | `409` | `conversation_busy` | The previous turn is still running. This message was not stored. Wait and retry with the same Idempotency-Key. | | `422` | `idempotency_key_reused` | The same Idempotency-Key was used with different text. Use a new key for each new message. | All codes and recovery steps: [Errors](https://docs.sonecentral.com/en/hatalar.md). --- # Responses API Page: https://docs.sonecentral.com/en/responses Markdown: https://docs.sonecentral.com/en/responses.md > Use OpenAI Responses, continue with previous_response_id or conversation, and read or delete stored responses. ```http POST https://pb.sonecentral.com/api/v1/responses ``` Uses OpenAI's Responses format. Continue by sending the previous response ID, without resending the entire history. When a turn contains several messages, `output_text` parts and stream deltas separate them with two newlines. This keeps the SDK's `output_text` or AI SDK's `text` readable. Each item's `sone.text` retains the original message. ## First message and continuation **OpenAI JS** ```ts const first = await client.responses.create({ model: 'default', input: 'I would like to book a hair coloring appointment.', }); console.log(first.output_text); const second = await client.responses.create({ model: 'default', previous_response_id: first.id, input: 'Would Thursday afternoon work?', }); console.log(second.output_text); ``` **OpenAI Python** ```python first = client.responses.create( model="default", input="I would like to book a hair coloring appointment.", ) print(first.output_text) second = client.responses.create( model="default", previous_response_id=first.id, input="Would Thursday afternoon work?", ) print(second.output_text) ``` **curl** ```bash curl https://pb.sonecentral.com/api/v1/responses \ -H "Authorization: Bearer $SONE_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "model": "default", "input": "I would like to book a hair coloring appointment." }' ``` `output_text` is an SDK convenience that joins every `output_text` part. ## Request | Field | Type | Description | | --- | --- | --- | | `model` | string | Assistant ID or "default". Required. | | `input` | string \| array | New message as text or OpenAI input items: message items with roles and item_reference items. input_text and output_text parts are read. Each item_reference ID is limited to 64 characters. The final item must be a visitor text message. Required. | | `previous_response_id` | string | Previous response ID: resp_ followed by 32 characters. Continues the same conversation. Cannot be combined with conversation. Longer values are rejected. | | `conversation` | string \| { id: string } | Conversation ID, sone.conversation_id. An alternative to previous_response_id. | | `store` | boolean | false disables response storage: GET /responses/{id} and previous_response_id cannot find it. Default: true. | | `instructions` | string | Server keys only: informational developer context, at most 2,000 characters and 4 KB JSON. Longer values are rejected. Ignored, not stored and omitted from responses for publishable keys. On stored reads, visible only to the storing key. | | `stream` | boolean | Stream the response as events. Default: false. | | `user` | string | Server keys only: internal visitor identifier for conversation lookup, at most 64 characters. Longer values are rejected. Not returned or stored. | | `safety_identifier` | string | Same lookup as user, taking precedence when both are supplied. At most 64 characters. Returned and stored for server keys; ignored, not stored and omitted for publishable keys. | | `metadata` | object | Echoed unchanged; not used for conversation lookup. Stored reads expose it only to the storing key. Up to 16 keys, each key 64 characters, each string value 512 characters, and 4 KB JSON total. | You can use `X-Sone-Conversation-Id` instead of `conversation`. Combining `conversation` and `previous_response_id` returns `400 conflicting_parameters`. Input `system` and `developer` messages are [developer context](https://docs.sonecentral.com/en/kimlik-dogrulama.md#gelistirici-baglami), like `instructions`. Returned assistant messages and `item_reference` items help find the conversation: [Conversation continuity](https://docs.sonecentral.com/en/sohbet-surekliligi.md#sohbet-nasil-bulunur). Model settings such as `temperature`, `top_p`, `max_output_tokens`, `reasoning`, `text.verbosity`, `tool_choice` and `truncation` are ignored. `max_output_tokens` is echoed in the response but does not change reply length. These inputs are rejected: | Input | Error | | --- | --- | | `tools` or function-call items such as `function_call` | `400 tools_not_supported` | | `background: true` | `400 unsupported_parameter` | | `text.format` other than `text` | `400 unsupported_parameter` | | Image or file in a new message | `400 unsupported_content` | | Unknown input item type | `400 unsupported_item` | `reasoning` items are silently skipped. ## Response ```json { "id": "resp_7c1e9a4f2b8d6e3a0f5c9b2d7e4a1c8f", "object": "response", "created_at": 1760866200, "completed_at": 1760866203, "status": "completed", "model": "k3m9x2p7q1w8e4r", "previous_response_id": null, "conversation": { "id": "conv_x8k2m9p4q7w1r3t" }, "output": [ { "type": "message", "id": "msg_w8e4rk3m9x2pq7a", "status": "completed", "role": "assistant", "content": [ { "type": "output_text", "text": "Of course. Which day works for you?", "annotations": [], "logprobs": [] } ], "sone": { "id": "msg_w8e4rk3m9x2pq7a", "role": "assistant", "type": "text", "text": "Of course. Which day works for you?", "created_at": 1760866203 } } ], "usage": { "input_tokens": 10, "input_tokens_details": { "cached_tokens": 0 }, "output_tokens": 8, "output_tokens_details": { "reasoning_tokens": 0 }, "total_tokens": 18 }, "sone": { "conversation_id": "conv_x8k2m9p4q7w1r3t", "status": "answered" } } ``` This example is abbreviated; actual responses also include OpenAI's other defined fields. `usage` is estimated from text length as described in [Chat Completions](https://docs.sonecentral.com/en/chat-completions.md). Each assistant message is a separate `message` output item. Its `id` is the Sonecentral message ID. Product cards, buttons and files include a plain text fallback in `content` and structured data in the item's `sone` field. See [Rich messages](https://docs.sonecentral.com/en/zengin-mesajlar.md). Top-level `sone` contains the conversation ID and turn outcome. `status` values match [Chat Completions](https://docs.sonecentral.com/en/chat-completions.md). `human` and `silent` turns have empty `output`. The conversation ID is also returned in `X-Sone-Conversation-Id`. ## Stored responses Responses are stored by default unless `store` is false. Stored responses expire after 30 days. The conversation itself remains in Messages. | Endpoint | Behavior | | --- | --- | | `GET /responses/{id}` | Returns the response in the same format. | | `GET /responses/{id}/input_items` | Lists this request's own input items, newest first by default; `?order=asc` returns oldest first. Single page, always `has_more: false`. | | `DELETE /responses/{id}` | Deletes the stored response: `{ "id": "resp_…", "object": "response.deleted", "deleted": true }`. Only the storing key can delete. Does not delete the conversation. | | `POST /responses/{id}/cancel` | Always returns `400 not_cancellable` if the response exists. Responses complete inside the request; no background task exists. | Only the request's own input items are retained: new visitor messages after the final assistant item, `item_reference`, and server-key `system`/`developer` messages. Publishable-key context is ignored and not stored. Replayed history is not stored again; the conversation retains it. Storage keeps the newest items, up to 40 items and 8 KB total JSON. The newest item is always retained, truncating its text from the end if needed to fit. Each item's text is limited to 4,000 characters. Items without IDs receive positional IDs such as `resp_…_in_0`. Stored request fields are bounded: `instructions` at 2,000 characters, `metadata` at 16 keys, `safety_identifier` and `user` at 64 characters, and each `item_reference` ID at 64 characters. `instructions` and `metadata` also have a 4 KB JSON limit; multi-byte text can exceed it within the character limit. Oversized requests return `400` and store nothing. `previous_response_id` is trimmed before storage; longer-than-ID values return `400 previous_response_id_too_long`. Retrying with the same `Idempotency-Key`, or the same `previous_response_id` and `input`, returns the original response with the same ID. It neither generates another reply nor stores another response. This also applies while the conversation belongs to the team. Any key allowed to call the response's assistant can read the response and input items by ID and continue with `previous_response_id`. An unavailable assistant's response appears absent: `404 response_not_found`, or `404 previous_response_not_found` for continuation. Only the storing key sees developer context, `metadata` and the submitted end-user identifier. Other keys see `instructions`, `safety_identifier` and `user` as `null`, empty `metadata` (`{}`), and no `system` or `developer` input items. Only the storing key can delete; others receive `404 response_not_found`. After rotation and revocation, old stored responses expire after 30 days. Response IDs are hard to guess, but keep them private. ```ts const stored = await client.responses.retrieve('resp_7c1e9a4f2b8d6e3a0f5c9b2d7e4a1c8f'); const items = await client.responses.inputItems.list('resp_7c1e9a4f2b8d6e3a0f5c9b2d7e4a1c8f'); await client.responses.delete('resp_7c1e9a4f2b8d6e3a0f5c9b2d7e4a1c8f'); ``` ## Streaming With `"stream": true`, events follow OpenAI's sequence. Each event has a `sequence_number` starting at 0 and increasing by one. Every assistant message opens and closes its own item: ```txt event: response.created data: {"type":"response.created","sequence_number":0,"response":{"id":"resp_7c1e","status":"in_progress","conversation":{"id":"conv_x8k2m9p4q7w1r3t"},"output":[]}} event: response.in_progress data: {"type":"response.in_progress","sequence_number":1,"response":{"id":"resp_7c1e","status":"in_progress"}} : sone.activity {"label":"Uygun saatlere bakılıyor"} event: response.output_item.added data: {"type":"response.output_item.added","sequence_number":2,"output_index":0,"item":{"type":"message","id":"msg_w8e4","status":"in_progress","role":"assistant","content":[],"sone":{…}}} event: response.content_part.added data: {"type":"response.content_part.added","sequence_number":3,"item_id":"msg_w8e4","output_index":0,"content_index":0,"part":{"type":"output_text","text":"","annotations":[],"logprobs":[]}} event: response.output_text.delta data: {"type":"response.output_text.delta","sequence_number":4,"item_id":"msg_w8e4","output_index":0,"content_index":0,"delta":"Of course. Which day works ","logprobs":[]} event: response.output_text.delta data: {"type":"response.output_text.delta","sequence_number":5,"item_id":"msg_w8e4","output_index":0,"content_index":0,"delta":"for you?","logprobs":[]} event: response.output_text.done data: {"type":"response.output_text.done","sequence_number":6,"item_id":"msg_w8e4","output_index":0,"content_index":0,"text":"Of course. Which day works for you?","logprobs":[]} event: response.content_part.done data: {"type":"response.content_part.done","sequence_number":7,"item_id":"msg_w8e4","output_index":0,"content_index":0,"part":{"type":"output_text","text":"Of course. Which day works for you?","annotations":[],"logprobs":[]}} event: response.output_item.done data: {"type":"response.output_item.done","sequence_number":8,"output_index":0,"item":{"type":"message","id":"msg_w8e4","status":"completed","role":"assistant","content":[{"type":"output_text","text":"Of course. Which day works for you?","annotations":[],"logprobs":[]}],"sone":{…}}} event: response.completed data: {"type":"response.completed","sequence_number":9,"response":{"id":"resp_7c1e","status":"completed","sone":{"conversation_id":"conv_x8k2m9p4q7w1r3t","status":"answered"}}} ``` Objects are abbreviated here; actual events contain all fields. `: sone.activity` is an SSE comment, not an event. Its `label` is a short Turkish activity description, such as “Uygun saatlere bakılıyor” (Checking available times), and it has no `sequence_number`. OpenAI SDKs skip comments. To display activity, parse lines beginning with `: sone.activity ` as JSON after the prefix. `: ping` lines are comments too. **OpenAI JS** ```ts import OpenAI from 'openai'; const stream = await client.responses.create({ model: 'default', input: 'I would like to book a hair coloring appointment.', stream: true, }); try { for await (const event of stream) { if (event.type === 'response.output_text.delta') process.stdout.write(event.delta); if (event.type === 'response.output_item.done') process.stdout.write('\n'); } } catch (error) { // Post-start failure: the SDK throws APIError for the error event. if (error instanceof OpenAI.APIError) console.error(error.code, error.message); else throw error; } ``` **OpenAI Python** ```python stream = client.responses.create( model="default", input="I would like to book a hair coloring appointment.", stream=True, ) for event in stream: if event.type == "response.output_text.delta": print(event.delta, end="", flush=True) elif event.type == "response.output_item.done": print() elif event.type == "error": print(event.code, event.message) ``` After streaming begins, failures send an `error` event carrying `code` and `message`, then `response.failed`, and close the stream. Responses never sends `data: [DONE]`; it ends with `response.completed` or `response.failed`. OpenAI's Node SDK throws on `error`; Python exposes an event with `type: "error"`. See [Errors](https://docs.sonecentral.com/en/hatalar.md#akis-sirasinda-hata). ## Errors | Status | `code` | When | | --- | --- | --- | | `400` | `missing_input` | Responses request has no input. | | `400` | `invalid_input` | Responses input is not text or an array of items. | | `400` | `invalid_role` | Unknown message role, or a Responses message item without a role. | | `400` | `missing_user_message` | The final message must be a user message containing text. | | `400` | `unsupported_item` | Unknown Responses input item type. | | `400` | `conflicting_parameters` | previous_response_id and conversation were supplied together. Use one. | | `400` | `instructions_too_long` | Responses instructions exceeds 2,000 characters or 4 KB JSON. | | `400` | `invalid_metadata` | Responses metadata exceeds 16 keys, 64 characters per key, 512 characters per string value, or 4 KB JSON. Values must be strings. | | `400` | `safety_identifier_too_long` | Responses safety_identifier exceeds 64 characters. | | `400` | `user_too_long` | Responses user exceeds 64 characters. | | `400` | `previous_response_id_too_long` | Trimmed previous_response_id is longer than a response ID. Send the original response id. | | `400` | `item_id_too_long` | An item_reference ID exceeds 64 characters. | | `400` | `not_cancellable` | Responses complete within the request; there is no background task to cancel. | | `400` | `conversation_model_mismatch` | The conversation belongs to another assistant. Use the same model or start a new conversation. | | `404` | `response_not_found` | Stored response is missing, deleted, older than 30 days or unavailable to the key. On DELETE, also returned when another key stored it. | | `404` | `previous_response_not_found` | The referenced response is missing, deleted, was created with store: false, or belongs to an assistant unavailable to the key. | | `404` | `conversation_not_found` | Not an API conversation in this workspace, or its assistant is unavailable to the key. Clear the saved ID and start a new conversation. | | `409` | `conversation_busy` | The previous turn is still running. This message was not stored. Wait and retry with the same Idempotency-Key. | All codes: [Errors](https://docs.sonecentral.com/en/hatalar.md). --- # Vercel AI SDK Page: https://docs.sonecentral.com/en/vercel-ai-sdk Markdown: https://docs.sonecentral.com/en/vercel-ai-sdk.md > Connect useChat directly to /chat with typed rich message parts, or use generateText and streamText through an OpenAI provider on your server. There are two integration paths: - **Direct `useChat`.** Connect your website chat to `POST /chat` with a publishable key, without your own server in between. Product cards, buttons and files arrive as typed data parts. - **As a provider.** Use `generateText` and `streamText` in server code with `@ai-sdk/openai` or `@ai-sdk/openai-compatible`. Examples were tested with `ai` 7, `@ai-sdk/react` 4, `@ai-sdk/openai` 4 and `@ai-sdk/openai-compatible` 3. `/chat` uses the UI message stream format from AI SDK 5 and later. ## Direct `useChat` ```npm npm i ai @ai-sdk/react ``` ```tsx title="components/chat.tsx" 'use client'; import { useChat } from '@ai-sdk/react'; import { DefaultChatTransport } from 'ai'; import { useState } from 'react'; const transport = new DefaultChatTransport({ api: 'https://pb.sonecentral.com/api/v1/chat', headers: { Authorization: `Bearer ${process.env.NEXT_PUBLIC_SONE_PUBLISHABLE_KEY}` }, body: { model: 'default' }, }); export function Chat() { const [input, setInput] = useState(''); const { messages, sendMessage, status, error } = useChat({ transport }); const busy = status === 'submitted' || status === 'streaming'; return (
{messages.map((message) => (
{message.parts.map((part, index) => part.type === 'text' ?

{part.text}

: null, )}
))} {error ?

{error.message}

: null}
{ event.preventDefault(); if (!input.trim() || busy) return; sendMessage({ text: input }); setInput(''); }} > setInput(event.target.value)} disabled={busy} />
); } ``` `NEXT_PUBLIC_SONE_PUBLISHABLE_KEY` must be a **publishable key**, prefixed with `pk-sone-v1-…`. Add your website to its allowed origins. A server key returns `403 secret_key_in_browser` here. This minimal example renders only text. For product cards, buttons and files carried in `data-sone-*`, see [Typed messages](#tipli-mesajlar). The input is disabled only while a reply is in progress. After failure, `status` becomes `error`, `error.message` contains a Turkish explanation, and visitors can write again. ### Request body `DefaultChatTransport` sends this on each message, plus any fields in `body`: ```json { "id": "chat_l7k2…", "messages": [{ "id": "…", "role": "user", "parts": [{ "type": "text", "text": "Merhaba" }] }], "trigger": "submit-message", "model": "default" } ``` | Field | Type | Description | | --- | --- | --- | | `id` | string | useChat's browser-selected chat ID. Continuation requires submitted history to match the latest recorded visitor message and reply, along with the continuity conditions below. | | `messages` | UIMessage[] | Conversation messages. Reads text parts and assistant data-sone-* data.text. User messages after the final assistant message are new. Files in new messages are rejected. | | `model` | string | Assistant ID; also accepted through X-Sone-Model. Default: "default". | | `conversationId` | string | Conversation ID to continue; also accepted through X-Sone-Conversation-Id. | | `user` | string | Server keys only: internal visitor identifier. | | `context` | string | Server keys only: informational developer context, at most 2,000 characters. | `trigger` and `messageId` are not read. ### Continuity Assistant messages carry `metadata.conversationId`. Returning those messages lets the server find the conversation. ID precedence is body `conversationId`, `X-Sone-Conversation-Id`, then the last assistant message's `metadata.conversationId`. After a refresh, restore both ID and messages with `useChat({ id, messages })`, retaining assistant metadata. `id` alone only works while the assistant owns the conversation, the latest reply was generated by the assistant itself, and submitted history starts at the first message and matches the latest exchange. Team ownership, stock replies, greetings, staff replies or truncated history start another conversation. Persistence example: [Conversation continuity](https://docs.sonecentral.com/en/sohbet-surekliligi.md#vercel-ai-sdkda). Message `id` and text recognize retries, avoiding duplicate storage and replies. Changing text with the same ID is a new message. With a publishable key, retries without a conversation ID from another network address open a new chat. This affects first messages and clients that discard `metadata.conversationId`: [Retries](https://docs.sonecentral.com/en/sohbet-surekliligi.md#tekrar-denemek). ### Send only the latest message in long chats Bodies are limited to 1 MB, and Sonecentral stores the authoritative history. With a conversation ID, send only the latest message: ```ts title="lib/transport.ts" import { DefaultChatTransport } from 'ai'; import type { SoneUIMessage } from '@/lib/sone'; export const transport = new DefaultChatTransport({ api: 'https://pb.sonecentral.com/api/v1/chat', headers: { Authorization: `Bearer ${process.env.NEXT_PUBLIC_SONE_PUBLISHABLE_KEY}` }, prepareSendMessagesRequest: ({ id, messages }) => { const conversationId = messages.findLast((m) => m.metadata?.conversationId)?.metadata?.conversationId; return { body: { id, model: 'default', conversationId, // With a conversation ID, send only the new visitor message; otherwise send history. messages: conversationId ? messages.slice(-1) : messages, }, }; }, }); ``` When using `prepareSendMessagesRequest`, the transport's `body` option is not applied; include `model` in the returned body yourself. Retries remain safe because the message's own `id` is sent. ### Stream parts Responses use AI SDK's UI message stream format with `x-vercel-ai-ui-message-stream: v1`: | Part | Meaning | | --- | --- | | `start` | Checks passed and the assistant starts. `messageMetadata` contains `conversationId` and `model`. | | `start-step` | Immediately follows `start`. | | `text-start`, `text-delta`, `text-end` | One sequence per assistant plain text message. `id` is the Sonecentral message ID. | | `data-sone-catalog` | Product or service cards. | | `data-sone-actions` | Buttons or quick replies. | | `data-sone-file` | A shared file. | | `data-sone-staff` | A staff plain text message written during this turn. | | `data-sone-status` | Current activity, such as the Turkish “Ürünlere bakılıyor” (Checking products). `transient: true`: delivered only to `onData`, not stored in the message. | | `finish-step`, `finish` | Turn completed. `finish.messageMetadata` contains `conversationId`, `model`, `status` and stored `messageIds`. Save the IDs to deduplicate polled messages. | | `error` | Post-start failure. `errorText` is a Turkish explanation. | The stream ends with `data: [DONE]`. AI SDK ignores heartbeat comments such as `: ping`. Rich parts carry rendering data: ```json { "type": "data-sone-catalog", "id": "msg_c4t2l9g7x1p3h8q", "data": { "text": "Linen shirt, stone\n1.290,00 TL\nM and L in stock\nhttps://www.ornek.com/urun/keten-gomlek-tas", "role": "assistant", "catalog": { "format": "card", "items": [ { "id": "r8k2m9p4q7w1x3z", "kind": "inventory", "title": "Linen shirt, stone", "price": "1.290,00 TL", "detail": "M and L in stock", "url": "https://www.ornek.com/urun/keten-gomlek-tas", "button_title": "View product" } ] } } } ``` Part `id` matches the Sonecentral ID in conversation messages. `data.text` is the plain text fallback. `data.role` is `assistant` or `staff`: staff can share cards, buttons and files too. Exactly the relevant `catalog`, `actions` or `file` payload is populated; see [Rich messages](https://docs.sonecentral.com/en/zengin-mesajlar.md). ### Typed messages ```ts title="lib/sone.ts" import type { UIMessage } from 'ai'; // Add SoneMessage, SoneCatalog, SoneActions and SoneFile from the Rich messages page. export type SoneStatus = 'answered' | 'handoff' | 'human' | 'silent'; // Data payload for rich parts in the /chat stream export type SoneDataPart = { text: string; // plain text fallback role: 'assistant' | 'staff'; catalog?: SoneCatalog; actions?: SoneActions; file?: SoneFile; }; export type SoneUIMessage = UIMessage< { conversationId?: string; model?: string; status?: SoneStatus; messageIds?: string[]; // stored business message IDs, for deduplicating polled history }, { 'sone-catalog': SoneDataPart; 'sone-actions': SoneDataPart; 'sone-file': SoneDataPart; 'sone-staff': { text: string; role: 'staff' }; 'sone-status': { label: string }; } >; ``` ```tsx title="components/chat.tsx" 'use client'; import { useChat } from '@ai-sdk/react'; import { DefaultChatTransport } from 'ai'; import { useState } from 'react'; import type { SoneUIMessage } from '@/lib/sone'; const transport = new DefaultChatTransport({ api: 'https://pb.sonecentral.com/api/v1/chat', headers: { Authorization: `Bearer ${process.env.NEXT_PUBLIC_SONE_PUBLISHABLE_KEY}` }, body: { model: 'default' }, }); export function Chat() { const [activity, setActivity] = useState(''); const { messages, sendMessage } = useChat({ transport, onData: (part) => { if (part.type === 'data-sone-status') setActivity(part.data.label); }, onError: () => setActivity(''), onFinish: () => setActivity(''), }); return (
{messages.map((message) => message.parts.map((part, index) => { const key = `${message.id}-${index}`; switch (part.type) { case 'text': return

{part.text}

; case 'data-sone-staff': return (

Ekip: {part.data.text}

); case 'data-sone-catalog': return ( ); case 'data-sone-actions': { const actions = part.data.actions; return (
{actions?.text ?

{actions.text}

: null} {actions?.buttons?.map((button) => ( {button.title} ))} {actions?.choices?.map((choice) => ( ))}
); } case 'data-sone-file': return part.data.file?.url ? ( {part.data.file.name} ) : (

{part.data.text}

); default: return null; } }), )} {activity ?

{activity}…

: null}
); } ``` Clicking a quick reply or a button without a URL sends its title as a new visitor message. ### Editing and stopping Server history is append-only. Old messages are not removed or rewritten. `regenerate()` resends the same visitor ID, so it replays the stored reply rather than generating an alternative. Requests ending with an assistant message return `400 missing_user_message`. Editing an earlier visitor message with the same conversation ID appends a new message. For a separate branch, start a new conversation and omit the old `conversationId` metadata too. `stop()` closes the browser stream only. The server continues the turn and stores replies. Retain the conversation ID and recover messages with `GET /conversations/{id}/messages`, continuing while `reply_pending` is true. Reuse message identity when resending; on `conversation_busy`, wait for `Retry-After`. ### Conversation ID and state ```ts const last = messages.at(-1); const conversationId = last?.metadata?.conversationId; const status = last?.metadata?.status; // 'human' means the team is replying ``` After `handoff` or `human`, subsequent team replies do not enter the original stream. Read them as described in [Human handoff and team replies](https://docs.sonecentral.com/en/insan-devri.md). `GET /conversations/{id}` reports ownership. Complete example: [Embedded chat widget](https://docs.sonecentral.com/en/sohbet-penceresi.md#react-ve-nextjs). ### Errors Before streaming starts, `/chat` rejects requests with plain Turkish text, not JSON. AI SDK exposes it unchanged as `error.message`. `X-Sone-Error-Code` carries the code for server requests, but is not exposed to browser code. Post-start failures use an `error` part and also produce a Turkish `error.message`. See [Errors](https://docs.sonecentral.com/en/hatalar.md). ### Disconnections `useChat({ resume: true })` requests `GET /chat/{id}/stream` on startup. After key validation, Sonecentral always returns `204`: there is no resumable stream because messages are saved as they are sent. Recover missed messages using `GET /conversations/{id}/messages`. ## As a server-side provider Use your assistant like any AI SDK model in server code, where a server key stays private. ```npm npm i ai @ai-sdk/openai ``` **@ai-sdk/openai** ```ts import { createOpenAI } from '@ai-sdk/openai'; import { generateText, streamText } from 'ai'; const sone = createOpenAI({ baseURL: 'https://pb.sonecentral.com/api/v1', apiKey: process.env.SONE_API_KEY, }); // Chat Completions endpoint const { text } = await generateText({ model: sone.chat('default'), prompt: 'How long does shipping take?', }); // Responses endpoint const result = streamText({ model: sone.responses('default'), prompt: 'How do returns work?', }); for await (const delta of result.textStream) { process.stdout.write(delta); } ``` **@ai-sdk/openai-compatible** ```ts import { createOpenAICompatible } from '@ai-sdk/openai-compatible'; import { generateText } from 'ai'; const sone = createOpenAICompatible({ name: 'sone', baseURL: 'https://pb.sonecentral.com/api/v1', apiKey: process.env.SONE_API_KEY, }); const { text } = await generateText({ model: sone.chatModel('default'), prompt: 'How long does shipping take?', }); ``` Every response carries `X-Sone-Conversation-Id`. Return it in the same header on the next request: ```ts const first = await generateText({ model: sone.chat('default'), prompt: 'How long does shipping take?', }); const conversationId = first.response.headers?.['x-sone-conversation-id']; const second = await generateText({ model: sone.chat('default'), headers: conversationId ? { 'X-Sone-Conversation-Id': conversationId } : undefined, prompt: 'What is the return window?', }); ``` With `@ai-sdk/openai`'s Responses model, returning previous turn messages causes the provider to send previous outputs as `item_reference`, which can also find the conversation: ```ts const turn1 = await generateText({ model: sone.responses('default'), prompt: 'What is your return policy?' }); const turn2 = await generateText({ model: sone.responses('default'), messages: [ { role: 'user', content: 'What is your return policy?' }, ...turn1.response.messages, { role: 'user', content: 'Within how many days?' }, ], }); ``` The provider path exposes text only. For rich cards and buttons, use OpenAI SDKs or direct `useChat` above. AI SDK tool calling through `tools` is unsupported; Sonecentral manages assistant tools. --- # Streaming Page: https://docs.sonecentral.com/en/akis Markdown: https://docs.sonecentral.com/en/akis.md > How streamed replies arrive, how connections stay alive and why text progresses message by message. All three formats support streaming: set `"stream": true` for Chat Completions and Responses; Vercel AI SDK's `/chat` always streams. All use **server-sent events (SSE)**. ## Connection Streamed response headers: ```http Content-Type: text/event-stream; charset=utf-8 Cache-Control: no-cache, no-transform Connection: keep-alive X-Accel-Buffering: no X-Sone-Conversation-Id: conv_x8k2m9p4q7w1r3t ``` Vercel AI SDK streams also include `x-vercel-ai-ui-message-stream: v1`. - **Checks come first.** Key, rate limit, assistant, conversation and plan checks run before streaming starts. A rejected request gets a normal HTTP error: [Errors](https://docs.sonecentral.com/en/hatalar.md). - **The first event arrives as the assistant starts.** After checks pass, Chat Completions sends its first role chunk, Responses sends `response.created`, and AI SDK sends `start` immediately. You know the connection is open and have the conversation ID while the assistant is still thinking. - **Heartbeat every ten seconds.** Long turns send `: ping` lines to keep the connection alive. Lines beginning with a colon are SSE comments. SDKs skip them; your own parser should too. - **Completion.** Chat Completions and AI SDK end with `data: [DONE]`. Responses ends with `response.completed`, or `response.failed` on an error, without `[DONE]`. ## Why text arrives message by message Before a reply reaches the visitor, business safety checks verify the complete message: prices and stock, prohibited promises, and whether it should be sent at all. A message is sent only after it is complete and has passed these checks. The stream progresses as follows: 1. Checks pass, the connection opens and the first event arrives. 2. The assistant thinks and uses tools. This can take several seconds. Short activity labels such as “Checking products” arrive during this time. 3. The first approved message arrives, quickly streamed in word groups to preserve the typing effect. 4. Further messages, product cards and buttons follow in order. Activity labels appear differently in each format: | Format | Activity | | --- | --- | | Chat Completions | Top-level `sone.activity` on a chunk with an empty `delta`. | | Responses | An SSE comment, not an event: `: sone.activity {"label":"Ürünlere bakılıyor"}`. It has no `sequence_number`. SDKs skip it; parse the stream yourself to read it. | | Vercel AI SDK | `data.label` on a transient (`transient: true`) `data-sone-status` part, delivered only to `onData`. | Activity text is a visitor-friendly Turkish sentence, rather than a tool name. There may be short pauses between messages; keep a typing indicator visible in your UI. Resending the same message with the same `Idempotency-Key`, or the conversation ID and matching history, does not generate another reply. The stored reply arrives together at the end of the stream. See [Retries](https://docs.sonecentral.com/en/sohbet-surekliligi.md#tekrar-denemek). ## Disconnections Each message is saved to the conversation when it is sent. If a stream disconnects, recover missing messages with `GET /conversations/{id}/messages`. The conversation ID is available at the start in `X-Sone-Conversation-Id`. See [Human handoff and team replies](https://docs.sonecentral.com/en/insan-devri.md). If you resend a message, reuse its original `Idempotency-Key`. The assistant does not answer twice. If the previous turn is still running, the request returns `409 conversation_busy`. ## Behind a proxy When forwarding a stream through your server, make sure your proxy does not buffer it. In Nginx, use `proxy_buffering off;`. In a Next.js route handler, forward the response body unchanged: ```ts title="app/api/sohbet/route.ts" export async function POST(request: Request) { const upstream = await fetch('https://pb.sonecentral.com/api/v1/chat/completions', { method: 'POST', headers: { Authorization: `Bearer ${process.env.SONE_API_KEY}`, 'Content-Type': 'application/json', 'Idempotency-Key': request.headers.get('Idempotency-Key') ?? crypto.randomUUID(), }, body: await request.text(), }); return new Response(upstream.body, { status: upstream.status, headers: { 'Content-Type': upstream.headers.get('Content-Type') ?? 'text/event-stream', 'Cache-Control': 'no-cache', 'X-Sone-Conversation-Id': upstream.headers.get('X-Sone-Conversation-Id') ?? '', }, }); } ``` With a server key, your server is responsible for authenticating and rate-limiting visitors. A publishable key is simpler for a website chat widget. --- # Channels and message types Page: https://docs.sonecentral.com/en/kanallar Markdown: https://docs.sonecentral.com/en/kanallar.md > Official TikTok support and how native messaging differs from the developer API. Sone Central officially supports **WhatsApp, Instagram, Facebook Messenger and TikTok** alongside phone calls, website chat and the developer API. Connect your TikTok Business account from **Integrations → TikTok**. Your published text assistant uses the same workspace instructions, public catalog and knowledge, with the message formats and identity checks available on each channel. ## TikTok direct messages | Capability | TikTok | | --- | --- | | Incoming customer messages | Shared inbox, assistant replies and team handoff | | Text replies | Up to 6,000 characters | | Images | One JPEG or PNG, up to 3 MB per message | | Product/service recommendations | An approved product image, with details and links in a separate text reply | | Knowledge Base images | Images the business marked as customer-shareable | | Reply window | Within 48 hours of the customer's last message, up to 10 outgoing messages | | Starting a conversation | The customer must message first | | Cards, carousels, buttons and quick replies | Not supported by TikTok DMs | | Audio, video and document sending | Not supported by TikTok DMs | | Delivery/read status | Sent status only; no delivered/read claim | Text and image cannot be combined in one TikTok message. Each separate image or text message uses one of the platform's replies. The assistant sends only approved assets and respects business sharing settings. If an attachment cannot be sent, it continues with an honest text answer. The account must be a **TikTok Business Account**, registered outside the EEA, Switzerland and the UK, and accept direct messages or message requests. Older connections made through TikTok's developer app may need reconnecting through the current Business connection flow. See [Zernio's TikTok inbox requirements](https://docs.zernio.com/platforms/tiktok/inbox). ## Developer API conversations The developer API creates its own **API channel** conversations. A TikTok connection does not add a `channel: "tiktok"` request parameter, and these endpoints do not send arbitrary messages into TikTok threads. Your client receives the API's structured catalog cards, actions and files as described in [Rich messages](https://docs.sonecentral.com/en/zengin-mesajlar.md). TikTok's native limits apply to TikTok conversations, not to rendering those API parts in your own application. A TikTok platform ID is not proof of a customer's phone number. Private records from connected stores or hosting panels remain behind server-verified identity checks. API visitors have their own [privacy boundary](https://docs.sonecentral.com/en/siparis-randevu-gizlilik.md). --- # 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 ``. 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. --- # Conversation continuity Page: https://docs.sonecentral.com/en/sohbet-surekliligi Markdown: https://docs.sonecentral.com/en/sohbet-surekliligi.md > Keep anonymous chats across refreshes, understand conversation lookup and retry messages safely. API visitors have no authenticated account or identity. Keep **the conversation ID** and **the displayed messages** in their browser so the chat can continue. ## The authoritative record is on the server Sonecentral stores each conversation and the assistant reads its history there. Submitted history is used only to: - Identify new messages. - Find a conversation when no ID was supplied. - Recognize retries. - Initialize a new conversation if none exists. Changing submitted history does not change the assistant's memory. It sees the same conversation your team sees in Messages. ## Conversation lookup Sonecentral uses the first matching method in this order: 1. **Conversation ID**, prefixed with `conv_`. - Chat Completions: body `conversation_id`, body `sone.conversation_id`, `X-Sone-Conversation-Id`, `metadata.conversation_id`, then `sone.conversation_id` on the most recent assistant message returned unchanged in history. The first non-empty value wins. - Responses: `conversation`, then `X-Sone-Conversation-Id`. - Vercel AI SDK: body `conversationId`, `X-Sone-Conversation-Id`, then a returned assistant message's `metadata.conversationId`. 2. **Responses `previous_response_id`.** 3. **Responses previous output IDs:** `item_reference` items or returned assistant messages with `msg_…` IDs. 4. **Vercel AI SDK's `useChat` ID**, subject to the four conditions below. 5. **`user` or `safety_identifier`**, server keys only; Vercel AI SDK uses body `user`. The same value and assistant find the same conversation. 6. **History fingerprint.** If history starts at the conversation's first message, Sonecentral records a fingerprint after the turn. Matching history finds the conversation subject to the four conditions below. Requests sending only a new message with an explicit conversation ID do not record a fingerprint. 7. **First-message retry.** The same `Idempotency-Key`, or AI SDK message ID, can find the first message's conversation for ten minutes if it is still the latest visitor message there. Publishable keys also require the same network address and, for AI SDK, the same `useChat` ID. See [Retries](#tekrar-denemek). 8. Otherwise, **a new conversation** is created. It imports the last 40 history messages, up to 4,000 characters each. Publishable keys import only visitor messages; assistant messages can be altered by the browser and are not imported. Sonecentral issues IDs used by the first three methods. Your server controls the identifier in method five. Explicit references are handled directly: - A conversation ID or `previous_response_id` that does not identify an API conversation in this workspace returns `404 conversation_not_found` or `404 previous_response_not_found`, rather than creating another conversation. - An assistant mismatch returns `400 conversation_model_mismatch`. - Combining `previous_response_id` and `conversation` returns `400 conflicting_parameters`. For the first two errors, discard the reference and start a new conversation. The browser controls method four (`useChat` ID) and method six (fingerprint). They continue a conversation only if **all four** conditions hold: 1. **The assistant owns it.** Neither method can resume a team-owned conversation. 2. **History contains the latest recorded exchange verbatim:** the final visitor message and its entire reply, including staff messages added in the meantime. 3. **The assistant generated at least one message in that reply for this conversation.** Shared stock phrases, staff messages, product/service/file shares and the configured greeting are insufficient because another visitor could know them too. 4. **History starts at the first message** and includes every recorded visitor message in order. The latest exchange alone is insufficient because it may be predictable, such as “Thanks” followed by the same courtesy phrase. If only the last 40 messages were imported when creating the conversation, older messages may remain at the start of submitted history. Comparison ignores whitespace and newlines, and merges consecutive messages with the same role. Visitor history imported through a publishable key does not break matching. Retrying the latest message with its own identity is a separate exception: [Retries](#tekrar-denemek). If any condition fails, a new conversation starts. This includes: - Team ownership. - A latest reply containing only stock text, staff text or a share. - Visitor messages not yet answered, such as silent or rejected turns. An empty assistant placeholder (`content: ""` or `null` in Chat Completions, or an AI SDK assistant without text) does not help. - A staff reply missing from submitted history. - A latest reply containing only the configured greeting. - Truncated history that no longer starts at the first message, for example after sending only new text with an ID and later dropping that ID. - An exact retry of an unanswered request without message identity. These inputs can be guessed. Using them alone could attach another visitor and expose staff replies. A `useChat` ID or fingerprint shared by two conversations is discarded permanently. Ambiguity splits the conversation. One remaining limitation: someone who reproduces an entire history verbatim may match a conversation through these two methods. In practice, this can happen after one predictable first exchange, such as “Hello” and an identical generated reply. A second visitor message closes that path. Publishable-key fingerprints are also bound to the visitor's network address, so only someone on the same network can match; changing networks opens a new conversation. **Send `conversation_id` on every request.** Also give each new message an `Idempotency-Key`. Conversation IDs arrive in `X-Sone-Conversation-Id` and in the response body: Chat Completions `sone.conversation_id`, Responses `conversation.id`, or AI SDK assistant `metadata.conversationId`. ## Retries After network errors, timeouts or `5xx` responses, retrying should not duplicate messages or replies. A retry must first find the conversation through its ID, a response reference (`previous_response_id` or `item_reference`), or the message's identity. History without an ID or identity cannot find an unanswered retry. - **`Idempotency-Key`** names the newest visitor message, up to 200 characters. Same key and text stores it once; an existing reply is replayed, or an unanswered message is answered now. Different text with the same key returns `422 idempotency_key_reused` with `x-should-retry: false`. Generate a new key per new message. - **Vercel AI SDK** names visitor messages using their `id` and text automatically. Editing text while retaining the same ID creates a new message. - **Messages without identity** can be aligned against the history tail when the request has a conversation ID. Previously answered messages replay their replies; messages stored during rejected requests are not stored twice. Writing the same text again after receiving its reply is a new message. A retry containing still-unanswered messages also does not duplicate them; if answered in the meantime, their replies are replayed. However, if staff wrote after your message, an unnamed retry can duplicate it. Use an `Idempotency-Key` for each message. Without an ID or identity, history-only retries start a new conversation, including rejected or team-waiting messages resent with new messages. With Responses, the exchange referenced by `previous_response_id` or `item_reference` can substitute for history when sending only new input. - **Neither history nor key:** a client sending only conversation ID and new text cannot distinguish a retry from a new message. Always use `Idempotency-Key` in this case. - **Publishable keys without a conversation or response reference**, such as first messages, bind message identity to the network address and, for AI SDK, `useChat` ID. Switching from Wi-Fi to mobile data opens another conversation on retry. Two visitors sharing an address can have first messages merged if they submit the same `Idempotency-Key`, or the same AI SDK chat/message IDs, with identical text. Generate these randomly per visitor; AI SDK does so by default. Retries with a conversation or response reference work from any address. Also: - A rejected first message, such as `ai_turn_ceiling`, `plan_required` or `assistant_paused`, creates no conversation and stores nothing. It can be resent later. - A still-running turn returns `409 conversation_busy`. Wait for `Retry-After` and resend with the same key. - OpenAI SDKs do not add `Idempotency-Key`. Pass `headers` in Node's second request argument, or Python's `extra_headers`: [Chat Completions](https://docs.sonecentral.com/en/chat-completions.md#tekrar-denemek). SDK retries reuse that header. With `fetch`: ```js title="gonder.js" // Try the same message up to four times, preserving Idempotency-Key. export async function sendOnce(url, key, body) { const name = crypto.randomUUID(); // identity for this message let response; for (let attempt = 0; attempt < 4; attempt += 1) { if (attempt > 0) await new Promise((done) => setTimeout(done, waitSeconds(response, attempt) * 1000)); try { response = await fetch(url, { method: 'POST', headers: { Authorization: `Bearer ${key}`, 'Content-Type': 'application/json', 'Idempotency-Key': name }, body: JSON.stringify(body), }); } catch { response = undefined; // network failure: retry continue; } const retry = response.headers.get('x-should-retry') !== 'false' && (response.status === 409 || response.status === 429 || response.status >= 500); if (!retry) return response; } if (response) return response; throw new Error('Could not send the message.'); } function waitSeconds(response, attempt) { const after = Number(response?.headers.get('retry-after')); return after > 0 && after <= 60 ? after : 2 ** (attempt - 1); } ``` ## Browser persistence Store the conversation ID and displayed messages in `localStorage`. Refreshing or navigating to another page then resumes the chat. ```js title="sohbet-kaydi.js" const STORE = 'sone-chat-v1'; export function loadChat() { try { return JSON.parse(localStorage.getItem(STORE)) ?? { conversationId: null, messages: [] }; } catch { return { conversationId: null, messages: [] }; } } export function saveChat(chat) { try { localStorage.setItem(STORE, JSON.stringify(chat)); } catch { // Private browsing or full storage: keep this tab working; refresh starts over. } } export function forgetChat() { try { localStorage.removeItem(STORE); } catch {} } ``` After every turn: ```js const chat = loadChat(); chat.conversationId = sone.conversation_id; // from the response chat.messages.push({ role: 'user', content: text }, ...sone.messages.map((m) => ({ role: 'assistant', message: m }))); saveChat(chat); ``` Send the ID on the next request: ```js headers: { Authorization: `Bearer ${KEY}`, 'Content-Type': 'application/json', 'Idempotency-Key': crypto.randomUUID(), // reuse on retries }, body: JSON.stringify({ model: 'default', conversation_id: chat.conversationId ?? undefined, messages: [{ role: 'user', content: text }], }), ``` With an explicit ID, sending only the new message is enough. An `Idempotency-Key` is required when doing so. A “New chat” button calls `forgetChat()` so the next message starts fresh. Also clear the stored ID after `404 conversation_not_found` or `400 conversation_model_mismatch`. Complete example: [Embedded chat widget](https://docs.sonecentral.com/en/sohbet-penceresi.md). ## Vercel AI SDK `useChat` retains the conversation ID in assistant messages' `metadata.conversationId`, which goes back with history. Save and restore both the `useChat` ID and messages, **including assistant metadata**. Saving only `id` is insufficient: browser-generated IDs require the [four conditions](#sohbet-nasil-bulunur) and never resume team-owned conversations. ```tsx 'use client'; import { useChat } from '@ai-sdk/react'; import type { ChatTransport } from 'ai'; import { useEffect, useState } from 'react'; import type { SoneUIMessage } from '@/lib/sone'; function storedChat(): { id: string; messages: SoneUIMessage[] } { try { const saved = JSON.parse(localStorage.getItem('sone-chat-v1') ?? 'null'); if (saved?.id) return saved; } catch {} return { id: crypto.randomUUID(), messages: [] }; } export function useStoredChat(transport: ChatTransport) { const [initial] = useState(storedChat); const chat = useChat({ id: initial.id, messages: initial.messages, transport }); useEffect(() => { if (chat.status === 'submitted' || chat.status === 'streaming') return; try { localStorage.setItem('sone-chat-v1', JSON.stringify({ id: initial.id, messages: chat.messages })); } catch {} }, [chat.status, chat.messages, initial.id]); return chat; } ``` Run this hook only in the browser. In Next.js, load its component from a `'use client'` file with `dynamic(() => import(...), { ssr: false })`. Sending just the latest message in long chats: [Vercel AI SDK](https://docs.sonecentral.com/en/vercel-ai-sdk.md#uzun-sohbetlerde-yalniz-son-mesaji-gondermek). Complete example: [Embedded chat widget](https://docs.sonecentral.com/en/sohbet-penceresi.md#react-ve-nextjs). ## Server keys and `user` For users authenticated in your system, put your internal user ID in `user`. The same identifier and assistant find the same conversation across devices. This is conversation lookup, not proof of identity. The assistant still treats the person as anonymous and does not disclose personal records: [Orders, appointments and privacy](https://docs.sonecentral.com/en/siparis-randevu-gizlilik.md). Publishable keys ignore `user` because visitors can alter it. Use an opaque identifier, never an email address or phone number. ## Limits - Up to 200 visitor messages per conversation; then `400 conversation_full`. Start a new chat. - Up to 12 new visitor messages per request. - Up to 4,000 characters per new message. - Up to 40 previous messages imported into a new conversation. - Request bodies up to 1 MB. Send the conversation ID instead of repeating long histories. --- # Embedded chat widget Page: https://docs.sonecentral.com/en/sohbet-penceresi Markdown: https://docs.sonecentral.com/en/sohbet-penceresi.md > A website chat connected to your assistant, with complete HTML/JavaScript and React/Next.js examples. Both examples read streaming replies, persist the conversation in the browser, render product cards and buttons, and display staff replies after handoff. Create a **publishable key** in Sonecentral first, and add your site to **İzin verilen siteler** (Allowed origins). For development you can add `http://localhost:3000`. See [Authentication and keys](https://docs.sonecentral.com/en/kimlik-dogrulama.md). ## Plain HTML and JavaScript No library is required. Paste these three pieces immediately before your page's `` and replace `KEY` with your publishable key. ```html title="sohbet.html" ``` ```css title="sohbet.css" #sone-open { position: fixed; right: 20px; bottom: 20px; z-index: 50; padding: 12px 18px; border: 0; border-radius: 999px; background: #17123f; color: #fff; font: 600 15px/1 system-ui, sans-serif; cursor: pointer; } #sone-panel { position: fixed; right: 20px; bottom: 76px; z-index: 50; width: min(380px, calc(100vw - 40px)); height: min(580px, calc(100dvh - 110px)); display: flex; flex-direction: column; overflow: hidden; background: #fff; color: #17123f; border-radius: 16px; box-shadow: 0 2px 6px rgba(23, 18, 63, 0.08), 0 12px 32px rgba(23, 18, 63, 0.12); font: 15px/1.45 system-ui, sans-serif; } #sone-panel[hidden] { display: none; } .sone-head { display: flex; align-items: center; justify-content: space-between; padding: 14px 16px; } .sone-head button { border: 0; background: none; color: #524d7a; font: inherit; font-size: 13px; cursor: pointer; } #sone-log { flex: 1; overflow-y: auto; margin: 0; padding: 4px 16px 12px; list-style: none; display: flex; flex-direction: column; gap: 8px; } .sone-row { display: flex; flex-direction: column; align-items: flex-start; } .sone-row.sone-user { align-items: flex-end; } .sone-text { margin: 0; max-width: 85%; padding: 8px 12px; border-radius: 14px; background: #f1eefb; white-space: pre-wrap; } .sone-user .sone-text { background: #17123f; color: #fff; } .sone-staff .sone-text::before { content: 'Ekip'; display: block; font-size: 12px; font-weight: 600; color: #524d7a; } .sone-catalog { display: grid; grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); gap: 8px; width: 100%; } .sone-item { display: flex; flex-direction: column; gap: 2px; padding: 10px; border-radius: 12px; background: #f7f5fd; } .sone-item img { width: 100%; aspect-ratio: 1; object-fit: cover; border-radius: 8px; margin-bottom: 4px; } .sone-item span { font-size: 13px; color: #524d7a; } .sone-item .sone-price { color: #17123f; font-weight: 600; } .sone-item a, .sone-file { color: #4f31d6; font-size: 14px; font-weight: 600; } .sone-actions { display: flex; flex-wrap: wrap; gap: 6px; } .sone-actions p { width: 100%; margin: 0 0 2px; } .sone-actions button, .sone-actions a { padding: 6px 12px; border: 1px solid #cfc5f7; border-radius: 999px; background: #fff; color: #4f31d6; font: inherit; font-size: 14px; text-decoration: none; cursor: pointer; } #sone-status { margin: 0; padding: 0 16px 8px; font-size: 13px; color: #524d7a; } #sone-form { display: flex; gap: 8px; padding: 12px; background: #faf9fe; } #sone-input { flex: 1; resize: none; border: 1px solid #ddd6f3; border-radius: 10px; padding: 8px 10px; font: inherit; } #sone-form button { border: 0; border-radius: 10px; padding: 0 14px; background: #17123f; color: #fff; font: inherit; font-weight: 600; cursor: pointer; } ``` ```html title="sohbet.js" ``` Customize CSS and button labels to match your site. To display the assistant's dashboard name, use `name` from `GET /models`. ### What this example does - Streams `POST /chat/completions` and displays text as it arrives. When a message completes, top-level `sone.message` redraws it as a product card, buttons or file. - Sends the conversation ID and only the new message, with a new `Idempotency-Key` for each message. - Shows `sone.activity` in the status line while the assistant uses tools. - Sends quick reply and non-URL button titles as new visitor messages. - Saves conversation ID, displayed messages and the latest cursor in `localStorage`. Refreshes resume the chat; if storage is unavailable, it continues within the current tab. - After `handoff` or `human`, polls `GET /conversations/{id}/messages?after=` every five seconds. Advances to `last_id`, skips already displayed visitor messages, and stops when `mode` is `"assistant"` and `reply_pending` is false. Pauses when the widget is closed or the tab is hidden. Increase the interval for many simultaneous visitors: [Polling frequency](https://docs.sonecentral.com/en/insan-devri.md#ne-siklikla-sormali). - After `ai_turn_ceiling`, `assistant_quota_exhausted`, `plan_required` or `assistant_paused` in an existing chat, explains that the message reached the team and waits for their reply. - Starts a new conversation on the next message if the stored one is gone (`conversation_not_found`). - Catches errors inside the stream and shows them in the status line. ## React and Next.js Use the CSS above, replacing selectors starting with `#sone-` with classes such as `.sone-panel`. This is the same widget using Vercel AI SDK. Add `SoneMessage`, `SoneCatalog`, `SoneActions` and `SoneFile` from [Rich messages](https://docs.sonecentral.com/en/zengin-mesajlar.md), and `SoneUIMessage` from [Vercel AI SDK](https://docs.sonecentral.com/en/vercel-ai-sdk.md#tipli-mesajlar), to `lib/sone.ts` first. ```npm npm i ai @ai-sdk/react ``` ```tsx title="components/sone-chat.tsx" 'use client'; import { useChat } from '@ai-sdk/react'; import { DefaultChatTransport } from 'ai'; import { useEffect, useState } from 'react'; import type { SoneMessage, SoneUIMessage } from '@/lib/sone'; const API = 'https://pb.sonecentral.com/api/v1'; const KEY = process.env.NEXT_PUBLIC_SONE_PUBLISHABLE_KEY!; const STORE = 'sone-chat-v1'; const conversationOf = (messages: SoneUIMessage[]) => messages.findLast((m) => m.metadata?.conversationId)?.metadata?.conversationId; const transport = new DefaultChatTransport({ api: `${API}/chat`, headers: { Authorization: `Bearer ${KEY}` }, // With a conversation ID, send only the new message; Sonecentral stores history. prepareSendMessagesRequest: ({ id, messages }) => { const conversationId = conversationOf(messages); return { body: { id, model: 'default', conversationId, messages: conversationId ? messages.slice(-1) : messages }, }; }, }); // team: team owns the chat. cursor: last message ID read. type Stored = { id: string; messages: SoneUIMessage[]; team: boolean; cursor: string | null }; type ThreadPage = { data: SoneMessage[]; has_more: boolean; mode: 'assistant' | 'team'; reply_pending: boolean; last_id?: string; }; const fresh = (): Stored => ({ id: crypto.randomUUID(), messages: [], team: false, cursor: null }); function readStored(): Stored { try { const saved = JSON.parse(localStorage.getItem(STORE) ?? 'null'); if (saved?.id) return { team: false, cursor: null, ...saved }; } catch {} return fresh(); } // Convert a polled team or assistant message into the same part format. function fromThread(message: SoneMessage): SoneUIMessage { if (message.type === 'text' && message.role === 'assistant') { return { id: message.id, role: 'assistant', parts: [{ type: 'text', text: message.text }] }; } const data = { text: message.text, role: message.role === 'staff' ? 'staff' as const : 'assistant' as const }; const part = message.type === 'catalog' ? { type: 'data-sone-catalog' as const, id: message.id, data: { ...data, catalog: message.catalog } } : message.type === 'actions' ? { type: 'data-sone-actions' as const, id: message.id, data: { ...data, actions: message.actions } } : message.type === 'file' ? { type: 'data-sone-file' as const, id: message.id, data: { ...data, file: message.file } } : { type: 'data-sone-staff' as const, id: message.id, data: { text: message.text, role: 'staff' as const } }; return { id: message.id, role: 'assistant', parts: [part] }; } // Read role from data parts to identify staff messages. function byTeam(message: SoneUIMessage) { return message.parts.some((part) => 'data' in part && 'role' in part.data && part.data.role === 'staff'); } export function SoneChat() { const [stored, setStored] = useState(readStored); const [open, setOpen] = useState(false); const [input, setInput] = useState(''); const [activity, setActivity] = useState(''); const [team, setTeam] = useState(stored.team); const [cursor, setCursor] = useState(stored.cursor); // Changing id starts a new useChat conversation. const { messages, setMessages, sendMessage, status, error } = useChat({ id: stored.id, messages: stored.messages, transport, onData: (part) => { if (part.type === 'data-sone-status') setActivity(part.data.label); }, onError: () => setActivity(''), onFinish: ({ message }) => { setActivity(''); const turn = message.metadata?.status; if (turn) setTeam(turn === 'handoff' || turn === 'human'); }, }); const busy = status === 'submitted' || status === 'streaming'; useEffect(() => { if (busy) return; try { localStorage.setItem(STORE, JSON.stringify({ id: stored.id, messages, team, cursor })); } catch {} }, [busy, messages, team, cursor, stored.id]); // Read team replies and delayed replies after returning to the assistant. // Stream finish metadata.messageIds prevents redrawing assistant messages. // First poll reads all pages from the start; later polls continue after the cursor. const conversationId = conversationOf(messages); useEffect(() => { if (!open || !team || busy || !conversationId) return; const read = async () => { if (document.hidden) return; let next = cursor; let mode: ThreadPage['mode'] = 'team'; let replyPending = true; const replies: SoneMessage[] = []; for (;;) { const url = new URL(`${API}/conversations/${encodeURIComponent(conversationId)}/messages`); if (next) url.searchParams.set('after', next); const response = await fetch(url, { headers: { Authorization: `Bearer ${KEY}` } }).catch(() => null); if (response?.status === 400) setCursor(null); // invalid_cursor: next poll restarts from the beginning if (!response?.ok) return; const page = (await response.json()) as ThreadPage; replies.push(...page.data.filter((m) => m.role !== 'user')); next = page.last_id ?? next; mode = page.mode; replyPending = page.reply_pending; if (!page.has_more) break; } setMessages((current) => { const known = new Set(current.flatMap((m) => [m.id, ...(m.metadata?.messageIds ?? []), ...m.parts.map((p) => ('id' in p ? p.id : undefined))])); const added = replies.filter((m) => !known.has(m.id)).map(fromThread); return added.length > 0 ? [...current, ...added] : current; }); setCursor(next); if (mode === 'assistant' && !replyPending) setTeam(false); }; const timer = setInterval(read, 5000); return () => clearInterval(timer); }, [open, team, busy, conversationId, cursor, setMessages]); const send = (text: string) => { if (!text.trim() || busy) return; sendMessage({ text }); }; const reset = () => { const next = fresh(); try { localStorage.removeItem(STORE); } catch {} setStored(next); setTeam(false); setCursor(null); setActivity(''); }; // Team-owned turns may have no reply; do not render empty assistant messages. const shown = messages.filter((m) => m.parts.some((p) => p.type === 'text' || p.type.startsWith('data-'))); // Show choices until the visitor writes again. const lastUser = shown.map((m) => m.role).lastIndexOf('user'); return ( <> {open ? (
Chat with us
    {shown.map((message, messageIndex) => (
  1. {message.parts.map((part, index) => { const key = `${message.id}-${index}`; if (part.type === 'text') return

    {part.text}

    ; if (part.type === 'data-sone-staff') return

    {part.data.text}

    ; if (part.type === 'data-sone-catalog') return (
    ); if (part.type === 'data-sone-actions') { const current = messageIndex > lastUser; const actions = part.data.actions; return (
    {actions?.title ? {actions.title} : null} {actions?.text ?

    {actions.text}

    : null} {actions?.buttons?.map((button) => ( {button.title} ))} {current ? actions?.choices?.map((choice) => ( )) : null}
    ); } if (part.type === 'data-sone-file') return part.data.file?.url ? ( {part.data.file.name} ) : (

    {part.data.text}

    ); return null; })}
  2. ))}
{activity ?

{activity}…

: null} {team ?

Our team will reply here as soon as possible.

: null} {error ?

{error.message || 'Could not send your message. Please try again later.'}

: null}
{ event.preventDefault(); send(input); setInput(''); }} >