Walker Formulation Academy for developers API reference Error catalog OpenAPI
Sections

AI sales chat templates

Three ready chat interfaces - as on our site, minimal and dark - in plain HTML, CSS and JavaScript, no libraries. Copy, recolour with variables and connect your channel.

How it works

The templates call the same /api/widget/* routes as the widget window - details and every field are in the client Chat API description. There is no API key and there must not be one: the channel is identified by the public channel_id, the visitor by a signed session token.

RouteWhat for
POST /api/widget/initchannel settings, consent
GET /api/widget/historyconversation history on open
POST /api/widget/messagessend a message, get the AI reply and a new token
GET /api/widget/eventsincoming messages over SSE; fallback - /api/widget/poll every 2.5 s

Things to know

Template: As on our site

The chat window of our own site ported to plain HTML, CSS and JS: a dark glass window with a gradient, a header with an avatar and an "Online" line, bubbles with the time, a typing indicator, a product card right in the feed, a "leave your contacts" card, a rounded input with a paper plane and a floating button with a pulse and an unread counter. Sizes, colours, shadows and animations come from the original component; the --sc-* variables replace Tailwind.

Chat window and buttonexample

A static preview: the messages and the card are made up, the field is disabled, animations are off. The window is built by the code from the "JS: elements" tab.

Contacts card in the feedexample

Shown when the message is passed to an operator, or on a page event.

Where the card and the contacts form come from

HTML

<div class="sc" data-sc-root data-channel="CHANNEL_ID" data-api="" data-contact-url="" data-privacy-url="/privacy">
  <div class="sc-panel" data-sc="panel" hidden role="complementary" aria-label="Chat with an advisor">
    <div class="sc-box" data-nosnippet>
      <div class="sc-bg"></div>
      <header class="sc-head">
        <div class="sc-who">
          <span class="sc-avatar"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 8V4H8"/><rect width="16" height="12" x="4" y="8" rx="2"/><path d="M2 14h2"/><path d="M20 14h2"/><path d="M15 13v2"/><path d="M9 13v2"/></svg><span class="sc-dot"></span></span>
          <div>
            <div class="sc-name">Advisor</div>
            <div class="sc-status"><span class="sc-pulse"></span>Online • Walker Formulation Academy</div>
          </div>
        </div>
        <button class="sc-close" type="button" data-sc="close" aria-label="Close chat"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg></button>
      </header>
      <div class="sc-log" data-sc="log" role="log" aria-live="polite"><!-- The script adds messages, cards and the form --></div>
      <label class="sc-consent" data-sc="consent" hidden><input type="checkbox" data-sc="consent-box"><span data-sc="consent-text"></span></label><!-- The script shows the consent when the channel enables it -->
      <div class="sc-note" data-sc="note" role="status"></div>
      <form data-sc="form" class="sc-form">
        <input class="sc-input" data-sc="input" maxlength="4000" autocomplete="off" required type="text" placeholder="Type a message..." aria-label="Type a message...">
        <button class="sc-send" data-sc="send" type="submit" aria-label="Send"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" class="sc-plane"><path d="M14.536 21.686a.5.5 0 0 0 .937-.024l6.5-19a.496.496 0 0 0-.635-.635l-19 6.5a.5.5 0 0 0-.024.937l7.93 3.18a2 2 0 0 1 1.112 1.11z"/><path d="m21.854 2.147-10.94 10.939"/></svg><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" class="sc-spin"><path d="M21 12a9 9 0 1 1-6.219-8.56"/></svg></button>
      </form>
    </div>
  </div>
  <button class="sc-fab" data-sc="fab" type="button" aria-expanded="false" aria-label="Open chat">
    <span class="sc-ping" aria-hidden="true"></span><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" class="sc-chat"><path d="M7.9 20A9 9 0 1 0 4 16.1L2 22Z"/></svg><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" class="sc-x"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>
    <span class="sc-badge" data-sc="badge" hidden></span>
  </button>
</div>

CSS

.sc {
  --sc-from: #1a3c2a; --sc-via: #0e1621; --sc-to: #1a2a3c; --sc-bg: #0e1621;
  --sc-accent: #10b981; --sc-accent-strong: #047857; --sc-online: #34d399;
  --sc-user: rgba(5, 150, 105, 0.4); --sc-ai: rgba(255, 255, 255, 0.1);
  --sc-fab-from: #2d6a4f; --sc-fab-to: #1a8a5e; --sc-radius: 16px; --sc-send-radius: 12px;
  --sc-placeholder: rgba(255, 255, 255, 0.55);
  --sc-edge: 16px; --sc-gap: 10px; --sc-fab: 56px; --sc-bottom: calc(16px + env(safe-area-inset-bottom, 0px));
  --sc-font: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
  font: 14px/1.5 var(--sc-font); color: #fff;
}
@media (min-width: 640px) { .sc { --sc-edge: 24px; --sc-gap: 12px; } }
.sc *, .sc *::before, .sc *::after { box-sizing: border-box; }
.sc h3, .sc p { margin: 0; }
.sc svg { display: block; flex: none; }
.sc-ico { display: flex; flex: none; }
.sc button, .sc input { font: inherit; }
.sc :focus-visible { outline: 2px solid var(--sc-online); outline-offset: 2px; }
.sc [hidden] { display: none !important; }

/* Окно: справа над кнопкой; на телефоне почти во всю ширину, высота не больше свободного места */
.sc-panel {
  position: fixed; z-index: 9999; right: var(--sc-edge); left: var(--sc-edge); bottom: calc(var(--sc-bottom) + var(--sc-fab) + var(--sc-gap));
  height: 520px; max-height: calc(100vh - var(--sc-bottom) - var(--sc-fab) - var(--sc-gap) - 12px);
  border-radius: var(--sc-radius); overflow: hidden; box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
  animation: sc-pop 0.3s cubic-bezier(0.16, 1, 0.3, 1);
}
@supports (height: 100dvh) { .sc-panel { max-height: calc(100dvh - var(--sc-bottom) - var(--sc-fab) - var(--sc-gap) - 12px); } }
@media (min-width: 640px) { .sc-panel { left: auto; width: 380px; } }
.sc-box { position: relative; display: flex; flex-direction: column; width: 100%; height: 100%; overflow: hidden; border: 1px solid rgba(255, 255, 255, 0.1); border-radius: var(--sc-radius); background: var(--sc-bg); }
@supports ((-webkit-backdrop-filter: blur(1px)) or (backdrop-filter: blur(1px))) {
  .sc-box { background: rgba(14, 22, 33, 0.95); -webkit-backdrop-filter: blur(24px); backdrop-filter: blur(24px); }
}
.sc-bg { position: absolute; top: 0; right: 0; bottom: 0; left: 0; background: linear-gradient(to bottom right, var(--sc-from), var(--sc-via), var(--sc-to)); }
.sc-head, .sc-log, .sc-consent, .sc-note, .sc-form { position: relative; z-index: 1; }

