How to Build an AI Phone Agent with Twilio Conversation Relay in C Sharp
Time to read:
How To Build An AI Phone Agent With Twilio Conversation Relay in C Sharp
Building a voice AI agent from scratch means wiring together speech recognition, text-to-speech, turn detection, and real-time audio streaming, all at low enough latency to feel natural. Twilio Conversation Relay handles the entire voice pipeline for you, so you can focus on the AI logic. In this tutorial, you will build a fully functional AI phone agent in C# using .NET 9's API tools.
Prerequisites
To follow this tutorial, you will need:
- A free Twilio account with a voice-capable phone number
- An OpenAI API key
- .NET 9 SDK
- ngrok to expose your local server to Twilio
Build the app
Step 1: How Conversation Relay works
Before writing any code, it helps to understand how Conversation Relay fits into the call flow. When someone dials your Twilio number, Twilio requests TwiML from a webhook you configure. Your server returns a <ConversationRelay> verb inside a <Connect> block, which tells Twilio to open a WebSocket connection to your server and hand the call off to Conversation Relay.
From that point on, Conversation Relay acts as the voice pipeline. It transcribes everything the caller says using speech-to-text (STT) and sends the transcript to your WebSocket server as a JSON message. Your server calls an AI model with the transcript, streams the response back over the WebSocket, and Conversation Relay synthesises each token into speech using text-to-speech (TTS), playing it to the caller in real time.
The flow looks like this:
Conversation Relay's median response latency is under 0.5 seconds, so conversations feel natural rather than robotic.
For this tutorial, you will build Hoot, an AI phone support agent for Owl Air, a fictional airline. Hoot can answer questions about flight status, baggage policy, loyalty points, and booking changes.
Step 2: Set up your project
Start by creating a project directory and a new .NET web application.
Add required nuget packages:
Next, create a .env file for the environment variables your application needs:
Paste your API key from OpenAI into this placeholder space.
DOMAIN is your ngrok hostname without the https:// prefix. You will get this value in a later step once ngrok is running.
You will have a Program.cs file as a starting point for your application. In this file, paste in the following code to start:
Step 3: Write a voice-optimized system prompt
The system prompt is one of the most important parts of a phone agent. Text-to-speech synthesizers read your agent's responses verbatim, so responses that look fine on screen can sound unnatural when spoken aloud. Markdown formatting, bullet points, emoji, and symbols like $ or % all produce awkward output.
To create this we will create a service that interfaces with OpenAI, and paste those instructions in there. Create a new file called OpenAiService.cs and paste in the following:
This handler will handle your interactions with OpenAI. Pay close attention to the system prompt here.
Spelling out numbers in the prompt itself (thirty dollars instead of $30) can encourage the model to use the same format in its responses. Capping replies at two or three sentences keeps the conversation from sounding like a wall of text being read aloud. And, explicitly forbidding Markdown prevents the model from adding asterisks, headers, or lists that would be spoken as literal characters.
Step 4: Create a Conversation Relay handler
You need a handler to process the information from ConversationRelay. First of all, create this file: ChatMessage.cs. Paste in this code:
This is a one-line record to help hold information about the conversation.
Now create your handler, ConversationRelayHandler.cs. Paste in the necessary code:
ConversationRelayHandler.cs is the persistent heart of a live phone call. When a call comes in, Twilio opens a WebSocket connection to /ws and this file takes over. It sits in a loop for the entire duration of the call, reading JSON messages from Twilio one at a time.
Each message has a type field that tells it what just happened.
The setup message arrives first, immediately after Conversation Relay opens the WebSocket connection. It contains the callSid for the call and any custom parameters you passed via <Parameter> elements in your TwiML. The messages list is initialised as empty here; the system prompt is prepended inside stream_response before each OpenAI API call.
The prompt message carries the caller's transcribed speech in data["voicePrompt"]. The last field indicates whether this is the final transcript for a turn. When partialPrompts is disabled (the default), you will only receive prompt messages with last: true, so the data.get("last") check ensures you only send requests to the model once the caller has finished speaking.
The interrupt message fires when the caller speaks while Hoot is talking. The utteranceUntilInterrupt field tells you how much of Hoot's response was played before the interruption. Because that response was cut short, it would be misleading to keep it in the conversation history as a complete assistant turn. The handler removes the last assistant message from the messages list, so the next request starts from a clean state.
The error message carries a numeric error code and a description. Common errors include invalid provider configurations and WebSocket timeout events. Logging them makes debugging significantly easier.
Step 5: Configure Conversation Relay attributes
In your P rogram.cs file, paste in the following (above app.Run();):
The url attribute points to the WebSocket endpoint on your server. It must use wss:// (the secure WebSocket protocol), not ws://. The welcomeGreeting is played to the caller immediately after Conversation Relay connects, before your WebSocket handler sends any response. Listing Hoot's four topic areas in the greeting orients callers right away and reduces the chance they ask for something outside the agent's scope.
The <ConversationRelay> verb accepts a rich set of attributes for tuning STT and TTS behaviour. The configuration above uses several that are worth understanding in detail.
ttsProvider="ElevenLabs" and transcriptionProvider="Deepgram" select the voice and speech recognition providers. ElevenLabs is the default TTS provider as of the Conversation Relay GA release, and it produces the most natural-sounding voices. Deepgram is the default STT provider and supports Deepgram Flux, the latest speech model.
speechModel="nova-3-general" enables Deepgram Flux, which combines transcription and end-of-turn detection in a single model. Compared to earlier models, Flux delivers 200 to 600 milliseconds less latency per response turn and approximately thirty percent fewer false interruptions. This means Hoot is less likely to cut a caller off mid-sentence or wait awkwardly after they have finished speaking.
eotThreshold="0.8" controls how confident Deepgram Flux must be that a speaker has finished before finalising the transcript. The value ranges from 0.5 (more aggressive, fires sooner) to 0.9 (more conservative, waits longer). A value of 0.8 is a good starting point for most use cases.
ignoreBackchannel="true" filters out conversational filler words like "yeah", "uh huh", and "okay" that callers say while listening. Without this setting, those sounds would trigger your WebSocket handler and send unnecessary requests to the model mid-response.
hints is a comma-separated list of words and phrases that Deepgram should give extra weight to during transcription. Business-specific terms like product names, place names, and technical vocabulary often trip up general speech recognition models. Adding them as hints improves accuracy for your specific use case.
interruptible="any" and interruptSensitivity="medium" control how the agent responds when a caller speaks while it is talking. Setting interruptible to any means both speech and DTMF keypresses can interrupt the agent. The medium sensitivity setting reduces false interruptions in noisier environments while still responding quickly to intentional interruptions.
elevenlabsTextNormalization="auto" tells ElevenLabs to automatically normalise numbers and abbreviations in the text before synthesising speech. This is useful as a safety net if your LLM occasionally outputs a digit or symbol despite your instructions.
Step 6: Build the WebSocket handler
Above the line app.Run();, add the WebSocket endpoint to Program.cs:
This registers a GET route at /ws that handles the WebSocket upgrade. When Twilio's Conversation Relay connects, it first checks that the incoming request is actually a WebSocket handshake — if not, it rejects it with a 400. If it is, it accepts the upgrade (which switches the connection from HTTP to WebSocket) and then hands it off to ConversationRelayHandler.HandleAsync, which takes over and runs for the entire duration of the call. The await on that last line means the route handler stays open and doesn't return until the call ends.
Step 7: Stream responses and calling tools
Right now, Hoot can only answer questions about facts baked into the system prompt. To look up real flight status, you need to give the model a tool it can call. The OpenAI function calling API lets you define tools as JSON schemas, and the model decides whether to call one based on the caller's request.
In your OpenAiService.cs file, you are going to add a tool that will allow the agent to get more detailed flight information. Add the following code to this file:
MockFlightData is a stand-in for a real database or API call. In a production agent, you would replace this with a live lookup. When a caller gives a flight number that is not in the data, ExecuteTool returns "No flight found with that number.", so Hoot always has a graceful response rather than hallucinating status information. The flight data uses spelled-out times ("two thirty PM") following the same voice-optimized convention as the system prompt, so ElevenLabs reads them naturally, when they appear in Hoot's response.
Each text message you send to Conversation Relay contains a token (a text fragment) and a last flag. Sending tokens one by one as they arrive with last: False lets Conversation Relay begin synthesising and playing speech before the full response is ready. The final message with last: True and an empty token signals the end of the turn. The function returns the full accumulated text so the WebSocket handler can append it to the conversation history.
Step 8: Running your server
You are ready to run the server. Start ngrok as follows:
ngrok will display a Forwarding line that looks something like this:
Copy the hostname (1234abcd.ngrok.app, without https://) and update your .env file:
Now start your .NET project by opening the terminal in VS code and typing:
You should see output that shows your server is up and running, listening on port 5000.
Step 9: Configuring your Twilio phone number
Log in to the Twilio Console and navigate to Numbers & Senders in the left sidebar, then Overview then My Inventory. Click your voice-capable phone number to open its configuration page.
Under the Voice Configuration section, set A Call Comes In to Webhook and enter the URL for your TwiML endpoint:
Replace 1234abcd.ngrok.app with your actual ngrok hostname. Set the HTTP method to HTTP POST, then click Save Configuration.
Test your agent
Call your Twilio phone number. You should hear Hoot's greeting within a second or two of the call connecting:
> "Thanks for calling Owl Air! I'm Hoot. I can help with flight status, baggage policy, loyalty points, or booking changes. Which of those can I help you with?"
Try a few test questions to verify the full flow is working:
- "What's the baggage policy?" - Hoot should describe carry-on and checked bag rules in natural spoken language.
- "How do loyalty points work?" - Hoot should explain the earn and redemption rates.
- "Can I change my flight?" - Hoot should give the change fee policy, with amounts spelled out in words.
- "When does check-in open?" - Hoot should mention the twenty-four hour window.
- "What's the status of flight OA205?" - Hoot should call the
lookup_flight_statustool and report that the flight is delayed by forty minutes. You will see the tool call and its result printed in your terminal. - "What's the status of flight OA318?" - Hoot should report the flight is cancelled.
- "What's the status of flight OA999?" - Hoot should gracefully report that no flight was found with that number.
Watch your terminal as you speak. You will see each caller turn printed as it arrives, Hoot's streamed response tokens accumulating, and the complete response logged once the turn is finished.
If Hoot does not respond, check that your ngrok tunnel is still running and that the DOMAIN value in .env matches your current ngrok hostname. ngrok generates a new hostname each time you restart the free tier, so you will need to update your Twilio webhook URL and restart your .NET server whenever you restart ngrok.
If you hear an error message on the call or see an error message in your terminal logs, check the error code against the Conversation Relay error code reference in the Twilio documentation.
What's next?
The agent you built here covers the core Conversation Relay pattern, but there are several natural extensions worth exploring.
Proactive topic re-offering - If a caller seems uncertain, Hoot will stay silent and wait. You can fix this with a single addition to the system prompt: Instruct Hoot to re-offer the four topic areas if the caller has not asked a clear question after one or two exchanges. No code change required.
Human handoff - The system prompt already tells Hoot to offer to connect callers with a live agent. To implement it, detect when the model's response includes that offer and send {"type": "end-session", "handoffData": "caller needs live support"} from the WebSocket handler. You will also need to add an action attribute to the <ConversationRelay> element in your TwiML (e.g. action="/handoff"). Twilio POSTs to that URL when the session ends, passing the handoffData string, where you can return new TwiML to route the call to a queue or a direct number using standard <Dial> logic.
Caller personalization - You can pass data into the setup message using <Parameter> elements inside <Connect> in your TwiML. For example, <Parameter name="customerName" value="Alex"/> makes customParameters.customerName available in the setup message, so you can have Hoot greet the caller by name. In a real deployment you would look up the caller's name from the From phone number before generating the TwiML.
Conclusion
You have built a fully functional AI phone agent using Twilio Conversation Relay, .NET 9, and the OpenAI API. Conversation Relay handled the speech-to-text and text-to-speech pipeline, while your WebSocket server connected the model to the call and managed the conversation history, including live flight lookups via tool calling. See the Conversation Relay documentation for the full list of attributes and message types.
If you ran into any problems working with this solution, you can view the full sample on Github.
Amanda Lange is a .NET Engineer of Technical Content. She is here to teach how to create great things using C# and .NET programming. She can be reached at amlange [ at] twilio.com.
Related Posts
Related Resources
Twilio Docs
From APIs to SDKs to sample apps
API reference documentation, SDKs, helper libraries, quickstarts, and tutorials for your language and platform.
Resource Center
The latest ebooks, industry reports, and webinars
Learn from customer engagement experts to improve your own communication.
Ahoy
Twilio's developer community hub
Best practices, code samples, and inspiration to build communications and digital engagement experiences.