Gå till innehåll

Bygg en röstagent på 20 minuter med ElevenLabs och Twilio

Publicerad

LyssnaLyssna på den här artikeln

En röstagent kan svara på inkommande telefonsamtal, transkribera samtalet i realtid med speech to text (STT), generera svar med en stor språkmodell (LLM) och prata tillbaka med en text to speech (TTS)-modul. Med ElevenLabs och Twilio kan du ha en fungerande agent på ett riktigt telefonnummer på ungefär 20 minuter.

För utvecklare består hela stacken av ElevenLabs för talsyntes (Flash v2.5) och transkribering (Scribe v2 Realtime), Twilio för telefoni och antingen OpenAI eller Anthropic som LLM. Alla dessa delar går att byta ut, så du kan välja de komponenter du är mest bekväm med.

Den här artikeln visar hur du bygger en röstagent på 20 minuter med Node.js och Typescript. Om du vill ha ett färdigt alternativ som hanterar turordning, avbrott och telefoni utan att du behöver bygga kedjan själv, gå till ElevenAgents. 

Så fungerar arkitekturen för en röstagent

Innan du skriver kod är det bra att förstå hur de tre tjänsterna i din tech-stack hänger ihop.

  • Twilio: Hanterar telefonsamtalet och ljudöverföringen.
  • ElevenLabs: Hanterar STT via Scribe v2 Realtime och TTS via Flash v2.5.
  • LLM: Hanterar verktygsanrop och skriver svaret.

Varje steg är en tunn adapter, vilket gör att du kan byta ut en tjänst utan att röra resten. Till exempel kan du byta ut OpenAI:s LLM mot Anthropic utan att behöva skriva om övriga komponenter.

Ett samtal når din server via Twilio. Twilio svarar på PSTN-samtalet, öppnar en WebSocket till din server och skickar inringares ljud som en ström av base64-kodade mu-law-ramar. Din server kör kedjan och strömmar syntetiserat ljud tillbaka över samma WebSocket, och Twilio spelar upp det för inringaren.

Build a voice agent diagram of a call processing system using Twilio for speech-to-text conversion and LLM for response.

Här är flödet du använder för att bygga en röstagent:

En inringare ringer ditt Twilio-nummer. Twilio hämtar ett TwiML-dokument från din webhook. TwiML:en säger åt Twilio att öppna en Media Stream till din WebSocket-endpoint. Twilio strömmar inkommande ljud som JSON-händelser med base64 mu-law (ulaw_8000)-data.

Din server skickar ljudbitar vidare till Scribe v2 Realtime för strömmande transkribering. När en inringares tur är klar skickar du transkriptionen till LLM, och syntetiserar sedan svaret med Flash v2.5 i ulaw_8000. Du skickar de syntetiserade mu-law-ramarna tillbaka till Twilio över WebSocket, base64-kodat, och Twilio spelar upp dem för inringaren.

Scribe v2 Realtime ger deltranskriptioner med cirka 150 ms fördröjning, och Flash v2.5 har ungefär 75 ms modellfördröjning, exklusive nätverk och applikation. LLM är den största och mest oförutsägbara delen av tiden till första ljud, och det är där största delen av fördröjningen hamnar. För att hålla gapet kort strömmar vi LLM-utdata token för token och börjar syntetisera innan modellen är klar med meningen.

För modellvalen bakom dessa beslut, se modellöversikten och förklaringen om förstå fördröjning.

Det du behöver innan du bygger en röstagent

Guiden utgår från att du har fyra saker på plats. Alla går snabbt att ordna, men saknas någon av dem kan inte servern köras.

Här är förutsättningarna att kolla:

  1. Ett Twilio-telefonnummer med Voice-stöd: Notera numret samt ditt Account SID och Auth Token från Twilio-konsolen.
  2. ElevenLabs API-nyckel: Skapas i din ElevenLabs-panel. Nyckeln skickas i xi-api-key-headern och är hemlig, så håll den bara på serversidan. Se API-autentisering.
  3. LLM API-nyckel: Den här guiden behandlar Anthropic Claude och OpenAI som utbytbara, så välj en.
  4. Ngrok (eller annan tunnel) för lokal utveckling: Twilio måste nå din server via en publik HTTPS- och WSS-adress, och ngrok löser det utan att du behöver deploya något.

