DEV Community

Héberger un agent IA vocal sur AWS Bedrock AgentCore communiquant via WebRTC

Aujourd'hui j'ai migré agent vocal IA de WebSocket vers WebRTC — voici ce qui a cassé et ce que j'ai appris.

Il y a quelques jours, je suis tombé sur le billet de blog et le repo de Darryl Ruggles pour un agent vocal bidirectionnel construit avec Strands BidiAgent et Amazon Nova Sonic v2. Son travail est remarquablement bien ficelé — j'avais un assistant vocal fonctionnel sur mon laptop en une dizaine de minutes. L'agent écoute votre voix, cherche dans une base de recettes, programme des minuteurs de cuisson, consulte des données nutritionnelles et convertit des unités, le tout par conversation naturelle.

La version de Darryl utilise WebSocket comme transport entre le navigateur et l'agent. Ça fonctionne bien, mais je voulais aller plus loin : passer le transport en WebRTC et déployer le tout sur Bedrock AgentCore Runtime. Ce billet couvre ce parcours — ce qui a changé, ce qui a cassé, et ce que j'en ai tiré.

Vous avez rêvé de demander la recette des crêpes à un agent IA ? je l'ai fait :)

Le code source complet est disponible sur GitHub. Le repo est entièrement géré par Terraform, mais vous pouvez toujours utiliser l'approche Makefile de Darryl si vous préférez garder Terraform pour l'infrastructure et le CLI pour le déploiement de l'agent.

Pourquoi WebRTC pour un agent vocal ?

La version WebSocket de l'agent fonctionne, alors pourquoi changer ? Plusieurs raisons m'ont poussé vers WebRTC.

D'abord, la latence. WebSocket tourne sur TCP, ce qui signifie que chaque paquet est garanti d'arriver dans l'ordre. C'est parfait pour des messages de chat, mais pour de l'audio en temps réel, un seul paquet perdu bloque tout le flux pendant que TCP retransmet. WebRTC1 utilise UDP — si un paquet est perdu, le flux continue. Pour une conversation vocale, un micro-glitch est bien préférable à une pause perceptible.

Ensuite, le navigateur fait plus de travail. Avec WebSocket, je devais capturer l'audio du micro via getUserMedia, le sous-échantillonner à 16kHz avec un ScriptProcessorNode, l'encoder en base64 PCM et l'envoyer en messages JSON. Côté lecture, il fallait un AudioWorklet avec un buffer circulaire pour gérer le flux audio entrant. Avec WebRTC, le navigateur gère nativement la capture audio, l'encodage (Opus) et la lecture via RTCPeerConnection. Le code frontend s'en trouve considérablement simplifié.

Enfin, WebRTC est prêt pour la vidéo. Les avatars IA arrivent à des latences acceptables, et WebRTC gère les pistes vidéo aussi naturellement que les pistes audio. Ajouter un flux vidéo plus tard revient simplement à ajouter une piste à la connexion existante — aucun changement d'architecture ne sera nécessaire.

Petit tour d'horizon des architectures WebRTC

Il existe deux façons fondamentalement différentes d'utiliser WebRTC, et le choix compte quand on construit un agent vocal.

Pair-à-pair (P2P)

En WebRTC P2P, deux pairs se connectent directement l'un à l'autre. Pas de serveur média au milieu — l'audio circule directement du navigateur à l'agent et retour. Un serveur relais TURN2 peut être nécessaire quand l'un ou les deux pairs sont derrière un NAT3 (ce qui est quasi systématique en production : les clients sont derrière un routeur Internet et les agents doivent être dans un VPC privé pour accéder aux outils de l'entreprise), mais le serveur TURN ne fait que relayer les paquets sans les inspecter ni les traiter.

P2P WebRTC

Basé sur des salles (SFU)

Dans une architecture basée sur des salles de visio, un serveur média (appelé SFU4 — Selective Forwarding Unit) se place au milieu. Les participants se connectent au serveur, pas entre eux. Le serveur reçoit les pistes audio/vidéo de chaque participant et les retransmet sélectivement aux autres. LiveKit, Amazon Chime SDK et Daily sont des exemples de plateformes SFU.

