Make Outbound Calls with Twilio Voice and Media Streams, GPT-Live-1 in the OpenAI API, and Node.js

September 10, 2026
Written by
Paul Kamp
Twilion

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-1 and gpt-5.6-terra models.
  • 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.
Outbound calling comes with rules. Placing calls means complying with the regulations in your jurisdiction. In the United States, that includes the Telephone Consumer Protection Act (TCPA). Read Twilio's Terms of Service and Voice Services Policies before you make outbound calls, and check with your counsel for compliance and legal advice.

Let's do this.

In a hurry? The finished application is on GitHub if you'd rather skip ahead.

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):

mkdir outbound-demo
cd outbound-demo
npm init -y

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.

npm install fastify @fastify/formbody @fastify/websocket ws dotenv twilio

For reference, as we went to press, here's what I had installed:

+-- @fastify/formbody@8.0.1
+-- @fastify/websocket@11.0.1
+-- dotenv@16.4.5
+-- fastify@5.0.0
+-- twilio@5.3.0
`-- ws@8.18.0

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:

touch .env outbound-demo.js

An outbound call needs more from .env than an inbound agent would. You're going to need to set the following environment variables:

OPENAI_API_KEY=
TWILIO_ACCOUNT_SID=
TWILIO_AUTH_TOKEN=
PHONE_NUMBER_FROM=
DOMAIN=
PORT=5050

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:

{
  "type": "module"
}

Step 4: Set up your imports and configuration

Open outbound-demo.js and paste this:

import Fastify from 'fastify';
import WebSocket from 'ws';
import dotenv from 'dotenv';
import fastifyFormBody from '@fastify/formbody';
import fastifyWs from '@fastify/websocket';
import twilio from 'twilio';
dotenv.config();
const {
    OPENAI_API_KEY, TWILIO_ACCOUNT_SID, TWILIO_AUTH_TOKEN, PHONE_NUMBER_FROM, DOMAIN
} = process.env;
if (!OPENAI_API_KEY || !TWILIO_ACCOUNT_SID || !TWILIO_AUTH_TOKEN || !PHONE_NUMBER_FROM || !DOMAIN) {
    console.error('Missing OPENAI_API_KEY, Twilio credentials, PHONE_NUMBER_FROM, or DOMAIN in the .env file.');
    process.exit(1);
}
const TO = process.argv.find((arg) => arg.startsWith('--call='))?.split('=')[1];
if (!TO) {
    console.error('Usage: node outbound-demo.js --call=+18885551212');
    process.exit(1);
}
// Defensive: fail on definitely malformed non-E.164 input before Twilio sees it.
if (!/^\+[1-9]\d{6,14}$/.test(TO)) {
    console.error(`--call=${TO} is not an E.164 phone number.`);
    process.exit(1);
}
const MODEL = 'gpt-live-1';
const DELEGATED_MODEL = 'gpt-5.6-terra';
const VOICE = 'marin';
const USER_AGENT = 'twilio-demos/Node 1.0.0';
const PORT = process.env.PORT || 5050;
const HOST = DOMAIN.replace(/^https?:\/\//, '').replace(/\/+$/, '');
// Replace the array with business logic to look up if you can call based on jurisdiction.
const OVERRIDE_NUMBERS = [];

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:

  • TO is who you're calling, read from the --call= flag.
  • HOST is your public hostname, scrubbed of its scheme (https://) and any trailing slash.
  • OVERRIDE_NUMBERS is where you would substitute business logic for determining who you can call, or add your cell phone number for the demo.
  • USER_AGENT is 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:

const OPENING = "Hi, this is Owlie calling back – I'm an AI voice assistant powered by Twilio and OpenAI's GPT-Live. Ready when you are.";
const VOICE_PROMPT = "You are Owlie, an AI voice assistant powered by Twilio and OpenAI's GPT-Live, returning a call. "
    + 'Never call yourself ChatGPT. '
    + 'You are cheerful, with a penchant for dad jokes, owl jokes, and subtle rickrolling. '
    + 'Do not claim to know the callback note until get_callback_reason has returned a result.';
const BACKEND_PROMPT = 'Use get_callback_reason for the callback note and web_search for facts. Answer in one or two sentences.';

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:

const TOOLS = [
    { type: 'web_search' },
    {
        type: 'function',
        name: 'get_callback_reason',
        description: 'Look up the note attached to this callback.',
        parameters: { type: 'object', properties: {}, additionalProperties: false }
    }
];

{ 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:

const NOTES = [
    'Your flight is on time. I checked twice. I will check again.',
    'Your table is ready, and I told them you prefer the booth by the window.',
    'Your package arrives Thursday. The driver has been briefed about the driveway.',
    'Your prescription is ready, along with a 17-inch-long receipt.',
    'Your appointment moved up an hour, which I spotted an hour before they called you. '
        + 'I asked them to send you a reminder and a reminder about the reminder, to be safe.',
    'Your order shipped, and I have been refreshing the tracking page on your behalf.',
];
// Mock tool call, slow on purpose.
const getCallbackReason = async () => {
    console.log("Consulting Owlie's notes...");
    await new Promise((resolve) => setTimeout(resolve, 2000));
    return { note: NOTES[Math.floor(Math.random() * NOTES.length)] };
};

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.

const client = twilio(TWILIO_ACCOUNT_SID, TWILIO_AUTH_TOKEN);
const makeCall = async (to) => {
    if (!OVERRIDE_NUMBERS.includes(to)) {
        const [owned, verified] = await Promise.all([
            client.incomingPhoneNumbers.list({ phoneNumber: to }),
            client.outgoingCallerIds.list({ phoneNumber: to })
        ]);
        if (!owned.length && !verified.length) {
            console.error(`${to} is not a Twilio number on this account or a verified caller ID.`);
            process.exit(1);
        }
    }
    const call = await client.calls.create({
        from: PHONE_NUMBER_FROM,
        to,
        twiml: `<Response><Connect><Stream url="wss://${HOST}/media-stream" /></Connect></Response>`
    });
    console.log(`Returning ${to}'s call — ${call.sid}`);
};

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:

