# 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 (
    <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](#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<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`:

| 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<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

```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.