Media Server WebRTC

Pour un agent vocal en 1:1, le P2P est plus simple et évite le coût et la complexité de faire tourner (ou de payer) un serveur média. J'ai opté pour le P2P avec Amazon Kinesis Video Streams (KVS) comme relais TURN managé — c'est l'approche documentée pour WebRTC sur AgentCore.

J'ai envisagé les solutions basées sur des salles, mais chaque plateforme SFU nécessite son propre SDK — on ne peut pas simplement se connecter avec un RTCPeerConnection standard. L'offre WebRTC d'AWS, Amazon Chime SDK, est riche en fonctionnalités (transcription, enregistrement, analytics) et nettement moins chère que les alternatives comme LiveKit ou Daily, mais elle n'offre pas encore de chemin balisé pour la communication agent-vers-salle côté serveur. C'est une fonctionnalité que j'aimerais beaucoup voir arriver, vu la qualité du reste du Chime SDK. Pour l'instant, le P2P avec KVS TURN était le chemin le plus direct. Je considérerai certainement le WebRTC en salle, mais c'est une histoire pour un autre billet.

La pile WebRTC : navigateur et serveur

Côté navigateur, WebRTC est intégré nativement. L'API RTCPeerConnection est disponible dans tous les navigateurs modernes — Chrome, Safari, Firefox, Edge. On crée une connexion pair, on ajoute une piste micro via getUserMedia, et le navigateur gère l'encodage audio (Opus), la collecte des candidats ICE et le chiffrement DTLS. Aucune bibliothèque nécessaire.

Côté serveur, c'est une autre histoire. WebRTC a été conçu pour les navigateurs, pas pour des backends Python. La bibliothèque de référence pour le WebRTC côté serveur en Python est aiortc — une implémentation asyncio de WebRTC et ORTC. Elle gère les connexions pair, la négociation ICE et les pistes média, et utilise PyAV (bindings FFmpeg) pour le traitement des trames audio/vidéo. Elle n'est pas aussi éprouvée que le WebRTC des navigateurs, mais elle fonctionne bien et c'est ce qu'utilise aussi le code d'exemple AWS.

Architecture : développement local vs. déployé

Un point que je voulais préserver du design original de Darryl est la possibilité de tout faire tourner localement pour le développement, sans aucune infrastructure cloud. La migration WebRTC maintient cela.

Mode local

En mode local, l'agent tourne sur votre machine. Le navigateur et l'agent sont sur le même réseau (ou la même machine), donc WebRTC se connecte en pair-à-pair sans avoir besoin de relais TURN. La signalisation — l'échange d'offres/réponses SDP5 et de candidats ICE6 — passe par le proxy du serveur de développement Vite vers le serveur FastAPI local.

Local mode

Mode hébergé sur Bedrock AgentCore

En mode hébergé, l'agent tourne dans un conteneur Docker sur Bedrock AgentCore Runtime, attaché à un VPC via une interface réseau élastique (ENI) dans un sous-réseau privé. Le navigateur ne peut pas atteindre l'agent directement — tout le trafic média passe par un relais KVS TURN. La signalisation passe par le endpoint HTTP /invocations d'AgentCore, authentifié en SigV4 via le SDK @aws-sdk/client-bedrock-agentcore.

Voice Agent deployed on AgentCore

Le diagramme suivant, tiré de la documentation AWS, montre le réseau VPC en détail — la signalisation passe par le endpoint HTTP d'AgentCore tandis que le trafic média passe par la NAT gateway du VPC vers le relais KVS TURN :

AgentCore WebRTC Architecture

Le point important à noter est que le code de l'agent est quasi identique entre les modes local et déployé. Le BidiAgent, le BidiNovaSonicModel et les quatre outils (recherche de recettes, minuteur, recherche nutritionnelle, conversion d'unités) sont totalement inchangés. La seule différence est la couche transport : en local, aiortc se connecte en P2P ; en déployé, il se connecte via KVS TURN. L'agent détecte dans quel mode il se trouve via la variable d'environnement CONTAINER_ENV et configure les serveurs ICE en conséquence.

