How to Connect Your Twilio Agent to External APIs with PHP

September 11, 2026
Written by

How to Connect Your Twilio Agent to External APIs with PHP

The world is starting to increasingly rely on voice-enabled AI agents to get work done. But an agent can only do so much. Voice AI agents by themselves are able to hold conversations, but what happens if the agent needs to do something like retrieve customer data, look at an inventory, or book an appointment for a user?

In order to make your AI agent truly helpful, you need for that AI agent to have access to real time information. External APIs can provide that information. When your agent works together with an API, your agent is empowered to get information your users really need, and take actions on the user's behalf like viewing inventory, calendars, menus, and more.

In this tutorial, you will use PHP and OpenSwoole to build a voice agent using Twilio Conversation Relay. Your agent will use tool calling from an LLM-driven conversation to dynamically fetch live data from an external REST API. This tutorial uses a simple API with no additional authentication requirements to showcase the potential of the AI tool. When you have completed the tutorial, you should understand the pipeline to interact with an external API, and how you could employ this functionality in your own builds.

Prerequisites

To complete this tutorial you will need the following:

Building the application

Step 1 - Set up the PHP project

Your first step is creating a new folder and a PHP project. Go into your terminal and type the following:

mkdir TwilioAgentApi
cd TwilioAgentApi

Step 2 - Install dependencies

Install the packages you will need for your project by typing the following into your terminal:

composer require openai-php/client, guzzlehttp/guzzle vlucas/phpdotenv twilio/sdk

These packages are necessary for your project setup: The PHP Dotenv package allows you to import your environment variables into your solution using a .env file. The OpenAI package will be used to connect your solution to OpenAI. The Twilio PHP Helper Library will be used to simplify generating TwiML.

Step 3 - Configure environment variables

This tutorial is simple enough not to require much information from your Twilio account. But you will need somewhere to safely store your OpenAI API key. Create a file called .env in your project folder. Add to that file the following text:

OPENAI_API_KEY=sk-...
HOST=0.0.0.0
PORT=8080

Your OpenAI API Key is generated from OpenAI's dashboard. HOST and PORT set the hostname/IP address and port for PHP to bind to. You shouldn't need any other keys in this file. However, if you decide later to call an API that has additional authentication, that key can be stored here as well.

Step 4 - Build the base application

You will use the very simple API, Cat Facts, in this demo. Our application is going to make a simple API call to request a "Cat Fact" from our agent. This API requires no additional authentication and has simple output, which makes it very useful for a demonstration.

Create a new file named server.php, thenupdated it with the following code:

<?php

declare(strict_types=1);

use Dotenv\Dotenv;
use Swoole\Http\Request;
use Swoole\Http\Response;
use Swoole\Runtime;
use Swoole\WebSocket\Frame;
use Swoole\WebSocket\Server;
use TwilioAgentApi\ConversationRelayHandler;
use TwilioAgentApi\OpenAiService;
use Twilio\TwiML\VoiceResponse;

require __DIR__ . '/vendor/autoload.php';

Runtime::enableCoroutine(SWOOLE_HOOK_ALL);

if (is_file(__DIR__ . '/.env')) {
    $dotenv = Dotenv::createImmutable(__DIR__);
    $dotenv->load();
}

$openAi = new OpenAiService();

$host = $_ENV['HOST'] ?: '0.0.0.0';
$port = (int) ($_ENV['PORT'] ?: 8080);

$server = new Server($host, $port);
$server->set([
    'worker_num' => 1,
    'enable_coroutine' => true,
]);

$server->on('request', static function (Request $req, Response $res): void {
    $uri = $req->server['request_uri'] ?? '';
    $method = strtoupper((string) ($req->server['request_method'] ?? 'GET'));

    if ($method === 'POST' && $uri === '/voice') {
        $host = $req->header['host'] ?? '';

        $response = new VoiceResponse();
        $connect = $response->connect();
        $conversationrelay = $connect->conversationRelay([
            'url' => "wss://{$host}/ws",
            'welcomeGreeting' => "Hello! Ask me for a cat fact.",
        ]);
        $res->header('Content-Type', 'application/xml');
        $res->end($response->asXML());
        return;
    }

    $res->status(404);
    $res->end();
});

$server->on('open', static function (Server $server, Request $req): void {
    if (($req->server['request_uri'] ?? '') !== '/ws') {
        $server->disconnect($req->fd, 1002, 'unexpected path');
        return;
    }
    ConversationRelayHandler::onOpen($req->fd);
});

$server->on('message', static function (Server $server, Frame $frame) use ($openAi): void {
    ConversationRelayHandler::onMessage($server, $frame, $openAi);
});

