There and back again - using Twilio Studio with AI assistants
Sorry Tolkien fans, this one is mainly for the Twilio audience.
If you’ve used Twilio AI assistants you would’ve seen the Assistant to Studio Guide. It allows us to transfer the conversation from the AI Assistant to Studio.
Which is great, but what if you want to FIRST start the conversation in Studio, THEN offer the option to chat with the bot, and potentially come back.
What if you still want your rule-based, structured (and complicated) flows and want to add an LLM component to it?
Before diving into how to do it, why would we want it in the first place?
- It allows us to add to existing Studio Flows.
- Easy to perform data lookups before offering options.
- It’s cheaper than AI assistants.
So what is the challenge?
Going from Assistant to Studio is clear - it’s documented in the Assistant to Studio Guide.
The Assistant uses a handover-to-studio tool to add a Studio webhook to the Conversation and the messages start flowing into Studio, while the Assistant is not triggered as long as the webhook stays there.
If we want to start the conversation in Studio and handover to the Assistant all we need to do is remove the Studio webhook. Simple, right?
I tested it by removing the Studio webhook using the Twilio CLI and it worked. Concept proven!
Removing it automatically proved a little bit more difficult.
If you use a Serverless function to remove it when requested, the webhook gets readded, because it happens before the execution ends. So it has to be removed AFTER the execution has ended.
Twilio Serverless Functions or any other HTTP function (GCP for example) guarantees execution only until you send the response. After the response is sent the execution is not guaranteed and can be terminated by the provider. Twilio GCP
Therefore, when using Studio with Functions, Studio always finishes last.
A possible solution
One way to do it is to separate the trigger from the worker. So we trigger an HTTP function from Studio, the function creates a task for a worker to pick up, without caring when it would be done, and respond back to Studio. Studio finishes, and our worker - still aware and ready for action, cleans up our our Studio webhook.
I used Cloud Run to implement it:
You can check out the full code on github but here are the functions in a nutshell:
// Function 1: Notifier Service (Twilio Webhook Receiver & Pub/Sub Publisher)
// Receives an HTTP POST request, publishes to Pub/Sub, and responds immediately 200 OK.
...
app.post('/', async (req, res) => {
const {
flowSid,
executionSid,
conversationSid,
} = req.body;
if (!flowSid || !executionSid || !conversationSid) {
console.error('Missing required fields.', req.body);
return res.status(400).send('Missing required fields.');
}
const payload = {
flowSid: flowSid,
executionSid: executionSid,
conversationSid: conversationSid
};
try {
const dataBuffer = Buffer.from(JSON.stringify(payload));
// Publish the message to the topic
const messageId = await pubSubClient
.topic(STUDIO_TOPIC_NAME)
.publishMessage({data: dataBuffer});
console.log(`Task published to Pub/Sub. Message ID: ${messageId}`);
// Respond immediately to Twilio
res.status(200).send('Task accepted and delegated to worker via Pub/Sub.');
} catch (error) {
console.error('Error publishing to Pub/Sub:', error);
res.status(500).send('Internal error while publishing task.');
}
});
And the Worker service:
// Function 2: Worker Service (Pub/Sub Subscriber & Twilio Cleanup)
// Is triggered by Pub/Sub, polls Twilio Studio status, and deletes webhooks.
...
// Worker service endpoint (listens for Pub/Sub push messages)
app.post('/', async (req, res) => {
// Return 200/204 only on success to signal Pub/Sub to stop retrying.
// Return anything else (e.g., 500) to tell Pub/Sub to retry the message.
if (!req.body || !req.body.message || !req.body.message.data) {
console.error('Invalid Pub/Sub message format.');
return res.status(400).send('Invalid Pub/Sub message.');
}
try {
// Pub/Sub push messages are Base64 encoded
const pubSubMessage = JSON.parse(
Buffer.from(req.body.message.data, 'base64').toString()
);
const { flowSid, executionSid, conversationSid } = pubSubMessage;
console.log(`\n--- Worker received task for Conversation: ${conversationSid}, Execution: ${executionSid} ---`);
if (!twilioClient) {
console.error('CRITICAL: Twilio client not initialized. Cannot proceed.');
return res.status(500).send('Server misconfiguration: Missing Twilio credentials.');
}
// 1. Poll the Studio execution status
const executionCompleted = await pollStudioExecution(flowSid, executionSid);
if (executionCompleted) {
console.log('Execution completed or failed. Proceeding to delete webhooks.');
} else {
// Execution timed out after 15s
console.warn('Execution polling timed out (15s).');
}
// 2. Delete the webhooks
await deleteStudioWebhooks(conversationSid);
console.log('Task fully completed. Acknowledging message.');
// Success: Respond 204 or 200 to acknowledge the message and stop retries
return res.status(204).send();
} catch (error) {
console.error('FATAL ERROR during task processing. Pub/Sub will retry:', error);
return res.status(500).send('Task failed. Pub/Sub will retry.');
}
});
...
And here are the supporting functions for polling Studio status and removing the webhook:
/**
* Polling function to check Twilio Studio Execution Status
* @param {string} flowSid - The SID of the Studio Flow
* @param {string} executionSid - The SID of the Studio Execution.
* @returns {Promise<boolean>} True if the execution completed, false otherwise (e.g., timeout).
*/
async function pollStudioExecution(flowSid, executionSid) {
const startTime = Date.now();
const endTime = startTime + TWILIO_POLL_TIMEOUT_MS;
console.log(`Starting poll for execution ${executionSid}.`);
while (Date.now() < endTime) {
try {
const execution = await twilioClient.studio.v2
.flows(flowSid).executions(executionSid).fetch();
const status = execution.status.toLowerCase();
console.log(`[${(Date.now() - startTime) / 1000}s] Status: ${status}`);
if (status == 'ended') {
console.log(`Execution ${executionSid} completed.`);
return true;
}
} catch (error) {
console.error(`Error fetching execution ${executionSid}:`, error.message);
}
// Wait 1 second before polling again
await new Promise(resolve => setTimeout(resolve, 1000));
}
console.warn(`Polling ${executionSid} timed out after ${TWILIO_POLL_TIMEOUT_MS}ms.`);
return false;
}
/**
* Deletes the Studio-related webhooks from the conversation.
* @param {string} conversationSid - The SID of the conversation.
*/
async function deleteStudioWebhooks(conversationSid) {
if (!twilioClient) {
throw new Error('Twilio client is not initialized due to missing credentials.');
}
try {
console.log(`Fetching webhooks for conversation ${conversationSid}`);
const webhooks = await twilioClient.conversations.v1
.conversations(conversationSid)
.webhooks.list();
// Filter Studio webhooks
const studioWebhooks = webhooks.filter(
(entry) => entry.target === 'studio'
);
if (studioWebhooks.length === 0) {
console.log('No Studio webhooks found. Deletion complete.');
return;
}
console.log(`Found ${studioWebhooks.length} Studio webhooks. Removing...`);
// Remove all studio webhooks
for (const webhook of studioWebhooks) {
console.log(`Removing webhook SID: ${webhook.sid}`);
await twilioClient.conversations.v1
.conversations(conversationSid)
.webhooks(webhook.sid)
.remove();
console.log(`Removed webhook SID: ${webhook.sid}`);
}
console.log('All Studio webhooks removed successfully.');
} catch (error) {
console.error('Error while deleting Studio webhooks:', error);
throw new Error('Webhook deletion failed. This will trigger a Pub/Sub retry.');
}
}
Other possible solutions
- Set an attribute on the conversation during the Studio execution, and then use Conversation’s “Pre-Webhooks” on every new message whether the attribute is set to decide which webhook should be added or removed. *I didn’t want to do this, because I didn’t want to mess up and lose messages accidentally.
- Use a combination of setting an attribute somewhere and listening to Event Streams from Twilio when an execution ends, to remove the webhook.
Conclusion
Probably over-engineered, but it’s been working fine. Thank you for reading, hope this post helps somebody!