Sätt dina hemligheter som miljövariabler och checka aldrig in dem.

export ELEVENLABS_API_KEY="..."
export ANTHROPIC_API_KEY="..."          # or OPENAI_API_KEY
export TWILIO_AUTH_TOKEN="..."          # used for webhook signature validation
export PUBLIC_HOST="your-subdomain.ngrok.app"

Starta sedan en tunnel mot porten din server använder:

ngrok http 8080

Förstå Twilio Media Streams-protokollet

Twilio ger dig inte en rå ljudsocket. Istället paketeras allt i ett strukturerat JSON-protokoll över WebSocket. Om du förstår de fyra händelsetyperna och sändformatet blir WebSocket-hanteraren i steg 2 enkel att följa när du skriver den.

När Twilio ansluter till din WebSocket skickas en sekvens av JSON-textmeddelanden, som kan ha fyra olika händelsetyper.

connected-händelsen kommer först och bekräftar att WebSocket är uppe. start-händelsen skickas en gång när mediastreamen börjar; den innehåller ett streamSid du måste spara, eftersom du behöver det för att skicka ljud tillbaka, och även samtalsmetadata under start.customParameters och start.callSid.

media-händelsen återkommer: media.payload är en base64-kodad bit av 8kHz mu-law-ljud, 20 ms per ram, och media.track är inbound för inringares ljud. Slutligen skickas stop när strömmen avslutas, oftast för att samtalet lagts på.

För att spela upp ljud skickar du ett meddelande av typen media med samma streamSid och en base64 mu-law-payload. För att avbryta ljud du redan köat skickar du ett clear-meddelande med streamSid, vilket tömmer Twilios utgående buffert.

In- och utgående kodning är identisk (ulaw_8000). Vi begär ulaw_8000 från ElevenLabs Text to Speech och skickar byten direkt till Twilio utan att omkoda något däremellan.

Steg 1: Servera TwiML-webhooken

När ett samtal kommer in gör Twilio en HTTP-förfrågan till din webhook, och du svarar med TwiML som kopplar samtalet till din Media Stream. <Connect><Stream>-verbet öppnar en tvåvägs-WebSocket. Använd <Connect> istället för <Start> här: det håller samtalet vid liv under hela strömmen och låter dig skicka ljud tillbaka, vilket är syftet med denna lösning.

TwiML:en som webhooken returnerar är:

<?xml version="1.0" encoding="UTF-8"?>
<Response>
  <Connect>
    <Stream url="wss://your-subdomain.ngrok.app/media" />
  </Connect>
</Response>

I Express är det en enkel POST-handler som fyller i host och returnerar dokumentet:

// ... imports and app setup
app.post("/incoming-call", (_req, res) => {
  const twiml = `<?xml version="1.0" encoding="UTF-8"?>
<Response>
  <Connect>
    <Stream url="wss://${process.env.PUBLIC_HOST}/media" />
  </Connect>
</Response>`;
  res.type("application/xml").send(twiml);
});

I Twilio-konsolen, sätt numrets "A call comes in"-webhook till https://your-subdomain.ngrok.app/incoming-call med HTTP POST.

Steg 2: Ta emot Media Stream-WebSocketen

WebSocket-hanteraren läser Twilio-händelser, driver kedjan och skriver tillbaka ljud.

Vi håller lite tillstånd per samtal: streamSid, en STT-anslutning och en flagga för om agenten pratar just nu. Hanteraren avkodar varje inkommande media-frame från base64 och skickar råa mu-law-byten till STT:

import { WebSocketServer } from "ws";
// ... http server bound to the same port as Express

const wss = new WebSocketServer({ server, path: "/media" });