$server->on('close', static fn (Server $_srv, int $fd) => ConversationRelayHandler::onClose($fd));

echo sprintf("Listening on %s:%d\n", $host, $port);
$server->start();

This code is making a connection to a websocket to enable your agent. You are using Conversation Relay to build the connection between OpenAI and your voice-capable Twilio number, creating a voice agent that can hold a natural sounding conversation. Notice that you have added a simple greeting for your agent using TwiML. This greeting line can be adjusted as needed to give the user an initial prompt for interaction.

Step 5 - Handle Conversation Relay

You will need some additional code to connect your websocket to Conversation Relay. Do this by creating a new PHP file, called ConversationRelayHandler.php, in your project folder. Then, paste the code below into the file.

<?php

declare(strict_types=1);

namespace TwilioAgentApi;

use JsonException;
use Swoole\WebSocket\Frame;
use Swoole\WebSocket\Server;

final class ConversationRelayHandler
{
    private static array $sessions = [];

    public static function onOpen(int $fd): void
    {
        self::$sessions[$fd] = ['callSid' => null, 'messages' => []];
    }

    public static function onClose(int $fd): void
    {
        $session = self::$sessions[$fd] ?? null;
        if ($session !== null) {
            echo sprintf("[%s] Call ended\n", $session['callSid'] ?? '');
            unset(self::$sessions[$fd]);
        }
    }

    public static function onMessage(Server $server, Frame $frame, OpenAiService $openAi): void
    {
        $fd = $frame->fd;
        if (!isset(self::$sessions[$fd])) {
            return;
        }

        try {
            $root = json_decode($frame->data, true, flags: JSON_THROW_ON_ERROR);
        } catch (JsonException) {
            return;
        }

        if (!is_array($root)) {
            return;
        }

        $session = &self::$sessions[$fd];
        $callSid = $session['callSid'];
        $msgType = $root['type'] ?? null;

        switch ($msgType) {
            case 'setup':
                $session['callSid'] = isset($root['callSid']) && is_string($root['callSid'])
                    ? $root['callSid']
                    : null;
                echo sprintf("[%s] Call connected\n", $session['callSid'] ?? '');
                break;

            case 'prompt':
                if (empty($root['last'])) {
                    break;
                }
                $userText = isset($root['voicePrompt']) && is_string($root['voicePrompt'])
                    ? $root['voicePrompt']
                    : '';
                echo sprintf("[%s] Caller: %s\n", $callSid ?? '', $userText);
                $session['messages'][] = new ConversationMessage('user', $userText);
                $responseText = $openAi->streamResponse($server, $fd, $callSid, $session['messages']);
                $session['messages'][] = new ConversationMessage('assistant', $responseText);
                break;

            case 'interrupt':
                $spoken = isset($root['utteranceUntilInterrupt']) && is_string($root['utteranceUntilInterrupt'])
                    ? $root['utteranceUntilInterrupt']
                    : '';
                echo sprintf("[%s] Interrupted after: '%s'\n", $callSid ?? '', $spoken);
                $lastIndex = array_key_last($session['messages']);
                if ($lastIndex !== null && $session['messages'][$lastIndex]->role === 'assistant') {
                    array_pop($session['messages']);
                }
                break;

            case 'error':
                $desc = isset($root['description']) && is_string($root['description'])
                    ? $root['description']
                    : '';
                echo sprintf("[%s] Conversation Relay error: %s\n", $callSid ?? '', $desc);
                break;
        }
    }

    public static function sendJson(Server $server, int $fd, array $payload): void
    {
        $json = json_encode($payload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
        if ($json === false) {
            return;
        }
        $server->push($fd, $json);
    }
}

This code is communicating with your websocket, breaking your voice inquiries down into conversation messages to be processed by the AI. Changing your voice responses to text, it then streams that text in real time to the AI in order to get fast responses.

This is one important component, but you still need to make the connection to OpenAI. You will do that in the next step.

Step 6 - Connect to OpenAI

In this step, you will configure tool function schemas using the OpenAI PHP SDK. For this, create a file called OpenAiService.php.

Write the system prompt instructing the agent when to execute external API calls based on user voice prompts. You'll see the prompt inside the SystemPrompt constant in the code below. You can adjust this to your needs. In this prompt, you make sure that the AI realizes it's being used for voice interaction, by reminding it not to use any bullet points or emojis when it communicates.

<?php

declare(strict_types=1);

namespace TwilioAgentApi;

use OpenAI;
use OpenAI\Contracts\ClientContract;
use RuntimeException;
use Swoole\Coroutine\Http\Client as HttpClient;
use Swoole\WebSocket\Server;
use Throwable;

final class OpenAiService
{
    private const string MODEL   = 'gpt-4o-mini';
    private const int MAX_TOKENS = 300;

