> 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.

# Integración de LLM personalizado

## Resumen

La [integración nativa con Twilio](/docs/es/eleven-agents/phone-numbers/twilio-integration/native-integration) de ElevenAgents cubre el caso en el que ElevenLabs aloja el LLM. Usa esta guía cuando necesites tener control total sobre el cerebro del LLM en tu propio servidor —tu propio modelo, pipeline de RAG, enrutamiento de llamadas a funciones u otro razonamiento del lado del servidor— y el agente siga estando en un número de teléfono de Twilio.

La parte del LLM personalizado se ofrece mediante el [SDK de Speech Engine](/docs/es/eleven-api/guides/cookbooks/speech-engine), que abre un WebSocket entre ElevenLabs y tu servidor para que tu LLM pueda transmitir respuestas a medida que transcurre la llamada. La parte de Twilio utiliza [Media Streams](https://www.twilio.com/docs/voice/media-streams) para transmitir el audio de la llamada al agente.

## Arquitectura

El SDK de Speech Engine expone dos rutas de WebSocket en el sistema de conversación del agente:

* El **WebSocket del cerebro** se ejecuta en tu servidor. ElevenLabs se conecta a él para enviar transcripciones y recibir texto generado por el LLM.
* El **WebSocket de conversación** se ejecuta en ElevenLabs. Los clientes se conectan a él para enviar audio y recibir audio sintetizado. El puente de Twilio se conecta mediante una URL firmada y retransmite audio μ-law en ambas direcciones.

Como Twilio Media Streams y Speech Engine utilizan `ulaw_8000`, el puente retransmite audio codificado en base64 sin transcodificación.

```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
```

El puente y el servidor del cerebro pueden ejecutarse en el mismo proceso si te resulta práctico; el ejemplo siguiente los combina.

## Cuándo usar este patrón

Tanto esta guía como la [integración nativa con Twilio](/docs/es/eleven-agents/phone-numbers/twilio-integration/native-integration) colocan un agente en un número de teléfono de Twilio. La diferencia está en quién se encarga del LLM:

* **Integración nativa**: ElevenLabs aloja el LLM y tú lo configuras a través del agente. Más sencillo.
* **LLM personalizado mediante el SDK de Speech Engine** (esta guía): alojas el LLM en tu propio servidor. Control total sobre el modelo, RAG, llamadas a funciones y lógica de negocio. Más componentes.

Si la lógica de tu LLM se ajusta a la configuración estándar del agente, prioriza la integración nativa. Usa esta guía cuando tu cerebro necesite ejecutar código en tu propia infraestructura.

Este patrón utiliza el SDK de Speech Engine, que emplea una conexión WebSocket para comunicarse entre tu servidor y la API de ElevenLabs. También puedes usar la guía de [LLM personalizado](/docs/es/eleven-agents/customization/llm/custom-llm), que utiliza una ruta HTTP compatible con OpenAI en lugar del SDK de Speech Engine.

La principal diferencia entre ambos son los WebSockets frente a las solicitudes HTTP. Usar WebSockets implica mantener una única conexión en lugar de establecer una nueva conexión HTTP en cada turno, lo que puede reducir la latencia.

## Requisitos previos

* Una cuenta de [Twilio](https://www.twilio.com/) y un número de teléfono con capacidad de voz.
* Un recurso de Speech Engine. Sigue la [guía de inicio rápido de Speech Engine](/docs/es/eleven-api/guides/cookbooks/speech-engine) para crear uno y conocer el patrón de servidor del cerebro.
* Un túnel HTTPS público (por ejemplo, [ngrok](https://ngrok.com)). Twilio llama a tu puente a través de Internet público.
* Python 3.9+ o Node.js 18+.

## Configura el agente para audio μ-law

Twilio Media Streams utiliza audio μ-law de 8 kHz. Configura Speech Engine para aceptar y emitir el mismo formato, de modo que el puente no tenga que transcodificar.

**`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` mantiene baja la latencia de texto a voz, algo importante en una llamada telefónica. El bloque `request_headers` indica a ElevenLabs que incluya `x-api-key: <shared-secret>` en cada conexión WebSocket del cerebro; el servidor del cerebro comprueba la cabecera para asegurar que solo tu Speech Engine pueda acceder a él.

## Crea el servidor puente

El puente ofrece tres rutas:

* `POST /incoming-call` — Webhook de Twilio. Devuelve TwiML que indica a Twilio que abra un Media Stream hacia `/media-stream`.
* `GET /media-stream` — WebSocket de Twilio Media Streams. Retransmite audio desde y hacia el WebSocket de conversación de Speech Engine.
* `GET /ws` — WebSocket del cerebro. ElevenLabs se conecta aquí cuando empieza una conversación. Ejecuta el servidor estándar `engine.serve()` / `engine.attach()`.

#### Instala las dependencias

**`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
```

#### Genera una URL firmada para Speech Engine

El puente solicita una URL firmada cada vez que llega una llamada nueva. La URL incorpora el ID de Speech Engine y una firma de un solo uso, por lo que el puente nunca necesita la clave de API sin procesar.

**`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;
}
```

#### Sirve la respuesta de TwiML

Cuando llega una llamada, Twilio envía un POST a `/incoming-call`. La respuesta es TwiML que abre un Media Stream hacia el WebSocket `/media-stream` del propio puente.

**`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) y `twilio.webhook({ validate: true })` (Node) comprueban la cabecera `X-Twilio-Signature` con `TWILIO_AUTH_TOKEN`. Sin validación, cualquiera en Internet público podría enviar un POST a `/incoming-call` y cargar llamadas a tu cuenta.

#### Conecta el Media Stream

El Media Stream es un WebSocket que envía una secuencia de eventos JSON: `connected`, `start`, `media` (la carga de audio) y `stop`. El puente abre un WebSocket de conversación de Speech Engine en `start` y retransmite audio en ambas direcciones hasta que se cierra el stream.

**`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;
}
```

El evento `interruption` de Speech Engine activa un evento `clear` en el stream de Twilio, que descarta cualquier audio almacenado en búfer para que la interrupción funcione correctamente. El evento `ping` se responde con `pong` para mantener activo el WebSocket de conversación.

#### Ejecuta el servidor del cerebro en paralelo

El servidor del cerebro es el servidor estándar de Speech Engine mostrado en la [guía de inicio rápido](/docs/es/eleven-api/guides/cookbooks/speech-engine). La única incorporación es la comprobación del secreto compartido durante la actualización del WebSocket: acepta la conexión solo si `x-api-key` coincide con el valor que configuraste en 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);
```

Consulta la [guía de inicio rápido de Speech Engine](/docs/es/eleven-api/guides/cookbooks/speech-engine#server-setup) para ver la implementación completa de `on_transcript`, incluida una llamada al LLM y una respuesta transmitida.

## Dirige Twilio al puente

#### Inicia el puente y un túnel público

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

Anota la URL `https://` que muestra ngrok: Twilio le enviará un POST.

#### Actualiza el ws_url de Speech Engine

Configura `speech_engine.ws_url` con la URL pública de WebSocket de la ruta de tu cerebro para que ElevenLabs sepa dónde conectarse.

```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" },
});
```

#### Configura el número de Twilio

En la consola de Twilio, abre la **configuración de voz** de tu número de teléfono:

* **Cuando entra una llamada**: Webhook
* **URL**: `https://abc123.ngrok.io/incoming-call`
* **Método HTTP**: POST

Si el número está vinculado a un Elastic SIP Trunk, desvincúlalo primero: un número de Twilio se enruta a un trunk o a un webhook, pero no a ambos.

#### Llama al número

Marca el número desde cualquier teléfono. El agente responde; habla durante la llamada y deberías oír la respuesta del agente. Con el registro de depuración activado, el puente registra el SID de la llamada, el ID de conversación y el formato de audio en cada turno.

## Consideraciones para producción

* **Validación de webhooks**: valida siempre `X-Twilio-Signature` en `/incoming-call`. El ejemplo anterior usa la biblioteca auxiliar de Twilio; no omitas este paso.
* **Secreto compartido**: aplica el secreto compartido en el WebSocket del cerebro. Sin él, cualquiera que adivine tu URL de ngrok puede conectarse y hacerse pasar por ElevenLabs.
* **Host estable**: las URL del nivel gratuito de ngrok cambian con cada reinicio. Usa un dominio de ngrok reservado o un nombre de host real para no tener que actualizar `ws_url` de Speech Engine y el webhook de Twilio después de cada reinicio.
* **Latencia**: cada llamada añade dos saltos de red además del tiempo hasta el primer token del LLM. Usa un modelo de baja latencia y transmite las respuestas en streaming para mantener baja la latencia percibida.
* **Un proceso o dos**: el ejemplo ubica el puente y el cerebro en el mismo puerto, de modo que un único túnel de ngrok cubre todo. En producción, puedes dividirlos en dos servicios siempre que cada uno tenga una URL pública.
* **Inyección de prompts**: la entrada de voz de una llamada telefónica es una entrada de usuario no fiable. Valida las transcripciones antes de que influyan en llamadas a herramientas o escrituras en la base de datos.

## Próximos pasos

#### [Integración nativa con Twilio](/docs/es/eleven-agents/phone-numbers/twilio-integration/native-integration)

Usa el LLM alojado en lugar de uno personalizado.

#### [Guía de inicio rápido de Speech Engine](/docs/es/eleven-api/guides/cookbooks/speech-engine)

Crea el servidor del cerebro de principio a fin con un LLM en streaming.

#### [LLM personalizado (compatible con OpenAI)](/docs/es/eleven-agents/customization/llm/custom-llm)

Un mecanismo alternativo de LLM personalizado que usa una ruta HTTP compatible con OpenAI.

#### [Referencia del SDK de Python](/docs/es/eleven-api/resources/libraries/speech-engine/python-sdk-reference)

Clases, métodos y eventos del SDK de Python de Speech Engine.

#### [Referencia del SDK de JavaScript](/docs/es/eleven-api/resources/libraries/speech-engine/javascript-sdk-reference)

Clases, métodos y eventos del SDK de JavaScript de Speech Engine.