wss.on("connection", (ws) => {
  const state = { streamSid: null as string | null, agentSpeaking: false };

  ws.on("message", async (raw) => {
    const event = JSON.parse(raw.toString());
    switch (event.event) {
      case "start":
        state.streamSid = event.start.streamSid;
        await startSttSession(ws, state);
        break;
      case "media":
        await forwardToStt(Buffer.from(event.media.payload, "base64"), state);
        break;
      case "stop":
        await teardown(state);
        ws.close();
        break;
    }
  });
});

Steg 3: Transkribera med Scribe v2 Realtime

Scribe v2 Realtime tar emot strömmande ljudbitar och returnerar del- och sluttranskriptioner, och den stöder mu-law-kodning direkt, så vi skickar Twilios ramar oförändrade.

Den erbjuder också Voice Activity Detection för segmentering baserat på tystnad och manuell commit-kontroll för att avsluta ett segment. För en telefonagent är VAD-baserad segmentering oftast bäst, eftersom en naturlig paus är den tydligaste signalen på att inringarens tur är slut.

Stegen är: Öppna en STT-ström när samtalet startar. Skicka varje inkommande mu-law-bit. Reagera på färdiga transkriptioner genom att anropa LLM.

Realtime-STT-klienten utvecklas fortfarande, så formen nedan ligger bakom en liten TypeScript-adapter (openRealtimeStt) som du implementerar mot det faktiska API:et istället för ett fast fältnamnsschema. Se onFinal som kroken som lämnar över en färdig inringartur till nästa steg.

async function startSttSession(ws, state) {
  // openRealtimeStt is a thin adapter over the realtime STT API:
  // model_id="scribe_v2_realtime", mu-law encoding, 8kHz, VAD on
  // so turns finalize on silence.
  const session = await openRealtimeStt({
    modelId: "scribe_v2_realtime",
    encoding: "ulaw",
    sampleRate: 8000,
  });
  state.stt = session;

  session.onFinal(async (text: string) => {
    if (text.trim()) await handleTurn(ws, state, text);
  });
}

async function forwardToStt(audioBytes, state) {
  if (state.stt) await state.stt.sendAudio(audioBytes);
}

Fördröjningen för realtidsigenkänning är cirka 150 ms för deltranskriptioner, vilket gör att gapet mellan att inringaren slutar prata och agenten börjar känns kort. För batch-varianten och hela funktionsuppsättningen, se Speech to Text-dokumentationen och realtids Speech to Text produktsidan.

Steg 4: Generera svar med en LLM

Detta steg tar fram svaret. LLM tar samtalshistoriken och returnerar assistentens text. Strömma svaret så du kan börja syntetisera redan på första meningen.

Här används OpenAI:

// ... client init: new OpenAI({ apiKey: process.env.OPENAI_API_KEY })
const SYSTEM_PROMPT =
  "You are a concise phone assistant. Keep replies to one or two sentences.";

async function llmReply(history) {
  const stream = await llm.chat.completions.create({
    model: "gpt-4.1-mini",
    stream: true,
    messages: [{ role: "system", content: SYSTEM_PROMPT }, ...history],
  });
  for await (const part of stream) {
    const token = part.choices[0]?.delta?.content;
    if (token) yield token; // incremental tokens
  }
}

Modell-ID:t ovan, gpt-4.1-mini, är ett exempel på ett lågfördröjningsval; claude-haiku-4-5 är ett likvärdigt alternativ hos Anthropic. Båda levererar till samma llmReply-kontrakt; byt bara ut funktionskroppen, så är resten av agenten oförändrad.

Systemprompten begränsar svarslängden, vilket är viktigt i telefon: långa svar känns långsamma och är svåra att avbryta naturligt.

Steg 5: Syntetisera med Flash TTS i ulaw_8000

Texten ska nu bli ljud som Twilio kan spela upp. Begär Flash v2.5 med outputFormat: "ulaw_8000" så byten matchar Twilios förväntade kodning, strömma sedan ljudet och skicka varje bit tillbaka över WebSocket som en media-händelse.