    private const SYSTEM_PROMPT = <<<'PROMPT'
        You are a cat fact generator. If your user asks you for a cat fact you will respond with a cat fact.
        Speak naturally as if talking on the phone. Use plain sentences only. Do not use lists or bullet points. Do not use any emojis.
        When you are asked for a cat fact you must call the get_cat_fact tool to retrieve one from the API.
        Do not just make up facts. If you do not know the answer, respond with "I don't know."
        PROMPT;

    /**
     * @return array<string, mixed>
     */
    private static function catFactTool(): array
    {
        return [
            'type' => 'function',
            'function' => [
                'name' => 'get_cat_fact',
                'description' => 'Retrieves a random cat fact from the catfact.ninja API.',
                'parameters' => [
                    'type' => 'object',
                    'properties' => new \stdClass(),
                    'required' => [],
                ],
            ],
        ];
    }

    private ClientContract $client;

    public function __construct()
    {
        $apiKey = $_ENV['OPENAI_API_KEY'];
        if (!is_string($apiKey) || $apiKey === '') {
            throw new RuntimeException('OPENAI_API_KEY is not set.');
        }
        $this->client = OpenAI::client($apiKey);
    }

    /**
     * @param list<ConversationMessage> $messages
     */
    public function streamResponse(Server $server, int $fd, ?string $callSid, array $messages): string
    {
        $openAiMessages = [
            ['role' => 'system', 'content' => self::SYSTEM_PROMPT],
        ];
        foreach ($messages as $m) {
            $openAiMessages[] = ['role' => $m->role, 'content' => $m->content];
        }

        $fullResponse = '';
        try {
            $stream = $this->client->chat()->createStreamed([
                'model' => self::MODEL,
                'messages' => $openAiMessages,
                'max_tokens' => self::MAX_TOKENS,
                'tools' => [self::catFactTool()],
            ]);

            $finishReason = null;

            /** @var array<int, array{id: ?string, name: ?string, arguments: string}> $toolCallsAcc */
            $toolCallsAcc = [];

            foreach ($stream as $update) {
                $data = $update->toArray();
                $choice = $data['choices'][0] ?? null;
                if ($choice === null) {
                    continue;
                }

                if (!empty($choice['finish_reason'])) {
                    $finishReason = $choice['finish_reason'];
                }

                $delta = $choice['delta'] ?? [];

                $content = $delta['content'] ?? '';
                if ($delta['content'] !== '') {
                    $fullResponse .= $delta['content'];
                    ConversationRelayHandler::sendJson($server, $fd, [
                        'type' => 'text',
                        'token' => $delta['content'],
                        'last' => false,
                    ]);
                }

                foreach ($delta['tool_calls'] ?? [] as $tc) {
                    $idx = $tc['index'] ?? 0;
                    if (!isset($toolCallsAcc[$idx])) {
                        $toolCallsAcc[$idx] = ['id' => null, 'name' => null, 'arguments' => ''];
                    }
                    if (!empty($tc['id'])) {
                        $toolCallsAcc[$idx]['id'] = $tc['id'];
                    }
                    if (!empty($tc['function']['name'])) {
                        $toolCallsAcc[$idx]['name'] = $tc['function']['name'];
                    }
                    if (isset($tc['function']['arguments']) && $tc['function']['arguments'] !== '') {
                        $toolCallsAcc[$idx]['arguments'] .= $tc['function']['arguments'];
                    }
                }
            }

            if ($finishReason === 'tool_calls' && count($toolCallsAcc) > 0) {
                $toolCalls = array_values($toolCallsAcc);

                $openAiMessages[] = [
                    'role' => 'assistant',
                    'content' => null,
                    'tool_calls' => array_map(
                        static fn (array $tc): array => [
                            'id' => $tc['id'] ?? '',
                            'type' => 'function',
                            'function' => [
                                'name' => $tc['name'] ?? '',
                                'arguments' => $tc['arguments'] !== '' ? $tc['arguments'] : '{}',
                            ],
                        ],
                        $toolCalls
                    ),
                ];

                foreach ($toolCalls as $tc) {
                    $result = self::executeTool($tc['name'] ?? '');
                    echo sprintf(
                        "[%s] Tool %s(%s) -> %s\n",
                        $callSid ?? '',
                        $tc['name'] ?? '',
                        $tc['arguments'],
                        $result
                    );
                    $openAiMessages[] = [
                        'role' => 'tool',
                        'tool_call_id' => $tc['id'] ?? '',
                        'content' => $result,
                    ];
                }

                $fullResponse = '';
                $stream2 = $this->client->chat()->createStreamed([
                    'model' => self::MODEL,
                    'messages' => $openAiMessages,
                    'max_tokens' => 400,
                ]);
                foreach ($stream2 as $update) {
                    $data = $update->toArray();
                    $delta = $data['choices'][0]['delta'] ?? null;
                    if (!is_array($delta)) {
                        continue;
                    }
                    if (isset($delta['content']) && is_string($delta['content']) && $delta['content'] !== '') {
                        $fullResponse .= $delta['content'];
                        ConversationRelayHandler::sendJson($server, $fd, [
                            'type' => 'text',
                            'token' => $delta['content'],
                            'last' => false,
                        ]);
                    }
                }
            }
        } finally {
            ConversationRelayHandler::sendJson($server, $fd, [
                'type' => 'text',
                'token' => '',
                'last' => true,
            ]);
        }

        echo sprintf("[%s] Assistant: %s\n", $callSid ?? '', $fullResponse);
        return $fullResponse;
    }

