sone centraldocs
Try the interactive chat

Sample store

Sone assistant

Hello! Ask me about linen shirts, shipping or our size guide.

Uses sample data; does not start a real conversation.

Vercel AI SDK

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 i ai @ai-sdk/react
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 (
    <div>
      {messages.map((message) => (
        <div key={message.id}>
          {message.parts.map((part, index) =>
            part.type === 'text' ? <p key={index}>{part.text}</p> : null,
          )}
        </div>
      ))}
      {error ? <p>{error.message}</p> : null}
      <form
        onSubmit={(event) => {
          event.preventDefault();
          if (!input.trim() || busy) return;
          sendMessage({ text: input });
          setInput('');
        }}
      >
        <input value={input} onChange={(event) => setInput(event.target.value)} disabled={busy} />
      </form>
    </div>
  );
}

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.

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:

{
  "id": "chat_l7k2…",
  "messages": [{ "id": "…", "role": "user", "parts": [{ "type": "text", "text": "Merhaba" }] }],
  "trigger": "submit-message",
  "model": "default"
}

Prop

Type

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.

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.

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:

lib/transport.ts
import { DefaultChatTransport } from 'ai';
import type { SoneUIMessage } from '@/lib/sone';

export const transport = new DefaultChatTransport<SoneUIMessage>({
  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:

PartMeaning
startChecks passed and the assistant starts. messageMetadata contains conversationId and model.
start-stepImmediately follows start.
text-start, text-delta, text-endOne sequence per assistant plain text message. id is the Sonecentral message ID.
data-sone-catalogProduct or service cards.
data-sone-actionsButtons or quick replies.
data-sone-fileA shared file.
data-sone-staffA staff plain text message written during this turn.
data-sone-statusCurrent 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, finishTurn completed. finish.messageMetadata contains conversationId, model, status and stored messageIds. Save the IDs to deduplicate polled messages.
errorPost-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:

{
  "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.

Typed messages

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 };
  }
>;
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<SoneUIMessage>({
  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<SoneUIMessage>({
    transport,
    onData: (part) => {
      if (part.type === 'data-sone-status') setActivity(part.data.label);
    },
    onError: () => setActivity(''),
    onFinish: () => setActivity(''),
  });

  return (
    <div>
      {messages.map((message) =>
        message.parts.map((part, index) => {
          const key = `${message.id}-${index}`;
          switch (part.type) {
            case 'text':
              return <p key={key}>{part.text}</p>;
            case 'data-sone-staff':
              return (
                <p key={key}>
                  <strong>Ekip: </strong>
                  {part.data.text}
                </p>
              );
            case 'data-sone-catalog':
              return (
                <ul key={key}>
                  {part.data.catalog?.items.map((item) => (
                    <li key={item.id}>
                      <strong>{item.title}</strong> {item.price}
                      {item.url ? (
                        <a href={item.url} target="_blank" rel="noreferrer">
                          {item.button_title || 'View'}
                        </a>
                      ) : null}
                    </li>
                  ))}
                </ul>
              );
            case 'data-sone-actions': {
              const actions = part.data.actions;
              return (
                <div key={key}>
                  {actions?.text ? <p>{actions.text}</p> : null}
                  {actions?.buttons?.map((button) => (
                    <a key={button.title} href={button.url} target="_blank" rel="noreferrer">
                      {button.title}
                    </a>
                  ))}
                  {actions?.choices?.map((choice) => (
                    <button key={choice} onClick={() => sendMessage({ text: choice })}>
                      {choice}
                    </button>
                  ))}
                </div>
              );
            }
            case 'data-sone-file':
              return part.data.file?.url ? (
                <a key={key} href={part.data.file.url} download>
                  {part.data.file.name}
                </a>
              ) : (
                <p key={key}>{part.data.text}</p>
              );
            default:
              return null;
          }
        }),
      )}
      {activity ? <p aria-live="polite">{activity}…</p> : null}
    </div>
  );
}

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

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. GET /conversations/{id} reports ownership. Complete example: Embedded chat widget.

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.

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 i ai @ai-sdk/openai
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);
}

Every response carries X-Sone-Conversation-Id. Return it in the same header on the next request:

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:

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.