Cinq composants web qui permettent à un visiteur de parler à votre agent — à voix haute, dans le navigateur, sans rien installer. La conversation passe par WebRTC, la réponse arrive en une seconde environ, et l'agent peut ouvrir des pages et lire ce qui est à l'écran.
Cette sphère est le widget. Appuyez et parlez.
Le même agent, cinq façons de l'atteindre. Mélangez librement — deux balises avec un seul agent-id est un montage tout à fait normal.
Sphère intégrée
370 px par défaut, dans le flux de la page. Donnez-lui la largeur voulue ; sur écran étroit elle se réduit d'elle-même.
<hanc-ai-inline-call> Une section de landing, une page de contact — là où l'appel est la raison d'être du bloc.
Sphère flottante
120 px, fixée à 32 px du coin inférieur droit. Trois autres coins au choix, ou static pour la remettre dans le flux.
<hanc-ai-floating-call> Accessible depuis n'importe quelle page sans prendre de place dans la mise en page.
Pilule
Un bouton horizontal : à gauche une sphère de 48 px, à droite votre propre libellé. Se place dans le flux comme n'importe quel bouton.
<hanc-ai-pill-call> Dans une rangée de boutons, dans l'en-tête, dans une carte.
Pilule flottante
Le même bouton, fixé à 32 px du coin inférieur droit. Les mêmes quatre coins au choix.
<hanc-ai-pill-floating-call> Quand une sphère ronde attire trop l'attention mais qu'on la veut toujours sous la main.
Formulaire de rappel
Sélecteur de pays, numéro formaté à la frappe, et un bouton. Le nombre de tentatives se règle sur l'agent, pas dans le balisage.
<hanc-ai-callback> Pour ceux qui ne parleront pas via le navigateur — et partout où un micro est malvenu.
Un seul attribut obligatoire : l'identifiant de l'agent, depuis votre tableau de bord. Aucune clé d'API n'a sa place dans le balisage — tout ce qui est dans le HTML est visible par le visiteur.
Récupérez l'identifiant de l'agent
Une chaîne du type 69d20781de6244c89509eb08, depuis le tableau de bord.
Ajoutez le script
Une ligne dans <head>. Via npm c'est un seul import — les balises s'enregistrent seules.
Placez la balise
Là où l'appel a sa place. Seul agent-id est obligatoire.
Servez en HTTPS
Les navigateurs ne donnent pas de micro en http://. Pendant le développement, localhost fonctionne.
<!-- 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
En dessous de la v19, les props inconnues sont passées en chaînes : impossible de transmettre des objets ainsi. Encapsulez avec @lit/react — la voie officielle, avec typage et événements en prime.
Next.js
Le widget a besoin d'un navigateur : 'use client' et un import dynamique avec ssr: false. Sinon la compilation casse au rendu serveur.
Vue
Fonctionne sans encapsulation. Indiquez au bundler que les balises hanc-ai- sont des éléments personnalisés.
Une réponse à l'oral est souvent le mauvais support — personne ne veut se faire lire un tableau de livraison. L'agent peut donc envoyer une commande à la page et lui demander ce que le visiteur regarde. Votre code garde la main : c'est lui qui décide ce qu'il honore.
navigate page_context cart_state navigate L'agent ouvre une page de votre site — un produit, une section, un formulaire déjà rempli.
page_context La page répond où se trouve le visiteur et ce qui est affiché, pour que l'agent parle de ce produit-là plutôt qu'en général.
cart_state La page répond ce qu'il y a dans le panier et ce que ça coûte, pour que l'agent annonce le total à voix haute.
Chaque commande arrive sous forme d'événement DOM annulable agent-command. Appeler preventDefault() signifie « traité » ; respond(data) renvoie une réponse à l'agent. Ignorez l'événement et l'agent est informé qu'il n'a pas été traité : il repasse alors par la parole.
Les commandes sont des demandes, pas des ordres : validez chaque chemin avant de le suivre. L'exemple refuse tout ce qui n'est pas un chemin interne au site.
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',
});
}
}); En production sur
milotec.com.ua
L'agent conduit le visiteur jusqu'à un produit, lui dit ce qui figure sur la page où il se trouve, et lui relit le panier.
iOS et Android l'affichent tous deux dans une vue web. Construisez une petite page HTML dans le code et chargez-la avec une base HTTPS — sans origine sécurisée, le moteur ne donnera pas de micro à la page.
On n'entend pas l'agent
Une vue web ne restitue pas l'audio WebRTC tant que la session audio de l'application reste en lecture. Basculez-la en mode conversation avec sortie haut-parleur pendant l'appel, puis remettez-la ensuite.
L'agent répond dans la mauvaise langue
Le widget prend sa langue dans navigator.language, qui dans une vue web est celle du système, pas celle choisie dans votre application. Surchargez-la au démarrage du document — et patchez fetch pour ajouter browser_language à la requête de salle, que le widget intégré n'envoie pas de lui-même.
Le micro exige les deux moitiés
L'autorisation système pour l'application et l'autorisation pour la page dans la vue web. Sur Android, demandez la première avant l'ouverture de l'écran ; sur iOS, accordez la demande de la page dans le délégué.
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")) Transmettez un jeton dans support-session-token. Le widget l'attache à l'appel tel quel, nous le transmettons à votre système dans l'en-tête X-Hanc-Support-Token, et votre serveur MCP décide de qui il s'agit. Le jeton nous est opaque : nous ne l'analysons pas, ne le vérifions pas et ne le stockons pas.
Ne faites jamais confiance à un identifiant qui arrive dans un jeton comme une affirmation. Demandez à l'émetteur du jeton de qui il s'agit, et travaillez avec sa réponse.
| Approche | Falsifiable | Verdict |
|---|---|---|
| Un identifiant en clair | Trivialement | Inacceptable |
| Un identifiant chiffré | Non, mais rejouable | Moitié de solution |
| Le jeton de votre fournisseur d'identité | Non : c'est le fournisseur qui signe | Fonctionne |
| Un ticket serveur à usage unique | Non, et non rejouable non plus | Le meilleur |
Activez « interlocuteur identifié requis » sur la connexion et un appel anonyme ne montera pas vos outils du tout — l'agent ne pourra pas parler du compte d'autrui, même si une erreur se glisse plus tard dans votre code.
Un appel sans jeton n'obtient aucun accès au compte. C'est le comportement par défaut, pas une option.
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 Onze thèmes, clair et sombre selon le réglage système ou forcés, et une quinzaine d'attributs supplémentaires pour la sphère elle-même.
| Attribut | Ce qu'il définit | Par défaut |
|---|---|---|
agent-id | Quel agent répond. Le seul obligatoire | — |
theme | L'un des onze thèmes de couleur | default |
size | Taille en pixels ; se réduit seule sur écran étroit | 370 |
button-start-text | Le libellé du bouton | Try to call |
position | Coin pour les formes flottantes | bottom-right |
glow-intensity | Intensité de la lueur, 0–2 | 0.8 |
audio-reactivity | Réactivité de la sphère à la voix | 3.0 |
terms-enabled | Panneau de consentement avant l'appel | false |
sound-enabled | Sons de début et de fin | true |
support-session-token | Le jeton de l'utilisateur connecté | — |
Événements pour votre propre code
Aucun des deux événements ne transporte de données : ce sont de simples Event, vous obtenez donc le fait et l'instant. Aucun contenu de conversation dans l'un ni l'autre — transcriptions et enregistrements vivent dans le tableau de bord et l'API.
Le canal est chiffré
Requêtes de contrôle en HTTPS, audio en WSS et SRTP. Sans HTTPS, le navigateur ne donne de toute façon pas de micro.
L'agent est vérifié
Un appel n'est monté que pour un identifiant d'agent qui existe.
L'accès est limité en débit
L'attribution de l'accès à la conversation est bridée, contre la force brute et l'abus.
Le jeton de session ne se dépose pas
Il est relu à chaque appel et n'est jamais écrit dans notre base.
La liste des domaines autorisés n'est pas une frontière de sécurité
Cette vérification s'exécute dans le navigateur. Elle est utile pour que le widget ne démarre pas par mégarde sur une copie de votre page, mais elle n'empêche personne d'appeler le service directement. Considérez comme public tout ce que l'agent dirait à un interlocuteur anonyme, et protégez le reste par un jeton de session.
Ce qui reste, et où
Dans le navigateur
Deux entrées dans localStorage : un marqueur de visiteur et le fait du consentement
Audio
En flux. Enregistré uniquement si l'enregistrement est activé pour l'agent
Transcription
Conservée avec l'enregistrement, consultable dans le tableau de bord
Localisation
Traitement et stockage dans l'UE
Pas de micro sans HTTPS
Testez sur localhost ou un domaine sécurisé
Safari veut un geste avant le son
L'appel doit démarrer sur un clic
Bloqueurs et proxys peuvent couper WebRTC
Gardez le formulaire de rappel comme voie de secours
Une fenêtre privée ne garde pas le marqueur
Chaque conversation semble être la première — c'est normal
React sous 19 ne passe pas d'objets en attribut
Encapsulez avec @lit/react
Le rendu serveur casse sur un import direct
Import dynamique avec ssr: false
Démarrez gratuitement en 60 secondes. Sans carte. Annulable. Le téléphone décroche tout seul.