Пять веб-компонентов, которые дают посетителю поговорить с вашим агентом — голосом, в браузере, ничего не устанавливая. Разговор идёт по WebRTC, ответ приходит примерно за секунду, а агент умеет открывать страницы и видеть, что на экране.
Эта сфера и есть виджет. Нажмите и говорите.
Один агент, пять способов до него добраться. Смешивайте свободно — два тега с одним agent-id это обычная схема.
Сфера в потоке
370 px по умолчанию, стоит прямо в потоке страницы. Задайте нужную ширину; на узком экране уменьшается сама.
<hanc-ai-inline-call> Секция на посадочной, страница «Связаться» — там, где разговор и есть смысл блока.
Плавающая сфера
120 px, прибита в 32 px от правого нижнего угла. Ещё три угла на выбор или static, чтобы поставить её в поток.
<hanc-ai-floating-call> Доступна с любой страницы и не занимает места в вёрстке.
Кнопка-пилюля
Горизонтальная кнопка: слева сфера 48 px, справа ваша подпись. Стоит в потоке, как любая другая кнопка.
<hanc-ai-pill-call> В ряду кнопок, в шапке, внутри карточки.
Плавающая пилюля
Та же кнопка, прибитая в 32 px от правого нижнего угла. Те же четыре угла на выбор.
<hanc-ai-pill-floating-call> Когда круглая сфера слишком заметна, но нужна под рукой на каждой странице.
Форма обратного звонка
Выбор страны, номер форматируется по мере ввода, одна кнопка. Сколько раз перезванивать — настраивается на агенте, а не в разметке.
<hanc-ai-callback> Для тех, кто не станет говорить через браузер, и для мест, где микрофон неуместен.
Обязательный атрибут один: идентификатор агента из кабинета. Ключам API в разметке не место — всё, что попадает в HTML, видно посетителю.
Возьмите идентификатор агента
Строка вида 69d20781de6244c89509eb08, из кабинета.
Подключите скрипт
Одна строка в <head>. Через npm это один импорт — теги регистрируются сами.
Поставьте тег
Там, где нужен звонок. Обязателен только agent-id.
Отдавайте по HTTPS
На http:// браузер не даст микрофон. Пока разрабатываете, подойдёт localhost.
<!-- 1. the script, once, in <head> --> <script src="https://unpkg.com/hanc-webrtc-widgets" async></script> <!-- 2. the widget, wherever the call belongs --> <hanc-ai-inline-call agent-id="69d20781de6244c89509eb08" size="320" theme="tangerine" button-start-text="Talk to us"> </hanc-ai-inline-call>
React
До 19-й версии неизвестные пропсы передаются строками, поэтому объекты так не отдать. Оберните через @lit/react — официальный способ, заодно даёт типизацию и события.
Next.js
Виджету нужен браузер: 'use client' и динамический импорт с ssr: false. Иначе сборка упадёт на серверном рендеринге.
Vue
Работает без обёрток. Скажите сборщику, что теги с префиксом hanc-ai- — пользовательские элементы.
Голосом отвечать удобно не всегда — таблицу доставки вслух никто слушать не станет. Поэтому агент может отправить странице команду и спросить у неё, что сейчас смотрит посетитель. Решаете при этом вы: ваш код сам выбирает, что выполнять.
navigate page_context cart_state navigate Агент открывает страницу у вас на сайте — товар, раздел, заранее заполненную форму.
page_context Страница отвечает, где находится посетитель и что показано, и агент говорит про этот товар, а не вообще.
cart_state Страница отвечает, что лежит в корзине и сколько стоит, и агент называет сумму вслух.
Каждая команда приходит отменяемым DOM-событием agent-command. Вызов preventDefault() означает «обработал», respond(data) отправляет ответ обратно агенту. Проигнорируете событие — агент узнает, что команду не выполнили, и вернётся к разговору.
Команда — это просьба, а не приказ: проверяйте каждый путь, прежде чем по нему пойти. В примере отвергается всё, что не является путём внутри сайта.
widget.addEventListener('agent-command', (event) => {
const command = event.detail;
if (command.type === 'navigate') {
const path = command.payload?.path;
// Only same-site paths. Never follow an absolute URL.
if (!path?.startsWith('/') || path.includes('://')) return;
event.preventDefault(); // "handled" — the ACK protocol
router.push(path);
}
if (command.type === 'cart_state') {
event.preventDefault();
command.respond({ // answer travels back to the agent
items: cart.map(i => ({ name: i.name, price: i.price })),
total: cart.total,
currency: 'UAH',
});
}
}); Работает на
milotec.com.ua
Агент доводит посетителя до товара, рассказывает, что на текущей странице, и зачитывает корзину.
И iOS, и Android показывают его в веб-вью. Соберите маленькую HTML-страницу в коде и загрузите её с HTTPS-базой — без защищённого источника движок не выдаст странице микрофон.
Собеседника не слышно
Веб-вью не выведет звук WebRTC, пока аудиосессия приложения стоит в режиме воспроизведения. Переключите её на разговорный режим с выводом в динамик на время звонка и верните обратно при закрытии.
Агент отвечает не на том языке
Виджет берёт язык из navigator.language, а в веб-вью это язык системы, а не выбранный в приложении. Подмените его на старте документа — и пропатчите fetch, дописав browser_language в запрос комнаты: встраиваемый виджет сам это поле не шлёт.
Микрофону нужны обе половины
Системное разрешение приложению и разрешение странице внутри веб-вью. На Android спрашивайте системное до открытия экрана, на iOS выдайте запрос страницы в делегате.
let html = """
<hanc-ai-inline-call
agent-id="\(agentId)"
support-session-token="\(sessionToken)"></hanc-ai-inline-call>
<script src="https://unpkg.com/hanc-webrtc-widgets" async></script>
"""
// An HTTPS base is required — without a secure origin
// the engine will not hand the page a microphone.
webView.loadHTMLString(html, baseURL: URL(string: "https://hanc.ai")) Передайте токен в support-session-token. Виджет прикладывает его к звонку как есть, мы отдаём его вашей системе заголовком X-Hanc-Support-Token, а ваш MCP-сервер решает, кто это. Для нас токен непрозрачен: мы его не разбираем, не проверяем и не храним.
Никогда не доверяйте идентификатору, который пришёл внутри токена как заявление. Спросите у того, кто выдал токен, кто это, и работайте с ответом.
| Подход | Подделать | Оценка |
|---|---|---|
| Открытый идентификатор | Тривиально | Недопустимо |
| Зашифрованный идентификатор | Нельзя, но можно предъявить повторно | Половина решения |
| Токен вашего провайдера входа | Нельзя: подпись ставит провайдер | Рабочий вариант |
| Разовый серверный талон | Нельзя, и переиспользовать тоже | Лучший вариант |
Включите на подключении «требуется опознанный собеседник» — и на анонимном звонке ваши инструменты вообще не поднимутся: агент не расскажет про чужой аккаунт, даже если в вашем коде однажды появится ошибка.
Звонок без токена не получает доступа к аккаунту вовсе. Это поведение по умолчанию, а не настройка.
async def verified_user_id(ctx) -> str:
token = ctx.request.headers.get("x-hanc-support-token", "").strip()
if not token:
raise ValueError("No signed-in user on this call.")
# Ask the issuer who this is. Never read the id out of the token.
resp = await http.get(f"{AUTH_URL}/auth/v1/user",
headers={"Authorization": f"Bearer {token}"})
if resp.status_code != 200:
raise ValueError("Token invalid or expired.")
return resp.json()["id"] # ← the trusted identity Одиннадцать тем, светлая и тёмная по системной настройке или принудительно, и ещё около пятнадцати атрибутов для самой сферы.
| Атрибут | Что задаёт | По умолчанию |
|---|---|---|
agent-id | Какой агент отвечает. Единственный обязательный | — |
theme | Одна из одиннадцати цветовых тем | default |
size | Размер в пикселях; на узких экранах уменьшается сам | 370 |
button-start-text | Надпись на кнопке | Try to call |
position | Угол для плавающих форм | bottom-right |
glow-intensity | Сила свечения, 0–2 | 0.8 |
audio-reactivity | Насколько сфера отзывается на голос | 3.0 |
terms-enabled | Окно согласия перед звонком | false |
sound-enabled | Звуки начала и конца | true |
support-session-token | Токен вошедшего пользователя | — |
События для вашего кода
Ни одно из событий не несёт данных: оба — обычный Event, то есть вы получаете сам факт и момент времени. Содержимого разговора нет ни в том, ни в другом — расшифровки и записи живут в кабинете и в API.
Канал зашифрован
Управляющие запросы по HTTPS, звук по WSS и SRTP. Без HTTPS браузер и так не даст микрофон.
Агент проверяется
Соединение поднимается только для существующего идентификатора агента.
Частота ограничена
Выдача доступа к разговору throttled — от перебора и накрутки.
Токен сессии не оседает
Читается заново на каждый звонок и в нашу базу не пишется.
Список разрешённых доменов не является границей безопасности
Эта проверка выполняется в браузере. Она удобна, чтобы виджет не запустился по недосмотру на чужой копии вашей страницы, но не мешает обратиться к сервису напрямую. Считайте публичным всё, что агент расскажет анонимному собеседнику, а остальное закрывайте токеном сессии.
Что и где остаётся
В браузере
Две записи в localStorage: метка посетителя и факт согласия
Звук
Потоком. Записывается, только если запись включена у агента
Расшифровка
Сохраняется вместе с записью, доступна в кабинете
Размещение
Обработка и хранение в ЕС
Без HTTPS микрофона не будет
Проверяйте на localhost или защищённом домене
Safari требует жеста перед звуком
Звонок должен начинаться по нажатию
Блокировщики и прокси могут резать WebRTC
Оставьте форму обратного звонка как запасной путь
В приватном окне метка не сохраняется
Каждый разговор выглядит как первый — это нормально
React ниже 19 не передаёт объекты атрибутом
Обёртка через @lit/react
Серверный рендеринг ломается на прямом импорте
Динамический импорт с ssr: false
Начните бесплатно за 60 секунд. Без карты. Отмена в любой момент. Просто телефон, который сам берёт трубку.