const fastify = Fastify();
fastify.register(fastifyFormBody);
fastify.register(fastifyWs);
fastify.register(async (fastify) => {
    fastify.get('/media-stream', { websocket: true }, (connection) => {
        console.log('Call answered, media stream connected');
        let streamSid = null;
        let sessionRequested = false;
        let sessionReady = false;
        const openAiWs = new WebSocket('wss://api.openai.com/v1/live/sessions', {
            headers: {
                Authorization: `Bearer ${OPENAI_API_KEY}`,
                'User-Agent': USER_AGENT
            }
        });
        const send = (event) => {
            if (openAiWs.readyState === WebSocket.OPEN) openAiWs.send(JSON.stringify(event));
        };
        const close = () => {
            sessionReady = false;
            connection.close();
            openAiWs.close();
        };

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:

// Wait for Twilio's stream ID before starting the session.
        const startSession = () => {
            if (sessionRequested || !streamSid || openAiWs.readyState !== WebSocket.OPEN) return;
            sessionRequested = true;
            send({ type: 'session.start', session: {
                model: MODEL,
                instructions: VOICE_PROMPT,
                audio: { format: { type: 'audio/pcmu', rate: 8000 }, output: { voice: VOICE } },
                delegation: {
                    type: 'responses',
                    responses: { model: DELEGATED_MODEL, instructions: BACKEND_PROMPT, tools: TOOLS }
                }
            } });
        };
        openAiWs.on('open', () => {
            console.log('Connected to GPT-Live-1');
            startSession();
        });

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:

openAiWs.on('message', async (data) => {
            try {
                const event = JSON.parse(data);
                if (event.type === 'session.started') {
                    sessionReady = true;
                    // Quote this ID if you ever need OpenAI's help with a call.
                    console.log('GPT-Live-1 session', event.session?.id);
                    send({ type: 'session.instructions.append', delegation_id: null,
                        content: `Your first spoken line on this call is, verbatim: "${OPENING}"` });
                    send({ type: 'session.commentary.append', delegation_id: null, content: OPENING });
                } else if (event.type === 'session.output_audio.delta' && streamSid && connection.readyState === WebSocket.OPEN) {
                    connection.send(JSON.stringify({ event: 'media', streamSid, media: { payload: event.delta } }));
                } else if (event.type === 'response.event'
                    && event.event?.type === 'response.output_item.done'
                    && event.event.item?.type === 'function_call' && event.event.item.status === 'completed') {
                    const { call_id, name, arguments: args } = event.event.item;
                    console.log('Tool call:', name, args);
                    const output = name === 'get_callback_reason'
                        ? await getCallbackReason()
                        : { error: 'unknown tool' };
                    send({ type: 'response.item.create',
                        item: { type: 'function_call_output', call_id, output: JSON.stringify(output) } });
                    send({ type: 'response.create' });
                } else if (event.type === 'session.output_transcript.delta') {
                    console.log('Assistant:', event.delta);
                } else if (event.type === 'error') {
                    console.error('GPT-Live-1 error:', event.error);
                }
            } catch (error) { console.error('Error processing the GPT-Live-1 message:', error); }
        });

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.started is GPT-Live-1 confirming it's ready. It carries the session id, which OpenAI wants if you ever ask them about helping debug a call. Next, we send two events: session.instructions.append with a "say this verbatim" directive, and session.commentary.append to trigger the model talking. GPT-Live-1 will open the call with your exact OPENING so 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:

connection.on('message', (message) => {
            try {
                const data = JSON.parse(message);
                if (data.event === 'media' && sessionReady && openAiWs.readyState === WebSocket.OPEN) {
                    send({ type: 'session.input_audio.append', audio: data.media.payload });
                } else if (data.event === 'start') {
                    streamSid = data.start.streamSid;
                    console.log('Outgoing stream has started', streamSid);
                    startSession();
                } else if (data.event === 'stop') {
                    close();
                }
            } catch (error) { console.error('Error parsing Twilio message:', error); }
        });

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:

connection.on('close', () => { close(); console.log('Call ended.'); });
        connection.on('error', close);
        openAiWs.on('close', (code, reason) => {
            close();
            console.log('Disconnected from GPT-Live-1', code, reason.toString());
        });
        openAiWs.on('error', (error) => { console.error('Error in the OpenAI WebSocket:', error); close(); });
    });
});
fastify.listen({ port: PORT }, async (err) => {
    if (err) { console.error(err); process.exit(1); }
    console.log(`Server is listening on port ${PORT}`);
    await makeCall(TO);
});

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:

ngrok http 5050

Copy the forwarding hostname and put it in .env as DOMAIN:

DOMAIN=abc123.ngrok.app

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:

node outbound-demo.js --call=+18005551212

Your terminal should show the call go out:

Server is listening on port 5050
Returning +18005551212's call — CAxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
Call answered, media stream connected
Connected to GPT-Live-1
GPT-Live-1 session live_xxxxxxxx
Outgoing stream has started MZxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx

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

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.