Custom chat interface
The site can render its own dialog list, messages, and composer. The SDK handles visitor authentication, sending, history, and subscriptions. First connect a compatible package and loader; the method reference lists their parameters.
Mode and connection
headless: true disables the floating button and built-in chat interface. A hidden iframe continues running: this is not an iframe-free HTTP client. open() and toggle() do not display this instance. Set the option at startup; it is incompatible with button_only and embedded.
import {
createSenlerWidget,
mergeWidgetMessages,
type SenlerWidgetMessage,
type SenlerWidgetDialogState,
type SenlerWidgetWelcomePreview,
} from "@senlerio/widget";
const widget = await createSenlerWidget({
src: "LOADER_URL_FROM_CABINET",
channelId: "CHANNEL_ID",
headless: true,
lang: "en",
});
Replace the loader URL and channel ID with values from the embed code. History and subscription methods also work with the built-in interface: omit headless to use it. For React, use the provider and hooks.
Select a dialog
const dialogs = await widget.getDialogs({ limit: 20 });
const initialDialogId = dialogs.items[0]?.id;
async function createDialog() {
connectDialog(await widget.startNewDialog());
}
The example selects the first visible dialog. If none exists, it displays a greeting and creates a dialog with the first question, as shown below. All TypeScript blocks in this article form one example; attach form handlers after initialization. To select another dialog, the site calls connectDialog(id); a separate button calls createDialog() to explicitly create an empty dialog. These are site functions defined in the example.
For recurring service requests, use getOrCreateDialog, then pass the result's id as dialogId. For example, a separate quick-edit handler can call this function:
function getEditingDialog() {
return widget.getOrCreateDialog({
ref: "text-edit",
showInWidgetHistory: false,
});
}
showInWidgetHistory applies only when creating a dialog: an existing dialog retains its visibility. A new dialog with showInWidgetHistory: false is hidden from widget history but remains in the Cabinet.
A list page contains items, hasMore, nextCursor, and nextOffset for search. Each item has id, title, optional avatarUrl, an unreadCount, and a revision.
Request the next ordinary page with before: dialogs.nextCursor; for search, pass the same query and offset: dialogs.nextOffset. Search strings contain 2–500 characters; limit ranges from 1 to 100. Hidden dialogs are excluded. Refresh the list on dialogsChanged.
History and new messages
Add the example's elements to the page:
<ol id="messages"></ol>
<p id="chat-status" role="status"></p>
<button id="retry-subscription" type="button" hidden>Retry connection</button>
The subscription receives initial history and subsequent messages, including visitor messages from other tabs. Do not fetch initial history separately before subscribing: a message could arrive between those operations.
const list = document.querySelector<HTMLOListElement>("#messages")!;
const status = document.querySelector<HTMLElement>("#chat-status")!;
const retry = document.querySelector<HTMLButtonElement>("#retry-subscription")!;
let dialogId: string | undefined;
let messages: SenlerWidgetMessage[] = [];
let state: SenlerWidgetDialogState | undefined;
let olderCursor: string | undefined;
let snapshotVersion = 0;
let generation = 0;
let ready = false;
let disposed = false;
let unsubscribe: (() => void) | undefined;
function render() {
list.replaceChildren(...messages.map(message => {
const item = document.createElement("li");
item.textContent = message.deleted
? "Message deleted"
: message.kind === "error"
? message.error?.message ?? "Message error"
: message.text || "Message with an attachment or buttons";
return item;
}));
}
function renderState() {
status.textContent = state?.typing.length ? "Preparing an answer…" : "";
}
function connectDialog(id: string) {
if (disposed) return;
const current = ++generation;
snapshotVersion++;
ready = false;
unsubscribe?.();
dialogId = id;
messages = [];
state = undefined;
olderCursor = undefined;
retry.hidden = true;
render();
status.textContent = "Connecting…";
unsubscribe = widget.subscribeDialog({
dialogId: id,
limit: 50,
onUpdate(update) {
if (disposed || current !== generation) return;
if (update.type === "snapshot") {
snapshotVersion++;
ready = true;
messages = update.messages.items;
olderCursor = update.messages.hasMore ? update.messages.olderCursor : undefined;
state = update.state;
render();
renderState();
} else if (update.type === "message") {
messages = mergeWidgetMessages(messages, [update.message]);
render();
} else if (update.type === "state") {
state = update.state;
renderState();
} else if (update.type === "deleted") {
generation++;
snapshotVersion++;
ready = false;
messages = [];
state = undefined;
olderCursor = undefined;
render();
status.textContent = "Dialog deleted";
}
},
onError(error) {
if (disposed || current !== generation) return;
generation++;
snapshotVersion++;
ready = false;
retry.hidden = !(error.retryable === true || error.code === "timeout");
status.textContent = `${error.code}: ${error.message}`;
},
});
}
retry.onclick = () => {
if (dialogId && !retry.hidden) connectDialog(dialogId);
};
async function send(text: string) {
if (disposed) throw new Error("Chat closed");
if (!dialogId) return sendFirstQuestion(text);
if (!ready) throw new Error("Wait for the connection");
return widget.ask({ dialogId, text });
}
function dispose() {
disposed = true;
ready = false;
generation++;
snapshotVersion++;
retry.onclick = null;
unsubscribe?.();
widget.destroy();
}
The site's form handler calls send(text) and handles Promise rejection. Its result contains answers and statuses according to the ask contract. The message list above updates through the subscription: do not append the same answers from the Promise again. This example displays final texts; the site adds files, buttons, and formatting using message data.
In onUpdate, type identifies the event: snapshot is history and state, message is a saved message or a new version, state is dialog state, deleted means the dialog was deleted, and chunk and streamEnded describe streaming. A snapshot's reason is initial on first load or recovery after a gap. The example renders messages only when history changes; state updates a separate indicator, while chunk and streamEnded do not change the DOM.
History pages and recovery
Use the example's variables for a “Load earlier” button:
async function loadOlder() {
if (!ready || !dialogId || !olderCursor) return;
const version = snapshotVersion;
const page = await widget.getMessages({ dialogId, before: olderCursor, limit: 50 });
if (!ready || version !== snapshotVersion) return;
messages = mergeWidgetMessages(messages, page.items);
olderCursor = page.hasMore ? page.olderCursor : undefined;
render();
}
The button handler must handle Promise rejection and prevent another load until the current one finishes. A page contains items, hasMore, olderCursor, and newerCursor; messages are ordered oldest first. limit ranges from 1 to 200, with a default of 50. To read newer messages, pass after: page.newerCursor. Do not combine before and after.
A lossless reconnect preserves loaded pages. A new snapshot after a gap replaces history: reload older pages as needed. The example's snapshotVersion counter prevents a late request from restoring pages belonging to an earlier snapshot. Do not fetch the entire history to recover.
subscribeDialog returns an unsubscribe function immediately. timeoutMs limits only initial synchronization. The SDK recovers transient disconnections itself; onError means the subscription has ended. For a temporary error or timeout, the example shows “Retry connection”: it calls connectDialog with the same ID, gets a fresh snapshot, and enables sending after that snapshot. It does not resend the question. Access errors and dialog deletion require resolving the cause or choosing another dialog; there is no automatic retry loop.
When switching dialogs, connectDialog unsubscribes from the previous one and clears its pages. The counters protect the interface from old callbacks and late history requests. Use destroy() only when removing the whole instance.
Streaming
Gradual answer delivery follows agent settings. The site can handle chunk or wait for the saved message, as in the example above.
A chunk contains dialogId, streamId, index, text, and optional iterationId and requestId. Keep drafts keyed by streamId; requestId, when known, refers to the original SDK request. A message update may also include streamId and requestId: replace the matching draft with the saved message.
streamEnded ends one stream. Its status is completed, failed, skipped, or scheduled; optional fields include eventId, reason, silent, and an error with code and message. On successful completion, stop the generation indicator and retain the draft until the message arrives. Completion can arrive before or after the saved message; do not recreate a draft that was already replaced. Remove drafts for failed, skipped, silent, or scheduled endings.
Chunks are not recovered after a reload or a gap. On a recovery snapshot, clear drafts, show current typing from state.typing, and wait for the saved message. A stream ending does not mean all answers to the question are complete: the ask Promise determines that.
Settings and dialog state
getSettings() returns:
features: availability offileUpload,voiceMessages, andemoji;canStartAgent,creditLimitExhausted,remainingCredits: agent availability and available credits; 10,000 server units equal 1 credit, andnulldoes not mean zero;welcomeTextandwelcomeButtons: localized greeting text and starter questions.
Refresh settings on settingsChanged; display connectivity through connectionChanged. The server checks permissions regardless of which buttons the site displays.
getDialogState({ dialogId, ref? }) retrieves the same state supplied by the subscription: dialogId, unreadCount, revision, typing, queueEnabled, pending, and optional pausedReason (error, limit, muted). An explicit ID also allows working with a hidden dialog. If questions use ref, pass it to getDialogState / subscribeDialog as well: the server determines queue rules using the agent selected by that ref. When omitted, the dialog's ref is used. Dialog selection and creation are described in sending messages.
A typing item has id, optional name and expiresAt, and a streaming flag. For visitor typing, call setTyping({ dialogId, typing: true }), throttling calls on the site; send typing: false after inactivity or blur.
Call markRead({ dialogId }) when the site actually displays messages to the visitor: it marks current dialog messages as read. The hidden iframe does not do this for the site's interface. Check visibility of your panel and browser tab.
Question queue
When queueEnabled is true, pending contains waiting questions with id, revision, text, createdAt, sending, and attachments (an array of objects with id). Display the queue separately from saved messages.
This example updates the first waiting question using state from the subscription:
async function editFirstQuestion(text: string) {
if (!ready || !dialogId) return;
const question = state?.pending.find(item => !item.sending);
if (!question) return;
await widget.updateQueuedQuestion({
dialogId,
questionId: question.id,
revision: question.revision,
text,
});
}
Pass the question's own revision: it protects against overwriting changes from another tab. On conflict, refresh state with getDialogState and ask the visitor to review the text. cancelQueuedQuestion({ dialogId, questionId }) removes a question that has not started sending; sendQueuedQuestion({ dialogId, questionId }) applies “Send now.” These methods do not stop an agent already running. The subscription updates queue state; queue commands for one dialog run sequentially.
Greeting without creating a dialog
getWelcome() returns mode (centered_text or automation_message), draftId, and optional token, expiresAt, and message. Finish the example with initialization: subscribe to an existing dialog, or show only the greeting for a new visitor. For centered_text, use getSettings().welcomeText.
let welcome: SenlerWidgetWelcomePreview | undefined;
let starting = false;
async function sendFirstQuestion(text: string) {
if (starting) throw new Error("The first question is already sending");
starting = true;
const current = generation;
const attach = (id: string | undefined) => {
if (id && !disposed && current === generation) connectDialog(id);
};
try {
if (welcome?.expiresAt && Date.parse(welcome.expiresAt) <= Date.now()) {
welcome = await widget.getWelcome();
}
if (disposed || current !== generation) throw new Error("Dialog changed");
const result = await widget.ask({
newDialog: true,
text,
welcome: welcome?.token
? { draftId: welcome.draftId, token: welcome.token }
: undefined,
onStatus: progress => attach(progress.dialogId),
});
attach(result.dialogId);
return result;
} finally {
starting = false;
}
}
if (initialDialogId) {
connectDialog(initialDialogId);
} else {
welcome = await widget.getWelcome();
messages = welcome.message ? [welcome.message] : [];
render();
if (welcome.mode === "centered_text") {
status.textContent = (await widget.getSettings()).welcomeText;
}
}
The first send(text) calls ask({ newDialog: true, ... }); signed greetings require that flag. Once onStatus provides a dialogId, attach starts the subscription. Further statuses do not recreate it; the result's ID is a fallback. Even if the answer was saved before subscribing, it appears in the initial snapshot. Subsequent questions use the same dialog. While the first request is running, prevent duplicate submission and dialog switching in the site's form; when it finishes, check the final ask status, and show an error if its Promise rejects. Retrying the send itself follows the requestId rules; the subscription recovery button does not do this.
For a greeting callback button, the first request sends text: "" and welcome: { draftId, token, buttonId: button.instanceId }, with the same onStatus connection logic. If expiresAt has passed, refresh the greeting and its buttons before selecting an action. Starter questions from getSettings().welcomeButtons can use send(text). Buttons on saved messages use clickButton.
Scheduled actions
getScheduledActions({ dialogId, cursor? }) returns enabled, items, and nextCursor. An action has id, optional title, scheduledAt, and a status of pending or processing. For the next page, pass the returned nextCursor as cursor.
To cancel a pending action, call cancelScheduledAction({ dialogId, actionId }) and refresh the list. Availability and permissions match the built-in chat; do not present an action already processing as cancellable.
Cleanup
Call dispose() when removing the example. If you add widget.on(...) listeners, save and call their unsubscribe functions. To sign a visitor out, use resetSession, then create an instance with the new visitor's details.
Registered site actions also work in headless. Do not run them again when displaying history: the SDK handles automatic execution. Built-in chat navigation actions are unavailable because that interface is not created.