Skip to content

Tocar para llamar: cómo convertimos nuestra entrevista de voz con IA en una llamada nativa de WhatsApp

· 17 min de lectura

tags: ai-agents, architecture, recruiting

En Selenios, los candidatos se postulan a un trabajo sin salir de WhatsApp. Le escriben al número de la empresa, completan un formulario dentro del chat y reciben un puntaje. El paso siguiente es una entrevista de screening con un agente de voz hecho sobre ElevenLabs Agents. Hasta esta semana, ese paso era un link: abrir el navegador, permitir el micrófono, hablar con el agente.

Funciona, pero es el único momento en que el candidato tiene que salir del chat, y es donde las cosas fallan: el navegador interno de la app, el permiso del micrófono, una computadora que no está a mano. Así que nos hicimos una pregunta simple. El candidato ya está en WhatsApp, y WhatsApp hace llamadas. ¿Por qué no hacer que la entrevista sea una llamada?

Hay una segunda razón, y en América Latina puede ser la más importante. Para mucha gente, WhatsApp es la app que siempre funciona: hay planes de celular que lo incluyen mientras el resto de los datos es limitado o se cobra aparte. Un paso que pasa en el navegador es un paso que algunos candidatos no pueden dar con el plan que tienen. Si la llamada está incluida depende de la operadora y del plan, pero cuanto más del proceso queda dentro de WhatsApp, menos candidatos perdemos en el camino.

Ahora la invitación trae un botón. El candidato lo toca, WhatsApp hace la llamada y atiende el mismo agente, con las mismas preguntas y el mismo scoring. El link web queda en el mismo mensaje como alternativa. La primera entrevista en producción, sobre una postulación interna, duró 3 minutos y 26 segundos y entró al pipeline como cualquier entrevista web: transcripción, puntaje, siguiente etapa.

Este post es sobre lo que hizo falta. Ningún proveedor podía llevar el audio por nosotros, así que construimos un servicio chico en AWS. Cuento cómo está armado, cómo es una llamada segundo a segundo, cómo nos aseguramos de atender solo a candidatos que invitamos, y los gotchas de AWS e IAM que nos costaron tiempo.

Nadie lleva el audio por vos

La primera opción obvia era la integración de WhatsApp de ElevenLabs. Funciona importando el número: ElevenLabs pasa a ser su proveedor. Nuestros números están registrados en Kapso, el proveedor de WhatsApp que usamos sobre la Cloud API de Meta, y un número no puede pertenecer a dos proveedores a la vez. Mover el número de cada cliente para tener llamadas habría roto todo lo demás que hacemos por WhatsApp.

Kapso soporta llamadas de WhatsApp. Reenvía los eventos de la llamada a un webhook tuyo y transmite las acciones que le devolvés: pre_accept, accept, reject, terminate. Lo que no hace es llevar el audio. Una llamada de WhatsApp es una sesión WebRTC entre Meta y un media server, y el media server lo corrés vos.

Kapso publica un starter open source que hace exactamente eso, en Python con Pipecat: atiende la llamada, termina el WebRTC y conecta el audio a un agente de ElevenLabs. Ya resuelve lo difícil: las rarezas del SDP de Meta, el orden entre pre_accept y accept, arrancar el agente recién cuando conecta el audio, y el protocolo de ElevenLabs Agents. Nuestro backend es Node, y portar el starter implicaba reconstruir la plomería de WebRTC sin una referencia. Lo forkeamos y lo convertimos en un servicio chico, el voice bridge, que hace señalización y audio y nada más.

Revisá tus números antes de prometer nada

Meta tiene requisitos para las llamadas, y deciden quién puede tener la feature:

  • Sin coexistencia. Muchas empresas comparten su número entre la app WhatsApp Business y la Cloud API. En esos números, las llamadas suenan en la app y nunca llegan a la API.
  • Un límite de mensajería de al menos 2.000 conversaciones por día. Un número en el tier más bajo no califica.
  • Llamadas habilitadas explícitamente en cada número.

Hay además un detalle de precios que definió el alcance. Las llamadas que inicia el usuario, desde un botón de llamada, son gratis y no requieren pedir permiso de llamada ni un medio de pago en la cuenta. Las que inicia la empresa necesitan primero el permiso del usuario y se cobran. Como el candidato siempre inicia la llamada desde nuestra invitación, construimos solo esa mitad.