Cette séparation propre a été possible grâce au protocole BidiInput/BidiOutput de Strands. J'ai écrit deux petites classes adaptateurs — WebRTCBidiInput et WebRTCBidiOutput — qui font le pont entre les pistes audio aiortc et le format d'événements attendu par BidiAgent. L'agent ne sait pas et ne se soucie pas de savoir si l'audio vient d'un WebSocket ou d'une piste WebRTC.

Ce qu'apporte le support WebRTC de Bedrock AgentCore

Le 20 mars 2026, AWS a annoncé le support WebRTC pour AgentCore Runtime. Je veux être honnête sur ce que cela signifie en pratique.

Je n'en suis pas sûr à 100%, et je suis prêt à être corrigé, mais mon impression est que les briques de base — le mode réseau VPC, KVS TURN, le endpoint HTTP /invocations — existaient tous déjà avant cette annonce. Le mode réseau VPC est disponible depuis la disponibilité générale d'AgentCore en octobre 2025. KVS TURN est une fonctionnalité de longue date de Kinesis Video Streams. Et /invocations a toujours été le endpoint HTTP standard des runtimes AgentCore.

Ce que la release du 20 mars ajoute, d'après ce que je comprends, c'est de la documentation officielle, du code d'exemple fonctionnel, et la déclaration explicite que WebRTC est un protocole supporté sur AgentCore Runtime. Avant cela, on pouvait techniquement assembler les mêmes pièces soi-même, mais on était seul — pas de docs, pas d'exemples, pas de garantie que ça continuerait à fonctionner.

Ce qu'AgentCore apporte est réellement précieux : un hébergement de conteneurs managé avec auto-scaling, l'isolation des sessions entre utilisateurs concurrents, l'observabilité intégrée (logs CloudWatch, traces X-Ray), et aucune infrastructure à gérer au-delà du VPC. Je n'ai pas eu à configurer ECS, des load balancers ou de l'orchestration de conteneurs.

Cela dit, il y a une bonne quantité de code custom. La signalisation WebRTC (échange SDP, gestion des candidats ICE), le cycle de vie de la connexion pair aiortc, le pont entre les pistes audio et BidiAgent, et la gestion des identifiants KVS TURN — tout cela est du code applicatif que j'ai écrit. AgentCore l'héberge et l'exécute, mais ne l'abstrait pas.

Défis et leçons apprises

La migration de WebSocket vers WebRTC a commencé en douceur, puis ça s'est corsé. Voici ce qui m'a fait trébucher.

Compatibilité des zones de disponibilité du VPC

AgentCore Runtime ne supporte que certaines zones de disponibilité. En us-east-1, seules use1-az4 (us-east-1a), use1-az1 (us-east-1c) et use1-az2 (us-east-1d) sont supportées. J'ai initialement laissé Terraform choisir les deux premières AZ automatiquement, ce qui m'a donné us-east-1a et us-east-1b. La mise à jour du runtime a échoué avec un statut cryptique UPDATE_FAILED. Le vrai message d'erreur — mentionnant l'AZ non supportée — était enfoui dans le champ failureReason de la réponse API, pas remonté dans l'erreur Terraform. J'ai fini par coder en dur les AZ supportées dans mon module VPC.

Affinité de session

Celui-ci m'a coûté des heures. La signalisation WebRTC est une poignée de main en plusieurs étapes — le navigateur et l'agent échangent plusieurs messages pour établir une connexion. L'agent doit se souvenir de l'état de la connexion du premier message quand il traite le deuxième et le troisième. Si ces messages atterrissent sur des instances serveur différentes, l'agent n'a aucune mémoire de la poignée de main en cours et la connexion échoue.

J'ai d'abord utilisé des requêtes HTTP POST signées SigV4, en supposant qu'inclure l'identifiant de session comme paramètre de requête fournirait l'affinité de routage. Ce n'était pas le cas. Les candidats ICE atterrissaient sur une instance de conteneur différente de celle qui détenait la connexion pair.

