> This is a page from the ElevenLabs documentation. For a complete page index, fetch https://elevenlabs.io/docs/llms.txt. For the full documentation in a single file, fetch https://elevenlabs.io/docs/llms-full.txt.

# Intégration LLM personnalisée

## Vue d’ensemble

L’[intégration Twilio native](/docs/fr/eleven-agents/phone-numbers/twilio-integration/native-integration) d’ElevenAgents couvre le cas où ElevenLabs héberge le LLM. Consultez ce guide lorsque vous avez besoin de contrôler entièrement le moteur LLM sur votre propre serveur, avec votre modèle, pipeline RAG, routage des appels de fonction ou autre logique côté serveur, tout en conservant l’agent sur un numéro de téléphone Twilio.

La partie LLM personnalisé est assurée par le [SDK Speech Engine](/docs/fr/eleven-api/guides/cookbooks/speech-engine), qui ouvre un WebSocket entre ElevenLabs et votre serveur afin que votre LLM puisse diffuser les réponses à mesure que l’appel se déroule. La partie Twilio utilise [Media Streams](https://www.twilio.com/docs/voice/media-streams) pour relayer l’audio des appels vers l’agent.

## Architecture

Le SDK Speech Engine expose deux points de terminaison WebSocket dans le système de conversation de l’agent :

* Le **WebSocket du moteur** s’exécute sur votre serveur. ElevenLabs s’y connecte pour transmettre les transcriptions et recevoir le texte généré par le LLM.
* Le **WebSocket de conversation** s’exécute chez ElevenLabs. Les clients s’y connectent pour envoyer de l’audio et recevoir l’audio synthétisé en retour. Le pont Twilio se connecte via une URL signée et relaie l’audio μ-law dans les deux sens.

Comme Twilio Media Streams et Speech Engine utilisent tous deux `ulaw_8000`, le pont relaie l’audio encodé en base64 sans transcodage.

```mermaid
sequenceDiagram
    participant Caller
    participant Twilio
    participant Bridge as Bridge Server
    participant EL as ElevenLabs (conversation WS)
    participant Brain as Brain Server

    Caller->>Twilio: Dial number
    Twilio->>Bridge: POST /incoming-call
    Bridge-->>Twilio: TwiML <Connect><Stream>
    Twilio->>Bridge: WebSocket /media-stream
    Bridge->>EL: Open conversation WebSocket (signed URL)

    loop Conversation
        Caller->>Twilio: Speak
        Twilio->>Bridge: media event (μ-law base64)
        Bridge->>EL: user_audio_chunk
        EL->>Brain: user_transcript
        Brain-->>EL: agent_response (streamed)
        EL->>Bridge: audio event (μ-law base64)
        Bridge->>Twilio: media event
        Twilio->>Caller: Play audio
    end
```

Le pont et le serveur du moteur peuvent s’exécuter dans le même processus si cela vous convient. L’exemple ci-dessous les combine.

## Quand utiliser ce modèle

Ce guide comme l’[intégration Twilio native](/docs/fr/eleven-agents/phone-numbers/twilio-integration/native-integration) placent un agent sur un numéro de téléphone Twilio. La différence réside dans l’hébergement du LLM :

* **Intégration native** : ElevenLabs héberge le LLM, que vous configurez via l’agent. Plus simple.
* **LLM personnalisé via le SDK Speech Engine** (ce guide) : vous hébergez le LLM sur votre propre serveur. Contrôle total du modèle, du RAG, des appels de fonction et de la logique métier. Plus d’éléments à gérer.

Si votre logique LLM correspond à la configuration standard d’un agent, privilégiez l’intégration native. Consultez ce guide lorsque votre moteur doit exécuter du code sur votre propre infrastructure.

Ce modèle utilise le SDK Speech Engine, qui s’appuie sur une connexion WebSocket pour communiquer entre votre serveur et l’API ElevenLabs. Vous pouvez également utiliser le guide [LLM personnalisé](/docs/fr/eleven-agents/customization/llm/custom-llm), qui emploie un point de terminaison HTTP compatible avec OpenAI à la place du SDK Speech Engine.

La principale différence entre les deux réside dans les WebSockets et les requêtes HTTP. Les WebSockets maintiennent une connexion unique plutôt que d’établir une nouvelle connexion HTTP à chaque tour, ce qui peut réduire la latence.

## Prérequis

* Un compte [Twilio](https://www.twilio.com/) et un numéro de téléphone compatible avec les appels vocaux.
* Une ressource Speech Engine. Suivez le [guide de démarrage rapide Speech Engine](/docs/fr/eleven-api/guides/cookbooks/speech-engine) pour en créer une et découvrir le modèle de serveur du moteur.
* Un tunnel HTTPS public (par exemple, [ngrok](https://ngrok.com)). Twilio contacte votre pont via l’internet public.
* Python 3.9+ ou Node.js 18+.

## Configurer l’agent pour l’audio μ-law

Twilio Media Streams utilise un audio μ-law à 8 kHz. Configurez Speech Engine pour accepter et émettre ce même format afin que le pont n’ait pas à effectuer de transcodage.

**`configure_engine.py`**

```python title="configure_engine.py"
import asyncio
import os
from elevenlabs import AsyncElevenLabs

elevenlabs = AsyncElevenLabs(api_key=os.environ["ELEVENLABS_API_KEY"])


async def update_engine():
    await elevenlabs.speech_engine.update(
        speech_engine_id="seng_8k3m9xr4hjnfg983brhmhkd98n6",
        asr={"user_input_audio_format": "ulaw_8000"},
        tts={
            "model_id": "eleven_flash_v2",
            "agent_output_audio_format": "ulaw_8000",
        },
        speech_engine={
            "request_headers": {"x-api-key": os.environ["SHARED_SECRET"]},
        },
    )


asyncio.run(update_engine())
```

**`configure-engine.mts`**

```typescript title="configure-engine.mts"
import { ElevenLabsClient } from "@elevenlabs/elevenlabs-js";
import "dotenv/config";

const elevenlabs = new ElevenLabsClient({
  apiKey: process.env.ELEVENLABS_API_KEY,
});

await elevenlabs.speechEngine.update("seng_8k3m9xr4hjnfg983brhmhkd98n6", {
  asr: { userInputAudioFormat: "ulaw_8000" },
  tts: {
    modelId: "eleven_flash_v2",
    agentOutputAudioFormat: "ulaw_8000",
  },
  speechEngine: {
    requestHeaders: { "x-api-key": process.env.SHARED_SECRET! },
  },
});
```

`eleven_flash_v2` maintient une faible latence de synthèse vocale, un point essentiel lors d’un appel téléphonique. Le bloc `request_headers` indique à ElevenLabs d’inclure `x-api-key: <shared-secret>` sur chaque connexion WebSocket du moteur. Le serveur du moteur vérifie cet en-tête afin de s’assurer que seul votre Speech Engine peut s’y connecter.

## Créer le serveur de pont

Le pont fournit trois routes :

* `POST /incoming-call` : webhook Twilio. Renvoie du TwiML demandant à Twilio d’ouvrir un Media Stream vers `/media-stream`.
* `GET /media-stream` : WebSocket Twilio Media Streams. Relaye l’audio vers et depuis le WebSocket de conversation Speech Engine.
* `GET /ws` : WebSocket du moteur. ElevenLabs s’y connecte lorsqu’une conversation démarre. Exécute le serveur standard `engine.serve()` / `engine.attach()`.

#### Installez les dépendances

**`Python`**

```bash title="Python"
pip install "elevenlabs" "aiohttp" "twilio" "python-dotenv"
```

**`Node`**

```bash title="Node"
npm install @elevenlabs/elevenlabs-js express ws twilio dotenv openai
```

#### Générez une URL signée pour Speech Engine

Le pont demande une URL signée à chaque nouvel appel. L’URL intègre l’ID Speech Engine et une signature à usage unique, afin que le pont n’ait jamais besoin de la clé API brute.

**`bridge.py`**

```python title="bridge.py"
from elevenlabs import AsyncElevenLabs

elevenlabs = AsyncElevenLabs(api_key=os.environ["ELEVENLABS_API_KEY"])

async def signed_url() -> str:
    response = await elevenlabs.conversational_ai.conversations.get_signed_url(
        agent_id=os.environ["SPEECH_ENGINE_ID"],
    )
    return response.signed_url
```

**`bridge.mts`**

```typescript title="bridge.mts"
import { ElevenLabsClient } from "@elevenlabs/elevenlabs-js";

const elevenlabs = new ElevenLabsClient({
  apiKey: process.env.ELEVENLABS_API_KEY,
});

async function signedUrl(): Promise<string> {
  const response = await elevenlabs.conversationalAi.conversations.getSignedUrl({
    agentId: process.env.SPEECH_ENGINE_ID!,
  });
  return response.signedUrl;
}
```

#### Renvoyez la réponse TwiML

Lorsqu’un appel arrive, Twilio envoie une requête POST à `/incoming-call`. La réponse est du TwiML qui ouvre un Media Stream vers le WebSocket `/media-stream` du pont.

**`bridge.py`**

```python title="bridge.py"
from aiohttp import web
from twilio.request_validator import RequestValidator

validator = RequestValidator(os.environ["TWILIO_AUTH_TOKEN"])


async def incoming_call(request: web.Request) -> web.Response:
    form = await request.post()
    signature = request.headers.get("X-Twilio-Signature", "")
    url = str(request.url)
    if not validator.validate(url, dict(form), signature):
        return web.Response(status=403, text="forbidden")

    host = request.headers.get("X-Forwarded-Host") or request.host
    twiml = (
        '<?xml version="1.0" encoding="UTF-8"?>'
        "<Response><Connect>"
        f'<Stream url="wss://{host}/media-stream"/>'
        "</Connect></Response>"
    )
    return web.Response(text=twiml, content_type="text/xml")
```

**`bridge.mts`**

```typescript title="bridge.mts"
import express from "express";
import twilio from "twilio";

const app = express();
app.use(express.urlencoded({ extended: false }));

app.post(
  "/incoming-call",
  twilio.webhook({ validate: true }),
  (req, res) => {
    const host = req.headers["x-forwarded-host"] ?? req.get("host");
    const twiml = `<?xml version="1.0" encoding="UTF-8"?>
      <Response>
        <Connect>
          <Stream url="wss://${host}/media-stream"/>
        </Connect>
      </Response>`;
    res.type("text/xml").send(twiml);
  },
);
```

`RequestValidator` (Python) et `twilio.webhook({ validate: true })` (Node) vérifient l’en-tête `X-Twilio-Signature` par rapport à `TWILIO_AUTH_TOKEN`. Sans validation, toute personne sur l’internet public pourrait envoyer une requête POST à `/incoming-call` et facturer des appels sur votre compte.

#### Reliez le Media Stream

Le Media Stream est un WebSocket qui envoie une séquence d’événements JSON : `connected`, `start`, `media` (la charge utile audio) et `stop`. Le pont ouvre un WebSocket de conversation Speech Engine à `start` et relaie l’audio dans les deux sens jusqu’à la fermeture du flux.

**`bridge.py`**

```python title="bridge.py" maxLines=0
import asyncio
import json

import aiohttp
from aiohttp import web


async def media_stream(request: web.Request) -> web.WebSocketResponse:
    twilio_ws = web.WebSocketResponse()
    await twilio_ws.prepare(request)

    stream_sid: str | None = None
    el_session: aiohttp.ClientSession | None = None
    el_ws: aiohttp.ClientWebSocketResponse | None = None
    pump_task: asyncio.Task | None = None

    async def pump_el_to_twilio(el: aiohttp.ClientWebSocketResponse):
        async for msg in el:
            if msg.type != aiohttp.WSMsgType.TEXT:
                continue
            event = json.loads(msg.data)
            etype = event.get("type")
            if etype == "audio":
                await twilio_ws.send_str(json.dumps({
                    "event": "media",
                    "streamSid": stream_sid,
                    "media": {"payload": event["audio_event"]["audio_base_64"]},
                }))
            elif etype == "interruption":
                await twilio_ws.send_str(json.dumps({
                    "event": "clear",
                    "streamSid": stream_sid,
                }))
            elif etype == "ping":
                event_id = event.get("ping_event", {}).get("event_id")
                await el.send_str(json.dumps({
                    "type": "pong", "event_id": event_id,
                }))

    try:
        async for msg in twilio_ws:
            if msg.type != aiohttp.WSMsgType.TEXT:
                continue
            event = json.loads(msg.data)

            if event["event"] == "start":
                stream_sid = event["start"]["streamSid"]
                el_session = aiohttp.ClientSession()
                el_ws = await el_session.ws_connect(await signed_url())
                await el_ws.send_str(json.dumps({
                    "type": "conversation_initiation_client_data",
                }))
                pump_task = asyncio.create_task(pump_el_to_twilio(el_ws))

            elif event["event"] == "media" and el_ws is not None:
                await el_ws.send_str(json.dumps({
                    "user_audio_chunk": event["media"]["payload"],
                }))

            elif event["event"] == "stop":
                break
    finally:
        if pump_task:
            pump_task.cancel()
        if el_ws and not el_ws.closed:
            await el_ws.close()
        if el_session and not el_session.closed:
            await el_session.close()

    return twilio_ws
```

**`bridge.mts`**

```typescript title="bridge.mts" maxLines=0
import { WebSocket, WebSocketServer } from "ws";
import { createServer } from "node:http";

const httpServer = createServer(app);
const wss = new WebSocketServer({ noServer: true });

httpServer.on("upgrade", (req, socket, head) => {
  if (req.url === "/media-stream") {
    wss.handleUpgrade(req, socket, head, (ws) => handleMediaStream(ws));
  } else {
    socket.destroy();
  }
});

async function handleMediaStream(twilioWs: WebSocket) {
  let streamSid: string | null = null;
  let elReady: Promise<WebSocket | null> | null = null;

  twilioWs.on("message", async (raw) => {
    const event = JSON.parse(raw.toString());

    if (event.event === "start") {
      streamSid = event.start.streamSid;
      // Convert rejection into a clean null + close so a failed signed-URL
      // fetch doesn't become an unhandled rejection on the next media event.
      elReady = openElevenLabsWebSocket(twilioWs, () => streamSid).catch((err) => {
        console.error("Failed to open Speech Engine conversation:", err);
        twilioWs.close();
        return null;
      });
    } else if (event.event === "media" && elReady) {
      const elWs = await elReady;
      if (!elWs) return;
      elWs.send(JSON.stringify({
        user_audio_chunk: event.media.payload,
      }));
    } else if (event.event === "stop") {
      twilioWs.close();
    }
  });

  twilioWs.on("close", async () => {
    (await elReady)?.close();
  });
}

async function openElevenLabsWebSocket(
  twilioWs: WebSocket,
  getStreamSid: () => string | null,
): Promise<WebSocket> {
  const elWs = new WebSocket(await signedUrl());
  await new Promise<void>((resolve, reject) => {
    elWs.once("open", () => resolve());
    elWs.once("error", reject);
  });
  elWs.send(JSON.stringify({
    type: "conversation_initiation_client_data",
  }));

  elWs.on("message", (raw) => {
    const event = JSON.parse(raw.toString());
    const streamSid = getStreamSid();
    if (event.type === "audio") {
      twilioWs.send(JSON.stringify({
        event: "media",
        streamSid,
        media: { payload: event.audio_event.audio_base_64 },
      }));
    } else if (event.type === "interruption") {
      twilioWs.send(JSON.stringify({ event: "clear", streamSid }));
    } else if (event.type === "ping") {
      elWs.send(JSON.stringify({
        type: "pong", event_id: event.ping_event?.event_id,
      }));
    }
  });

  return elWs;
}
```

L’événement `interruption` de Speech Engine déclenche un événement `clear` sur le flux Twilio, qui supprime tout audio mis en mémoire tampon afin que l’interruption fonctionne correctement. L’événement `ping` reçoit une réponse `pong` pour maintenir le WebSocket de conversation actif.

#### Exécutez le serveur du moteur en parallèle

Le serveur du moteur est le serveur Speech Engine standard présenté dans le [guide de démarrage rapide](/docs/fr/eleven-api/guides/cookbooks/speech-engine). Le seul ajout est la vérification du secret partagé lors de la mise à niveau WebSocket. Acceptez la connexion uniquement si `x-api-key` correspond à la valeur définie sur Speech Engine.

**`bridge.py`**

```python title="bridge.py" maxLines=0
import os

from elevenlabs import AsyncElevenLabs

elevenlabs = AsyncElevenLabs(api_key=os.environ["ELEVENLABS_API_KEY"])
SHARED_SECRET = os.environ["SHARED_SECRET"]


async def brain_ws(request: web.Request) -> web.WebSocketResponse:
    if request.headers.get("x-api-key") != SHARED_SECRET:
        return web.Response(status=401, text="unauthorized")

    ws = web.WebSocketResponse()
    await ws.prepare(request)

    engine = await elevenlabs.speech_engine.get(os.environ["SPEECH_ENGINE_ID"])
    session = engine.create_session(ws)

    async def on_transcript(transcript):
        # Replace this with your own LLM call; see the quickstart.
        await session.send_response("Hello, you've reached the demo.")

    session.on("user_transcript", on_transcript)
    await session.run()
    return ws


def make_app() -> web.Application:
    app = web.Application()
    app.router.add_post("/incoming-call", incoming_call)
    app.router.add_get("/media-stream", media_stream)
    app.router.add_get("/ws", brain_ws)
    return app


if __name__ == "__main__":
    web.run_app(make_app(), port=3001)
```

**`bridge.mts`**

```typescript title="bridge.mts" maxLines=0
httpServer.on("upgrade", async (req, socket, head) => {
  if (req.url === "/ws") {
    if (req.headers["x-api-key"] !== process.env.SHARED_SECRET) {
      socket.write("HTTP/1.1 401 Unauthorized\r\n\r\n");
      socket.destroy();
      return;
    }
    // Hand off to engine.attach() — see the Speech Engine quickstart.
  } else if (req.url === "/media-stream") {
    wss.handleUpgrade(req, socket, head, (ws) => handleMediaStream(ws));
  } else {
    socket.destroy();
  }
});

httpServer.listen(3001);
```

Consultez le [guide de démarrage rapide Speech Engine](/docs/fr/eleven-api/guides/cookbooks/speech-engine#server-setup) pour découvrir l’implémentation complète de `on_transcript`, y compris un appel au LLM et une réponse diffusée en continu.

## Diriger Twilio vers le pont

#### Démarrez le pont et un tunnel public

```bash
ngrok http 3001
python bridge.py
```

Notez l’URL `https://` affichée par ngrok. Twilio y enverra une requête POST.

#### Mettez à jour ws_url de Speech Engine

Définissez `speech_engine.ws_url` sur l’URL WebSocket publique de votre point de terminaison du moteur afin qu’ElevenLabs sache où se connecter.

```python
await elevenlabs.speech_engine.update(
    speech_engine_id="seng_8k3m9xr4hjnfg983brhmhkd98n6",
    speech_engine={"ws_url": "wss://abc123.ngrok.io/ws"},
)
```

```typescript
await elevenlabs.speechEngine.update("seng_8k3m9xr4hjnfg983brhmhkd98n6", {
  speechEngine: { wsUrl: "wss://abc123.ngrok.io/ws" },
});
```

#### Configurez le numéro Twilio

Dans la console Twilio, ouvrez la **configuration vocale** de votre numéro de téléphone :

* **Un appel arrive** : Webhook
* **URL** : `https://abc123.ngrok.io/incoming-call`
* **Méthode HTTP** : POST

Si le numéro est associé à un Elastic SIP Trunk, dissociez-le d’abord. Un numéro Twilio est acheminé soit vers un trunk, soit vers un webhook, mais pas vers les deux.

#### Appelez le numéro

Composez le numéro depuis n’importe quel téléphone. L’agent répond. Parlez pendant l’appel et vous devriez entendre la réponse de l’agent. Lorsque la journalisation de débogage est activée, le pont consigne le SID de l’appel, l’ID de conversation et le format audio pour chaque tour.

## Considérations pour la production

* **Validation des webhooks** : validez systématiquement `X-Twilio-Signature` sur `/incoming-call`. L’exemple ci-dessus utilise la bibliothèque d’assistance Twilio, ne sautez pas cette étape.
* **Secret partagé** : appliquez le secret partagé sur le WebSocket du moteur. Sans cela, toute personne qui devine votre URL ngrok peut se connecter et se faire passer pour ElevenLabs.
* **Hôte stable** : les URL du forfait gratuit ngrok changent à chaque redémarrage. Utilisez un domaine ngrok réservé ou un véritable nom d’hôte afin de ne pas avoir à mettre à jour le `ws_url` de Speech Engine et le webhook Twilio après chaque redémarrage.
* **Latence** : chaque appel ajoute deux sauts réseau au délai avant le premier jeton du LLM. Utilisez un modèle à faible latence et diffusez les réponses pour maintenir une faible latence perçue.
* **Un ou deux processus** : l’exemple place le pont et le moteur sur le même port, de sorte qu’un seul tunnel ngrok couvre l’ensemble. En production, vous pouvez les répartir sur deux services, à condition que chacun dispose d’une URL publique.
* **Injection de prompt** : l’entrée vocale d’un appel téléphonique est une entrée utilisateur non fiable. Validez les transcriptions avant qu’elles n’influencent les appels d’outils ou les écritures en base de données.

## Prochaines étapes

#### [Intégration Twilio native](/docs/fr/eleven-agents/phone-numbers/twilio-integration/native-integration)

Utilisez le LLM hébergé plutôt qu’un LLM personnalisé.

#### [Guide de démarrage rapide Speech Engine](/docs/fr/eleven-api/guides/cookbooks/speech-engine)

Créez le serveur du moteur de bout en bout avec un LLM en streaming.

#### [LLM personnalisé (compatible avec OpenAI)](/docs/fr/eleven-agents/customization/llm/custom-llm)

Un mécanisme alternatif de LLM personnalisé utilisant un point de terminaison HTTP compatible avec OpenAI.

#### [Référence du SDK Python](/docs/fr/eleven-api/resources/libraries/speech-engine/python-sdk-reference)

Classes, méthodes et événements du SDK Python Speech Engine.

#### [Référence du SDK JavaScript](/docs/fr/eleven-api/resources/libraries/speech-engine/javascript-sdk-reference)

Classes, méthodes et événements du SDK JavaScript Speech Engine.