Try the interactive chat
Conversation continuity
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:
- Conversation ID, prefixed with
conv_.- Chat Completions: body
conversation_id, bodysone.conversation_id,X-Sone-Conversation-Id,metadata.conversation_id, thensone.conversation_idon the most recent assistant message returned unchanged in history. The first non-empty value wins. - Responses:
conversation, thenX-Sone-Conversation-Id. - Vercel AI SDK: body
conversationId,X-Sone-Conversation-Id, then a returned assistant message'smetadata.conversationId.
- Chat Completions: body
- Responses
previous_response_id. - Responses previous output IDs:
item_referenceitems or returned assistant messages withmsg_…IDs. - Vercel AI SDK's
useChatID, subject to the four conditions below. userorsafety_identifier, server keys only; Vercel AI SDK uses bodyuser. The same value and assistant find the same conversation.- 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.
- 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 sameuseChatID. See Retries. - 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_idthat does not identify an API conversation in this workspace returns404 conversation_not_foundor404 previous_response_not_found, rather than creating another conversation. - An assistant mismatch returns
400 conversation_model_mismatch. - Combining
previous_response_idandconversationreturns400 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:
- The assistant owns it. Neither method can resume a team-owned conversation.
- History contains the latest recorded exchange verbatim: the final visitor message and its entire reply, including staff messages added in the meantime.
- 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.
- 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.
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: ""ornullin 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-Keynames 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 returns422 idempotency_key_reusedwithx-should-retry: false. Generate a new key per new message.- Vercel AI SDK names visitor messages using their
idand 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-Keyfor 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 byprevious_response_idoritem_referencecan 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-Keyin 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,
useChatID. 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 sameIdempotency-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_requiredorassistant_paused, creates no conversation and stores nothing. It can be resent later. - A still-running turn returns
409 conversation_busy. Wait forRetry-Afterand resend with the same key. - OpenAI SDKs do not add
Idempotency-Key. Passheadersin Node's second request argument, or Python'sextra_headers: Chat Completions. SDK retries reuse that header.
With fetch:
// 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.
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:
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:
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.
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 and never resume team-owned conversations.
'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<SoneUIMessage>) {
const [initial] = useState(storedChat);
const chat = useChat<SoneUIMessage>({ 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. Complete example: Embedded chat widget.
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. 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.