La solution a été d'utiliser le SDK @aws-sdk/client-bedrock-agentcore avec InvokeAgentRuntimeCommand et le paramètre runtimeSessionId. C'est le seul moyen fiable de s'assurer que toutes les requêtes d'une session WebRTC atteignent la même instance de conteneur. Le code d'exemple AWS utilise ce pattern aussi — je ne l'avais simplement pas remarqué au début parce que j'étais concentré sur les parties WebRTC.

Filtrage des candidats SDP

Quand l'agent crée une connexion pair à l'intérieur du VPC, aiortc génère des candidats ICE pour toutes les interfaces réseau disponibles — y compris des IP internes au VPC comme 169.254.0.2. Ces candidats hôtes se retrouvent dans la réponse SDP envoyée au navigateur. Le navigateur essaie consciencieusement de s'y connecter, échoue (parce qu'ils sont injoignables depuis l'Internet public), et ne se rabat sur les candidats relais qu'ensuite. Cela ajoute plusieurs secondes au temps de connexion.

La solution est simple : retirer les candidats non-relais de la réponse SDP avant de la renvoyer au navigateur. En mode déployé, les seuls candidats qui peuvent fonctionner sont les candidats relais TURN, donc il n'y a aucune raison d'inclure les autres.

Mode TURN uniquement

Similaire au problème de filtrage SDP, l'instance aiortc de l'agent essaie les candidats hôtes avant les candidats relais par défaut. Comme les candidats hôtes utilisent des IP internes au VPC qui ne peuvent jamais fonctionner du point de vue du navigateur, c'est du temps perdu. Configurer aiortc pour n'utiliser que les candidats relais TURN (turn_only=True) saute directement aux candidats qui fonctionnent réellement.

Initialisation paresseuse de KVS

J'appelais initialement kvs.init() au moment de l'import du module, protégé par un if IS_CONTAINER. Ça fonctionnait bien en local mais faisait crasher le conteneur sur AgentCore. L'appel API KVS pour trouver ou créer le canal de signalisation nécessite des identifiants AWS, et au démarrage du conteneur il peut y avoir un bref délai avant que les identifiants du rôle IAM soient disponibles. Déplacer l'initialisation à la première requête réelle (init paresseuse) a résolu le crash.

Comportement au démarrage à froid

Après que le conteneur est resté inactif un moment, la première tentative de connexion WebRTC échoue parfois. Les requêtes de signalisation réussissent (AgentCore renvoie 200), mais la connexion ICE ne s'établit jamais. Je soupçonne que c'est lié au fait qu'AgentCore démarre une nouvelle instance de conteneur — les premières requêtes peuvent être traitées par une instance qui n'est pas encore complètement prête. Côté agent, j'ai explicitement mis --workers 1 dans la commande uvicorn pour m'assurer que toutes les requêtes au sein d'un conteneur touchent le même processus (et donc le même état de connexion pair en mémoire). Côté frontend, j'ai ajouté un mécanisme de retry : attendre que ICE atteigne l'état "connected", et si ce n'est pas le cas dans les 10 secondes, tout démonter et réessayer avec un nouvel identifiant de session. Ensemble, ces deux mesures ont rendu la connexion fiable.

Code clé

Je ne vais pas parcourir chaque fichier, mais voici les pièces qui font fonctionner l'intégration WebRTC.

L'adaptateur WebRTCBidiInput lit les trames audio de la piste aiortc, les rééchantillonne à 16kHz, et les renvoie comme événements bidi_audio_input que BidiAgent comprend :

class WebRTCBidiInput:
    def __init__(self, track):
        self._track = track

    async def __call__(self):
        try:
            frame = await self._track.recv()
        except MediaStreamError:
            raise StopAsyncIteration
        resampled = _resampler.resample(frame)
        pcm = b"".join(f.planes[0] for f in resampled)
        return {
            "type": "bidi_audio_input",
            "audio": base64.b64encode(pcm).decode("utf-8"),
            "sample_rate": 16000,
        }
Enter fullscreen mode Exit fullscreen mode

L'adaptateur WebRTCBidiOutput fait l'inverse — il reçoit les événements de BidiAgent et pousse l'audio vers la piste de sortie aiortc :

