Голосовой агент на вашем сайте. Один тег, без бэкенда.
Пять веб-компонентов, которые дают посетителю поговорить с вашим агентом, голосом, в браузере, ничего не устанавливая.
Эта сфера и есть виджет. Нажмите и говорите.
Выберите ту форму, которая нужна странице
Один агент, пять способов до него добраться. Смешивайте свободно, два тега с одним agent-id это обычная схема.
Разговор идёт по WebRTC, ответ приходит примерно за секунду, а агент умеет открывать страницы и видеть, что на экране.
-
Сфера в потоке
<hanc-ai-inline-call>370 px по умолчанию, стоит прямо в потоке страницы. Задайте нужную ширину; на узком экране уменьшается сама.
Секция на посадочной, страница «Связаться», там, где разговор и есть смысл блока.
-
Плавающая сфера
<hanc-ai-floating-call>120 px, прибита в 32 px от правого нижнего угла. Ещё три угла на выбор или
static, чтобы поставить её в поток.Доступна с любой страницы и не занимает места в вёрстке.
-
Кнопка-пилюля
<hanc-ai-pill-call>Горизонтальная кнопка: слева сфера 48 px, справа ваша подпись. Стоит в потоке, как любая другая кнопка.
В ряду кнопок, в шапке, внутри карточки.
-
Плавающая пилюля
<hanc-ai-pill-floating-call>Та же кнопка, прибитая в 32 px от правого нижнего угла. Те же четыре угла на выбор.
Когда круглая сфера слишком заметна, но нужна под рукой на каждой странице.
-
Форма обратного звонка
<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 - Страница отвечает, что лежит в корзине и сколько стоит, и агент называет сумму вслух.
Каждая команда приходит отменяемым 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',
});
}
}); Тот же виджет внутри вашего приложения
И 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-сервер решает, кто это. Для нас токен непрозрачен: мы его не разбираем, не проверяем и не храним.
- Ваше приложение
- Виджет
- Hanc.AI
- Ваш 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 Включите на подключении «требуется опознанный собеседник», и на анонимном звонке ваши инструменты вообще не поднимутся: агент не расскажет про чужой аккаунт, даже если в вашем коде однажды появится ошибка.
Звонок без токена не получает доступа к аккаунту вовсе. Это поведение по умолчанию, а не настройка.
Он должен выглядеть как ваш сайт, а не как наш
11 тем, светлая и тёмная по системной настройке или принудительно, и ещё около пятнадцати атрибутов для самой сферы.
-
default -
emerald -
rose -
amber -
cyan -
purple -
blue -
white -
black -
tangerine -
ember
| Атрибут | Что задаёт | По умолчанию |
|---|---|---|
agent-id | Какой агент отвечает. Единственный обязательный | |
theme | Одна из 11 цветовых тем | 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
Ваш телефон, который наконец работает на вас.