📚 v3.0 — the simpler starting point → v3.0 branch
Start here if you are learning Conversation Relay. v3.0 is the proof-of-concept version: fewer moving parts, an event-emitter architecture, file-based configuration, and CRelay parameters set directly in
TwilioService.ts. It is the easiest way to see how the pieces fit together. Not intended for production.Use this if you are deploying. It is the extended implementation: a per-WebSocket
ConversationRelaySessionowning all per-call state, an in-code tool registry, Zod-validated wire frames, centralised configuration, dependency injection, Twilio webhook signature validation, and authenticated outbound calling.Moving from v3.0 to v4.x is not a drop-in upgrade — the architecture changed substantially. See the CHANGELOG for the full v4.0 → v4.14 history.
This is a reference implementation aimed at introducing the key concepts of Conversation Relay. The key here is to ensure it is a workable environment that can be used to understand the basic concepts of Conversation Relay. It is intentionally simple and only the minimum has been done to ensure the understanding is focussed on the core concepts.
ConversationRelaySession (the transport) now owns everything that is ConversationRelay protocol; the ResponseService owns everything that is conversation content. Test suite 121 → 170.
🏗️ Architecture:
- Call events: the session sends
setup,prompt,dtmf,interrupt,statusandcontextevents throughResponseService.handleEvent(). It no longer writes prompt text. DTMF is passed on instead of dropped. - Contexts belong to the service: the transport loads none.
OpenAIResponseServicereads its prompt fromContextStore(server/assets/<key>.md), including a per-callcustomParameters.contextKey. - Call actions: tools in
tools/cr/only request an action (endCall,sendDigits,play,language,listenMode,silence); only the transport builds CR frames.toolResultreturns anActionOutcome, so the model learns when an action was dropped or substituted. LLM-only tools live intools/llm/. - Silence policy stays in the transport; a service may only reword reminders (
silenceReminder). - Languages come from the call's TwiML (
crLanguages/crTtsLanguageparameters), falling back toserverConfig.json.
🔒 Security: the /conversation-relay WebSocket upgrade now checks X-Twilio-Signature, following TWILIO_VALIDATE_WEBHOOKS.
🐛 Fixes: a switch-language call with an undeclared code (e.g. en-GB) was rejected by Twilio and left the call unresponsive. The code is now mapped to a declared one by primary tag (en-GB → en-AU), or the action is dropped and the model told.
- Twilio Sync asset loading removed:
"file"is the onlyassetLoaderType. activeContextKeyremoved fromserverConfig.json. Choose a prompt per call with thecontextKeyparameter; the default isdefaultContext.md.- Tool results return
actioninstead ofoutgoingMessage/listenMode/silenceEnabled; tool handlers take(args, ctx)and no longer see the session.
🧪 Testing:
npm test- Run full test suite (170 tests)npm run typecheck- Type-checksrc/**and test files (tests were previously unchecked)npm run test:watch- Watch modenpm run test:ui- Interactive UInpm run test:coverage- Coverage report
See the CHANGELOG.md for detailed release history.
Twilio signature validation, an authenticated and rate-limited /outboundCall, automatic caller-language detection, graceful live-agent handoff, and the outbound speech and listen-mode fixes. Full notes in the CHANGELOG.
- Node.js ≥ v20 (required by
twiliov6) - pnpm (or npm)
- ngrok
- TypeScript (installed as a dev dependency; no global install needed)
.
├── server/ # WebSocket server for conversation relay
│ ├── .env.example # Example environment configuration
│ ├── package.json # Server dependencies and scripts
│ ├── tsconfig.json # TypeScript configuration
│ ├── assets/
│ │ ├── defaultContext.md # Default LLM system prompt (markdown)
│ │ ├── serverConfig.json # TwiML config, silence detection, listen mode
│ │ └── legs/ # Optional per-leg context.md overrides
│ └── src/
│ ├── server.ts # Express + WebSocket entrypoint
│ ├── config/
│ │ └── ServerConfig.ts # Centralised env + config (v4.10)
│ ├── interfaces/ # Type definitions (.d.ts)
│ │ ├── ConversationRelay.d.ts # Re-exports + TwiML types + SessionData
│ │ ├── ResponseService.d.ts # ResponseService + ResponseHandler interfaces
│ │ ├── AssetLoader.d.ts
│ │ └── CachedAssetsService.d.ts
│ ├── services/
│ │ ├── ConversationRelaySession.ts # Per-WebSocket session (v4.12)
│ │ ├── OpenAIResponseService.ts # OpenAI Responses API + ToolRegistry
│ │ ├── CachedAssetsService.ts # Context cache + server config (v4.12 slim)
│ │ ├── FileAssetLoader.ts # Load assets from disk
│ │ ├── SilenceHandler.ts # Progressive-timeout silence detection (v4.12)
│ │ └── TwilioService.ts # TwiML + outbound + status callback
│ ├── tools/ # CR tools (v4.12: defineTool + ToolRegistry)
│ │ ├── define-tool.ts # defineTool() factory
│ │ ├── tool-registry.ts # ToolRegistry class
│ │ ├── index.ts # buildDefaultRegistry() — startup wiring
│ │ ├── end-call.ts
│ │ ├── live-agent-handoff.ts
│ │ ├── send-dtmf.ts
│ │ ├── send-sms.ts # Factory: createSendSMSTool(config)
│ │ ├── play-media.ts
│ │ ├── switch-language.ts
│ │ ├── set-listen-mode.ts
│ │ ├── set-silence-detection.ts
│ │ └── change-context.ts # Factory: createChangeContextTool(cache)
│ ├── types/
│ │ └── crelay.ts # Zod schemas for CR wire frames + SDK drift guards
│ └── utils/
│ └── logger.ts
The server handles WebSocket connections and manages conversation relay functionality. It includes GPT service integration for natural language processing and Twilio integration for voice call handling.
- Navigate to the server directory:
cd server- Install dependencies:
# Using pnpm (recommended)
pnpm install
# Or using npm
npm install- For development, start the development server:
# Using pnpm
pnpm dev
# Or using npm
npm run devFor production, build and start the server:
# Using pnpm
pnpm build
pnpm start
# Or using npm
npm run build
npm start- Ensure the server is running on port 3007 (or configured port in
.env).
Note: If the configured port is already in use, the server will automatically retry on the next available port (e.g., 3002, 3003, etc.). This allows running multiple server instances simultaneously for testing and development.
- Optionally, expose the server using ngrok:
ngrok http --domain server-yourdomain.ngrok.dev 3007-
Initialization: Silence monitoring starts after the initial setup message, ensuring the system is ready for conversation.
-
Message Tracking:
- The system tracks the time since the last meaningful message
- Info-type messages are intentionally ignored to prevent false resets
- Valid messages (prompt, interrupt, dtmf) reset both the timer and retry counter
-
Response Sequence (configured in
serverConfig.jsonunderConversationRelay.SilenceDetection):- After
secondsThresholdseconds of silence (default 20): sends the next reminder from themessagesarray (e.g. "Still there?") - After all reminders have fired and silence persists: ends the call with
reasonCode: 'unresponsive' - v4.12: the handler uses a progressive
setTimeoutchain (not per-second polling), so it's idle between breaches
- After
-
Cleanup: The session stops its silence handler and clears all per-call state when the WebSocket closes.
- Configure your Twilio phone number to point to the "connectConversationRelay" endpoint:
- Go to your Twilio Console > Phone Numbers > Active Numbers
- Select your phone number
- Under "Voice & Fax" > "A Call Comes In"
- Set it to "Webhook" and enter:
https://server-yourdomain.ngrok.dev/connectConversationRelay - Method: HTTP POST
The server dynamically generates TwiML from server/assets/serverConfig.json. Instead of hardcoded values, all conversation relay parameters are loaded from that configuration:
// TwiML is generated dynamically from serverConfig.json
const config = await this.getConversationRelayConfig(); // Loads from serverConfig.json
const languages = await this.getLanguages(); // Loads language settings
const conversationRelay = connect.conversationRelay({
url: `wss://${serverBaseUrl}/conversation-relay`,
transcriptionProvider: config.transcriptionProvider, // From serverConfig.json
speechModel: config.speechModel, // From serverConfig.json
interruptible: config.interruptible, // From serverConfig.json
ttsProvider: config.ttsProvider, // From serverConfig.json
voice: config.voice, // From serverConfig.json
dtmfDetection: config.dtmfDetection, // From serverConfig.json
welcomeGreeting: config.welcomeGreeting // From serverConfig.json
});Configuration Management:
- Source: Configuration loaded from
serverConfig.jsonat startup - Language Support: Languages array nested in
ConversationRelay.Configuration.languages[] - Enhanced Properties: Full Twilio ConversationRelay TwiML properties supported
Twilio Edge Locations allow you to route API calls through specific data centers for improved latency and performance. This is particularly useful when your infrastructure or users are located in specific geographic regions.
Configuration:
Add these optional environment variables to your .env file:
TWILIO_EDGE=sydney # Edge location (e.g., sydney, dublin, ashburn)
TWILIO_REGION=au1 # Region code (e.g., au1, ie1, us1)Available Edge Locations:
| Location | Edge Value | Region Value | Hostname |
|---|---|---|---|
| Sydney (Australia) | sydney |
au1 |
api.sydney.au1.twilio.com |
| Dublin (Ireland) | dublin |
ie1 |
api.dublin.ie1.twilio.com |
| Ashburn (US East) | ashburn |
us1 |
api.ashburn.us1.twilio.com |
Important Notes:
- Both
TWILIO_EDGEandTWILIO_REGIONmust be specified together - If not configured, the system defaults to Twilio's global low-latency routing
- Edge routing applies to all Twilio API calls (voice, SMS, etc.)
When to Use:
- Your infrastructure is deployed in a specific region (e.g., hosting in Australia)
- You need predictable IP address ranges for firewall configuration
- You want to optimize latency for users in a particular geographic area
- When a call is received, Twilio initiates a WebSocket connection to
wss://server-yourdomain.ngrok.dev/conversation-relay - The server receives a 'setup' message containing call details and custom parameters
- The server creates service instances and begins processing incoming messages
- Each WebSocket connection maintains its own isolated session in a wsSessionsMap
Contexts are loaded from local files in server/assets/:
File-Based Loading ("assetLoaderType": "file", the only supported loader):
- Contexts: All
.mdfiles and files containing "context" in the name - Manifests: All
.jsonfiles containing "manifest" or "tool" in the name (excludingserverConfig.json) - Best for: Development, version control of assets, simple deployments
Context documents are .md files in server/assets/, keyed by filename:
Context Structure:
- AI Assistant Persona - Define the AI's role and personality
- Conversation Guidelines - Set tone, style, and behavior rules
- Response Formatting - Specify how responses should be structured
- Process Instructions - Detail specific conversation flows and steps
- Domain Knowledge - Include relevant business context and rules
Key Sections to Configure:
- Objective - Define the AI's primary role and tasks
- Style Guardrails - Set conversation tone and behavior boundaries
- Response Guidelines - Specify formatting and delivery requirements
- Instructions - Detail specific process steps and workflows
Tools are defined in code under server/src/tools/ using the defineTool({ name, description, parameters, handler }) factory. The defaultToolManifest.json / legs/*/toolManifest.json files have been removed — schema (what OpenAI sees) and handler (what runs) are now co-located in each tool file. buildDefaultRegistry(config, contexts) in server/src/tools/index.ts registers all tools once at startup, and the same ToolRegistry is handed to every OpenAIResponseService. Tools belong to the ResponseService, not the transport: a handler receives (args, ctx), where ctx is the service's ToolContext (currently changeContext(context, summary)), never the session. Tools affect the call only through the action they return.
Tools are split by what they touch:
server/src/tools/cr/— call-control tools. Each only requests a call action (endCall,sendDigits,play,language,listenMode,silence);ConversationRelaySessionowns what the action means on the wire (which frame, and thatendCallwaits for the farewell). No tool builds a CR frame.server/src/tools/llm/— tools that don't act on the call (send-sms,change-context).
Any other ResponseService uses the same actions: it passes toolData.action to toolResult and gets back an ActionOutcome.
Available Tools (9 total, all registered by default):
end-call— Gracefully terminates the current calllive-agent-handoff— Transfers the call to a human agentsend-dtmf— Sends DTMF tones during the callsend-sms— Sends SMS messages during the call (factory: capturesServerConfig)switch-language— Changes TTS and/or transcription languagesplay-media— Plays audio media from URLsset-listen-mode— Toggles outbound text/play/language suppression for listen-only modeset-silence-detection— Enables/disables the silence reminder timer mid-callchange-context— Switches the LLM's system prompt mid-call (factory: capturesContextStore; applies viactx.changeContext)
Adding a new tool:
- Create
server/src/tools/cr/my-tool.ts(acts on the call) orserver/src/tools/llm/my-tool.tsthat exports adefineTool({...})record (or a factory returning one, if it needs DI). - Add one
.register(myTool)line inserver/src/tools/index.ts.
No JSON editing, no filename/manifest-name coupling. Type parameters on defineTool<TArgs, TResult> tie the handler's args type to the declared schema — a mismatch fails at compile time.
Tool-result conventions: handlers return { success, message } plus:
action(optional) — aCallActionthe transport applies:endCall {handoffData?}(deferred until after the farewell; the only action that ends the call),sendDigits {digits},play {source, loop?, …},language {ttsLanguage?, transcriptionLanguage?},listenMode {enabled},silence {enabled}. Invalid actions are dropped by the transport's frame validation.languagecodes are resolved against the call's declared<Language>s (en-GB→en-AU; no match → dropped), and an applied switch stops automatic language detection for the rest of the call. The transport returns anActionOutcome {applied, terminal, detail?}fromtoolResult;OpenAIResponseServiceputs that in the function output so the model hears what really happened, and usesterminalto skip a second farewell.- Any other fields — passed back to the LLM as the function-call output.
The system supports dynamic language switching during active calls using the switch-language tool. Users can request language changes naturally, and the system will immediately switch both text-to-speech and speech-to-text languages.
Example Conversation Flow:
User: "Can you switch to Australian English?"
System Processing:
[SwitchLanguage] Switch language function called with arguments: {
"ttsLanguage": "en-AU",
"transcriptionLanguage": "en-AU"
}
[Conversation Relay] Sending immediate message: {
"type": "language",
"ttsLanguage": "en-AU",
"transcriptionLanguage": "en-AU"
}
AI Response: "No worries, I've switched to Australian English now.
Is there something specific you'd like to know about how the system works,
or do you want a general walk-through?"
Supported Language Configurations:
- en-AU: Australian English with ElevenLabs voice
IKne3meq5aSn9XLyUdCD - en-US: US English with ElevenLabs voice
tnSpp4vdxKPjI9w0GnoV
Technical Implementation:
- Language switching uses Twilio's
SwitchLanguageMessageWebSocket message type - Changes are applied immediately without call interruption
- Both TTS (text-to-speech) and transcription languages are updated simultaneously
- System maintains conversation context across language switches
Per-Call Configuration:
// WebSocket setup message with custom configuration
{
"type": "setup",
"customParameters": {
"contextKey": "customerServiceContext"
}
}Runtime Configuration Updates:
# Swap the system prompt for an active call
curl -X POST '/updateResponseService' \
--data '{
"callSid": "CA1234...",
"contextKey": "escalationContext"
}'Note:
manifestKeyis accepted by/updateResponseServicefor backward compatibility but is ignored. Tools in v4.12 are registered once in code (all-tools-all-legs); the registry is not swapped mid-call.
Initial Setup (Automatic):
# 1. Start the server (automatically creates defaults)
npm run dev
# 2. Make a test call (OpenAI mode uses defaultContext.md automatically)
# No additional setup required!Adding a Custom Context:
- Drop a new
<key>.mdfile intoserver/assets/. It is read on first use byContextStore(OpenAI mode only). - Select it per call with
customParameters.contextKey, or switch an active call viaPOST /updateResponseServicewithcontextKey. The default is alwaysdefaultContext.md.
Configuration Examples by Use Case:
Context Keys:
defaultContext- General purpose conversation (shipped)customerServiceContext- Customer support scenariossalesContext- Sales and lead qualificationtechnicalSupportContext- Technical troubleshooting
The server supports environment-specific .env files using NODE_ENV-based selection for clean separation between development and production configurations.
Environment Files:
.env.dev- Development configuration (local development, ngrok URLs, dev API keys).env.prod- Production configuration (production URLs, production credentials).env- Fallback configuration (backward compatibility, CI/CD)
Usage:
# Development mode (uses .env.dev)
pnpm dev
# Production mode (uses .env.prod)
pnpm build
pnpm start:prod
# Fallback mode (uses .env)
pnpm startHow It Works:
- Development script sets
NODE_ENV=dev→ loads.env.dev - Production script sets
NODE_ENV=prod→ loads.env.prod - No NODE_ENV → falls back to
.env
Benefits:
- No manual env file swapping between environments
- Clear separation of dev/prod configurations
- Reduced risk of using wrong credentials
- Validated environment variables on startup
Create environment files (.env.dev, .env.prod, or .env) in the server directory with the following variables:
# Server Configuration
PORT=3007 # Server port number
SERVER_BASE_URL=your_server_url # Base URL for your server (e.g., ngrok URL)
# OpenAI Configuration
OPENAI_API_KEY=your_openai_api_key # OpenAI API key for GPT integration
OPENAI_MODEL=gpt-4o # OpenAI model to use for conversations
# Twilio Configuration (required for voice services)
ACCOUNT_SID=your_twilio_account_sid # Twilio Account SID for voice operations
AUTH_TOKEN=your_twilio_auth_token # Twilio Auth Token for authentication
API_KEY=your_twilio_api_key # Twilio API Key for enhanced authentication
API_SECRET=your_twilio_api_secret # Twilio API Secret for enhanced authentication
FROM_NUMBER=your_twilio_phone_number # Twilio phone number for calls/SMSValidation: The server validates all required environment variables on startup:
PORT,SERVER_BASE_URL,OPENAI_API_KEYACCOUNT_SID,AUTH_TOKEN,FROM_NUMBER
If any required variables are missing, the server will fail fast with a clear error message listing all missing variables.
The system requires the following Twilio services to be enabled in your account:
- Voice - For handling phone calls and conversation relay
- SMS (optional) - For send-sms tool functionality
Two kinds of asset, owned by different layers:
- Server config —
server/assets/serverConfig.json. Loaded at startup byCachedAssetsService(viaFileAssetLoader) for the ConversationRelay side: TwiML attributes,<Language>s, silence detection and listen mode. - Contexts (LLM prompts) —
server/assets/<key>.md. SCR does not load these. Only a ResponseService that owns its prompt reads them, throughContextStore, on first use:OpenAIResponseServiceusesdefaultContext.md, or the file named bycustomParameters.contextKeyon the call's setup.
{
"AssetLoader": {
"assetLoaderType": "file"
}
}"file" is the only supported loader (Twilio Sync loading was removed); any
other value fails at startup.
Context keys must be plain names (A-Z a-z 0-9 _ -) and map to
server/assets/<key>.md.
WebSocket Setup with Custom Keys:
{
"type": "setup",
"customParameters": {
"contextKey": "customerServiceContext"
}
}API-Based Configuration Updates:
# Update context for active call
curl -X POST 'https://your-server/updateResponseService' \
--header 'Content-Type: application/json' \
--data-raw '{
"callSid": "CA1234...",
"contextKey": "newContext"
}'Version 4.4.3 introduces a comprehensive silence detection configuration system that eliminates hardcoded values and provides maximum flexibility for customizing silence handling behavior.
Silence detection is configured through the ConversationRelay.SilenceDetection object in serverConfig.json:
{
"ConversationRelay": {
"SilenceDetection": {
"enabled": false,
"secondsThreshold": 20,
"messages": [
"Still there?",
"Just checking you are still there?",
"Hello? Are you still on the line?"
]
}
},
"AssetLoader": {
"assetLoaderType": "file"
}
}-
enabled(boolean): Controls whether silence detection is activetrue: Silence detection operates normallyfalse: No silence monitoring or timeout messages
-
secondsThreshold(number): Seconds of silence before triggering response- Default:
20seconds - Configurable based on conversation type and user expectations
- Default:
-
messages(array): Progressive reminder messages sent to user- Array-based progression: System iterates through messages in order
- Flexible count: Add or remove messages without code changes
- Call termination: When all messages are exhausted, the call ends gracefully
- Silence Monitoring: System tracks time since last meaningful message
- Message Progression: When threshold exceeded, sends first message from array
- Escalation: Subsequent silence periods trigger next messages in sequence
- Conversation Reset: Valid user responses reset message index to beginning
- Call Termination: After all messages exhausted, call ends with "unresponsive" reason
Who owns what: the policy — when to remind, how many reminders, when to end —
belongs to ConversationRelay (ConversationRelaySession) and applies whichever
ResponseService is active. Only the wording may come from the service, via the
optional silenceReminder(count); if it returns nothing, or doesn't answer within
1.5s, the configured messages[count-1] is spoken. A service can reword a
reminder but cannot skip it or change when the call ends. OpenAIResponseService
uses the configured messages.
Version 4.9.7 introduces runtime control of silence detection, allowing the LLM to enable or disable silence monitoring during active calls. This is particularly useful for scenarios where the caller may not speak for extended periods.
Key Features:
- Runtime Control: Enable/disable silence detection during active calls using the
set-silence-detectiontool - Flag-Based Design: Timer continues running but checks an enabled flag before taking action
- Tool-Driven Architecture: Uses the existing outgoingMessage pattern for clean service communication
- No Service Coupling: Avoids dependency injection between services
Common Use Cases:
- Payment Processing: Disable silence detection while collecting card information via DTMF
- Document Lookup: Disable monitoring when caller is searching for documents or information
- IVR Navigation: Disable during automated phone tree navigation
- Form Input: Disable when caller is entering multiple pieces of information
How It Works:
- Continuous Timer: The silence timer runs continuously throughout the call (checking every 1 second)
- Flag Check: Before triggering any silence messages, the timer checks if silence detection is enabled
- Early Return: If disabled (
enabled: false), the timer returns immediately without taking action - Tool Control: The LLM can call
set-silence-detectiontool to dynamically toggle the enabled flag - Service Communication: Tool returns an
outgoingMessagewith typesetSilenceDetectionthat routes through OpenAIResponseService to ConversationRelayService
Technical Implementation:
// In SilenceHandler.ts - Timer checks enabled flag
setInterval(() => {
if (!this.enabled) return; // Skip if disabled
// ... normal silence detection logic
}, 1000);
// Tool response format
{
success: true,
message: "Silence detection enabled",
outgoingMessage: {
type: 'setSilenceDetection',
enabled: true
}
}
// ConversationRelayService routes the message
case "setSilenceDetection":
if (this.silenceHandler) {
this.silenceHandler.set(silenceMsg.enabled);
}
break;Using the set-silence-detection Tool:
{
"type": "function",
"name": "set-silence-detection",
"description": "Enables or disables silence detection monitoring during the call. Use this to temporarily disable silence detection during activities where the caller may not speak for extended periods (e.g., entering payment information, looking up documents).",
"parameters": {
"type": "object",
"properties": {
"enabled": {
"type": "boolean",
"description": "Set to true to enable silence detection, false to disable it"
}
},
"required": ["enabled"]
}
}Example Conversation Flow:
AI: "I'll need your credit card number. Please enter it now."
[AI calls set-silence-detection tool with enabled: false]
[Caller enters digits via DTMF - no silence warnings triggered]
AI: "Thank you, I've received your card number. Now please enter the security code."
[Caller continues entering data without silence interruptions]
AI: "Perfect, your payment is processing."
[AI calls set-silence-detection tool with enabled: true]
[Silence detection resumes normal operation]
Benefits:
- Better User Experience: No interruptions during legitimate silence periods
- Flexible Control: LLM decides when silence detection is appropriate
- Simple Implementation: Flag-based design avoids complex timer management
- Type-Safe: Proper TypeScript interfaces throughout the message routing chain
Development/Testing Configuration:
"silenceDetection": {
"enabled": true,
"secondsThreshold": 5,
"messages": ["Quick test - still there?"]
}Customer Service Configuration:
"silenceDetection": {
"enabled": true,
"secondsThreshold": 30,
"messages": [
"I'm sorry, I didn't catch that. Are you still there?",
"Hello? Can you hear me?",
"We seem to have lost connection. I'll end this call now."
]
}No Timeout Configuration:
"silenceDetection": {
"enabled": false,
"secondsThreshold": 20,
"messages": []
}- No Code Changes: Modify thresholds and messages through JSON configuration
- A/B Testing: Easy testing of different message strategies and timing
- Environment-Specific: Different configurations for development, staging, production
- User Experience: Customizable messaging tailored to specific use cases
- Type Safety: Full TypeScript support with compile-time validation
Version 4.5.0 introduces Listen Mode, a powerful feature that enables automated operations by suppressing text responses while maintaining full tool execution capability. This is particularly useful for automated tasks, IVR navigation, and background data collection.
Listen mode is configured through the Server.ListenMode object in serverConfig.json:
{
"ConversationRelay": {
"Configuration": {},
"Languages": []
},
"AssetLoader": {
"assetLoaderType": "file"
},
"Server": {
"ListenMode": {
"enabled": true
}
}
}enabled(boolean): Controls whether listen mode is activetrue: Text responses are suppressed, only tool execution occursfalse: Normal text responses are generated (default behavior)
- Silent Operation: When enabled, the system processes audio transcription and executes tools but suppresses all text-to-speech responses
- Tool Execution Preserved: All tool functionality remains fully operational (DTMF sending, SMS, call control, etc.)
- Audio Processing: System continues to transcribe and process incoming audio normally
- Early Break Processing: Efficient implementation using early break patterns to skip text response generation
Listen mode can be controlled dynamically during active conversations using the set-listen-mode tool:
{
"type": "function",
"name": "set-listen-mode",
"description": "Enable/disable listen mode to control text response generation",
"parameters": {
"type": "object",
"properties": {
"enabled": {
"type": "boolean",
"description": "True for listen-only operation, false for normal responses"
}
},
"required": ["enabled"]
}
}Enable Listen Mode for Automated Operations:
// During a call, enable silent mode
toolCall('set-listen-mode', { enabled: true });
// System now operates silently, executing tools without speakingDisable Listen Mode for Interactive Conversation:
// Switch back to normal interactive mode
toolCall('set-listen-mode', { enabled: false });
// System resumes normal text responses- Silent Navigation: Navigate phone tree systems without generating speech responses
- DTMF Control: Send touch-tone signals while remaining silent
- Data Collection: Document navigation paths and menu options
- Terminal Detection: Automatically detect and handle end conditions
- Automated Testing: Run conversation flow tests without audio output
- Data Mining: Collect information while operating silently
- System Monitoring: Monitor call flows without user-facing responses
- Debug Mode: Test tool execution without generating responses
- Performance Testing: Measure tool execution performance without TTS overhead
- Integration Testing: Validate tool functionality in isolated mode
Listen mode integrates seamlessly with the existing architecture:
- CachedAssetsService: Loads listen mode configuration from
serverConfig.json - OpenAIResponseService: Implements early break pattern to skip text processing
- Tool Integration: All tools continue to function normally in listen mode
- Runtime Control: Dynamic switching through standard tool calling patterns
- Reduced Processing: Skip unnecessary text generation for automated tasks
- Lower Bandwidth: No text transmission when operating silently
- Faster Execution: Optimized processing flow for automated operations
- Runtime Control: Switch between silent and interactive modes during calls
- Configuration Driven: Control behavior through simple boolean configuration
- Tool Preservation: Maintain full tool functionality while suppressing responses
- Simple Configuration: Single boolean parameter controls entire feature
- Standard Integration: Uses existing tool calling patterns for control
- Type Safety: Full TypeScript support with proper interface definitions
This listen mode system provides a comprehensive solution for automated operations while maintaining the full power and flexibility of the conversation relay system.
To deploy the server to Fly.io:
- Navigate to the server directory:
cd server- For new deployments, use the
--no-deployoption:
fly launch --no-deployTake note of the server URL under app = 'XXXXXX' and update your .env file accordingly.
SERVER_BASE_URL=XXXXXX.fly.dev
- Ensure your
fly.tomlfile has the correct port configuration, aligned with your .env PORT variable:
[http]
internal_port = 3007- Add the volume mount configuration:
[mounts]
source = "assets"
destination = "/assets"- Import your environment variables as secrets:
fly secrets import < .env- Deploy your application:
fly deploy- express - Web application framework
- express-ws - WebSocket support for Express
- openai - OpenAI API client for GPT integration
- dotenv - Environment configuration
- winston - Logging framework
- uuid - Unique identifier generation
The server includes several built-in tools for call management:
end-call- Gracefully terminates the current calllive-agent-handoff- Transfers the call to a human agentsend-dtmf- Sends DTMF tones during the callsend-sms- Sends SMS messages during the callswitch-language- Changes TTS and/or transcription languagesplay-media- Plays audio media from URLs
The /conversation endpoint provides a simple HTTP POST interface for messaging and chat applications, enabling the same backend Response Service to be used for both voice (Conversation Relay) and text-based conversations.
🎯 Key Features:
- Unified Architecture: Same OpenAIResponseService powers both voice and text conversations
- HTTP POST Interface: Simple JSON request/response for messaging applications
- Session Management: Stateful conversations with automatic GUID-based session tracking
- Shared Configuration: Same context, manifest, and tools work across both channels
- Multi-Turn Conversations: Full conversation history maintained across requests
✅ Benefits:
- Build messaging applications without WebSocket complexity
- Test conversation flows using simple HTTP requests
- Deploy same AI logic to multiple communication channels (voice + text)
- Consistent AI behavior across voice calls and text conversations
- Same tool execution (send-sms, etc.) works in both voice and messaging contexts
POST /conversation
interface ConversationRequest {
sessionId?: string; // [OPTIONAL] Session ID for continuing conversation
message: string; // [REQUIRED] Message to send to OpenAI
role?: 'user' | 'system'; // [OPTIONAL] Message role (defaults to 'user')
}interface ConversationResponse {
success: boolean;
sessionId: string; // Session ID for conversation continuity
response: string; // OpenAI's response text
error?: string; // Error message if request failed
}# First message (creates new session)
curl -X POST http://localhost:3000/conversation \
-H "Content-Type: application/json" \
-d '{"message": "Hello, I need help"}'
# Follow-up message (uses existing session)
curl -X POST http://localhost:3000/conversation \
-H "Content-Type: application/json" \
-d '{
"sessionId": "550e8400-e29b-41d4-a716-446655440000",
"message": "Tell me more"
}'The /conversation endpoint shares the same underlying architecture as the /conversation-relay WebSocket endpoint:
- OpenAIResponseService: Same response generation and tool execution
- CachedAssetsService: Same context, manifest, and tool configurations
- Session Independence: Voice sessions (wsSessionsMap) and messaging sessions (conversationSessionMap) are independent
This unified architecture enables developers to build conversational AI applications that work seamlessly across voice and messaging channels using a single backend service.
To handle incoming SMS messages and forward them to the /conversation endpoint, you can create a Twilio Function that acts as a bridge between Twilio's SMS service and your conversation relay server.
📱 Key Features:
- Automatic SMS Routing: Forwards incoming SMS to the conversation endpoint
- Session Management: Uses phone number as session ID for stateful conversations
- Error Handling: Graceful fallback messages if server is unavailable
- Long Message Support: Handles SMS responses over 1600 characters
exports.handler = async function(context, event, callback) {
const twiml = new Twilio.twiml.MessagingResponse();
try {
// Extract SMS details
const fromNumber = event.From;
const messageBody = event.Body;
// Use phone number as session ID for stateful conversations
const sessionId = fromNumber.replace(/[^0-9]/g, ''); // Remove non-numeric chars
// Your conversation relay server URL
const serverUrl = context.SERVER_URL || 'https://your-server.com';
const conversationEndpoint = `${serverUrl}/conversation`;
// Prepare request payload
const payload = {
message: messageBody,
sessionId: sessionId,
role: 'user'
};
// Make request to conversation endpoint
const response = await fetch(conversationEndpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(payload)
});
if (!response.ok) {
throw new Error(`Server returned ${response.status}: ${response.statusText}`);
}
const data = await response.json();
if (data.success && data.response) {
// Handle long messages by splitting into 1600 char segments
const maxLength = 1600;
const responseText = data.response;
if (responseText.length > maxLength) {
// Split into multiple messages
for (let i = 0; i < responseText.length; i += maxLength) {
twiml.message(responseText.substring(i, i + maxLength));
}
} else {
twiml.message(responseText);
}
} else {
twiml.message('Sorry, I encountered an error processing your message.');
}
} catch (error) {
console.error('Error:', error);
twiml.message('Sorry, I\'m temporarily unavailable. Please try again later.');
}
return callback(null, twiml);
};-
Create the Function:
- Navigate to Twilio Console → Functions & Assets → Services
- Create a new Service (e.g., "SMS Conversation Handler")
- Add a new Function with the code above
- Set the Function path (e.g.,
/sms-handler)
-
Add Environment Variables:
SERVER_URL = https://your-server.comReplace with your actual conversation relay server URL.
-
Deploy the Function:
- Click "Deploy All" to make the Function live
- Copy the Function URL (e.g.,
https://your-service-1234.twil.io/sms-handler)
-
Configure Your SMS Phone Number:
- Go to Phone Numbers → Active Numbers
- Select your phone number
- Under "Messaging Configuration"
- Set "A Message Comes In" to your Function URL
- Method: HTTP POST
- SMS Received: User sends SMS to your Twilio phone number
- Function Triggered: Twilio invokes your Function with SMS data
- Forward to Server: Function sends message to
/conversationendpoint - Session Continuity: Phone number is used as session ID to maintain conversation context
- AI Response: Server generates response using OpenAI
- Reply via SMS: Function sends AI response back to user via SMS
- Unified AI Logic: Same conversation context and tools work across SMS and voice
- Stateful Conversations: Each phone number maintains its own conversation history
- Automatic Message Splitting: Long responses are automatically split into multiple SMS
- Error Resilience: Graceful handling of server errors with user-friendly messages
Send an SMS to your configured Twilio phone number:
User: Hello, what can you help me with?
AI: [Response from your conversation endpoint]
User: Tell me more
AI: [Contextual response based on conversation history]
The system maintains conversation context per phone number, enabling natural multi-turn conversations over SMS.
The system supports initiating outbound calls via an API endpoint:
POST /outboundCall
All properties except phoneNumber are passed as Conversation Relay parameters and accessible via message.customParameters in the WebSocket session.
interface RequestData {
properties: {
phoneNumber: string; // [REQUIRED] Destination phone number in E.164 format (extracted for routing)
[key: string]: any; // [OPTIONAL] All other fields passed as Conversation Relay parameters
}
}Common Parameters:
callReference- Unique reference to associate with the callcontextKey- Select specific conversation context for this callmanifestKey- Select specific tool manifest for this call- Any custom fields - Available in WebSocket session via
message.customParameters
curl -X POST \
'https://server-yourdomain.ngrok.dev/outboundCall' \
--header 'Content-Type: application/json' \
--data-raw '{
"properties": {
"phoneNumber": "+1234567890",
"callReference": "abc123",
"firstname": "Bob",
"lastname": "Jones"
}
}'The server uses dependency inversion for multi LLM operators and a clean dependency injection architecture with handler interfaces for service communication.
The server is organized into modular services:
- ConversationRelayService - Manages the core conversation flow and WebSocket communication
- OpenAIResponseService - Implements the ResponseService interface for OpenAI integration
- SilenceHandler - Manages silence detection and response with configurable thresholds
- TwilioService - Manages Twilio-specific functionality and call control operations
The TwilioService follows a specific architectural pattern to maintain clean separation between service-level operations and self-contained LLM tools:
Use TwilioService when:
- Making complex API calls that use multiple Twilio services
- Implementation requires non-API level business logic
- Building server endpoints or internal service operations
- NOT creating an LLM tool (tools should be self-contained)
Don't use TwilioService (use direct Twilio API) when:
- Creating LLM tools in the
src/tools/directory - Tools should call the Twilio API directly for self-contained execution
- This ensures tools remain portable and don't depend on service layer coupling
- Example:
src/tools/llm/send-sms.tsuses directtwilio.messages.create()call instead of TwilioService
This architectural decision ensures LLM tools remain self-contained and portable, avoiding unnecessary service layer dependencies while complex business logic resides appropriately in the service layer.
ResponseHandler - Handles LLM service responses:
export interface ResponseHandler {
content(response: ContentResponse): void;
toolResult(toolResult: ToolResultEvent): void;
error(error: Error): void;
callSid(callSid: string, responseMessage: any): void;
}ConversationRelayHandler - Handles conversation relay events:
export interface ConversationRelayHandler {
outgoingMessage(message: OutgoingMessage): void;
callSid(callSid: string, responseMessage: any): void;
silence(message: OutgoingMessage): void;
}Services use unified handler creation methods:
// Create service instances
const responseService = await OpenAIResponseService.create(contextFile, toolManifest);
const conversationRelay = new ConversationRelayService(responseService, sessionData);
// Set up response handler
const responseHandler = {
content: (response) => { /* handle content */ },
toolResult: (toolResult) => { /* handle tool results */ },
error: (error) => { /* handle errors */ },
callSid: (callSid, responseMessage) => { /* handle call events */ }
};
responseService.createResponseHandler(responseHandler);
// Set up conversation relay handler
const conversationRelayHandler = {
outgoingMessage: (message) => ws.send(JSON.stringify(message)),
callSid: (callSid, responseMessage) => { /* handle call events */ },
silence: (silenceMessage) => ws.send(JSON.stringify(silenceMessage))
};
conversationRelay.createConversationRelayHandler(conversationRelayHandler);Services communicate through unified handler interfaces:
// ResponseService using unified handler
this.responseHandler.content(response);
this.responseHandler.toolResult(toolResult);
this.responseHandler.error(error);- Direct Function Calls: Fast, direct handler invocation with minimal overhead
- Optimized Memory Usage: Lightweight handler objects
- Low Latency: Immediate function calls for responsive service communication
- Compile-Time Validation: TypeScript enforces correct handler signatures
- IntelliSense Support: Full IDE autocompletion and documentation
- Strong Type Contracts: Clear, enforceable contracts between services
- Easy Mocking: Simple function mocking for unit tests
- Clear Dependencies: Explicit handler dependencies make service relationships transparent
- Better Debugging: Direct call stacks make debugging straightforward
- Single Responsibility: Each handler focuses on one specific communication channel
- Interface Segregation: Services only implement handlers they actually need
- Dependency Inversion: Services depend on handler abstractions, not concrete implementations
The system includes comprehensive TypeScript interfaces for all Twilio WebSocket message types:
TextTokensMessage: For sending text to be converted to speechPlayMediaMessage: For playing audio from URLsSendDigitsMessage: For sending DTMF digitsSwitchLanguageMessage: For changing TTS and transcription languagesEndSessionMessage: For terminating conversation sessions
These are unified under the OutgoingMessage union type, ensuring compile-time validation:
const textMessage: TextTokensMessage = {
type: 'text',
token: 'Hello, how can I help you?',
last: true,
interruptible: true
};
await conversationRelaySession.outgoingMessage(textMessage);The system implements a pure tool type-driven architecture using OutgoingMessage types for routing:
- Generic LLM Tools - Standard tools processed by OpenAI (e.g.,
send-sms) - CRelay Tools with Immediate Delivery - WebSocket tools sent immediately (e.g.,
send-dtmf) - CRelay Tools with Delayed Delivery - Terminal tools sent after OpenAI response (e.g.,
end-call,live-agent-handoff)
Generic LLM Tool (send-sms.ts):
export default async function (functionArguments: SendSMSFunctionArguments): Promise<SendSMSResponse> {
// Tool logic here
const result = await twilioService.sendSMS(args.to, args.message);
// Return simple response for OpenAI to process
return {
success: true,
message: `SMS sent successfully`,
recipient: args.to
};
}CRelay Tool with Immediate Delivery (send-dtmf.ts):
import { SendDigitsMessage } from '../interfaces/ConversationRelay.js';
export default function (functionArguments: SendDTMFFunctionArguments): SendDTMFResponse {
return {
success: true,
message: `DTMF digits sent successfully`,
digits: functionArguments.dtmfDigit,
outgoingMessage: {
type: "sendDigits",
digits: functionArguments.dtmfDigit
} as SendDigitsMessage
};
}CRelay Tool with Delayed Delivery (end-call.ts):
import { EndSessionMessage } from '../interfaces/ConversationRelay.js';
export default function (functionArguments: EndCallFunctionArguments): EndCallResponse {
return {
success: true,
message: `Call ended successfully`,
summary: functionArguments.summary,
outgoingMessage: {
type: "end",
handoffData: JSON.stringify({
reasonCode: "end-call",
reason: "Ending the call",
conversationSummary: functionArguments.summary
})
} as EndSessionMessage
};
}ConversationRelayService routes based on outgoingMessage.type:
sendDigits,play,language- Immediate WebSocket deliveryend- Stored and sent after OpenAI response completiontextor no outgoingMessage - Standard OpenAI processing
The ResponseService supports interrupting ongoing AI responses using a boolean flag approach for simplicity:
interrupt(): void {
this.isInterrupted = true;
}
private async processStream(stream: any): Promise<void> {
for await (const event of stream) {
if (this.isInterrupted) {
break; // Exit when interrupted
}
// Process events...
}
}