Antes de diseñar nada, escribimos un script de solo lectura que recorrió todos los números conectados y los clasificó según esos requisitos. Varios clientes resultaron usar coexistencia. Esa respuesta le dio forma al producto: la feature se habilita por cliente, la página de integraciones explica cuándo un número no puede recibir llamadas y por qué, y los clientes que no la tienen siguen recibiendo exactamente la invitación de antes.

Cómo está armado

Quién habla con quién en una entrevista por llamada de WhatsApp El teléfono del candidato llama a través de Meta. Kapso reenvía los eventos de la llamada al voice bridge, un servicio nuestro en AWS Fargate, y transmite sus acciones de aceptar y cortar. El audio va directo entre Meta y el bridge por WebRTC, y entre el bridge y el agente de ElevenLabs por WebSocket. El bridge le pregunta al backend de Selenios si atender y le informa los eventos de la llamada. ElevenLabs le manda la transcripción al backend al terminar, y el backend envía la invitación con el botón de llamada a través de Kapso. Teléfono del candidato WhatsApp Meta WhatsApp Cloud API Kapso proveedor de WhatsApp voice-bridge Python + Pipecat, AWS Fargate ElevenLabs agente de voz Backend de Selenios todas las reglas llamada eventos audio WebRTC Opus por UDP, directo entra el webhook firmado; salen accept y terminate audio por WebSocket ¿atiendo? eventos de llamada webhook post-llamada: transcripción, scoring invitación con botón audio señalización y datos lo que construimos
El bridge solo lleva señalización y audio. Todas las decisiones sobre el candidato quedan en el backend, y el camino post-llamada es el mismo que ya usaba la entrevista web.

Dos reglas sostuvieron el diseño.

El bridge no tiene reglas de negocio. Cuando llega una llamada, le hace una sola pregunta al backend: ¿atiendo esto y, si sí, con qué agente y qué variables? El backend contesta con el mismo código que arranca una entrevista web. Si el bridge tuviera su propia idea de quién puede entrevistarse, las dos versiones se separarían.

El bridge se puede caer sin que se pierda nada. Kapso manda los eventos de llamada por el webhook crudo de Meta para el número, y ese webhook trae todos los eventos, incluidos los mensajes de los candidatos. El bridge responde 200 y descarta todo lo que no sea una llamada, sin loguear el body. Los mensajes siguen llegando al backend por su propio webhook, separado. Si el bridge se cae, el botón deja de funcionar y el link del mismo mensaje sigue andando.

Reusar la entrevista web resultó ser la decisión más importante del lado del backend. Arrancar una entrevista corre unas 700 líneas de controles: si la entrevista sigue abierta, las reglas de reintento, el umbral de puntaje, una foto de las preguntas y una URL firmada de ElevenLabs con las variables de la entrevista. Copiar esa lógica habría sido rápido. Refactorizarla en el camino habría sido riesgoso. En cambio, un adaptador fino llama en el mismo proceso a los propios handlers de la entrevista web y captura su respuesta. Hay una razón concreta: cuando ElevenLabs manda la transcripción después de la llamada, nuestro webhook la descarta como intento abandonado salvo que su conversación sea la registrada en la postulación. Una segunda implementación se habría separado justo ahí, y el síntoma habrían sido entrevistas que ocurrieron y nunca recibieron puntaje.

Anatomía de una llamada

  1. El candidato toca el botón. Meta manda un evento connect con la oferta SDP y el payload del botón, que es un token aleatorio que pusimos al enviar la invitación. El payload está en el nivel superior del objeto de la llamada, y Kapso no lo guarda en su registro de llamadas: el webhook es el único lugar donde se ve.
  2. El bridge verifica la firma y le pregunta al backend si atender. Si la respuesta es no, rechaza la llamada antes de que exista audio.
  3. Si es sí, arma la respuesta SDP y manda pre_accept y después accept. Meta solo acepta un fingerprint DTLS SHA-256, así que los demás que ofrece la librería de WebRTC se sacan de la respuesta.
  4. Cuando conecta el audio, el bridge abre la sesión de ElevenLabs con el mismo agente y las mismas variables que la web. Nuestros agentes más nuevos dejan que el modelo escriba el primer turno y esperan un mensaje de señal; el bridge manda el mismo que manda el navegador. En los agentes con saludo fijo, esa señal los hace saludar dos veces, así que el backend le dice al bridge de qué tipo es.
  5. Cuando termina la entrevista, el agente corta la llamada con su propia tool. El bridge espera a que termine el audio de despedida y manda terminate. A partir de ahí, el webhook post-llamada de ElevenLabs llega al backend igual que con las entrevistas web.
