Tutoriel OpenAI Realtime API 2026 : créer un agent vocal IA avec Next.js et WebRTC

AI Bot
Par AI Bot ·

Chargement du lecteur de synthèse vocale...

La voix est l'interface qui rend enfin les agents IA vivants. Les chatbots textuels ont un plafond : ils obligent l'utilisateur à attendre, à lire, puis à relire. Un agent vocal qui répond en environ 300 millisecondes, accepte d'être interrompu en pleine phrase et appelle de vrais outils sur votre backend appartient à une autre catégorie de produit. L'API OpenAI Realtime a rendu cette expérience accessible sans avoir à assembler un pipeline speech-to-text, un LLM et un moteur de synthèse vocale. Un seul modèle, une seule connexion, full duplex.

Dans ce tutoriel, vous allez construire un agent vocal qui tourne dans le navigateur, dialogue avec l'API OpenAI Realtime via WebRTC, appelle des outils côté serveur grâce au function calling, et reste sûr à déployer en production. Nous utiliserons Next.js 15 (App Router), TypeScript et un client WebRTC léger. À la fin, vous aurez un agent fonctionnel capable de prendre un appel client, de retrouver une commande dans votre base et de dicter la réponse en parole naturelle.

Ce que vous allez construire

Un agent de support vocal pour une boutique e-commerce fictive. L'utilisateur appuie sur un bouton, pose une question à voix haute, et l'agent répond oralement. En coulisses, il peut :

  • Diffuser l'audio du microphone vers OpenAI en temps réel
  • Renvoyer l'audio dans les haut-parleurs avec une latence inférieure à la seconde
  • Détecter quand l'utilisateur parle et s'interrompre s'il est interrompu
  • Appeler un outil côté serveur lookup_order qui interroge une base de données
  • Persister la transcription pour la relecture ou le fine-tuning

Prérequis

Avant de commencer, assurez-vous d'avoir :

  • Node.js 20 ou plus récent
  • Une clé API OpenAI avec accès à l'API Realtime
  • Une connaissance de base de React, du Next.js App Router et de TypeScript
  • Un navigateur moderne (Chrome, Edge ou Safari) avec autorisation microphone
  • Un éditeur de code — VS Code recommandé

Vous devez aussi être à l'aise avec les concepts WebRTC à un niveau général. Nous n'implémenterons pas la signalisation à la main : OpenAI expose un point d'entrée HTTP unique qui gère l'échange SDP pour vous.

Pourquoi l'API Realtime plutôt que le pipeline traditionnel

Une stack vocale classique ressemble à : microphone, puis Whisper, puis GPT, puis un service TTS comme ElevenLabs. Chaque saut ajoute de la latence et complique les interruptions. L'API Realtime remplace tout cela par un unique modèle multimodal qui consomme et émet des trames audio. Elle gère aussi la détection d'activité vocale côté serveur, ce qui fait que le barge-in, l'alternance de tours et la détection de silence fonctionnent immédiatement.

Deux autres raisons comptent en production :

  • Une facture, un seul rate limit. Plus besoin de coordonner des quotas chez trois fournisseurs.
  • Le function calling est natif. Le même modèle qui écoute l'utilisateur peut appeler vos outils sans aller-retour supplémentaire.

Étape 1 : initialiser le projet

Créez une nouvelle application Next.js avec TypeScript et Tailwind :

npx create-next-app@latest voice-agent --typescript --app --tailwind --eslint
cd voice-agent
npm install openai zod

Ajoutez votre clé API dans un fichier d'environnement local :

# .env.local
OPENAI_API_KEY=sk-proj-...

La valeur OPENAI_API_KEY ne doit jamais arriver dans le navigateur. Nous l'utiliserons uniquement côté serveur, pour générer des jetons éphémères courts que le navigateur pourra utiliser pour ouvrir une connexion WebRTC.

Étape 2 : générer des jetons éphémères côté serveur

L'erreur la plus fréquente avec Realtime est d'envoyer la clé API principale au client. Ne le faites pas. Exposez plutôt un Route Handler Next.js qui retourne un jeton éphémère valable 60 secondes lié à une session précise.

Créez app/api/realtime/session/route.ts :

import { NextResponse } from "next/server";
 
export async function POST() {
  const response = await fetch(
    "https://api.openai.com/v1/realtime/sessions",
    {
      method: "POST",
      headers: {
        Authorization: `Bearer ${process.env.OPENAI_API_KEY}`,
        "Content-Type": "application/json",
      },
      body: JSON.stringify({
        model: "gpt-realtime",
        voice: "marin",
        modalities: ["audio", "text"],
        instructions:
          "Vous êtes un agent vocal serviable et concis pour une boutique en ligne. Répondez toujours dans la langue de l'utilisateur. Confirmez les numéros de commande avant d'appeler lookup_order.",
      }),
    },
  );
 
  if (!response.ok) {
    return NextResponse.json(
      { error: "Échec de la création de la session" },
      { status: 500 },
    );
  }
 
  const data = await response.json();
  return NextResponse.json(data);
}

