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
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
- El candidato toca el botón. Meta manda un evento
connectcon 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. - 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.
- Si es sí, arma la respuesta SDP y manda
pre_accepty despuésaccept. 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. - 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.
- 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.
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.
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.
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
psy 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:res un modificador de historial::rsaca la extensión de un archivo. Así quearn:aws:iam::$ACCOUNT:role/deployse 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_COMPLETEtampoco 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.