Del toque en el botón a la primera palabra del agente Medido en las llamadas de prueba. Entre el webhook de la llamada y el pre_accept pasan unos 6,8 s, que el candidato escucha como el teléfono sonando. Después, 1,6 s hasta el accept, 0,55 s hasta que la sesión de ElevenLabs está lista y 0,18 s hasta el primer audio del agente. Segundos desde que llegó el webhook de la llamada 0 s 2 s 4 s 6 s 8 s 10 s Antes de atender el candidato escucha sonar 6,8 s pre_accept a accept el audio conecta en el accept 1,6 s Sesión del agente lista WebSocket de ElevenLabs 0,55 s Primer audio del agente 0,18 s
Llamadas de prueba antes del lanzamiento. En la primera entrevista en producción, del pre_accept al accept pasaron 1,3 s y el agente habló unos 0,2 s después de tener la sesión lista.

La mayor parte de la espera pasa antes de atender. Entre el webhook connect y el pre_accept pasan unos 6,8 segundos. Para el candidato suena como el teléfono sonando, que es lo que hacen los teléfonos, así que no se siente roto. Sospechamos que el tiempo se va en juntar candidatos de red mientras se arma la respuesta SDP, y lo seguimos midiendo. Una vez aceptada la llamada, todo es rápido. El audio va directo, sin servidor TURN: una tarea de Fargate con IP pública es alcanzable desde Meta. La sesión del agente está lista en medio segundo y su primer audio llega en menos de dos décimas.

Solo por invitación, en capas

El requisito era directo: si cualquiera llama al número, no tiene que funcionar. Un número de empresa es público, y un botón es solo un mensaje que se puede reenviar. Así que cada llamada pasa por los mismos controles, y la mayoría corre antes de que exista audio.

Los controles que pasa una llamada antes de que alguien atienda Meta oculta el ícono de llamada del chat. El WAF descarta todo lo que no sea un webhook firmado. El bridge verifica el HMAC y descarta todo lo que no sea una llamada. El backend exige la capability, una invitación vigente para el número, que llame el candidato invitado, que el token del botón coincida, los límites de llamadas y los propios controles de la entrevista web. Recién ahí el bridge atiende y empieza el audio. Capa Control Qué frena Meta ícono de llamada oculto en el chat llamadas que no salen del botón WAF solo pasan webhooks firmados ruido de internet, scanners Bridge HMAC sobre el body crudo webhooks falsificados solo llamadas, nunca loguea el body mensajes (datos personales) en logs Backend feature activa para el cliente clientes sin la feature invitación vigente para este número cualquiera que llame al número llama el candidato invitado un botón reenviado el token coincide con su hash payloads adivinados o reusados una a la vez, límites de llamadas loops de reintentos y abuso controles de la entrevista web entrevistas completas o cerradas Recién ahí: pre_accept, accept, audio
Todos los controles anteriores a la respuesta del backend corren sin abrir audio. Una llamada rechazada nunca conecta media, y los motivos de rechazo son códigos cortos, nunca datos personales.

Algunos detalles fáciles de hacer mal:

  • El token del botón es aleatorio, de al menos 128 bits, se guarda solo como hash SHA-256 y nunca se loguea.
  • El control de quién llama compara contra el ID de WhatsApp que devuelve Meta al enviar la invitación, no contra el teléfono como se cargó en la postulación. El mismo celular argentino se puede escribir con o sin el 9, y el ID de Meta es canónico.
  • Las claves del rate limit van hasheadas, porque el limiter guarda sus registros en la base, y esos registros no deberían tener teléfonos.
  • Una invitación, varias llamadas. Si una llamada se corta, el candidato toca el mismo botón y la entrevista sigue, una llamada a la vez y hasta un límite. Cada paso es idempotente por ID de llamada, porque los webhooks llegan dos veces.
  • La capa de Meta es defensa en profundidad. Ocultamos el ícono de llamada del chat, así el botón es la única forma de iniciar una llamada. En la primera llamada de prueba verificamos que el botón sigue funcionando con el ícono oculto. El control real está en el backend, así que esta capa es solo una más.