Samla LLM-tokens till meningsstora fragment och syntetisera varje fragment när det är klart, istället för att vänta på hela svaret. Det förkortar tiden till första ljud, eftersom inringaren hör första meningen medan modellen fortfarande producerar nästa. För mer kontroll över inkrementell syntes visar guiden för realtids-TTS-WebSocket hur du matar in text i en öppen syntessocket; HTTP-strömningen nedan är enklare och räcker för korta samtalsturer.

import { ElevenLabsClient } from "@elevenlabs/elevenlabs-js";

const eleven = new ElevenLabsClient(); // reads ELEVENLABS_API_KEY
const VOICE_ID = "JBFqnCBsd6RMkjVDRZzb"; // George, a default voice

async function speak(ws, state, text: string) {
  state.agentSpeaking = true;
  const stream = await eleven.textToSpeech.stream(VOICE_ID, {
    text,
    modelId: "eleven_flash_v2_5",
    outputFormat: "ulaw_8000",
  });
  for await (const chunk of stream) {
    if (!state.agentSpeaking) break; // interrupted by barge-in
    ws.send(
      JSON.stringify({
        event: "media",
        streamSid: state.streamSid,
        media: { payload: Buffer.from(chunk).toString("base64") },
      })
    );
  }
  state.agentSpeaking = false;
}

Gör din AI-röstagent redo för produktion

Efter de fem stegen ovan har du en fungerande agent. Det är inte samma sak som en produktionssättning.

Det finns flera saker du behöver tänka på innan du kopplar agenten till ett riktigt telefonnummer.

Validera Twilio-webhook-signaturer

Alla som får tag på din webhook-URL kan POST:a till den, så första steget är att bekräfta att förfrågan verkligen kommer från Twilio. Twilio signerar varje förfrågan med din Auth Token i X-Twilio-Signature-headern, och du ska neka allt som inte klarar valideringen. Signaturen beräknas på hela URL:en och POST-parametrarna, så du måste räkna ut den på samma sätt som Twilio gör.

Twilios hjälpfunktion gör detta åt dig:

import twilio from "twilio";

app.post("/incoming-call", express.urlencoded({ extended: false }), (req, res) => {
  const url = `https://${process.env.PUBLIC_HOST}/incoming-call`;
  const valid = twilio.validateRequest(
    process.env.TWILIO_AUTH_TOKEN!,
    req.header("X-Twilio-Signature") || "",
    url,
    req.body
  );
  if (!valid) return res.sendStatus(403);
  // ... return TwiML as before
});

Hantera hemligheter på rätt sätt

Förvara ELEVENLABS_API_KEY, LLM-nyckeln och TWILIO_AUTH_TOKEN i en secrets manager, inte i källkoden och inte i okrypterade env-filer i repo. Begränsa ElevenLabs-nyckeln till bara de endpoints tjänsten behöver och sätt en kreditgräns så att ett läckage får begränsad effekt. 

Enterprise-planer kan dessutom begränsa en nyckel till specifika IP-intervall med IP-whitelistning. Den här servern använder API-nyckeln direkt eftersom den aldrig lämnar backend; om någon ljudlogik flyttas till webbläsare eller mobilklient, byt till engångstokens så nyckeln aldrig exponeras på klientsidan.

Förstå samtidighetsgränsen

Varje plan har en samtidighetsgräns som skiljer sig mellan modellfamiljer, och gränsen räknar hur många förfrågningar som samtidigt genererar ljud.

För en telefonagent är det till din fördel. Ljudgenerering är snabbare än uppspelning, så varje samtal använder bara TTS-samtidighet under de korta stunder då ett svar syntetiseras, inte under hela samtalet. Som tumregel kan en samtidighetsgräns på runt fem hantera cirka 100 samtidiga samtal, eftersom genereringen är klar långt innan uppspelningen.

