Un agente de voz en tu web. Una etiqueta, sin backend.
Cinco componentes web que permiten a un visitante hablar con tu agente, en voz alta, en el navegador, sin instalar nada.
Esta esfera es el widget. Púlsala y habla.
Elige la forma que necesita la página
El mismo agente, cinco maneras de llegar a él. Mézclalas libremente, dos etiquetas con un mismo agent-id es un montaje normal.
La conversación va por WebRTC, la respuesta llega en aproximadamente un segundo, y el agente puede abrir páginas y leer lo que hay en pantalla.
-
Esfera en línea
<hanc-ai-inline-call>370 px por defecto, dentro del flujo de la página. Dale el ancho que quieras; en pantalla estrecha se reduce sola.
Una sección de una landing, una página de contacto, donde la llamada sea el sentido del bloque.
-
Esfera flotante
<hanc-ai-floating-call>120 px, fijada a 32 px de la esquina inferior derecha. Otras tres esquinas a elegir, o
staticpara meterla en el flujo.Accesible desde cualquier página sin ocupar sitio en la maquetación.
-
Píldora
<hanc-ai-pill-call>Un botón horizontal: a la izquierda una esfera de 48 px, a la derecha tu propia etiqueta. Va en el flujo como cualquier botón.
En una fila de botones, en la cabecera, dentro de una tarjeta.
-
Píldora flotante
<hanc-ai-pill-floating-call>El mismo botón, fijado a 32 px de la esquina inferior derecha. Las mismas cuatro esquinas a elegir.
Cuando una esfera redonda llama demasiado la atención pero la quieres siempre a mano.
-
Formulario de rellamada
<hanc-ai-callback>Selector de país, número formateado a medida que se escribe, y un botón. Cuántos reintentos hacer se configura en el agente, no en el marcado.
Para quien no va a hablar por el navegador, y para cualquier sitio donde un micrófono resulta incómodo.
Instalarlo
Un solo atributo obligatorio: el id del agente, desde tu panel. Ninguna clave de API va en el marcado, todo lo que está en el HTML lo ve el visitante.
- Toma el id del agente Una cadena como 69d20781de6244c89509eb08, desde el panel.
- Añade el script Una línea en <head>. Por npm es un solo import, las etiquetas se registran solas.
- Coloca la etiqueta Donde corresponda la llamada. Solo agent-id es obligatorio.
- Sírvelo por HTTPS Los navegadores no dan micrófono en http://. Mientras desarrollas, localhost funciona.
<!-- 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
Por debajo de la v19 las props desconocidas se pasan como cadenas, así que los objetos no se pueden entregar por ahí. Envuélvelo con @lit/react, la vía oficial, y además te da tipado y eventos.
-
Next.js
El widget necesita un navegador: 'use client' y un import dinámico con ssr: false. Si no, la compilación se rompe en el renderizado de servidor.
-
Vue
Funciona sin envoltorio. Dile al empaquetador que las etiquetas hanc-ai- son elementos personalizados.
El agente puede abrir páginas y leer lo que hay en pantalla
Una respuesta hablada a menudo es el medio equivocado, nadie quiere que le lean una tabla de envíos. Por eso el agente puede enviar un comando a la página y preguntarle qué está mirando el visitante. Tu código sigue al mando: decide qué atiende.
-
navigate - El agente abre una página de tu sitio, un producto, una sección, un formulario ya rellenado.
-
page_context - La página responde dónde está el visitante y qué se muestra, para que el agente hable de este producto y no en general.
-
cart_state - La página responde qué hay en el carrito y cuánto cuesta, para que el agente sume en voz alta.
Cada comando llega como un evento DOM cancelable agent-command. Llamar a preventDefault() significa «atendido»; respond(data) devuelve una respuesta al agente. Si ignoras el evento, al agente se le informa de que no se atendió y vuelve a responder hablando.
Los comandos son peticiones, no órdenes: valida cada ruta antes de seguirla. El ejemplo rechaza todo lo que no sea una ruta del propio sitio.
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',
});
}
}); El mismo widget dentro de tu app
iOS y Android lo muestran en una vista web. Construye una pequeña página HTML en código y cárgala con una base HTTPS, sin un origen seguro el motor no entrega micrófono a la página.
-
No se oye al agente
Una vista web no reproduce audio WebRTC mientras la sesión de audio de la app está en modo reproducción. Cámbiala a un modo conversacional con salida por altavoz mientras dure la llamada y devuélvela después.
-
El agente responde en el idioma equivocado
El widget toma el idioma de navigator.language, que en una vista web es el del sistema, no el elegido en tu app. Sobrescríbelo al inicio del documento, y parchea fetch para añadir browser_language a la petición de sala, que el widget incrustado no envía por sí mismo.
-
El micrófono necesita las dos mitades
El permiso del sistema para la app y el permiso para la página dentro de la vista web. En Android pide el del sistema antes de abrir la pantalla; en iOS concede la petición de la página en el delegado.
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"))
Deja que el agente vea al usuario identificado, y solo a él
Pasa un token en support-session-token. El widget lo adjunta a la llamada tal cual, nosotros lo reenviamos a tu sistema en la cabecera X-Hanc-Support-Token, y tu servidor MCP decide quién es. El token nos resulta opaco: no lo analizamos, ni lo verificamos, ni lo guardamos.
- Tu app
- El widget
- Hanc.AI
- Tu MCP
- Tus datos
Nunca confíes en un identificador que llega dentro de un token como afirmación. Pregunta a quien emitió el token quién es, y trabaja con la respuesta.
| Enfoque | Falsificable | Veredicto |
|---|---|---|
| Un id de usuario en claro | Trivialmente | Inaceptable |
| Un id de usuario cifrado | No, pero se puede reutilizar | Media solución |
| El token de tu proveedor de acceso | No: lo firma el proveedor | Funciona |
| Un vale de servidor de un solo uso | No, y tampoco se puede reutilizar | Lo mejor |
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 Activa «requiere interlocutor identificado» en la conexión y una llamada anónima no levantará tus herramientas en absoluto, el agente no podrá hablar de la cuenta de otro ni aunque más adelante aparezca un fallo en tu código.
Una llamada sin token no obtiene acceso alguno a la cuenta. Es el comportamiento por defecto, no una opción.
Debe parecerse a tu web, no a la nuestra
11 temas, claro y oscuro según el ajuste del sistema o forzados, y alrededor de quince atributos más para la propia esfera.
-
default -
emerald -
rose -
amber -
cyan -
purple -
blue -
white -
black -
tangerine -
ember
| Atributo | Qué define | Por defecto |
|---|---|---|
agent-id | Qué agente responde. El único obligatorio | |
theme | Uno de los 11 temas de color | default |
size | Tamaño en píxeles; se reduce solo en pantallas estrechas | 370 |
button-start-text | El texto del botón | Try to call |
position | Esquina para las formas flotantes | bottom-right |
glow-intensity | Intensidad del brillo, 0-2 | 0.8 |
audio-reactivity | Cuánto responde la esfera a la voz | 3.0 |
terms-enabled | Panel de consentimiento antes de la llamada | false |
sound-enabled | Sonidos de inicio y fin | true |
support-session-token | El token del usuario identificado |
Eventos para tu propio código
Ninguno de los dos eventos lleva datos: ambos son un Event simple, así que obtienes el hecho y el momento. En ninguno hay contenido de la conversación, las transcripciones y grabaciones viven en el panel y en la API.
Qué te protege y qué no
-
El canal está cifrado
Peticiones de control por HTTPS, audio por WSS y SRTP. Sin HTTPS el navegador no da micrófono, de entrada.
-
Se comprueba el agente
Solo se levanta una llamada para un id de agente que existe.
-
El acceso está limitado por tasa
La entrega de acceso a la conversación está limitada, contra fuerza bruta y abuso.
-
El token de sesión no se queda
Se lee de nuevo en cada llamada y nunca se escribe en nuestra base de datos.
La lista de dominios permitidos no es una frontera de seguridad
Esa comprobación se ejecuta en el navegador. Sirve para que el widget no arranque en una copia perdida de tu página, pero no impide llamar al servicio directamente. Trata como público todo lo que el agente le contaría a un interlocutor anónimo, y protege el resto con un token de sesión.
Qué se guarda y dónde
- En el navegador
- Dos entradas en localStorage: una marca de visitante y el hecho del consentimiento
- Audio
- En streaming. Se graba solo si la grabación está activada para el agente
- Transcripción
- Se guarda con la grabación y es legible en el panel
- Ubicación
- Procesamiento y almacenamiento en la UE
Límites que conviene conocer antes de empezar
- Sin HTTPS no hay micrófono
- Prueba en localhost o en un dominio seguro
- Safari quiere un gesto antes del audio
- La llamada tiene que arrancar con un clic
- Bloqueadores y proxies pueden cortar WebRTC
- Deja el formulario de rellamada como alternativa
- Una ventana privada no guarda la marca de visitante
- Cada conversación parece la primera, es normal
- React por debajo de 19 no pasa objetos como atributos
- Envuélvelo con @lit/react
- El renderizado de servidor se rompe con un import directo
- Import dinámico con ssr: false
Su teléfono, por fin trabajando para usted.