Make Outbound Calls with Twilio Voice and Media Streams, GPT-Live-1 in the OpenAI API, and Node.js
Time to read:
Your voice agents don't need to wait by the phone – they can dial it.
Our friends at OpenAI just brought GPT-Live – the voice models behind the new ChatGPT Voice – to the OpenAI API. In a companion tutorial, I showed you how to build an assistant that answers a call to your Twilio number. But often, you need things to work in the other direction; AI agents need to call out for appointment reminders, delivery updates, and all the other callbacks someone might request and then forget about.
In this tutorial, I'll show you how to place an outbound call from an AI voice agent with Twilio Programmable Voice and Media Streams, along with GPT-Live-1 in the OpenAI API. Your assistant will explain why it's calling, look up a note attached to the callback, and even search the web when needed.
Enough holding – let's dial!
Prerequisites and common pitfalls
To follow along with this tutorial, you'll need:
- A Twilio account – sign up for a free account if you don't yet have one.
- A Twilio phone number with Voice capability – here's how to search and buy a phone number.
- A phone number you're allowed to call. On a trial account that means a verified caller ID, or another Twilio number you own. More on this in Step 7.
- An OpenAI account with GPT-Live-1 access:
- An API key with permission to use the
gpt-live-1andgpt-5.6-terramodels. - Node.js 22 or later – I used 24.5.0 writing this tutorial.
- A way to expose localhost to the internet – ngrok is an excellent choice.
- A phone you don't mind interrupting. It's going to ring.
Let's do this.
Build the app
Step 1: Initialize the outbound calling project
Fire up your console... it's time to start a new project!
Make a directory and initialize it (so npm has a package.json to write dependencies):
Step 2: Install dependencies
We will use Fastify for the server side of this tutorial, ws for the client side, and dotenv for loading credentials. We'll add the twilio Node.js helper library to handle Twilio orchestration.
For reference, as we went to press, here's what I had installed:
Step 3: Create the project files
You're going to need two files: one will store secrets, and the other will contain our server code. And we're using ES modules, so add "type": "module" to the existing package.json while we're here:
An outbound call needs more from .env than an inbound agent would. You're going to need to set the following environment variables:
PHONE_NUMBER_FROM is your Twilio number in E.164 format (that’s +18005551212, not 800-555-1212). Leave DOMAIN empty for now – you'll fill it in from your tunnel in Step 10. Your Account SID and Auth Token are on the Twilio Console dashboard.
And, as I mentioned a second ago, make a small change to the module type in package.json:
Step 4: Set up your imports and configuration
Open outbound-demo.js and paste this:
Most of that block is the same as the inbound tutorial. You should visit that page for more of an explanation, but here's what's new:
TOis who you're calling, read from the --call= flag.HOSTis your public hostname, scrubbed of its scheme (https://) and any trailing slash.OVERRIDE_NUMBERSis where you would substitute business logic for determining who you can call, or add your cell phone number for the demo.USER_AGENTis how your app introduces itself to OpenAI: [company or library]/[language] [version]. (Put your name in this one, not ours.)
Step 5: Write your prompts
Two models, two jobs, two sets of instructions! Here's what to paste next:
VOICE_PROMPT shapes your agent's identity and personality. Your assistant is Owlie, who is always cheerful (and also a little over-prepared). Notice we lead the prompt with an AI disclosure and the "returning a call" framing – you can test this when you call by asking why Owlie is calling.
BACKEND_PROMPT defines behavior for the delegation model: which tool to reach for, and how long an answer to give.
OPENING is your assistant's first spoken line, verbatim.
Step 6: Define the tools your assistant can call
Now onto the tools! Paste this next, I'll explain in a moment:
{ type: 'web_search' } is OpenAI's hosted web search – you configure it, and OpenAI will run it when needed.
get_callback_reason is your business logic (though for now it’s a small array of silly notes). It currently takes no arguments and returns a random note.
The description is how the reasoning model decides whether to call the tool. When you build your version of the agent, write it the way you'd write documentation for a colleague who can't see your code.
Now the function itself, plus some notes for it to return:
Swap NOTES for a database query or your own business logic and you have a real callback agent. (And the two-second delay is just a simulation of a backend in motion – we're testing a likely scenario here, after all!)
Step 7: Place the call
Here's the part with no inbound equivalent: outbound. In short, you ask Twilio to start a call, then you hand it TwiML, or Twilio Markup Language, to instruct it how to behave.
And the block starting if (!OVERRIDE_NUMBERS is worth explaining. It's a quick test of numbers you should be able to call, which you would replace with your own logic when you build your agent. For my demo, I'm allowing outbound calls to Twilio numbers I own, caller IDs verified on my account, and numbers in the OVERRIDE_NUMBERS array.
calls.create does the rest: from is your Twilio number, to is the flag we discussed, and twiml is Media Streams (the same <Connect><Stream> you returned from a webhook in the inbound tutorial). The URL we pass there is also why HOST had to be a hostname we defined in advance.
Step 8: Open the GPT-Live-1 session
Now, the WebSocket route. Paste this next:
One of those headers is housekeeping. The other is a request from OpenAI: identify your app with a User-Agent so they can tell your traffic apart from everyone else's. Mine says "Twilio" because I built this as a Twilio demo – yours probably shouldn't.
Next, configure the session:
session.start is the handshake. It carries session.model and the session configuration. GPT-Live-1 replies with session.started when ready. That reply is where you have Owlie say your OPENING verbatim. (I'll show more in Step 9.)
startSession waits for two things: the OpenAI socket to be open, and Twilio's streamSid, that is: the ID that says which call your audio belongs to. sessionRequested makes sure the session gets configured exactly once.
delegation is the two-model setup. type: 'responses' routes turns that need reasoning or tools to the Responses API delegation backend. Everything under responses belongs to the reasoning model.
Step 9: Relay events between Twilio and OpenAI
Now the part that passes the baton back and forth. Add the OpenAI message handler:
The only branch that behaves differently on outbound is session.started, so we'll cover that one here and link out for the other four.
session.startedis GPT-Live-1 confirming it's ready. It carries the sessionid, which OpenAI wants if you ever ask them about helping debug a call. Next, we send two events:session.instructions.appendwith a "say this verbatim" directive, andsession.commentary.appendto trigger the model talking. GPT-Live-1 will open the call with your exactOPENINGso the AI assistant introduces itself instead of waiting for the caller.
For session.output_audio.delta, the function_call handling inside response.event, session.output_transcript.delta, and error, see Step 9 of the inbound tutorial – they're the same code, same explanation.
Now, the Twilio side:
Twilio's start event is the other half of the handshake from Step 8. Here, you grab the streamSid, then try to start the session again. media frames are the call recipient's voice, gated on sessionReady. stop means the call ended.
And finally, the cleanup handlers and the server start. Paste this:
That last block is the other big difference from the inbound tutorial. Inbound, your server started and then waited. Here, it starts and immediately places a call: makeCall runs inside a listen callback.
And that's the whole application! Let's make it run.
Step 10: Expose your server
Your server has to be reachable before it dials.
Start your tunnel. If you're using ngrok, it'll look like this:
Copy the forwarding hostname and put it in .env as DOMAIN:
Run and test your assistant
Now the fun part. Pick a number you're allowed to call – your own cell phone number is the obvious choice – and run:
Your terminal should show the call go out:
Then your phone rings! Answer it, let Owlie greet you, and try a few things:
Say hello back. (A good default!)
Ask what it's calling about. This is to try out the tool call. Watch the terminal:
Tool call: get_callback_reason {}
Consulting Owlie's notes...
Assistant: Good news about your flight – it's on time. I checked twice, and I'll check again.
Assistant: I'd say you're all set for takeoff.
The demo returns a random note each time, so what Owlie riffs on depends on the roll – your call may land on the table booking, the prescription, or one of the others.
Ask for a fact. "Who provided the portrait for the Transit Authority in Simcity 2000?" That routes to OpenAI's hosted web_search instead of your function.
Interrupt it. Ask it to count to twenty and cut it off halfway. Ask where it got to... it should be close, within a few numbers.
Isn't it a hoot?
Troubleshooting
+1... is not a Twilio number on this account or a verified caller ID . Verify the number in the Twilio Console under Phone Numbers → Manage → Verified Caller IDs, or instead dial a Twilio number you already own.
The call connects but I hear nothing. Your DOMAIN is wrong or your tunnel isn't public. Check the wss:// URL your app built by looking at the call in the Twilio Console – if the hostname isn't your tunnel, that's the bug.
Missing OPENAI_API_KEY, Twilio credentials, PHONE_NUMBER_FROM, or DOMAIN . One of the five is empty. Confirm .env sits in the same directory as outbound-demo.js, and remember you'll need to fill in DOMAIN from Step 10 before running.
Nothing happens after the call connects. If the terminal shows the stream started but you hear silence, check that your OpenAI key has access to both GPT-Live-1 and the delegation model, (gpt-5.6-terra unless you changed it).
Conclusion
Congratulations! You built an AI voice assistant that places a phone call, explains itself, searches the web, and refuses to dial a number you haven't cleared. And you didn't even write that many lines of code!
You've now seen this bridge run in both directions, and the interesting part is how little changed in the middle – the relay in Step 9 is the same code either way. What changed is everything around it: who starts the call, who speaks first, and who you're allowed to reach.
Swap in your business logic, rewrite the prompts, and replace that number check with something real. We can't wait to get a call from your agent!
Additional resources
- OpenAI's voice API – product page for Realtime and GPT-Live
- Introducing GPT-Live
- Companion inbound tutorial
- Twilio Programmable Voice documentation
- Twilio Media Streams documentation
- Making calls with the Twilio Voice API
Paul Kamp is the Technical Editor-in-Chief at Twilio. Owlie called him quite a bit during the making of this tutorial. He can be reached at pkamp [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.