Botón o link: la ventana de 24 horas

Lo primero que nos enseñaron las llamadas de prueba: el mensaje con el botón de llamada es un mensaje de sesión. WhatsApp solo deja que una empresa los mande dentro de las 24 horas desde el último mensaje del usuario, y Kapso rechaza el envío antes de que llegue a Meta. La mayoría de las invitaciones sale justo después de la postulación, así que están dentro de la ventana. Una notificación diferida, o un recruiter que avanza a un candidato tres días después, no.

Qué invitación recibe el candidato Si el cliente no tiene la feature, o su número no puede recibir llamadas, el candidato recibe el texto con el link web, como antes. Si puede y la conversación está dentro de la ventana de 24 horas, un mensaje lleva el botón de llamada y el link. Fuera de la ventana, el plan es una plantilla con botón de llamada; hoy va el link web. Invitación a entrevista por WhatsApp ¿Feature activa y número listo? sin coexistencia, límite 2.000+, calling no Texto con el link web, como antes sí ¿Dentro de la ventana de 24 horas? no Plantilla con botón de llamada planeado; hoy, el link web sí Un mensaje: botón + link web el candidato elige
Toda rama que no es el botón de llamada manda exactamente el mensaje que salía antes de que existiera la feature.

Para los casos fuera de la ventana elegimos el fallback que no cambia nada: el link web, como antes. El paso siguiente es una plantilla con botón de llamada: las plantillas son los mensajes que WhatsApp sí permite fuera de la ventana. La misma regla aplica a los clientes sin la feature: sus candidatos reciben el mismo mensaje, byte por byte. Hasta sacamos un texto personalizable que habíamos agregado para la versión con llamada, para que el editor de mensajes de los clientes que no pueden usar llamadas no muestre ningún campo nuevo.

AWS, sin plataforma

Un backend web está hecho para servir requests HTTP. WebRTC necesita UDP en puertos abiertos a internet, así que el bridge necesitaba un lugar propio, y no queríamos sumar una plataforma de hosting solo para esto.

  • ECS Fargate, subnet pública, IP pública. El security group permite HTTP solo desde el load balancer y UDP solo en los puertos de media. La tarea corre con un usuario no root, filesystem de solo lectura, todas las capabilities de Linux quitadas, sin acceso a una shell y con un rol de tarea sin ningún permiso.
  • El webhook entra por un ALB con TLS y un WAF. El WAF descarta todo lo que no parece un webhook firmado, así el ruido de fondo de internet nunca llega al contenedor. Los smoke tests cubren cada capa, con requests válidos e inválidos.
  • Un rango fijo para los puertos de media. La librería de WebRTC toma sus puertos UDP del rango efímero del kernel, así que el security group tenía que abrirlo entero. Fargate deja que una task definition configure net.ipv4.ip_local_port_range, así que lo fijamos en mil puertos y abrimos exactamente esos. El bridge loguea el rango que ve al arrancar y avisa si no coincide. Ese cambio se está desplegando mientras escribo esto.
  • Las alarmas van a Slack por AWS Chatbot, con una política guardrail que deniega todo: el bot nos puede avisar que algo anda mal, pero no puede actuar sobre la cuenta.

Seguridad como código

El bridge vive en su propio repositorio, con las mismas reglas que el resto de nuestra infraestructura. La mayor parte del tiempo del proyecto se fue acá, y casi todo es reusable.

  • Deploys desde GitHub Actions con OIDC, sin claves de larga vida. Los deploys de producción requieren aprobación y solo corren desde main. Las actions están fijadas por SHA de commit.
  • Un rol por entorno, generado desde el mismo template. Staging no puede tocar nada de producción, ni siquiera taggear una task definition.
  • Un permissions boundary en todo lo que crea CloudFormation. Solo puede crear roles que lleven el boundary, y no puede sacarlo ni adjuntar una política de administrador.
  • IAM tiene tests. Un script corre IAM Access Analyzer, que tiene que reportar cero warnings, y unos noventa casos del policy simulator: cosas que tienen que estar permitidas y cosas que tienen que estar denegadas, como caminos de escalación, recursos de otros servicios y el otro entorno. Corre en CI y antes de cada cambio a los roles.
  • El template también tiene tests, para lo que IAM no puede expresar: la tarea corre solo nuestra imagen, recibe secretos solo del path de su entorno y se engancha solo a su propio target group.
  • Los secretos viven en SSM Parameter Store, cifrados con una KMS key por entorno que solo puede usar el rol de ejecución de la tarea. Ni un administrador de la cuenta puede leer los valores. El script que los carga nunca pone un valor en la línea de comandos, donde aparecería en ps y en el historial de la shell.