    private static function executeTool(string $name): string
    {
        if ($name !== 'get_cat_fact') {
            return 'Unknown tool.';
        }

        try {
            $client = new HttpClient('catfact.ninja', 443, true);
            $client->set(['timeout' => 10]);
            $client->setHeaders([
                'User-Agent' => 'TwilioAgentApi/1.0',
                'Accept' => 'application/json',
            ]);
            $client->get('/fact');

            $status = $client->statusCode;
            $body = (string) $client->body;
            $client->close();

            if ($status !== 200) {
                return sprintf('Error retrieving cat fact: HTTP %d', $status);
            }

            $decoded = json_decode($body, true);
            if (is_array($decoded) && isset($decoded['fact']) && is_string($decoded['fact'])) {
                return $decoded['fact'];
            }
            return 'No fact returned.';
        } catch (Throwable $ex) {
            return sprintf('Error retrieving cat fact: %s', $ex->getMessage());
        }
    }
}

The onMessage() function is what actually calls our external API, indirectly by calling the OpenAiService class' streamResponse() function. It reaches out to the API located at https://catfact.ninja and parses the json response from the API. If it can't find a fact, say, if the connection to the API is interrupted, it returns an error.

Now, create one final file named ConversationMessage.php and paste the code below into the file.

<?php

declare(strict_types=1);

namespace TwilioAgentApi;

final readonly class ConversationMessage
{
    public function __construct(
        public string $role,
        public string $content,
    ) {
    }
}

Testing your application

Now it is time to test your application and chat with your AI.

First, run your application using this command in the terminal:

php server.php

Once your webhook is running, you will need to expose it to the internet by using ngrok or another tunneling service.

ngrok http localhost:8080

Replace "8080" with whatever port your application is running on if you have a different port.

Now ngrok will provide you with a url for utilizing in your Twilio Console. Go into your Twilio Console and find the Twilio phone number that you prepared. Under the option A Call Comes In, choose Webhook, and fill in your ngrok URL followed by "/voice", as shown in the graphic below:

configuration for webhooks in the Twilio console
configuration for webhooks in the Twilio console

Be sure also that your HTTP block is set to POST.

Now save this configuration, and call your Twilio Phone Number.

You should hear a message with the AI greeting that you provided in Program.cs.

Try asking your AI about a cat fact and you will get a cat fact from the cat fact API!

Troubleshooting

If you are having some difficulty with your call, there are some common problems you might want to check. First of all, make sure your ngrok URL is correct in the console and matches the one that's in your terminal, with /voice appended to the end.

If you are still having issues, check your environment variables. You will need to make sure your API keys are correct for any key that you happen to be using, including your key for OpenAI. The sample API requires no additional keys, but if you decide to expand the application, you will also need to authenticate any external APIs that you call. Check the rules for your individual APIs.

Conclusion

Connecting LLM function tools to external HTTP endpoints empowers Twilio voice agents with real-time data. With the use of external APIs, you can create an agent that doesn't just respond to questions, but truly does the work your customers need.

Are you looking for some further project ideas or further reading? We also have a series on getting started creating your AI Phone Agent with Conversation Relay. Or check out how to do function and tool calling in node.JS or your language of choice!

If you got stuck at any point during this tutorial, the full solution is available for reference on Github.

We can't wait to see what we can help you build!

Matthew Setter is a PHP, Go, and Rust Editor in the Twilio Voices team. He’s also the author of Mezzio Essentials and Deploy with Docker Compose. You can find him at msetter@twilio.com. He's also on LinkedIn and GitHub.