Na Selenios, os candidatos se candidatam a uma vaga sem sair do WhatsApp. Eles mandam mensagem para o número da empresa, preenchem um formulário dentro do chat e recebem uma pontuação. O passo seguinte é uma entrevista de triagem com um agente de voz feito sobre o ElevenLabs Agents. Até esta semana, esse passo era um link: abrir o navegador, permitir o microfone, falar com o agente.
Funciona, mas é o único momento em que o candidato precisa sair do chat, e é onde as coisas dão errado: o navegador interno do app, a permissão do microfone, um computador que não está à mão. Então nos fizemos uma pergunta simples. O candidato já está no WhatsApp, e o WhatsApp faz ligações. Por que não fazer da entrevista uma ligação?
Há um segundo motivo, e na América Latina ele pode ser o maior. Para muita gente, o WhatsApp é o app que sempre funciona: há planos de celular que o incluem enquanto o resto dos dados é limitado ou cobrado à parte. Um passo que acontece no navegador é um passo que alguns candidatos não conseguem dar com o plano que têm. Se a ligação está incluída depende da operadora e do plano, mas quanto mais do processo fica dentro do WhatsApp, menos candidatos perdemos no caminho.
Agora o convite traz um botão. O candidato toca, o WhatsApp faz a chamada e o mesmo agente atende, com as mesmas perguntas e o mesmo scoring. O link web continua na mesma mensagem como alternativa. A primeira entrevista em produção, numa candidatura interna, durou 3 minutos e 26 segundos e entrou no pipeline como qualquer entrevista web: transcrição, pontuação, próxima etapa.
Este post é sobre o que foi preciso. Nenhum provedor podia levar o áudio por nós, então construímos um serviço pequeno na AWS. Conto como ele é montado, como é uma chamada segundo a segundo, como garantimos atender só candidatos que convidamos, e as pegadinhas de AWS e IAM que nos custaram tempo.
Ninguém leva o áudio por você
A primeira opção óbvia era a integração de WhatsApp da própria ElevenLabs. Ela funciona importando o número: a ElevenLabs passa a ser o provedor dele. Nossos números estão registrados na Kapso, o provedor de WhatsApp que usamos sobre a Cloud API da Meta, e um número não pode pertencer a dois provedores ao mesmo tempo. Mover o número de cada cliente para ter chamadas teria quebrado todo o resto que fazemos no WhatsApp.
A Kapso suporta chamadas de WhatsApp. Ela encaminha os eventos da chamada para um webhook seu e repassa as ações que você devolve: pre_accept, accept, reject, terminate. O que ela não faz é levar o áudio. Uma chamada de WhatsApp é uma sessão WebRTC entre a Meta e um media server, e o media server é você quem roda.
A Kapso publica um starter open source que faz exatamente isso, em Python com Pipecat: atende a chamada, termina o WebRTC e conecta o áudio a um agente da ElevenLabs. Ele já resolve a parte difícil: as esquisitices do SDP da Meta, a ordem entre pre_accept e accept, iniciar o agente só quando o áudio conecta, e o protocolo do ElevenLabs Agents. Nosso backend é Node, e portar o starter significaria reconstruir o encanamento de WebRTC sem uma referência. Fizemos um fork e o transformamos num serviço pequeno, o voice bridge, que faz sinalização e áudio e nada mais.
Verifique seus números antes de prometer qualquer coisa
A Meta tem requisitos para chamadas, e eles decidem quem pode ter a feature:
- Sem coexistência. Muitas empresas compartilham o número entre o app WhatsApp Business e a Cloud API. Nesses números, as chamadas tocam no app e nunca chegam à API.
- Um limite de mensagens de pelo menos 2.000 conversas por dia. Um número no tier mais baixo não se qualifica.
- Chamadas habilitadas explicitamente em cada número.
Há também um detalhe de preço que definiu o escopo. As chamadas que o usuário inicia, a partir de um botão de chamada, são gratuitas e não exigem pedido de permissão de chamada nem meio de pagamento na conta. As que a empresa inicia precisam antes da permissão do usuário e são cobradas. Como o candidato sempre inicia a chamada a partir do nosso convite, construímos só essa metade.
Antes de desenhar qualquer coisa, escrevemos um script somente leitura que percorreu todos os números conectados e os classificou segundo esses requisitos. Vários clientes usavam coexistência. Essa resposta deu forma ao produto: a feature é habilitada por cliente, a página de integrações explica quando um número não pode receber chamadas e por quê, e os clientes que não a têm continuam recebendo exatamente o convite de antes.
Como está montado
Duas regras sustentaram o desenho.
O bridge não tem regras de negócio. Quando chega uma chamada, ele faz uma única pergunta ao backend: atendo isto e, se sim, com qual agente e quais variáveis? O backend responde com o mesmo código que inicia uma entrevista web. Se o bridge tivesse sua própria ideia de quem pode ser entrevistado, as duas versões iam se afastar.
O bridge pode cair sem que nada se perca. A Kapso manda os eventos de chamada pelo webhook bruto da Meta para o número, e esse webhook traz todos os eventos, inclusive as mensagens dos candidatos. O bridge responde 200 e descarta tudo o que não é chamada, sem logar o body. As mensagens continuam chegando ao backend pelo próprio webhook, separado. Se o bridge cair, o botão para de funcionar e o link da mesma mensagem continua funcionando.
Reusar a entrevista web acabou sendo a decisão mais importante do lado do backend. Iniciar uma entrevista roda umas 700 linhas de verificações: se a entrevista continua aberta, as regras de nova tentativa, o corte por pontuação, uma foto das perguntas e uma URL assinada da ElevenLabs com as variáveis da entrevista. Copiar essa lógica teria sido rápido. Refatorá-la no caminho teria sido arriscado. Em vez disso, um adaptador fino chama no mesmo processo os próprios handlers da entrevista web e captura a resposta. Há um motivo concreto: quando a ElevenLabs envia a transcrição depois da chamada, nosso webhook a descarta como tentativa abandonada, a menos que a conversa seja a registrada na candidatura. Uma segunda implementação teria se afastado justamente aí, e o sintoma seriam entrevistas que aconteceram e nunca receberam pontuação.
Anatomia de uma chamada
- O candidato toca o botão. A Meta envia um evento
connectcom a oferta SDP e o payload do botão, que é um token aleatório que colocamos ao enviar o convite. O payload está no nível superior do objeto da chamada, e a Kapso não o guarda no registro de chamadas: o webhook é o único lugar onde ele aparece. - O bridge verifica a assinatura e pergunta ao backend se deve atender. Se a resposta é não, rejeita a chamada antes de existir áudio.
- Se é sim, monta a resposta SDP e envia
pre_accepte depoisaccept. A Meta só aceita um fingerprint DTLS SHA-256, então os outros que a biblioteca de WebRTC oferece são removidos da resposta. - Quando o áudio conecta, o bridge abre a sessão da ElevenLabs com o mesmo agente e as mesmas variáveis da web. Nossos agentes mais novos deixam o modelo escrever o primeiro turno e esperam uma mensagem de sinal; o bridge envia a mesma que o navegador envia. Nos agentes com saudação fixa, esse sinal faz com que cumprimentem duas vezes, então o backend diz ao bridge de que tipo é o agente.
- Quando a entrevista termina, o agente encerra a chamada com sua própria tool. O bridge espera o áudio de despedida acabar e envia
terminate. A partir daí, o webhook pós-chamada da ElevenLabs chega ao backend como acontecia com as entrevistas web.
A maior parte da espera acontece antes de atender. Entre o webhook connect e o pre_accept passam uns 6,8 segundos. Para o candidato, soa como o telefone tocando, que é o que telefones fazem, então não parece quebrado. Suspeitamos que o tempo vai na coleta de candidatos de rede enquanto a resposta SDP é montada, e continuamos medindo. Depois que a chamada é aceita, tudo é rápido. O áudio vai direto, sem servidor TURN: uma task do Fargate com IP público é alcançável pela Meta. A sessão do agente fica pronta em meio segundo e o primeiro áudio chega em menos de dois décimos.
Só com convite, em camadas
O requisito era direto: se qualquer pessoa ligar para o número, não pode funcionar. Um número de empresa é público, e um botão é só uma mensagem que pode ser encaminhada. Então toda chamada passa pelas mesmas verificações, e a maioria roda antes de existir áudio.
Alguns detalhes fáceis de errar:
- O token do botão é aleatório, de pelo menos 128 bits, guardado só como hash SHA-256 e nunca logado.
- A verificação de quem liga compara com o ID de WhatsApp que a Meta devolve ao enviar o convite, não com o telefone como foi digitado na candidatura. O mesmo celular argentino pode ser escrito com ou sem o 9, e o ID da Meta é canônico.
- As chaves do rate limit são hasheadas, porque o limiter guarda seus registros no banco, e esses registros não deveriam ter telefones.
- Um convite, várias chamadas. Se uma chamada cai, o candidato toca o mesmo botão e a entrevista continua, uma chamada por vez e até um limite. Cada passo é idempotente por ID de chamada, porque webhooks chegam duas vezes.
- A camada da Meta é defesa em profundidade. Escondemos o ícone de chamada do chat, então o botão é a única forma de iniciar uma chamada. Na primeira chamada de teste verificamos que o botão continua funcionando com o ícone oculto. O controle real está no backend, então esta camada é só mais uma.
Botão ou link: a janela de 24 horas
A primeira coisa que as chamadas de teste nos ensinaram: a mensagem com o botão de chamada é uma mensagem de sessão. O WhatsApp só deixa uma empresa enviá-las dentro de 24 horas da última mensagem do usuário, e a Kapso recusa o envio antes de chegar à Meta. A maioria dos convites sai logo depois da candidatura, então está dentro da janela. Uma notificação adiada, ou um recrutador que avança um candidato três dias depois, não.
Para os casos fora da janela, escolhemos o fallback que não muda nada: o link web, como antes. O próximo passo é um template com botão de chamada: templates são as mensagens que o WhatsApp permite fora da janela. A mesma regra vale para os clientes sem a feature: os candidatos deles recebem a mesma mensagem, byte por byte. Até removemos um texto personalizável que tínhamos adicionado para a versão com chamada, para que o editor de mensagens dos clientes que não podem usar chamadas não mostre nenhum campo novo.
AWS, sem plataforma
Um backend web é feito para servir requests HTTP. WebRTC precisa de UDP em portas abertas para a internet, então o bridge precisava de um lugar próprio, e não queríamos adicionar uma plataforma de hospedagem só para isso.
- ECS Fargate, subnet pública, IP público. O security group permite HTTP só a partir do load balancer e UDP só nas portas de mídia. A task roda com um usuário não root, sistema de arquivos somente leitura, todas as capabilities do Linux removidas, sem acesso a shell e com uma role de task sem nenhuma permissão.
- O webhook entra por um ALB com TLS e um WAF. O WAF descarta tudo o que não parece um webhook assinado, então o ruído de fundo da internet nunca chega ao contêiner. Os smoke tests cobrem cada camada, com requests válidos e inválidos.
- Uma faixa fixa para as portas de mídia. A biblioteca de WebRTC pega suas portas UDP da faixa efêmera do kernel, então o security group precisava abri-la inteira. O Fargate permite que uma task definition configure
net.ipv4.ip_local_port_range, então fixamos em mil portas e abrimos exatamente essas. O bridge loga a faixa que vê ao iniciar e avisa se não bater. Essa mudança está sendo implantada enquanto escrevo. - Os alarmes vão para o Slack pelo AWS Chatbot, com uma política guardrail que nega tudo: o bot pode nos avisar que algo está errado, mas não pode agir na conta.
Segurança como código
O bridge vive no próprio repositório, com as mesmas regras do resto da nossa infraestrutura. A maior parte do tempo do projeto foi aqui, e quase tudo é reutilizável.
- Deploys pelo GitHub Actions com OIDC, sem chaves de longa duração. Deploys de produção exigem aprovação e só rodam a partir da
main. As actions são fixadas por SHA de commit. - Uma role por ambiente, gerada a partir do mesmo template. Staging não pode tocar em nada de produção, nem mesmo taguear uma task definition.
- Um permissions boundary em tudo o que o CloudFormation cria. Ele só pode criar roles que levem o boundary, e não pode removê-lo nem anexar uma política de administrador.
- O IAM tem testes. Um script roda o IAM Access Analyzer, que precisa reportar zero warnings, e uns noventa casos do policy simulator: coisas que precisam ser permitidas e coisas que precisam ser negadas, como caminhos de escalada, recursos de outros serviços e o outro ambiente. Roda no CI e antes de cada mudança nas roles.
- O template também tem testes, para o que o IAM não consegue expressar: a task roda só a nossa imagem, recebe segredos só do path do seu ambiente e se conecta só ao seu próprio target group.
- Os segredos vivem no SSM Parameter Store, criptografados com uma chave KMS por ambiente que só a role de execução da task pode usar. Nem um administrador da conta consegue ler os valores. O script que os carrega nunca coloca um valor na linha de comando, onde apareceria no
pse no histórico do shell.
As pegadinhas
Nenhuma foi difícil depois de encontrada. Todas custaram mais tempo do que deveriam, principalmente porque a mensagem de erro apontava para outro lugar.
- zsh e os ARNs. No zsh,
$VAR:ré um modificador de histórico::rremove a extensão de um arquivo. Entãoarn:aws:iam::$ACCOUNT:role/deployvirou outra coisa em silêncio. O valor foi para um secret do GitHub, que aparece como***, e o erro era só "Request ARN is invalid". Use sempre${VAR}e valide o formato antes de salvar. - O AWS CLI no macOS não lê
file:///dev/stdin. Passar o JSON do segredo por pipe parecia o jeito mais seguro de mantê-lo fora da linha de comando. Falha com "Invalid JSON received". Acabamos com um arquivo temporário, legível só pelo dono, apagado ao sair. - O CloudWatch Logs autoriza contra
log-group:NOME:*, não contra o nome puro. Nossa política listava o log group do WAF sem o:*, e o primeiro deploy fez rollback por um erro de permissão ao taguear. A correção veio com testes para as duas formas do ARN. - Recursos com política de retenção sobrevivem a uma criação que falhou. O rollback deixou para trás o bucket de access logs e o log group, e a tentativa seguinte falhou com "already exists". Um stack em
ROLLBACK_COMPLETEtambém não pode ser atualizado: é preciso apagá-lo e criá-lo de novo. - O AWS Chatbot admite uma única configuração por canal do Slack numa conta. Queríamos os alarmes do bridge no canal que nossos serviços de mapas já usam, e o primeiro deploy de produção fez rollback. O bridge ganhou o próprio canal.
- O EC2 tem um conjunto de caracteres para as descrições de regras de security groups. O deploy que fixava as portas de mídia fez rollback porque uma descrição dizia
task's, e o EC2 não aceita apóstrofos ali. O cfn-lint não pegou. Agora um teste do template pega.
O texto também importava
O primeiro convite em produção dizia "complete a entrevista no link a seguir", com o link, e depois um botão que dizia "Iniciar entrevista". Duas formas de começar, e o texto apontava para a que queríamos que fosse menos usada. Reescrevemos para oferecer primeiro a chamada e a web como alternativa, e o botão agora diz "Ligar agora". Quando uma mensagem dá duas opções para alguém, ela precisa dizer qual é qual.
Se você está pensando em fazer isso
- Classifique seus números primeiro. A coexistência e os limites de mensagens decidem quem pode ter a feature. Descubra com um script somente leitura antes de desenhar qualquer coisa.
- Mantenha burro o serviço de mídia. Sinalização e áudio num serviço pequeno, todas as decisões no backend que já as toma.
- Reuse o fluxo existente em vez de reconstruí-lo. Principalmente a parte que decide qual conversa conta.
- Faça só com convite desde o primeiro dia. Um número público com um agente de IA é uma linha aberta. Verifique o convite, quem liga e o token antes de existir áudio.
- Nunca logue bodies de webhooks que possam trazer dados pessoais. Logue IDs, e logue onde um campo chegou, nunca o valor.
- Teste o IAM como código, com casos que precisam ser negados, não só casos que precisam funcionar.
- Faça o spike com perguntas explícitas de go e no-go. O nosso tinha seis, de "o áudio chega ao Fargate?" a "o botão precisa da janela de 24 horas?". Duas das respostas mudaram o desenho.
O que vem agora
O template com botão de chamada, para que os convites fora da janela de 24 horas também o levem. Os quase sete segundos de toque, quando soubermos para onde vão. Testes de carga, e escalar horizontalmente quando o volume pedir. E os custos reais por entrevista, agora que há chamadas para medir.
Do lado do candidato, tudo isso é um botão que diz "Ligar agora". É assim que tem que ser.