La réponse contient client_secret.value, qui est le jeton éphémère. Il est valable environ une minute et limité à une seule session. Même si un utilisateur le récupère depuis l'onglet réseau, il ne peut continuer que l'appel déjà en cours.

Étape 3 : connecter le navigateur en WebRTC

WebRTC est le bon transport ici parce qu'il transporte de l'audio avec un jitter buffer intégré, une récupération de paquets perdus et un encodage Opus. Il donne aussi au navigateur un contrôle bas niveau sur la capture et la lecture.

Créez lib/realtime-client.ts :

export type RealtimeEvent =
  | { type: "session.created"; session: unknown }
  | { type: "input_audio_buffer.speech_started" }
  | { type: "input_audio_buffer.speech_stopped" }
  | { type: "response.audio_transcript.delta"; delta: string }
  | { type: "response.function_call_arguments.done"; name: string; call_id: string; arguments: string }
  | { type: "error"; error: { message: string } };
 
export interface RealtimeClient {
  pc: RTCPeerConnection;
  dc: RTCDataChannel;
  audio: HTMLAudioElement;
  stop: () => void;
}
 
export async function startRealtime(
  onEvent: (event: RealtimeEvent) => void,
): Promise<RealtimeClient> {
  const tokenRes = await fetch("/api/realtime/session", { method: "POST" });
  const { client_secret } = await tokenRes.json();
 
  const pc = new RTCPeerConnection();
 
  const audio = new Audio();
  audio.autoplay = true;
  pc.ontrack = (event) => {
    audio.srcObject = event.streams[0];
  };
 
  const mic = await navigator.mediaDevices.getUserMedia({ audio: true });
  pc.addTrack(mic.getAudioTracks()[0]);
 
  const dc = pc.createDataChannel("oai-events");
  dc.onmessage = (event) => {
    const parsed = JSON.parse(event.data) as RealtimeEvent;
    onEvent(parsed);
  };
 
  const offer = await pc.createOffer();
  await pc.setLocalDescription(offer);
 
  const sdpRes = await fetch(
    "https://api.openai.com/v1/realtime?model=gpt-realtime",
    {
      method: "POST",
      body: offer.sdp,
      headers: {
        Authorization: `Bearer ${client_secret.value}`,
        "Content-Type": "application/sdp",
      },
    },
  );
 
  const answer = { type: "answer" as const, sdp: await sdpRes.text() };
  await pc.setRemoteDescription(answer);
 
  return {
    pc,
    dc,
    audio,
    stop: () => {
      mic.getTracks().forEach((t) => t.stop());
      dc.close();
      pc.close();
    },
  };
}

Quelques détails faciles à oublier :

  • L'appel à getUserMedia doit avoir lieu dans un gestionnaire d'événement déclenché par l'utilisateur, sinon le navigateur le rejette.
  • Le nom du data channel est oai-events. Il est imposé par l'API.
  • Nous ne définissons jamais pc.onicecandidate. Le point d'entrée Realtime accepte une seule offre SDP et retourne la réponse en un seul échange, donc le trickle ICE est inutile.

Étape 4 : construire l'interface

Créez un composant client simple qui démarre et arrête l'agent. Sauvegardez-le dans app/page.tsx :

"use client";
 
import { useRef, useState } from "react";
import { startRealtime, type RealtimeClient } from "@/lib/realtime-client";
 
export default function Home() {
  const [status, setStatus] = useState<"idle" | "connecting" | "live">("idle");
  const [transcript, setTranscript] = useState("");
  const clientRef = useRef<RealtimeClient | null>(null);
 
  async function start() {
    setStatus("connecting");
    const client = await startRealtime((event) => {
      if (event.type === "response.audio_transcript.delta") {
        setTranscript((prev) => prev + event.delta);
      }
      if (event.type === "session.created") {
        setStatus("live");
      }
    });
    clientRef.current = client;
  }
 
  function stop() {
    clientRef.current?.stop();
    clientRef.current = null;
    setStatus("idle");
  }
 
  return (
    <main className="mx-auto max-w-2xl p-8 space-y-6">
      <h1 className="text-3xl font-bold">Agent vocal</h1>
      <button
        onClick={status === "idle" ? start : stop}
        className="rounded-full bg-black text-white px-6 py-3"
      >
        {status === "idle" ? "Démarrer l'appel" : "Terminer l'appel"}
      </button>
      <p className="text-sm text-zinc-500">Statut : {status}</p>
      <pre className="whitespace-pre-wrap rounded-lg bg-zinc-100 p-4 text-sm">
        {transcript}
      </pre>
    </main>
  );
}