/* Шапка: аватар с точкой «онлайн», название, строка статуса с пульсом, кнопка 44x44 */
.sc-head { display: flex; align-items: center; justify-content: space-between; padding: 12px 16px; border-bottom: 1px solid rgba(255, 255, 255, 0.1); background: rgba(0, 0, 0, 0.2); }
.sc-who { display: flex; align-items: center; gap: 12px; min-width: 0; }
.sc-avatar { position: relative; flex: none; width: 40px; height: 40px; border-radius: 50%; display: flex; align-items: center; justify-content: center; color: #fff; background: linear-gradient(to bottom right, #10b981, #0d9488); box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -4px rgba(0, 0, 0, 0.1); }
.sc-avatar svg { width: 20px; height: 20px; }
.sc-dot { position: absolute; right: 0; bottom: 0; width: 12px; height: 12px; border-radius: 50%; background: var(--sc-online); border: 2px solid var(--sc-bg); }
.sc-name { font-size: 14px; font-weight: 600; line-height: 1.25; letter-spacing: 0; color: #fff; }
.sc-status { display: flex; align-items: center; gap: 4px; font-size: 11px; line-height: 1.45; color: rgba(52, 211, 153, 0.9); }
.sc-pulse { width: 6px; height: 6px; border-radius: 50%; background: var(--sc-online); animation: sc-pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite; }
.sc-close { flex: none; display: flex; align-items: center; justify-content: center; min-width: 44px; min-height: 44px; padding: 10px; border: 0; border-radius: 8px; background: transparent; color: rgba(255, 255, 255, 0.8); cursor: pointer; transition: background-color 0.15s; }
.sc-close:hover { background: rgba(255, 255, 255, 0.1); }
.sc-close svg { width: 20px; height: 20px; }

/* Лента: пузыри с временем, карточка товара, индикатор набора, форма контактов */
.sc-log { flex: 1; min-height: 0; display: flex; flex-direction: column; gap: 12px; padding: 12px 16px; overflow-y: auto; font-size: 14px; -webkit-overflow-scrolling: touch; }
.sc-log > * { flex-shrink: 0; }
.sc-msg { max-width: 85%; padding: 10px 12px; border-radius: 12px; align-self: flex-start; color: rgba(255, 255, 255, 0.9); background: var(--sc-ai); box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -2px rgba(0, 0, 0, 0.1); animation: sc-in 0.3s ease-out; }
.sc-msg[data-sender="user"] { align-self: flex-end; margin-left: auto; color: #fff; background: var(--sc-user); }
.sc-msg[data-sender="operator"] { color: #fff; background: rgba(14, 165, 233, 0.2); border: 1px solid rgba(125, 211, 252, 0.3); }
.sc-msg[data-sender="system"] { align-self: center; max-width: 100%; text-align: center; font-size: 12px; font-style: italic; color: #fef08a; background: rgba(234, 179, 8, 0.2); }
.sc-op { margin-bottom: 4px; font-size: 10px; font-weight: 500; color: rgba(186, 230, 253, 0.8); }
.sc-text { font-size: 13px; line-height: 1.625; white-space: pre-wrap; overflow-wrap: anywhere; }
.sc-time { margin-top: 4px; font-size: 9px; text-align: right; color: rgba(255, 255, 255, 0.45); }
.sc-typing { align-self: flex-start; display: flex; align-items: center; gap: 6px; width: fit-content; padding: 8px 12px; border-radius: 12px; background: var(--sc-ai); }
.sc-typing i { width: 8px; height: 8px; border-radius: 50%; background: var(--sc-online); animation: sc-pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite; }
.sc-typing i:nth-child(2) { animation-delay: 0.2s; }
.sc-typing i:nth-child(3) { animation-delay: 0.4s; }
.sc-card { align-self: flex-start; display: block; width: 240px; max-width: 100%; overflow: hidden; border: 1px solid #e5e7eb; border-radius: 12px; background: #fff; color: #111827; text-align: left; text-decoration: none; box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05); transition: box-shadow 0.15s; animation: sc-in 0.3s ease-out; }
.sc-card:hover { box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -2px rgba(0, 0, 0, 0.1); }
.sc-card-img { display: block; width: 100%; height: 112px; object-fit: cover; }
.sc-card-body { padding: 12px; }
.sc-card-top { display: flex; align-items: flex-start; justify-content: space-between; gap: 8px; }
.sc-card-name { font-size: 13px; font-weight: 600; line-height: 1.375; }
.sc-card-badge { flex: none; padding: 2px 6px; border-radius: 4px; font-size: 10px; font-weight: 500; color: #047857; background: #d1fae5; }
.sc-card-text { margin-top: 4px; font-size: 11px; line-height: 1.45; color: #6b7280; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
.sc-card-row { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-top: 8px; }
.sc-card-price { font-size: 12px; font-weight: 600; color: #047857; }
.sc-card-btn { display: inline-flex; align-items: center; gap: 4px; padding: 4px 10px; border-radius: 8px; font-size: 11px; font-weight: 500; color: #fff; background: var(--sc-accent-strong); }
.sc-card-btn svg { width: 12px; height: 12px; }
.sc-lead { padding: 16px; border: 1px solid rgba(255, 255, 255, 0.1); border-radius: 12px; background: var(--sc-ai); animation: sc-in 0.3s ease-out; }
@supports ((-webkit-backdrop-filter: blur(1px)) or (backdrop-filter: blur(1px))) { .sc-lead, .sc-msg { -webkit-backdrop-filter: blur(8px); backdrop-filter: blur(8px); } }
.sc-lead-title { margin-bottom: 12px !important; font-size: 14px; font-weight: 500; color: #fff; }
.sc-field { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; padding: 8px 12px; border-radius: 8px; background: rgba(0, 0, 0, 0.3); }
.sc-field svg { width: 16px; height: 16px; color: rgba(255, 255, 255, 0.5); }
.sc-field input { flex: 1; min-width: 0; padding: 0; border: 0; background: transparent; color: #fff; font-size: 14px; }
.sc-field input::placeholder, .sc-input::placeholder { color: var(--sc-placeholder); opacity: 1; }
.sc-field:focus-within { outline: 2px solid var(--sc-online); outline-offset: 2px; }
.sc-field input:focus-visible { outline: none; }
.sc-check, .sc-consent { display: flex; align-items: flex-start; gap: 8px; margin-bottom: 8px; font-size: 11px; line-height: 1.25; color: rgba(255, 255, 255, 0.6); }
.sc-check input, .sc-consent input { margin: 2px 0 0; accent-color: var(--sc-accent); }
.sc-check a, .sc-consent a { color: rgba(255, 255, 255, 0.8); text-decoration: underline; }
.sc-error { margin: 0 0 8px !important; padding: 8px 12px; border: 1px solid rgba(244, 63, 94, 0.2); border-radius: 8px; font-size: 11px; line-height: 1.35; color: #fda4af; background: rgba(244, 63, 94, 0.1); }
.sc-submit { display: flex; align-items: center; justify-content: center; gap: 8px; width: 100%; padding: 8px; border: 0; border-radius: 8px; font-size: 14px; font-weight: 500; color: #fff; background: var(--sc-accent-strong); cursor: pointer; transition: background-color 0.15s; }
.sc-submit:hover:not(:disabled) { background: var(--sc-accent); }
.sc-submit:disabled { opacity: 0.4; cursor: default; }
.sc-submit svg { width: 16px; height: 16px; }

/* Строка состояния, согласие и поле ввода с круглой кнопкой-самолётиком */
.sc-consent { margin: 0; padding: 0 16px 8px; }
.sc-note { min-height: 1.3em; margin: 0; padding: 0 16px; font-size: 12px; color: #fef08a; }
.sc-form { display: flex; align-items: center; gap: 8px; padding: 12px; border-top: 1px solid rgba(255, 255, 255, 0.1); }
.sc-input { flex: 1; min-width: 0; padding: 10px 12px; border: 1px solid rgba(255, 255, 255, 0.1); border-radius: 12px; font-size: 14px; color: #fff; background: rgba(0, 0, 0, 0.4); }
.sc-send { flex: none; display: flex; align-items: center; justify-content: center; width: 40px; height: 40px; border: 0; border-radius: var(--sc-send-radius); color: #fff; background: rgba(5, 150, 105, 0.8); cursor: pointer; transition: background-color 0.15s; }
.sc-send:hover:not(:disabled) { background: var(--sc-accent); }
.sc-send:disabled { opacity: 0.3; cursor: default; }
.sc-send svg { width: 16px; height: 16px; }
.sc .sc-spin { display: none; animation: sc-spin 1s linear infinite; }
.sc-send[data-busy] .sc-spin { display: block; }
.sc-send[data-busy] .sc-plane { display: none; }

/* Плавающая кнопка: пульс-кольцо, счётчик непрочитанных; открытая - крестик */
.sc-fab { position: fixed; z-index: 9998; right: var(--sc-edge); bottom: var(--sc-bottom); width: var(--sc-fab); height: var(--sc-fab); display: flex; align-items: center; justify-content: center; padding: 0; border: 0; border-radius: 50%; color: #fff; cursor: pointer; background: linear-gradient(135deg, var(--sc-fab-from), var(--sc-fab-to)); box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -4px rgba(0, 0, 0, 0.1); transition: transform 0.15s; }
.sc-fab:hover { transform: scale(1.08); }
.sc-fab:active { transform: scale(0.95); }
.sc-fab svg { width: 24px; height: 24px; }
.sc-fab .sc-x, .sc[data-open] .sc-fab .sc-chat, .sc[data-open] .sc-ping { display: none; }
.sc[data-open] .sc-fab .sc-x { display: block; }
.sc-ping { position: absolute; top: 0; right: 0; bottom: 0; left: 0; border-radius: 50%; background: rgba(16, 185, 129, 0.3); pointer-events: none; animation: sc-ping 3s cubic-bezier(0, 0, 0.2, 1) infinite; }
.sc-badge { position: absolute; top: -4px; right: -4px; display: flex; align-items: center; justify-content: center; width: 20px; height: 20px; border-radius: 50%; font-size: 10px; font-weight: 700; color: #fff; background: #ef4444; animation: sc-pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite; }

@keyframes sc-pop { from { opacity: 0; transform: translateY(20px) scale(0.95); } to { opacity: 1; transform: none; } }
@keyframes sc-in { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: none; } }
@keyframes sc-pulse { 50% { opacity: 0.5; } }
@keyframes sc-ping { 75%, 100% { transform: scale(2); opacity: 0; } }
@keyframes sc-spin { to { transform: rotate(360deg); } }
@media (prefers-reduced-motion: reduce) {
  .sc *, .sc *::before, .sc *::after { animation: none !important; transition: none !important; }
  .sc-ping { display: none; }
}

JS: elements

Builds bubbles, cards and the form. Include this script first.

var SC_TEXT = {
  details: 'Details', typing: 'The advisor is typing', operator: 'Operator', contactTitle: 'Leave your contacts and a manager will get in touch',
  name: 'Your name', phone: 'Phone', email: 'Email', consent: 'I agree to the processing of my personal data.', privacy: 'Privacy policy',
  news: 'I would like to receive news and useful materials.', submit: 'Send'
};
// Icons are outline, 24 x 24 (the Lucide set)
var SC_ICONS = {
  user: '<path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/>',
  phone: '<path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92z"/>',
  mail: '<rect width="20" height="16" x="2" y="4" rx="2"/><path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7"/>',
  arrow: '<path d="M5 12h14"/><path d="m12 5 7 7-7 7"/>'
};

function scEl(tag, cls, text) {
  var n = document.createElement(tag);
  if (cls) n.className = cls;
  if (text !== undefined) n.textContent = text;   // textContent, not innerHTML: the text is not yours
  return n;
}
function scIcon(name) {
  var s = scEl('span', 'sc-ico');
  s.innerHTML = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">' + SC_ICONS[name] + '</svg>';
  return s;
}
function scSafeUrl(u, image) {                     // Own paths, http(s) and data:image pictures only - a foreign scheme never reaches an address
  if (typeof u !== 'string' || !u) return '';
  if (u.charAt(0) === '/' && u.charAt(1) !== '/') return u;
  if (image && u.indexOf('data:image/') === 0) return u;
  try { var p = new URL(u).protocol; return p === 'https:' || p === 'http:' ? u : ''; } catch (e) { return ''; }
}

/* Пузырь: sender - user | ai | operator | system; time - готовая строка */
function scBubble(sender, text, time) {
  var m = scEl('div', 'sc-msg');
  m.setAttribute('data-sender', sender);
  if (sender === 'operator') m.appendChild(scEl('div', 'sc-op', SC_TEXT.operator));
  m.appendChild(scEl('div', 'sc-text', text));
  if (time) m.appendChild(scEl('div', 'sc-time', time));
  return m;
}

function scTyping() {
  var t = scEl('div', 'sc-typing');
  t.setAttribute('role', 'status');
  t.setAttribute('aria-label', SC_TEXT.typing);
  for (var i = 0; i < 3; i++) t.appendChild(scEl('i'));
  return t;
}

/* Карточка товара: { name, text, price, badge, image, url } */
function scProductCard(card) {
  var a = scEl('a', 'sc-card');
  var href = scSafeUrl(card.url);
  if (href) { a.setAttribute('href', href); a.setAttribute('target', '_blank'); a.setAttribute('rel', 'noopener noreferrer'); }
  var src = scSafeUrl(card.image, true);
  if (src) {
    var img = scEl('img', 'sc-card-img');
    img.setAttribute('src', src); img.setAttribute('alt', card.name); img.setAttribute('loading', 'lazy');
    a.appendChild(img);
  }
  var body = scEl('div', 'sc-card-body');
  var top = scEl('div', 'sc-card-top');
  top.appendChild(scEl('span', 'sc-card-name', card.name));
  if (card.badge) top.appendChild(scEl('span', 'sc-card-badge', card.badge));
  body.appendChild(top);
  if (card.text) body.appendChild(scEl('div', 'sc-card-text', card.text));
  var row = scEl('div', 'sc-card-row');
  row.appendChild(scEl('span', 'sc-card-price', card.price || ''));
  var btn = scEl('span', 'sc-card-btn');
  btn.appendChild(scEl('span', '', SC_TEXT.details));
  btn.appendChild(scIcon('arrow'));
  row.appendChild(btn);
  body.appendChild(row);
  a.appendChild(body);
  return a;
}

/* Карточка контактов: имя, телефон, почта, два согласия, кнопка; привязка - data-sc */
function scField(icon, type, name, placeholder, autocomplete) {
  var w = scEl('label', 'sc-field');
  w.appendChild(scIcon(icon));
  var i = scEl('input');
  i.setAttribute('type', type); i.setAttribute('name', name); i.setAttribute('autocomplete', autocomplete);
  i.setAttribute('placeholder', placeholder); i.setAttribute('aria-label', placeholder);
  w.appendChild(i);
  return w;
}
function scCheck(name, text, linkText, linkHref) {
  var l = scEl('label', 'sc-check');
  var box = scEl('input');
  box.setAttribute('type', 'checkbox'); box.setAttribute('name', name);
  l.appendChild(box);
  var s = scEl('span');
  s.appendChild(scEl('span', '', text + (linkText ? ' ' : '')));
  if (linkText) {
    var a = scEl('a', '', linkText);
    if (linkHref) { a.setAttribute('href', linkHref); a.setAttribute('target', '_blank'); a.setAttribute('rel', 'noopener'); }
    s.appendChild(a);
  }
  l.appendChild(s);
  return l;
}
function scContactCard(privacyUrl) {
  var c = scEl('div', 'sc-lead');
  c.setAttribute('role', 'group'); c.setAttribute('aria-label', SC_TEXT.contactTitle); c.setAttribute('data-sc', 'lead');
  c.appendChild(scEl('div', 'sc-lead-title', SC_TEXT.contactTitle));
  c.appendChild(scField('user', 'text', 'name', SC_TEXT.name + ' *', 'name'));
  c.appendChild(scField('phone', 'tel', 'phone', SC_TEXT.phone + ' *', 'tel'));
  c.appendChild(scField('mail', 'email', 'email', SC_TEXT.email, 'email'));
  c.appendChild(scCheck('consent', SC_TEXT.consent, SC_TEXT.privacy, privacyUrl));
  c.appendChild(scCheck('news', SC_TEXT.news));
  var err = scEl('div', 'sc-error');
  err.setAttribute('role', 'alert'); err.setAttribute('data-sc', 'lead-error'); err.setAttribute('hidden', '');
  c.appendChild(err);
  var go = scEl('button', 'sc-submit');
  go.setAttribute('type', 'button'); go.setAttribute('data-sc', 'lead-submit'); go.setAttribute('disabled', '');
  go.appendChild(scEl('span', '', SC_TEXT.submit));
  go.appendChild(scIcon('arrow'));
  c.appendChild(go);
  return c;
}

JS: wiring

Works with the Chat API and page events; include it second.

(function () {
  var root = document.querySelector('[data-sc-root]');
  if (!root) return;
  var API = root.getAttribute('data-api') || '';   // Cell address (scheme and domain). Empty - the page lives on the cell domain itself.
  var CHANNEL = root.getAttribute('data-channel');
  var CONTACT_URL = root.getAttribute('data-contact-url') || '';
  var PRIVACY_URL = root.getAttribute('data-privacy-url') || '';
  var KEY = 'chat_token:' + CHANNEL;
  var TEXT = {
    unavailable: 'Chat is unavailable', needConsent: 'Tick the consent box to send a message', consentRetry: 'Tick the consent box and send the message again', tooLong: 'The message is too long',
    tooMany: 'Too many requests, wait a minute', failed: 'Could not send', queued: 'Passed to an operator, who will reply later', open: 'Open chat', close: 'Close chat',
    leadSent: 'Thank you! Your contacts are passed on, we will get in touch.', leadFail: 'Could not send, please try again'
  };
  // Your own showcase: code -> card. The data is made up, put your own link and picture.
  // The public Chat API returns no cards (the reply is plain text). On our site they come from the
  // internal chat route; here a card is taken from CATALOG by a [CARD:code] marker in the reply text
  // (if your channel instruction asks the AI to put one) or from the sc:card event.
  var CATALOG = {
    basic: { name: 'Foundation course', text: 'Four weeks of practice: from your first emulsion to your own formula.', price: '£149', badge: 'Popular', image: '', url: '/courses/basic' }
  };

  function q(name) { return root.querySelector('[data-sc="' + name + '"]'); }
  var panel = q('panel'), fab = q('fab'), badge = q('badge'), log = q('log'), form = q('form'), input = q('input');
  var sendBtn = q('send'), note = q('note'), closeBtn = q('close');
  var consentWrap = q('consent'), consentBox = q('consent-box'), consentText = q('consent-text');
  var shown = {}, token = '', consent = null, accepted = '', stream = null, timer = null, lastAt = '';
  var unread = 0, busy = false, typing = null, lead = null;
  try { token = localStorage.getItem(KEY) || ''; } catch (e) { /* storage blocked */ }

  function time() { return new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); }
  function isOpen() { return !panel.hidden; }
  function toBottom() { log.scrollTop = log.scrollHeight; }
  function setNote(text) { note.textContent = text || ''; }
  function paintBadge() { badge.hidden = unread < 1; badge.textContent = String(unread); }
  function updateSend() { sendBtn.disabled = busy || !input.value.trim(); }

  function setOpen(on) {
    panel.hidden = !on;
    if (on) root.setAttribute('data-open', ''); else root.removeAttribute('data-open');
    fab.setAttribute('aria-expanded', on ? 'true' : 'false');
    fab.setAttribute('aria-label', on ? TEXT.close : TEXT.open);
    if (on) { unread = 0; paintBadge(); toBottom(); setTimeout(function () { input.focus(); }, 300); }
  }
  function push(node, fromThem) {
    log.appendChild(node);
    toBottom();
    if (fromThem && !isOpen()) { unread++; paintBadge(); }
  }

  function addCard(card) { push(scProductCard(card), true); }
  function addMessage(sender, content, id) {
    if (id) { if (shown[id]) return; shown[id] = true; }
    var last = log.lastElementChild;               // An AI reply can arrive both in the send response and on the stream (no shared id): compare with the last one.
    if (sender !== 'user' && last && last.getAttribute('data-sender') === sender && last.textContent.indexOf(content) === 0) return;
    var codes = [];
    var text = String(content).replace(/\[CARD:([a-z0-9_-]+)\]/gi, function (m, code) { codes.push(code.toLowerCase()); return ''; }).trim();
    if (text) push(scBubble(sender, text, time()), sender !== 'user');
    codes.forEach(function (code) { if (CATALOG[code]) addCard(CATALOG[code]); });
  }
  function setTyping(on) {
    if (on && !typing) { typing = scTyping(); log.appendChild(typing); toBottom(); }
    if (!on && typing) { log.removeChild(typing); typing = null; }
  }

  function showLead() {
    if (lead) return;
    lead = scContactCard(PRIVACY_URL);
    push(lead, true);
    var name = lead.querySelector('[name="name"]'), phone = lead.querySelector('[name="phone"]'), email = lead.querySelector('[name="email"]');
    var ok = lead.querySelector('[name="consent"]'), news = lead.querySelector('[name="news"]');
    var err = lead.querySelector('[data-sc="lead-error"]'), go = lead.querySelector('[data-sc="lead-submit"]');
    function ready() { go.disabled = !(name.value.trim() && phone.value.trim() && ok.checked); }
    [name, phone, ok].forEach(function (el) { el.addEventListener('input', ready); el.addEventListener('change', ready); });
    go.addEventListener('click', async function () {
      var data = { name: name.value.trim(), phone: phone.value.trim(), email: email.value.trim(), newsletter: news.checked, channel_id: CHANNEL, page_url: location.href };
      go.disabled = true; err.hidden = true;
      try {
        window.dispatchEvent(new CustomEvent('sc:contact', { detail: data }));
        if (CONTACT_URL) {
          var r = await fetch(CONTACT_URL, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) });
          if (!r.ok) throw new Error('contact');
        }
        log.removeChild(lead); lead = null;
        addMessage('system', TEXT.leadSent);
      } catch (e) { err.textContent = TEXT.leadFail; err.hidden = false; ready(); }
    });
  }

  async function api(path, options) {
    var res = await fetch(API + path, options);
    var body = null;
    try { body = await res.json(); } catch (e) { /* not JSON */ }
    return { ok: res.ok, status: res.status, body: body };
  }
  function post(path, payload) {
    return api(path, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) });
  }
  function showConsent(c) {
    consent = c;
    consentText.textContent = c.text + ' ';
    if (c.url) {
      var a = document.createElement('a');
      a.href = c.url; a.target = '_blank'; a.rel = 'noopener'; a.textContent = c.url;
      consentText.appendChild(a);
    }
    if (accepted !== c.version) consentBox.checked = false;
    consentWrap.hidden = false;
  }

  async function init() {
    var r = await post('/api/widget/init', { channel_id: CHANNEL });
    if (!r.ok) { setNote(TEXT.unavailable); sendBtn.disabled = true; return; }
    if (r.body.consent) showConsent(r.body.consent);
    var restored = false;
    if (token) {
      var h = await api('/api/widget/history?st=' + encodeURIComponent(token));
      if (h.ok && h.body && h.body.messages && h.body.messages.length) {
        h.body.messages.forEach(function (m) { addMessage(m.sender, m.content, m.id); lastAt = m.created_at; });
        restored = true;
      } else if (h.status === 401 || h.status === 404) {
        token = '';
        try { localStorage.removeItem(KEY); } catch (e) { /* ignore */ }
      }
    }
    if (!restored && r.body.greeting) addMessage('ai', r.body.greeting);   // closed window: the counter shows 1
    listen();
  }

  function listen() {                              // Incoming: SSE with a fallback poll every 2.5 seconds
    if (!token || stream || timer) return;
    if (typeof EventSource === 'undefined') { poll(); return; }
    stream = new EventSource(API + '/api/widget/events?st=' + encodeURIComponent(token));
    stream.addEventListener('message', function (e) {
      var m = JSON.parse(e.data);
      setTyping(false); addMessage(m.sender, m.content, m.id); lastAt = m.created_at || lastAt;
    });
    stream.addEventListener('closed', stopStream);
    stream.addEventListener('error', function () { stopStream(); poll(); });
  }
  function stopStream() { if (stream) { stream.close(); stream = null; } }
  async function poll() {
    if (!token) return;
    var r = await api('/api/widget/poll?st=' + encodeURIComponent(token) + (lastAt ? '&after=' + encodeURIComponent(lastAt) : ''));
    if (r.ok && r.body && r.body.new_messages) {
      r.body.new_messages.forEach(function (m) { addMessage(m.sender, m.content, m.id); lastAt = m.created_at || lastAt; });
    }
    timer = setTimeout(poll, 2500);
  }

  async function send(text) {
    var payload = { channel_id: CHANNEL, content: text, session_token: token || undefined, page_url: location.href, page_title: document.title };
    if (consent && consentBox.checked) payload.consent_version = consent.version;
    var r = await post('/api/widget/messages', payload);
    if (r.status === 400 && r.body && r.body.error === 'consent_required') { showConsent(r.body.consent); throw new Error(TEXT.consentRetry); }
    if (r.status === 413) throw new Error(TEXT.tooLong);
    if (r.status === 429) throw new Error(TEXT.tooMany);
    if (!r.ok) throw new Error(TEXT.failed);
    if (consent && consentBox.checked) accepted = consent.version;
    token = r.body.session_token;
    try { localStorage.setItem(KEY, token); } catch (e) { /* ignore */ }
    if (r.body.queued) { addMessage('system', TEXT.queued); showLead(); }
    else if (r.body.response) addMessage('ai', r.body.response);
    listen();
  }

  form.addEventListener('submit', async function (e) {
    e.preventDefault();
    var text = input.value.trim();
    if (!text || busy) return;
    if (consent && !consentBox.checked) { setNote(TEXT.needConsent); return; }
    busy = true; sendBtn.setAttribute('data-busy', ''); updateSend(); setNote('');
    addMessage('user', text); input.value = ''; setTyping(true);
    try { await send(text); }
    catch (err) { setNote(err.message); input.value = text; }
    finally { setTyping(false); busy = false; sendBtn.removeAttribute('data-busy'); updateSend(); input.focus(); }
  });
  input.addEventListener('input', updateSend);
  fab.addEventListener('click', function () { setOpen(!isOpen()); });
  closeBtn.addEventListener('click', function () { setOpen(false); fab.focus(); });
  root.addEventListener('keydown', function (e) { if (e.key === 'Escape' && isOpen()) { setOpen(false); fab.focus(); } });
  // Page events: sc:open ({ prefill }), sc:card (a card), sc:contact-form (show the contacts form).
  // The contacts form goes to the sc:contact event and, if data-contact-url is set, as a POST to YOUR address
  // (the Chat API has no public route for contacts).
  window.addEventListener('sc:open', function (e) {
    setOpen(true);
    var p = e.detail && e.detail.prefill;
    if (typeof p === 'string' && p && !input.value.trim()) { input.value = p; updateSend(); }
  });
  window.addEventListener('sc:card', function (e) { if (e.detail) addCard(e.detail); });
  window.addEventListener('sc:contact-form', showLead);

  updateSend();
  init().catch(function () { setNote(TEXT.unavailable); sendBtn.disabled = true; });
})();

What to recolour

VariableDefaultWhat it changes
--sc-from / --sc-via / --sc-to#1a3c2a / #0e1621 / #1a2a3cThe window background gradient (diagonal)
--sc-bg#0e1621Solid background where blur is unavailable, and the online dot border
--sc-accent / --sc-accent-strong#10b981 / #047857Buttons, checkboxes and their hover; the card button
--sc-online#34d399The online dot and line, typing dots, focus
--sc-user / --sc-airgba(5,150,105,.4) / rgba(255,255,255,.1)Bubble backgrounds: visitor and AI
--sc-fab-from / --sc-fab-to#2d6a4f / #1a8a5eThe floating button gradient
--sc-radius / --sc-send-radius16px / 12pxRounding of the window and the send button (50% makes it round)
--sc-placeholderrgba(255,255,255,.55)Placeholder colour (keep 4.5:1 or more)
--sc-edge / --sc-bottom24px / 16pxOffset of the window and the button from the screen edge

As on our site, on a phone the window is almost full width (16 px from the edges) and never taller than the screen; with prefers-reduced-motion the pulses and the entrance are off. Differences from the original are deliberate: field hints are lighter (contrast) and the green buttons darker so that white text stays readable.

What it looks like on our site

Screenshots of the working chat of the school site; no messages were sent.

The closed chat on formula-cream.pro: a round button at the bottom right
the chat on our site formula-cream.pro - an example of the branded look; desktop, closed
The open chat on formula-cream.pro: a dark glass window with a greeting
the chat on our site formula-cream.pro - an example of the branded look; desktop, open
The closed chat on formula-cream.pro on a phone
the chat on our site formula-cream.pro - an example of the branded look; phone, closed
The open chat on formula-cream.pro on a phone
the chat on our site formula-cream.pro - an example of the branded look; phone, open: the window takes almost the whole screen

What to recolour in the minimal and dark ones

The look of these two templates is set by variables on the .ch root; the rest of the CSS only reads them. Change the values, no need to touch the structure.

VariableDefaultWhat it changes
--ch-accent#2d6a4fSend button, visitor messages, avatar, focus
--ch-accent-fg#ffffffText on the accent (keep contrast at 4.5:1 or more)
--ch-bg / --ch-fg#ffffff / #1f2d26Window background and main text
--ch-muted#4f6458Captions, hints, status lines
--ch-line#e4ddcfBorders and dividers
--ch-field#7a8c80Input border (at least 3:1 against the background)
--ch-ai / --ch-ai-fg#eef3ee / #1f2d26Background and text of AI and operator replies
--ch-radius14pxRounding of the window, bubbles and field
--ch-fontsystem-ui, ...Font; system by default

Template: Minimal

No header, restrained tones, 8 px corners. For a chat that should blend into the page.

Minimalexample
Hello! I can help with courses and prices. What would you like to know?
Which course suits a beginner?
The foundation course is a good start: it runs four weeks and the recordings stay with you.

A static preview: the messages are made up and the field is disabled. The live chat works once your channel id is in place.

HTML

<section class="ch" data-ch-root data-channel="CHANNEL_ID" data-api="" aria-label="Chat with an advisor">
  <div class="ch-log" data-ch="log" role="log" aria-live="polite"><!-- Messages are added by the script --></div>
  <label class="ch-consent" data-ch="consent" hidden><input type="checkbox" data-ch="consent-box"><span data-ch="consent-text"></span></label>
  <p class="ch-status" data-ch="status" role="status"></p>
  <form class="ch-form" data-ch="form">
    <input class="ch-input" data-ch="input" type="text" maxlength="4000" autocomplete="off" placeholder="Your question" aria-label="Your question" required>
    <button class="ch-send" data-ch="send" type="submit">Send</button>
  </form>
</section>

CSS

.ch {
  --ch-accent: #2d6a4f; --ch-accent-fg: #ffffff; --ch-bg: #ffffff; --ch-fg: #1f2d26; --ch-muted: #4f6458;
  --ch-line: #e4ddcf; --ch-field: #7a8c80; --ch-ai: #eef3ee; --ch-ai-fg: #1f2d26; --ch-radius: 14px;
  --ch-font: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
  display: flex; flex-direction: column; width: 100%; max-width: 380px; height: 520px; max-height: 100%;
  background: var(--ch-bg); color: var(--ch-fg); border: 1px solid var(--ch-line); border-radius: var(--ch-radius);
  font: 15px/1.45 var(--ch-font); overflow: hidden; box-sizing: border-box;
}
.ch *, .ch *::before, .ch *::after { box-sizing: border-box; }
.ch :focus-visible { outline: 2px solid var(--ch-accent); outline-offset: 2px; }
.ch-head { display: flex; align-items: center; gap: 10px; padding: 12px 14px; border-bottom: 1px solid var(--ch-line); }
.ch-avatar { flex: none; width: 36px; height: 36px; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-weight: 700; background: var(--ch-accent); color: var(--ch-accent-fg); }
.ch-title { display: block; font-weight: 700; }
.ch-sub { display: block; font-size: 12px; color: var(--ch-muted); }
.ch-log { flex: 1; min-height: 0; overflow-y: auto; padding: 14px; display: flex; flex-direction: column; gap: 8px; -webkit-overflow-scrolling: touch; }
.ch-msg { max-width: 85%; padding: 9px 12px; border-radius: calc(var(--ch-radius) - 4px); white-space: pre-wrap; overflow-wrap: anywhere; }
.ch-msg[data-sender="user"] { align-self: flex-end; background: var(--ch-accent); color: var(--ch-accent-fg); }
.ch-msg[data-sender="ai"], .ch-msg[data-sender="operator"] { align-self: flex-start; background: var(--ch-ai); color: var(--ch-ai-fg); }
.ch-msg[data-sender="system"] { align-self: center; max-width: 100%; padding: 2px 8px; font-size: 13px; color: var(--ch-muted); text-align: center; }
.ch-msg a { color: inherit; text-decoration: underline; }
.ch-card { display: flex; flex-direction: column; gap: 4px; margin-top: 6px; padding: 8px; border: 1px solid var(--ch-line); border-radius: 8px; white-space: normal; }
.ch-consent { display: flex; gap: 8px; align-items: flex-start; padding: 0 14px 8px; font-size: 13px; color: var(--ch-muted); }
.ch-consent[hidden] { display: none; }
.ch-consent a { color: var(--ch-accent); overflow-wrap: anywhere; }
.ch-status { margin: 0; padding: 0 14px; min-height: 1.3em; font-size: 13px; color: var(--ch-muted); }
.ch-form { display: flex; gap: 8px; padding: 10px 14px 14px; }
.ch-input { flex: 1; min-width: 0; padding: 10px 12px; font: inherit; color: var(--ch-fg); background: var(--ch-bg); border: 1px solid var(--ch-field); border-radius: calc(var(--ch-radius) - 4px); }
.ch-input::placeholder { color: var(--ch-muted); opacity: 1; }
.ch-send { flex: none; padding: 10px 16px; font: inherit; font-weight: 600; cursor: pointer; color: var(--ch-accent-fg); background: var(--ch-accent); border: 0; border-radius: calc(var(--ch-radius) - 4px); }
.ch-send:disabled { opacity: 0.55; cursor: default; }
@media (max-width: 480px) { .ch { max-width: none; height: 70vh; height: 70dvh; } }

.ch { --ch-accent: #111111; --ch-accent-fg: #ffffff; --ch-line: #d9d9d9; --ch-field: #767676; --ch-ai: #f1f1f1; --ch-ai-fg: #111111; --ch-fg: #111111; --ch-muted: #595959; --ch-radius: 8px; }
@media (prefers-color-scheme: dark) {
  .ch:not([data-theme="light"]) { --ch-bg: #121212; --ch-fg: #f2f2f2; --ch-muted: #b3b3b3; --ch-line: #333333; --ch-field: #8a8a8a; --ch-ai: #262626; --ch-ai-fg: #f2f2f2; --ch-accent: #f2f2f2; --ch-accent-fg: #111111; }
}
.ch[data-theme="dark"] { --ch-bg: #121212; --ch-fg: #f2f2f2; --ch-muted: #b3b3b3; --ch-line: #333333; --ch-field: #8a8a8a; --ch-ai: #262626; --ch-ai-fg: #f2f2f2; --ch-accent: #f2f2f2; --ch-accent-fg: #111111; }
.ch .ch-msg { border-radius: 6px; }

JS

(function () {
  var root = document.querySelector('[data-ch-root]');
  if (!root) return;
  var API = root.getAttribute('data-api') || '';   // Cell address (scheme and domain). Empty - the page lives on the cell domain itself.
  // From another domain the browser lets only init through: use your own proxy forwarding /api/widget/*
  // (turn buffering off for /api/widget/events: proxy_buffering off).
  var CHANNEL = root.getAttribute('data-channel');
  var KEY = 'chat_token:' + CHANNEL;
  var TEXT = {
    unavailable: 'Chat is unavailable', needConsent: 'Tick the consent box to send a message', consentRetry: 'Tick the consent box and send the message again',
    tooLong: 'The message is too long', tooMany: 'Too many requests, wait a minute', failed: 'Could not send', queued: 'Passed to an operator, who will reply later',
    online: 'Online', price: 'Price'
  };
  function q(name) { return root.querySelector('[data-ch="' + name + '"]'); }
  var log = q('log'), statusEl = q('status'), form = q('form'), input = q('input'), sendBtn = q('send');
  var consentWrap = q('consent'), consentBox = q('consent-box'), consentText = q('consent-text');
  var shown = {};                                  // ids of messages already shown
  var token = '', consent = null, accepted = '', stream = null, timer = null, lastAt = '';
  try { token = localStorage.getItem(KEY) || ''; } catch (e) { /* storage blocked: a new conversation after reload */ }
  function saveToken(t) {                          // The token is refreshed in every response - save it every time
    token = t;
    try { localStorage.setItem(KEY, t); } catch (e) { /* ignore */ }
  }

  async function api(path, options) {
    var res = await fetch(API + path, options);
    var body = null;
    try { body = await res.json(); } catch (e) { /* not JSON */ }
    return { ok: res.ok, status: res.status, body: body };
  }
  function post(path, payload) {
    return api(path, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) });
  }
  function setStatus(text) { statusEl.textContent = text || ''; }

  function safeUrl(u) { return typeof u === 'string' && (/^https:\/\//.test(u) || (u.charAt(0) === '/' && u.charAt(1) !== '/')); }
  function link(href, text) {
    var a = document.createElement('a');
    a.href = href; a.target = '_blank'; a.rel = 'noopener'; a.textContent = text;
    return a;
  }

  function addMessage(sender, content, id, extra) {
    if (id) { if (shown[id]) return; shown[id] = true; }
    extra = extra || {};
    var last = log.lastElementChild;               // An AI reply can arrive both in the send response and on the stream (no shared id): compare with the last one.
    if (sender !== 'user' && !extra.attachments && !extra.cards && last && last.getAttribute('data-sender') === sender && last.textContent === content) return;
    var node = document.createElement('div');
    node.className = 'ch-msg';
    node.setAttribute('data-sender', sender);      // user | ai | operator | system
    if (extra.operator && extra.operator.name) node.setAttribute('data-operator', extra.operator.name);
    if (content) node.appendChild(document.createTextNode(content));   // textContent, not innerHTML: the text is not yours
    // Extension: attachments. The link is signed and short-lived (expires_at): do not cache the address.
    (extra.attachments || []).forEach(function (f) {
      if (!safeUrl(f.url)) return;
      var line = document.createElement('div');
      line.appendChild(link(f.url, f.name || 'file'));
      node.appendChild(line);
    });
    // Extension: a card (product or item) with buttons. Addresses are https or a path on your site only.
    (extra.cards || []).forEach(function (card) {
      var box = document.createElement('div');
      box.className = 'ch-card';
      var title = document.createElement('strong');
      title.textContent = card.name || '';
      box.appendChild(title);
      if (card.price_text) { var price = document.createElement('div'); price.textContent = TEXT.price + ': ' + card.price_text; box.appendChild(price); }
      if (card.description) { var d = document.createElement('div'); d.textContent = card.description; box.appendChild(d); }
      (card.buttons || []).forEach(function (b) { if (safeUrl(b.url)) box.appendChild(link(b.url, b.label || '')); });
      node.appendChild(box);
    });
    log.appendChild(node);
    log.scrollTop = log.scrollHeight;
  }
  function addFromServer(m) {
    addMessage(m.sender, m.content, m.id, { operator: m.operator, attachments: m.attachments, cards: m.cards });
    lastAt = m.created_at || lastAt;
  }

  // Extension: conversation state (ai | queued | operator). The header and data-state on the root follow it; the styling is yours.
  var cfg = {};
  function applyState(state, operator) {
    state = state || 'ai';
    root.setAttribute('data-state', state);
    var title = root.querySelector('.ch-title'), sub = root.querySelector('.ch-sub');
    var t = null, s = null;
    if (state === 'operator' && operator && operator.name) { t = operator.name; s = TEXT.online; }
    else if (state === 'queued' && cfg.queue) { t = cfg.queue.title; s = cfg.queue.subtitle; }
    else if (cfg.assistant) { t = cfg.assistant.name; s = cfg.assistant.subtitle; }
    if (title && t) title.textContent = t;
    if (sub && s) sub.textContent = s;
  }

  function showConsent(c) {
    consent = c;
    consentText.textContent = c.text + ' ';
    if (c.url) {
      var a = document.createElement('a');
      a.href = c.url; a.target = '_blank'; a.rel = 'noopener'; a.textContent = c.url;
      consentText.appendChild(a);
    }
    if (accepted !== c.version) consentBox.checked = false;
    consentWrap.hidden = false;
  }

  async function init() {                          // Channel settings, consent, history; the widget does not store the greeting - show it yourself
    var r = await post('/api/widget/init', { channel_id: CHANNEL });
    if (!r.ok) { setStatus(TEXT.unavailable); sendBtn.disabled = true; return; }
    cfg = r.body;
    applyState('ai');
    if (r.body.color && root.hasAttribute('data-use-color')) root.style.setProperty('--ch-accent', r.body.color);   // Optional: take the colour from the channel settings (data-use-color on the root)
    if (r.body.consent) showConsent(r.body.consent);
    var restored = false;
    if (token) {
      var h = await api('/api/widget/history?st=' + encodeURIComponent(token));
      if (h.ok && h.body && h.body.messages && h.body.messages.length) {
        h.body.messages.forEach(addFromServer);
        if (h.body.state) applyState(h.body.state, h.body.operator);
        restored = true;
      } else if (h.status === 401 || h.status === 404) {
        token = '';
        try { localStorage.removeItem(KEY); } catch (e) { /* ignore */ }
      }
    }
    if (!restored) addMessage('ai', r.body.greeting || '');
    listen();
  }

  function listen() {                              // Incoming: SSE with a fallback poll every 2.5 seconds
    if (!token || stream || timer) return;
    if (typeof EventSource === 'undefined') { poll(); return; }
    stream = new EventSource(API + '/api/widget/events?st=' + encodeURIComponent(token));
    stream.addEventListener('message', function (e) {
      addFromServer(JSON.parse(e.data));
    });
    stream.addEventListener('conversation:update', function (e) {
      var u = JSON.parse(e.data);
      applyState(u.state, u.operator);
    });
    stream.addEventListener('closed', stopStream);
    stream.addEventListener('error', function () { stopStream(); poll(); });
  }
  function stopStream() { if (stream) { stream.close(); stream = null; } }
  async function poll() {
    if (!token) return;
    var r = await api('/api/widget/poll?st=' + encodeURIComponent(token) + (lastAt ? '&after=' + encodeURIComponent(lastAt) : ''));
    if (r.ok && r.body && r.body.new_messages) {
      r.body.new_messages.forEach(addFromServer);
    }
    if (r.ok && r.body && r.body.state) applyState(r.body.state, r.body.operator);
    timer = setTimeout(poll, 2500);
  }

  async function send(text) {
    var payload = { channel_id: CHANNEL, content: text, session_token: token || undefined, page_url: location.href, page_title: document.title };
    if (consent && consentBox.checked) payload.consent_version = consent.version;
    var r = await post('/api/widget/messages', payload);
    if (r.status === 400 && r.body && r.body.error === 'consent_required') { showConsent(r.body.consent); throw new Error(TEXT.consentRetry); }
    if (r.status === 413) throw new Error(TEXT.tooLong);
    if (r.status === 429) throw new Error(TEXT.tooMany);
    if (!r.ok) throw new Error(TEXT.failed);
    if (consent && consentBox.checked) accepted = consent.version;
    saveToken(r.body.session_token);
    if (r.body.queued) addMessage('system', TEXT.queued);
    else if (r.body.response) addMessage('ai', r.body.response);
    listen();
  }

  form.addEventListener('submit', async function (e) {
    e.preventDefault();
    var text = input.value.trim();
    if (!text) return;
    if (consent && !consentBox.checked) { setStatus(TEXT.needConsent); return; }
    sendBtn.disabled = true; setStatus('');
    addMessage('user', text);
    try { await send(text); input.value = ''; }
    catch (err) { setStatus(err.message); }
    finally { sendBtn.disabled = false; input.focus(); }
  });

  init().catch(function () { setStatus(TEXT.unavailable); sendBtn.disabled = true; });
})();

Template: Dark

Always dark, teal accent. For sites with a dark design.

Darkexample
AdvisorAn AI replies, an operator joins when needed
Hello! I can help with courses and prices. What would you like to know?
Which course suits a beginner?
The foundation course is a good start: it runs four weeks and the recordings stay with you.

A static preview: the messages are made up and the field is disabled. The live chat works once your channel id is in place.

HTML

<section class="ch" data-ch-root data-channel="CHANNEL_ID" data-api="" aria-label="Chat with an advisor">
  <header class="ch-head">
    <span class="ch-avatar" aria-hidden="true">A</span>
    <div><strong class="ch-title">Advisor</strong><span class="ch-sub">An AI replies, an operator joins when needed</span></div>
  </header>
  <div class="ch-log" data-ch="log" role="log" aria-live="polite"><!-- Messages are added by the script --></div>
  <label class="ch-consent" data-ch="consent" hidden><input type="checkbox" data-ch="consent-box"><span data-ch="consent-text"></span></label>
  <p class="ch-status" data-ch="status" role="status"></p>
  <form class="ch-form" data-ch="form">
    <input class="ch-input" data-ch="input" type="text" maxlength="4000" autocomplete="off" placeholder="Your question" aria-label="Your question" required>
    <button class="ch-send" data-ch="send" type="submit">Send</button>
  </form>
</section>

CSS

.ch {
  --ch-accent: #2d6a4f; --ch-accent-fg: #ffffff; --ch-bg: #ffffff; --ch-fg: #1f2d26; --ch-muted: #4f6458;
  --ch-line: #e4ddcf; --ch-field: #7a8c80; --ch-ai: #eef3ee; --ch-ai-fg: #1f2d26; --ch-radius: 14px;
  --ch-font: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
  display: flex; flex-direction: column; width: 100%; max-width: 380px; height: 520px; max-height: 100%;
  background: var(--ch-bg); color: var(--ch-fg); border: 1px solid var(--ch-line); border-radius: var(--ch-radius);
  font: 15px/1.45 var(--ch-font); overflow: hidden; box-sizing: border-box;
}
.ch *, .ch *::before, .ch *::after { box-sizing: border-box; }
.ch :focus-visible { outline: 2px solid var(--ch-accent); outline-offset: 2px; }
.ch-head { display: flex; align-items: center; gap: 10px; padding: 12px 14px; border-bottom: 1px solid var(--ch-line); }
.ch-avatar { flex: none; width: 36px; height: 36px; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-weight: 700; background: var(--ch-accent); color: var(--ch-accent-fg); }
.ch-title { display: block; font-weight: 700; }
.ch-sub { display: block; font-size: 12px; color: var(--ch-muted); }
.ch-log { flex: 1; min-height: 0; overflow-y: auto; padding: 14px; display: flex; flex-direction: column; gap: 8px; -webkit-overflow-scrolling: touch; }
.ch-msg { max-width: 85%; padding: 9px 12px; border-radius: calc(var(--ch-radius) - 4px); white-space: pre-wrap; overflow-wrap: anywhere; }
.ch-msg[data-sender="user"] { align-self: flex-end; background: var(--ch-accent); color: var(--ch-accent-fg); }
.ch-msg[data-sender="ai"], .ch-msg[data-sender="operator"] { align-self: flex-start; background: var(--ch-ai); color: var(--ch-ai-fg); }
.ch-msg[data-sender="system"] { align-self: center; max-width: 100%; padding: 2px 8px; font-size: 13px; color: var(--ch-muted); text-align: center; }
.ch-msg a { color: inherit; text-decoration: underline; }
.ch-card { display: flex; flex-direction: column; gap: 4px; margin-top: 6px; padding: 8px; border: 1px solid var(--ch-line); border-radius: 8px; white-space: normal; }
.ch-consent { display: flex; gap: 8px; align-items: flex-start; padding: 0 14px 8px; font-size: 13px; color: var(--ch-muted); }
.ch-consent[hidden] { display: none; }
.ch-consent a { color: var(--ch-accent); overflow-wrap: anywhere; }
.ch-status { margin: 0; padding: 0 14px; min-height: 1.3em; font-size: 13px; color: var(--ch-muted); }
.ch-form { display: flex; gap: 8px; padding: 10px 14px 14px; }
.ch-input { flex: 1; min-width: 0; padding: 10px 12px; font: inherit; color: var(--ch-fg); background: var(--ch-bg); border: 1px solid var(--ch-field); border-radius: calc(var(--ch-radius) - 4px); }
.ch-input::placeholder { color: var(--ch-muted); opacity: 1; }
.ch-send { flex: none; padding: 10px 16px; font: inherit; font-weight: 600; cursor: pointer; color: var(--ch-accent-fg); background: var(--ch-accent); border: 0; border-radius: calc(var(--ch-radius) - 4px); }
.ch-send:disabled { opacity: 0.55; cursor: default; }
@media (max-width: 480px) { .ch { max-width: none; height: 70vh; height: 70dvh; } }

.ch { --ch-bg: #0f1a24; --ch-fg: #e6eef5; --ch-muted: #9fb3c5; --ch-line: #243545; --ch-field: #5f7a92; --ch-ai: #1b2a38; --ch-ai-fg: #e6eef5; --ch-accent: #5eead4; --ch-accent-fg: #04201c; --ch-radius: 16px; }
.ch { box-shadow: 0 16px 40px rgba(0, 0, 0, 0.45); }
.ch .ch-head { background: linear-gradient(180deg, #162636, #0f1a24); }
.ch .ch-msg[data-sender="user"] { font-weight: 500; }
.ch .ch-input { background: #0a131b; }

JS

(function () {
  var root = document.querySelector('[data-ch-root]');
  if (!root) return;
  var API = root.getAttribute('data-api') || '';   // Cell address (scheme and domain). Empty - the page lives on the cell domain itself.
  // From another domain the browser lets only init through: use your own proxy forwarding /api/widget/*
  // (turn buffering off for /api/widget/events: proxy_buffering off).
  var CHANNEL = root.getAttribute('data-channel');
  var KEY = 'chat_token:' + CHANNEL;
  var TEXT = {
    unavailable: 'Chat is unavailable', needConsent: 'Tick the consent box to send a message', consentRetry: 'Tick the consent box and send the message again',
    tooLong: 'The message is too long', tooMany: 'Too many requests, wait a minute', failed: 'Could not send', queued: 'Passed to an operator, who will reply later',
    online: 'Online', price: 'Price'
  };
  function q(name) { return root.querySelector('[data-ch="' + name + '"]'); }
  var log = q('log'), statusEl = q('status'), form = q('form'), input = q('input'), sendBtn = q('send');
  var consentWrap = q('consent'), consentBox = q('consent-box'), consentText = q('consent-text');
  var shown = {};                                  // ids of messages already shown
  var token = '', consent = null, accepted = '', stream = null, timer = null, lastAt = '';
  try { token = localStorage.getItem(KEY) || ''; } catch (e) { /* storage blocked: a new conversation after reload */ }
  function saveToken(t) {                          // The token is refreshed in every response - save it every time
    token = t;
    try { localStorage.setItem(KEY, t); } catch (e) { /* ignore */ }
  }

  async function api(path, options) {
    var res = await fetch(API + path, options);
    var body = null;
    try { body = await res.json(); } catch (e) { /* not JSON */ }
    return { ok: res.ok, status: res.status, body: body };
  }
  function post(path, payload) {
    return api(path, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) });
  }
  function setStatus(text) { statusEl.textContent = text || ''; }

  function safeUrl(u) { return typeof u === 'string' && (/^https:\/\//.test(u) || (u.charAt(0) === '/' && u.charAt(1) !== '/')); }
  function link(href, text) {
    var a = document.createElement('a');
    a.href = href; a.target = '_blank'; a.rel = 'noopener'; a.textContent = text;
    return a;
  }

  function addMessage(sender, content, id, extra) {
    if (id) { if (shown[id]) return; shown[id] = true; }
    extra = extra || {};
    var last = log.lastElementChild;               // An AI reply can arrive both in the send response and on the stream (no shared id): compare with the last one.
    if (sender !== 'user' && !extra.attachments && !extra.cards && last && last.getAttribute('data-sender') === sender && last.textContent === content) return;
    var node = document.createElement('div');
    node.className = 'ch-msg';
    node.setAttribute('data-sender', sender);      // user | ai | operator | system
    if (extra.operator && extra.operator.name) node.setAttribute('data-operator', extra.operator.name);
    if (content) node.appendChild(document.createTextNode(content));   // textContent, not innerHTML: the text is not yours
    // Extension: attachments. The link is signed and short-lived (expires_at): do not cache the address.
    (extra.attachments || []).forEach(function (f) {
      if (!safeUrl(f.url)) return;
      var line = document.createElement('div');
      line.appendChild(link(f.url, f.name || 'file'));
      node.appendChild(line);
    });
    // Extension: a card (product or item) with buttons. Addresses are https or a path on your site only.
    (extra.cards || []).forEach(function (card) {
      var box = document.createElement('div');
      box.className = 'ch-card';
      var title = document.createElement('strong');
      title.textContent = card.name || '';
      box.appendChild(title);
      if (card.price_text) { var price = document.createElement('div'); price.textContent = TEXT.price + ': ' + card.price_text; box.appendChild(price); }
      if (card.description) { var d = document.createElement('div'); d.textContent = card.description; box.appendChild(d); }
      (card.buttons || []).forEach(function (b) { if (safeUrl(b.url)) box.appendChild(link(b.url, b.label || '')); });
      node.appendChild(box);
    });
    log.appendChild(node);
    log.scrollTop = log.scrollHeight;
  }
  function addFromServer(m) {
    addMessage(m.sender, m.content, m.id, { operator: m.operator, attachments: m.attachments, cards: m.cards });
    lastAt = m.created_at || lastAt;
  }

  // Extension: conversation state (ai | queued | operator). The header and data-state on the root follow it; the styling is yours.
  var cfg = {};
  function applyState(state, operator) {
    state = state || 'ai';
    root.setAttribute('data-state', state);
    var title = root.querySelector('.ch-title'), sub = root.querySelector('.ch-sub');
    var t = null, s = null;
    if (state === 'operator' && operator && operator.name) { t = operator.name; s = TEXT.online; }
    else if (state === 'queued' && cfg.queue) { t = cfg.queue.title; s = cfg.queue.subtitle; }
    else if (cfg.assistant) { t = cfg.assistant.name; s = cfg.assistant.subtitle; }
    if (title && t) title.textContent = t;
    if (sub && s) sub.textContent = s;
  }

  function showConsent(c) {
    consent = c;
    consentText.textContent = c.text + ' ';
    if (c.url) {
      var a = document.createElement('a');
      a.href = c.url; a.target = '_blank'; a.rel = 'noopener'; a.textContent = c.url;
      consentText.appendChild(a);
    }
    if (accepted !== c.version) consentBox.checked = false;
    consentWrap.hidden = false;
  }

  async function init() {                          // Channel settings, consent, history; the widget does not store the greeting - show it yourself
    var r = await post('/api/widget/init', { channel_id: CHANNEL });
    if (!r.ok) { setStatus(TEXT.unavailable); sendBtn.disabled = true; return; }
    cfg = r.body;
    applyState('ai');
    if (r.body.color && root.hasAttribute('data-use-color')) root.style.setProperty('--ch-accent', r.body.color);   // Optional: take the colour from the channel settings (data-use-color on the root)
    if (r.body.consent) showConsent(r.body.consent);
    var restored = false;
    if (token) {
      var h = await api('/api/widget/history?st=' + encodeURIComponent(token));
      if (h.ok && h.body && h.body.messages && h.body.messages.length) {
        h.body.messages.forEach(addFromServer);
        if (h.body.state) applyState(h.body.state, h.body.operator);
        restored = true;
      } else if (h.status === 401 || h.status === 404) {
        token = '';
        try { localStorage.removeItem(KEY); } catch (e) { /* ignore */ }
      }
    }
    if (!restored) addMessage('ai', r.body.greeting || '');
    listen();
  }

  function listen() {                              // Incoming: SSE with a fallback poll every 2.5 seconds
    if (!token || stream || timer) return;
    if (typeof EventSource === 'undefined') { poll(); return; }
    stream = new EventSource(API + '/api/widget/events?st=' + encodeURIComponent(token));
    stream.addEventListener('message', function (e) {
      addFromServer(JSON.parse(e.data));
    });
    stream.addEventListener('conversation:update', function (e) {
      var u = JSON.parse(e.data);
      applyState(u.state, u.operator);
    });
    stream.addEventListener('closed', stopStream);
    stream.addEventListener('error', function () { stopStream(); poll(); });
  }
  function stopStream() { if (stream) { stream.close(); stream = null; } }
  async function poll() {
    if (!token) return;
    var r = await api('/api/widget/poll?st=' + encodeURIComponent(token) + (lastAt ? '&after=' + encodeURIComponent(lastAt) : ''));
    if (r.ok && r.body && r.body.new_messages) {
      r.body.new_messages.forEach(addFromServer);
    }
    if (r.ok && r.body && r.body.state) applyState(r.body.state, r.body.operator);
    timer = setTimeout(poll, 2500);
  }

  async function send(text) {
    var payload = { channel_id: CHANNEL, content: text, session_token: token || undefined, page_url: location.href, page_title: document.title };
    if (consent && consentBox.checked) payload.consent_version = consent.version;
    var r = await post('/api/widget/messages', payload);
    if (r.status === 400 && r.body && r.body.error === 'consent_required') { showConsent(r.body.consent); throw new Error(TEXT.consentRetry); }
    if (r.status === 413) throw new Error(TEXT.tooLong);
    if (r.status === 429) throw new Error(TEXT.tooMany);
    if (!r.ok) throw new Error(TEXT.failed);
    if (consent && consentBox.checked) accepted = consent.version;
    saveToken(r.body.session_token);
    if (r.body.queued) addMessage('system', TEXT.queued);
    else if (r.body.response) addMessage('ai', r.body.response);
    listen();
  }

  form.addEventListener('submit', async function (e) {
    e.preventDefault();
    var text = input.value.trim();
    if (!text) return;
    if (consent && !consentBox.checked) { setStatus(TEXT.needConsent); return; }
    sendBtn.disabled = true; setStatus('');
    addMessage('user', text);
    try { await send(text); input.value = ''; }
    catch (err) { setStatus(err.message); }
    finally { sendBtn.disabled = false; input.focus(); }
  });

  init().catch(function () { setStatus(TEXT.unavailable); sendBtn.disabled = true; });
})();

Your own button and an embedded block

If you want the ready widget chat but with your own button, or right inside the page, the template opens the chat page /widget/<channel_id> in a frame. This is a workaround: supported open methods and an embedded-block mode are planned for the widget - see the widget JS API. The chat page address is internal, not a contract; the frame is created on first open.

Template buttonexample

The chat panel opens over the page on click; in the preview the button is disabled.

HTML

<!-- Your own button and panel. data-origin is the cell address (scheme and domain); empty - the same domain.
     An in-page block instead of a popup: add data-mode="inline" and remove hidden from the panel. -->
<div class="my-chat" data-chat-embed data-origin="" data-channel="CHANNEL_ID" data-frame-theme="light">
  <button type="button" class="my-chat-open" data-chat-open aria-expanded="false" aria-controls="my-chat-panel">Ask a question</button>
  <div class="my-chat-panel" id="my-chat-panel" data-chat-panel hidden>
    <button type="button" class="my-chat-close" data-chat-close aria-label="Close chat">&times;</button>
  </div>
</div>

CSS

.my-chat { --mc-accent: #2d6a4f; --mc-accent-fg: #ffffff; --mc-bg: #ffffff; --mc-radius: 16px; }
.my-chat-open {
  font: 600 15px/1.2 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; cursor: pointer;
  padding: 12px 22px; border: 0; border-radius: 999px; color: var(--mc-accent-fg); background: var(--mc-accent);
}
.my-chat-open:focus-visible, .my-chat-close:focus-visible { outline: 2px solid var(--mc-accent); outline-offset: 2px; }
.my-chat-panel {
  position: fixed; right: 16px; bottom: 16px; z-index: 1000; width: 380px; max-width: calc(100vw - 32px);
  height: 560px; max-height: calc(100vh - 32px); background: var(--mc-bg); border-radius: var(--mc-radius);
  box-shadow: 0 16px 48px rgba(0, 0, 0, 0.25); overflow: hidden;
}
.my-chat-panel[hidden] { display: none; }
.my-chat-frame { display: block; width: 100%; height: 100%; border: 0; }
.my-chat-close {
  position: absolute; top: 8px; right: 8px; z-index: 1; width: 32px; height: 32px; border: 0; border-radius: 50%;
  font-size: 20px; line-height: 1; cursor: pointer; color: #1f2d26; background: rgba(255, 255, 255, 0.9);
}
.my-chat[data-mode="inline"] .my-chat-open, .my-chat[data-mode="inline"] .my-chat-close { display: none; }
.my-chat[data-mode="inline"] .my-chat-panel {
  position: static; width: 100%; max-width: 380px; height: 560px; max-height: none; box-shadow: none; border: 1px solid #e4ddcf;
}
@media (max-width: 480px) { .my-chat-panel { right: 8px; bottom: 8px; max-width: calc(100vw - 16px); } }

JS

(function () {
  var box = document.querySelector('[data-chat-embed]');
  if (!box) return;
  var openBtn = box.querySelector('[data-chat-open]');
  var closeBtn = box.querySelector('[data-chat-close]');
  var panel = box.querySelector('[data-chat-panel]');
  var frame = null;

  function ensureFrame() {                         // The frame is created on first open: until then the page address does not reach the platform
    if (frame) return;
    var origin = box.getAttribute('data-origin') || '';
    var src = origin + '/widget/' + encodeURIComponent(box.getAttribute('data-channel'))
      + '?theme=' + encodeURIComponent(box.getAttribute('data-frame-theme') || 'light')
      + '&page=' + encodeURIComponent(location.origin + location.pathname)
      + '&title=' + encodeURIComponent(document.title);
    frame = document.createElement('iframe');
    frame.className = 'my-chat-frame';
    frame.title = 'Chat with an advisor';
    frame.setAttribute('allow', 'clipboard-write');
    frame.src = src;
    panel.appendChild(frame);
  }
  function setOpen(isOpen) {
    if (isOpen) ensureFrame();
    panel.hidden = !isOpen;
    openBtn.setAttribute('aria-expanded', String(isOpen));
    if (!isOpen) openBtn.focus();
  }

  openBtn.addEventListener('click', function () { setOpen(panel.hidden); });
  closeBtn.addEventListener('click', function () { setOpen(false); });
  document.addEventListener('keydown', function (e) { if (e.key === 'Escape' && !panel.hidden) setOpen(false); });
  // The close button inside the frame sends the parent the string sapport:close - an internal protocol, not a contract
  window.addEventListener('message', function (e) {
    if (frame && e.source === frame.contentWindow && e.data === 'sapport:close') setOpen(false);
  });
  if (!panel.hidden) ensureFrame();                // data-mode="inline": the block is open at once
})();

Before launch