Skip to content

Toque para ligar: como transformamos nossa entrevista de voz com IA numa chamada nativa de WhatsApp

· 17 min de leitura

tags: ai-agents, architecture, recruiting

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

Quem fala com quem numa entrevista por chamada de WhatsApp O telefone do candidato liga pela Meta. A Kapso encaminha os eventos da chamada para o voice bridge, um serviço nosso na AWS Fargate, e repassa as ações de aceitar e encerrar. O áudio vai direto entre a Meta e o bridge por WebRTC, e entre o bridge e o agente da ElevenLabs por WebSocket. O bridge pergunta ao backend da Selenios se deve atender e informa os eventos da chamada. A ElevenLabs envia a transcrição ao backend no fim, e o backend envia o convite com o botão de chamada pela Kapso. Telefone do candidato WhatsApp Meta WhatsApp Cloud API Kapso provedor de WhatsApp voice-bridge Python + Pipecat, AWS Fargate ElevenLabs agente de voz Backend da Selenios todas as regras chamada eventos áudio WebRTC Opus por UDP, direto entra o webhook assinado; saem accept e terminate áudio por WebSocket atendo? eventos da chamada webhook pós-chamada: transcrição, scoring convite com botão áudio sinalização e dados o que construímos
O bridge só carrega sinalização e áudio. Todas as decisões sobre o candidato ficam no backend, e o caminho pós-chamada é o mesmo que a entrevista web já usava.

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

  1. O candidato toca o botão. A Meta envia um evento connect com 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.
  2. O bridge verifica a assinatura e pergunta ao backend se deve atender. Se a resposta é não, rejeita a chamada antes de existir áudio.
  3. Se é sim, monta a resposta SDP e envia pre_accept e depois accept. A Meta só aceita um fingerprint DTLS SHA-256, então os outros que a biblioteca de WebRTC oferece são removidos da resposta.
  4. 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.
  5. 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.
Do toque no botão à primeira palavra do agente Medido nas chamadas de teste. Entre o webhook da chamada e o pre_accept passam uns 6,8 s, que o candidato ouve como o telefone tocando. Depois, 1,6 s até o accept, 0,55 s até a sessão da ElevenLabs ficar pronta e 0,18 s até o primeiro áudio do agente. Segundos desde que chegou o webhook da chamada 0 s 2 s 4 s 6 s 8 s 10 s Antes de atender o candidato ouve tocar 6,8 s pre_accept a accept o áudio conecta no accept 1,6 s Sessão do agente pronta WebSocket da ElevenLabs 0,55 s Primeiro áudio do agente 0,18 s
Chamadas de teste antes do lançamento. Na primeira entrevista em produção, do pre_accept ao accept passaram 1,3 s e o agente falou uns 0,2 s depois de ter a sessão pronta.

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.

As verificações que uma chamada passa antes de alguém atender A Meta esconde o ícone de chamada do chat. O WAF descarta tudo o que não é um webhook assinado. O bridge verifica o HMAC e descarta tudo o que não é chamada. O backend exige a capability, um convite válido para o número, que ligue o candidato convidado, que o token do botão confira, os limites de chamadas e as próprias verificações da entrevista web. Só então o bridge atende e o áudio começa. Camada Verificação O que barra Meta ícone de chamada oculto no chat chamadas que não saem do botão WAF só passam webhooks assinados ruído da internet, scanners Bridge HMAC sobre o body cru webhooks forjados só chamadas, nunca loga o body mensagens (dados pessoais) em logs Backend feature ativa para o cliente clientes sem a feature convite válido para este número qualquer um que ligue ao número liga o candidato convidado um botão encaminhado o token confere com o hash payloads adivinhados ou reusados uma por vez, limites de chamadas loops de retentativa e abuso verificações da entrevista web entrevistas concluídas ou fechadas Só então: pre_accept, accept, áudio
Todas as verificações anteriores à resposta do backend rodam sem abrir áudio. Uma chamada rejeitada nunca conecta mídia, e os motivos de rejeição são códigos curtos, nunca dados pessoais.

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.

Qual convite o candidato recebe Se o cliente não tem a feature, ou o número dele não pode receber chamadas, o candidato recebe o texto com o link web, como antes. Se pode e a conversa está dentro da janela de 24 horas, uma mensagem leva o botão de chamada e o link. Fora da janela, o plano é um template com botão de chamada; hoje vai o link web. Convite para entrevista no WhatsApp Feature ativa e número pronto? sem coexistência, limite 2.000+, calling não Texto com o link web, como antes sim Dentro da janela de 24 horas? não Template com botão de chamada planejado; hoje, o link web sim Uma mensagem: botão + link web o candidato escolhe
Todo ramo que não é o botão de chamada envia exatamente a mensagem que saía antes de a feature existir.

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 ps e 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: :r remove a extensão de um arquivo. Então arn:aws:iam::$ACCOUNT:role/deploy virou 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_COMPLETE també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.