Los gotchas

Ninguno fue difícil una vez encontrado. Todos costaron más tiempo del que deberían, sobre todo porque el mensaje de error apuntaba a otro lado.

  • zsh y los ARNs. En zsh, $VAR:r es un modificador de historial: :r saca la extensión de un archivo. Así que arn:aws:iam::$ACCOUNT:role/deploy se convirtió en silencio en otra cosa. El valor fue a un secret de GitHub, que se ve como ***, y el error era solo "Request ARN is invalid". Usá siempre ${VAR} y validá el formato antes de guardarlo.
  • El AWS CLI en macOS no lee file:///dev/stdin. Pasar el JSON del secreto por pipe parecía la forma más segura de mantenerlo fuera de la línea de comandos. Falla con "Invalid JSON received". Terminamos con un archivo temporal, legible solo por su dueño, que se borra al salir.
  • CloudWatch Logs autoriza contra log-group:NOMBRE:*, no contra el nombre pelado. Nuestra política listaba el log group del WAF sin el :*, y el primer deploy hizo rollback por un error de permisos al taggearlo. El fix vino con tests para las dos formas del ARN.
  • Los recursos con política de retención sobreviven a una creación fallida. El rollback dejó atrás el bucket de access logs y el log group, y el siguiente intento falló con "already exists". Un stack en ROLLBACK_COMPLETE tampoco se puede actualizar: hay que borrarlo y crearlo de nuevo.
  • AWS Chatbot admite una sola configuración por canal de Slack en una cuenta. Queríamos las alarmas del bridge en el canal que ya usan nuestros servicios de mapas, y el primer deploy de producción hizo rollback. El bridge tiene su propio canal.
  • EC2 tiene un set de caracteres para las descripciones de reglas de security groups. El deploy que fijaba los puertos de media hizo rollback porque una descripción decía task's, y EC2 no acepta apóstrofos ahí. cfn-lint no lo detectó. Ahora lo detecta un test del template.

El texto también importaba

La primera invitación en producción decía "completá la entrevista en el siguiente enlace", con el link, y después un botón que decía "Iniciar entrevista". Dos formas de empezar, y el texto apuntaba a la que queríamos que se usara menos. Lo reescribimos para ofrecer primero la llamada y la web como alternativa, y el botón ahora dice "Llamar ahora". Cuando un mensaje le da dos opciones a alguien, tiene que decir cuál es cuál.

Si estás pensando en hacer esto

  • Clasificá tus números primero. La coexistencia y los límites de mensajería deciden quién puede tener la feature. Averigualo con un script de solo lectura antes de diseñar nada.
  • Mantené tonto el servicio de media. Señalización y audio en un servicio chico, todas las decisiones en el backend que ya las toma.
  • Reusá el flujo existente en vez de reconstruirlo. Sobre todo la parte que decide qué conversación cuenta.
  • Hacelo solo por invitación desde el primer día. Un número público con un agente de IA es una línea abierta. Verificá la invitación, quién llama y el token antes de que exista audio.
  • Nunca loguees bodies de webhooks que puedan traer datos personales. Logueá IDs, y logueá dónde llegó un campo, nunca su valor.
  • Testeá IAM como código, con casos que tienen que estar denegados, no solo casos que tienen que funcionar.
  • Hacé el spike con preguntas explícitas de go y no-go. El nuestro tenía seis, desde "¿llega el audio a Fargate?" hasta "¿el botón necesita la ventana de 24 horas?". Dos de las respuestas cambiaron el diseño.

Lo que sigue

La plantilla con botón de llamada, para que las invitaciones fuera de la ventana de 24 horas también lo lleven. Los casi siete segundos de timbre, cuando sepamos dónde se van. Pruebas de carga, y escalar horizontalmente cuando el volumen lo pida. Y los costos reales por entrevista, ahora que hay llamadas para medir.

Del lado del candidato, todo esto es un botón que dice "Llamar ahora". Así tiene que ser.