Skip to main content
This guide walks through creating a phone-based voice agent. You will create a local TypeScript server that answers an inbound Twilio call, streams audio over WebSockets, detects turn boundaries locally with Silero VAD, sends the caller’s speech to Together AI for transcription, generates a reply with a chat model, synthesizes that reply back to speech, and plays it into the same call.

Architecture

agent architecture diagram

Prerequisites

Before you start, make sure you have:
  • Node.js 18+
  • A Together AI account and API key
  • A Twilio account with a voice-capable phone number
  • ngrok or another HTTPS tunnel for local testing
  • The Silero VAD ONNX model saved in your project root as silero_vad.onnx

Step 1: Create the Project

Create a new directory and install the dependencies:
Shell
Add these scripts to the scripts field in your generated package.json:
package.json
Add a tsconfig.json:
tsconfig.json

Step 2: Add Environment Variables

Create a .env file:
.env
The build below supports three personas:
  • kira - a support engineer at Together AI
  • account_exec - an account executive at Together AI
  • marcus - an engineer at Together AI

Step 3: Add the Audio Conversion Layer

Create audio-convert.ts. This file handles:
  • mu-law encode and decode - this is needed to convert audio I/O over the phone
  • sample-rate conversion between 8 kHz(needed for phone), 16 kHz(needed for STT), and 24 kHz(output by TTS)
  • parsing WAV headers when the first TTS chunk arrives with a WAV header attached
  • converting Twilio chunks into Together STT input
  • converting Together TTS output back into Twilio playback audio
audio-convert.ts

Step 4: Add Local Voice Activity Detection

agent architecture diagram Create vad.ts. This file wraps the Silero VAD ONNX model and runs it locally on the CPU via onnxruntime-node. Silero VAD is a lightweight voice activity detection model that takes a short window of audio and returns a probability between 0 and 1 indicating whether that window contains speech. In this project it serves two purposes:
  • Turn-boundary detection — while the server is listening, VAD probabilities decide when the caller has started speaking and when they have stopped. Once speech ends (probability drops below a threshold for long enough), the server commits the buffered STT audio and triggers a reply.
  • Barge-in detection — while the assistant is speaking, VAD probabilities detect whether the caller is trying to interrupt. If the probability exceeds a higher threshold for several consecutive frames, the server immediately clears Twilio’s playback buffer and switches back to listening.
The wrapper loads the ONNX model once and shares the session across all concurrent calls. Each call gets its own SileroVad instance with independent RNN hidden state so one caller’s audio never bleeds into another’s detection.
vad.ts

Step 5: Build the Realtime STT -> LLM -> TTS Pipeline

agent architecture diagram Create pipeline.ts. This file does four jobs:
  1. Defines the personas and system prompts used by the assistant
  2. Maintains a long-lived realtime STT WebSocket per call
  3. Maintains a long-lived realtime TTS WebSocket per call
  4. Orchestrates each turn: commit STT, stream chat completions, split by sentence, and synthesize those sentences immediately
pipeline.ts

Step 6: Build the Twilio Media Stream Session

agent architecture diagram Create media-stream.ts. This is the per-call state machine. It handles:
  • Twilio connected, start, media, mark, and stop events
  • local voice activity detection
  • turn transitions between listening, processing, and speaking
  • barge-in by clearing Twilio’s playback buffer and interrupting TTS
  • bounded in-memory conversation history
media-stream.ts

Step 7: Add the HTTP Server and TwiML Endpoint

agent architecture diagram Create server.ts. This file serves two purposes:
  • POST /twiml returns TwiML that tells Twilio to open a bidirectional Media Stream to your server
  • the WebSocketServer accepts those /media-stream connections and hands them to handleMediaStream()
server.ts

Step 8: Check Your Project Layout

At this point your project should look like this:

Step 9: Start the Server

Run:
Shell
You should see startup output like this:

Step 10: Expose the App and Connect Twilio

In another terminal:
Shell
Copy the https:// forwarding URL and configure your Twilio number:
  1. Open the Twilio Console and select your phone number.
  2. Under voice configuration, set the incoming call webhook to https://your-ngrok-domain/twiml.
  3. Use HTTP POST.
  4. Save the number configuration.
When the call comes in, Twilio will request /twiml, receive a <Connect><Stream> response, and open a bidirectional Media Stream back to your /media-stream endpoint.

Step 11: Call the Number

Dial your Twilio number from any phone. The expected flow is:
  1. Twilio connects the call and opens the WebSocket
  2. The server warms up STT, TTS, and VAD
  3. The assistant plays a short greeting
  4. The caller speaks
  5. Local VAD decides when the caller has stopped
  6. The server commits the buffered STT stream
  7. The chat model starts streaming a reply
  8. Completed sentences are sent immediately to TTS
  9. TTS audio is converted back to audio/x-mulaw and played to the caller
  10. If the caller interrupts, the server sends Twilio a clear event and starts listening again

How the Low-Latency Path Works

This architecture stays fast because it avoids unnecessary waits:
  • caller audio streams into STT continuously instead of being uploaded after the turn
  • turn detection happens locally with Silero VAD, so there is no extra network hop to decide when to process
  • chat completions stream token by token
  • TTS starts on each completed sentence instead of waiting for the full reply
  • Twilio playback can be interrupted immediately with a clear event

Tuning the Voice Experience

The behavior is mostly controlled by a few thresholds in media-stream.ts:
  • SPEECH_START_PROB
  • SPEECH_END_PROB
  • SILENCE_DURATION_MS
  • MIN_SPEECH_MS
  • BARGE_IN_PROB_THRESHOLD
  • BARGE_IN_CONSECUTIVE_FRAMES
If the assistant cuts in too often, raise the barge-in threshold or require more consecutive frames. If it waits too long after the caller stops, reduce the silence duration slightly.