class WebRTCBidiOutput:
    def __init__(self, output_track):
        self._output_track = output_track

    async def __call__(self, event):
        if event.get("type") == "bidi_audio_stream":
            audio_bytes = base64.b64decode(event["audio"])
            self._output_track.add_audio(audio_bytes)
        elif event.get("type") == "bidi_interruption":
            self._output_track.clear()
Enter fullscreen mode Exit fullscreen mode

Côté frontend, le hook useWebRTCSession utilise le SDK AgentCore pour la signalisation :

const invoke = async (action, data = {}) => {
  const client = new BedrockAgentCoreClient({ region, credentials });
  const resp = await client.send(new InvokeAgentRuntimeCommand({
    agentRuntimeArn,
    runtimeSessionId: sessionId,  // assure l'affinité de session
    contentType: 'application/json',
    payload: new TextEncoder().encode(JSON.stringify({ action, data })),
  }));
  return JSON.parse(new TextDecoder().decode(
    await resp.response.transformToByteArray()
  ));
};
Enter fullscreen mode Exit fullscreen mode

Le code source complet est dans le repo — la branche feat/webrtc contient la version locale uniquement, et feat/webrtc-agentcore la version déployée complète avec Terraform.

Outillage de développement

J'ai construit ce projet avec Kiro CLI, l'assistant de développement IA d'Amazon. Il a géré la planification, la génération de code, le débogage et le déploiement itératif — y compris les nombreux allers-retours d'essais-erreurs avec la configuration WebRTC que ce billet décrit. Le va-et-vient entre écriture de code, déploiement, vérification des logs et correction des problèmes se prêtait naturellement à un workflow de pair-programming avec une IA.

Essayez vous-même

Pour lancer en local :

git clone https://github.com/psantus/strands-bidir-nova.git
cd strands-bidir-nova
git checkout feat/webrtc
uv sync && make install-frontend
# Terminal 1 :
make serve
# Terminal 2 :
make serve-frontend
Enter fullscreen mode Exit fullscreen mode

Ouvrez http://localhost:5173, cliquez sur le micro, et commencez à parler.

Pour la version déployée sur AgentCore, passez sur la branche feat/webrtc-agentcore et suivez le README. Vous aurez besoin d'une Knowledge Base Bedrock avec quelques recettes, d'un pool d'utilisateurs Cognito et de Docker pour construire l'image du conteneur. Un seul terraform apply gère le reste.

Si vous préférez commencer par la version WebSocket, le billet original de Darryl Ruggles est le bon point de départ.


Paul Santus est consultant cloud indépendant chez TerraCloud. Il accompagne les organisations dans la construction et le déploiement d'applications IA sur AWS. Retrouvez-le sur LinkedIn.


  1. WebRTC (Web Real-Time Communication) — Standard ouvert pour la communication audio, vidéo et données en temps réel directement entre navigateurs et appareils, utilisant un transport basé sur UDP. 

  2. TURN (Traversal Using Relays around NAT) — Serveur relais qui transfère le trafic média quand deux pairs ne peuvent pas se connecter directement. Les deux côtés envoient leur audio au serveur TURN, qui le relaie à l'autre côté. 

  3. NAT (Network Address Translation) — Mécanisme réseau qui fait correspondre des adresses IP privées à des adresses publiques. La plupart des routeurs domestiques et des VPC cloud utilisent le NAT, ce qui empêche les connexions entrantes directes. 

  4. SFU (Selective Forwarding Unit) — Serveur média qui reçoit les pistes audio/vidéo des participants et les retransmet sélectivement aux autres, sans mixage ni transcodage. Utilisé par LiveKit, Chime SDK, Daily, etc. 

  5. SDP (Session Description Protocol) — Format texte décrivant une session multimédia : codecs, adresses de transport et types de média. En WebRTC, les pairs échangent des « offres » et « réponses » SDP pour négocier la connexion. 

  6. ICE (Interactive Connectivity Establishment) — Protocole pour trouver le meilleur chemin réseau entre deux pairs. Il collecte des adresses candidates (locales, réflexives serveur, relais) et teste la connectivité entre elles. 

Top comments (0)