Men övervaka alltid marginalen istället för att gissa. ElevenLabs-svar visar current-concurrent-requests och maximum-concurrent-requests i headers; logga dem och larma när du närmar dig max. Om du överskrider gränsen köas förfrågningar efter prioritet, vilket oftast lägger till cirka 50 ms, och vid långvarig överbelastning returneras HTTP 429.

Hantera HTTP 429-svar med kort backoff. Om de kvarstår, höj gränserna via prissidan eller, för Enterprise-kunder, via din account manager.

Hantera barge-in och avbrott

En inringare som börjar prata medan agenten talar förväntar sig att agenten tystnar. Det kallas barge-in, och att hantera det rätt är avgörande för att agenten ska kännas naturlig och inte skriptad.

Upptäck inringares tal under agentens uppspelning med STT VAD-signalen. När du upptäcker det, gör två saker. Först, sluta skicka TTS-bitar, vilket flaggan agentSpeaking redan hanterar genom att bryta loopen. Sedan, skicka ett clear-meddelande till Twilio för att tömma ljudet du redan köat på deras sida.

function interrupt(ws, state) {
  state.agentSpeaking = false;
  ws.send(JSON.stringify({ event: "clear", streamSid: state.streamSid }));
}

Om du hoppar över clear fortsätter Twilio spela buffrat ljud efter att du slutat skicka, så agenten verkar prata över inringaren.

Logga, övervaka och hantera fel snyggt

Instrumentera varje steg så du kan se var fördröjningen uppstår om ett samtal känns långsamt. Mät tiden från sluttranskription till första LLM-token, från första LLM-token till första TTS-byte, och från första TTS-byte till ramen som skickas till Twilio. Du kommer märka att mest varierande fördröjning ligger i LLM-steget; STT och TTS är mer stabila.

Planera sedan för delvisa fel. LLM kan få timeout, STT-strömmen kan brytas och nätverksrundan till ElevenLabs varierar mellan cirka 20 och 200 ms över publika internet beroende på geografi. Placera din server nära inringarna, inte bara nära ElevenLabs, eftersom ElevenLabs redan routar till närmaste kluster i Nordamerika, Europa och Sydostasien.

Om ett steg misslyckas, lämna inte inringaren i tystnad: syntetisera en kort reservfras ("Förlåt, kan du säga det igen?") och håll samtalet vid liv. Lägg in timeout och try/catch runt varje steg så att ett misslyckat tur inte river hela WebSocketen.

Några fler standardinställningar är bra att sätta innan du går live:

  • Begränsa svarslängden i systemprompten, så turerna hålls korta och möjliga att avbryta.
  • Begränsa samtalshistoriken så att långa samtal inte gör LLM-kontexten oändlig.
  • Sätt en maxlängd på samtal som skydd mot fastnade sessioner som tyst förbrukar samtidighet.

För att fortsätta justera det du kan påverka, se latensdokumentet som förklarar var tiden till första ljud kommer ifrån, modellöversikten som går igenom hastighet och kvalitet, och guiden för realtids-TTS-WebSocket som visar hur du kan pressa ner syntesfördröjningen med inkrementell text.

Bygg produktionsklara röstagenter med ElevenAPI

Nu har det gått 20 minuter och du har alla lager för en produktionsklar röstagent. Twilio hanterar telefoni, Scribe v2 Realtime transkriberar, en LLM genererar svar och Flash v2.5 pratar tillbaka över samma WebSocket.

Om du inte vill underhålla kedjan själv erbjuder ElevenAgents turordning, avbrottshantering och telefoni som en hanterad tjänst byggd på samma modeller du just kopplat ihop.

Vill du fortsätta justera en stack du själv styr, kolla in ElevenAPI:s produktsida för planer, samtidighetsgränser och röstbibliotek. Eller så kan du registrera dig och komma igång idag för att ringa ditt första samtal.

Bygg en röstagent med Twilio och ElevenLabs – FAQ

Liknande artiklar

Skapa med AI-ljud av högsta kvalitet