Lancez npm run dev, cliquez sur Démarrer l'appel, autorisez l'accès au microphone, puis dites bonjour. L'agent doit répondre en environ 500 ms.

Étape 5 : ajouter des outils avec le function calling

Un agent vocal qui ne fait que discuter est un jouet. La partie intéressante apparaît quand il peut agir. L'API Realtime utilise le même function calling à base de schémas JSON que l'API Chat Completions. Définissez les outils au moment de la création de la session.

Mettez à jour le corps du Route Handler /api/realtime/session pour inclure un tableau tools :

body: JSON.stringify({
  model: "gpt-realtime",
  voice: "marin",
  modalities: ["audio", "text"],
  instructions:
    "Vous êtes un agent vocal serviable et concis pour une boutique en ligne. Confirmez les numéros de commande avant d'appeler lookup_order.",
  tools: [
    {
      type: "function",
      name: "lookup_order",
      description: "Retrouver le statut d'une commande client par son numéro.",
      parameters: {
        type: "object",
        properties: {
          order_number: {
            type: "string",
            description: "Le numéro de commande, par exemple ORD-12345.",
          },
        },
        required: ["order_number"],
      },
    },
  ],
  tool_choice: "auto",
}),

Quand le modèle décide d'appeler l'outil, le data channel émet un événement response.function_call_arguments.done. Le navigateur n'est pas le bon endroit pour exécuter de la logique métier ; transférez l'appel à votre backend, exécutez-le, puis renvoyez le résultat par le même data channel.

Dans app/page.tsx, étendez le gestionnaire d'événements :

const client = await startRealtime(async (event) => {
  if (event.type === "response.audio_transcript.delta") {
    setTranscript((prev) => prev + event.delta);
  }
  if (event.type === "response.function_call_arguments.done") {
    const args = JSON.parse(event.arguments);
    const res = await fetch("/api/orders/lookup", {
      method: "POST",
      body: JSON.stringify(args),
    });
    const result = await res.json();
 
    clientRef.current?.dc.send(
      JSON.stringify({
        type: "conversation.item.create",
        item: {
          type: "function_call_output",
          call_id: event.call_id,
          output: JSON.stringify(result),
        },
      }),
    );
    clientRef.current?.dc.send(JSON.stringify({ type: "response.create" }));
  }
});

Ajoutez ensuite la route backend dans app/api/orders/lookup/route.ts :

import { NextResponse } from "next/server";
import { z } from "zod";
 
const Schema = z.object({ order_number: z.string() });
 
export async function POST(request: Request) {
  const body = Schema.parse(await request.json());
 
  const order = await db.orders.findUnique({
    where: { id: body.order_number },
  });
 
  if (!order) {
    return NextResponse.json({ found: false });
  }
 
  return NextResponse.json({
    found: true,
    status: order.status,
    expected_delivery: order.expectedDelivery,
  });
}

L'agent va alors entendre le numéro de commande, le répéter pour confirmation, appeler votre backend et lire la réponse à voix naturelle. Le tout dans un seul tour de conversation.

Étape 6 : détection d'activité vocale et interruption

Par défaut, l'API Realtime utilise le VAD côté serveur pour détecter les frontières de tour. Cela signifie que le modèle sait quand l'utilisateur a fini de parler et commence à répondre immédiatement. Il sait aussi quand l'utilisateur reprend la parole, ce qui permet l'interruption — le client reçoit un événement response.cancelled et arrête la lecture.

Vous pouvez régler le VAD en envoyant un événement session.update après la création de la session :

clientRef.current?.dc.send(
  JSON.stringify({
    type: "session.update",
    session: {
      turn_detection: {
        type: "server_vad",
        threshold: 0.5,
        prefix_padding_ms: 300,
        silence_duration_ms: 600,
      },
    },
  }),
);

Une silence_duration_ms de 600 est un bon défaut. Des valeurs plus faibles font intervenir l'agent trop vite, des valeurs plus élevées le rendent lent.

Étape 7 : persister les transcriptions

Pour l'analyse, les journaux d'audit ou le fine-tuning, vous voudrez sauvegarder la transcription complète côté serveur. L'API Realtime émet des événements conversation.item.created pour les deux côtés de la conversation. Redirigez-les vers un petit point d'entrée de logs :

if (event.type === "conversation.item.created") {
  fetch("/api/logs/turn", {
    method: "POST",
    body: JSON.stringify({
      sessionId: client.sessionId,
      role: event.item.role,
      content: event.item.content,
      ts: Date.now(),
    }),
  });
}

Stockez ces données dans Postgres ou tout journal append-only. Ne stockez pas l'audio brut sans raison claire et sans consentement utilisateur — c'est un autre sujet de conformité.

Étape 8 : considérations de production

Une démo qui marche sur votre laptop n'est pas un agent en production. Avant de livrer :

  • Limitez le débit du point d'entrée de session. Chaque appel à /api/realtime/session crée une session OpenAI payante. Encadrez-le avec Upstash ou Arcjet pour qu'un utilisateur ne puisse pas boucler sur Démarrer l'appel et brûler votre budget.
  • Imposez une durée maximale. Les sessions Realtime peuvent durer jusqu'à 30 minutes. Ajoutez un minuteur côté client qui appelle stop() après une limite raisonnable, par exemple cinq minutes pour un appel de support.
  • Masquez les données personnelles dans les transcriptions. Faites passer le texte sauvegardé par une étape de rédaction avant qu'il n'arrive dans votre entrepôt.
  • Localisez la voix. Passez la langue de l'utilisateur dans le prompt système pour que le modèle choisisse le bon accent. La région MENA bénéficie particulièrement d'un prompt système en arabe natif plutôt que d'instructions anglaises traduites à la volée.
  • Surveillez avec Langfuse ou OpenTelemetry. Suivez la latence, le taux de succès des appels d'outils et le coût moyen par session.

Dépannage

La demande de permission microphone n'apparaît jamais. Vous avez appelé getUserMedia en dehors d'un geste utilisateur. Encapsulez l'appel dans le handler de clic du bouton Démarrer l'appel.

L'audio est saccadé. Vérifiez que autoplay est activé sur l'élément <audio>. Certains navigateurs bloquent l'autoplay tant que l'utilisateur n'a pas interagi avec la page une fois. Le premier clic sur Démarrer l'appel compte.

L'agent n'appelle jamais l'outil. Confirmez que tool_choice: "auto" est défini, et que le prompt système n'interdit pas l'usage d'outils. Inspectez aussi l'événement response.function_call_arguments.done dans votre debugger — parfois le modèle invente un numéro de commande sans le demander, ce qui veut dire qu'il faut renforcer le prompt.

La connexion WebRTC échoue derrière un proxy d'entreprise. Ajoutez un serveur TURN. Pour la plupart des déploiements publics, la configuration STUN par défaut de l'API suffit.

Étapes suivantes

Vous avez maintenant la colonne vertébrale d'un vrai produit vocal. À partir d'ici, vous pouvez ajouter :

  • Le passage entre agents — routez un appel d'un agent de triage vers un spécialiste facturation grâce à un outil transfer_to_agent. Voir notre tutoriel sur les agents stateful avec LangGraph pour le pattern d'orchestration.
  • Des réponses augmentées par recherche — donnez à l'agent un outil search_docs qui interroge votre vector store. Notre guide RAG agentique avec Next.js couvre le côté indexation.
  • L'intégration téléphonique — pontez l'agent vers des appels téléphoniques entrants et sortants via un trunk SIP Twilio Voice ou LiveKit.
  • L'observabilité — branchez chaque appel d'outil et chaque tour dans Langfuse pour mesurer la qualité d'une release à l'autre.

Conclusion

L'API Realtime ramène la stack vocale IA à quelque chose qu'une petite équipe peut réellement maintenir. Trois choses ont compté le plus dans ce projet : les jetons éphémères pour garder la clé principale côté serveur, WebRTC pour garder une latence humaine, et le function calling pour rendre l'agent utile et pas seulement bavard. Avec ces primitives en place, le reste est du travail produit — définir les bons outils, écrire un prompt système précis et instrumenter les conversations qui partent en prod.

La voix n'est plus un gadget. Les équipes qui apprennent à la livrer maintenant définiront la prochaine génération d'IA orientée client.


Vous voulez lire plus de tutoriels? Découvrez notre dernier tutoriel sur Angular 19 avec Signals et Resource API : créer des applications réactives en 2026.

Discutez de votre projet avec nous

Nous sommes ici pour vous aider avec vos besoins en développement Web. Planifiez un appel pour discuter de votre projet et comment nous pouvons vous aider.

Trouvons les meilleures solutions pour vos besoins.

Articles connexes

Construire un Agent IA Autonome avec Agentic RAG et Next.js

Apprenez a construire un agent IA qui decide de maniere autonome quand et comment recuperer des informations depuis des bases de donnees vectorielles. Un guide pratique complet avec Vercel AI SDK et Next.js, accompagne d'exemples executables.

30 min read·