Call Routing in Cloud Call Centres: Skills, Priority & Business Rules with Vonage
π― What You'll Build Today
- β A skills-based routing engine that matches callers to the right agent
- β Business hours checking with timezone support
- β Priority queuing (VIP customers jump the queue)
- β Overflow handling β voicemail when no agents are available
- β A multi-agent pool with status management
π§ Routing Architecture
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β ROUTING ENGINE ARCHITECTURE β
β β
β INBOUND CALL β
β β β
β βΌ β
β βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ β
β β STEP 1: BUSINESS HOURS CHECK β β
β β β β
β β Is it MonβFri 09:00β18:00 GMT? β β
β β β β
β β YES βββββββββββββββββββββββββββββββββββββββββββββββΊ β β
β β NO β Play "we are closed" β Offer voicemail β β
β βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ β
β β β
β βΌ β
β βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ β
β β STEP 2: CUSTOMER IDENTIFICATION β β
β β β β
β β Look up caller's number in database β β
β β Determine: tier (standard / VIP), language, history β β
β βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ β
β β β
β βΌ β
β βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ β
β β STEP 3: IVR β INTENT DETECTION β β
β β β β
β β "Press 1 for Billing, 2 for Technical, 3 for Sales" β β
β β β Maps selection to required SKILL β β
β βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ β
β β β
β βΌ β
β βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ β
β β STEP 4: AGENT MATCHING β β
β β β β
β β Find available agents with required skill β β
β β Apply priority rules (VIP β senior agents first) β β
β β Select best match β β
β βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ β
β β β
β βΌ β
β βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ β
β β STEP 5: CONNECT OR QUEUE β β
β β β β
β β Agent available? β β
β β YES β Connect immediately β β
β β NO β Queue with hold music β try again every 30s β β
β β After 3 min β offer callback / voicemail β β
β βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
STEP 1 β Define Your Agent Pool
Create agents/pool.js to manage agent state:
// agents/pool.js
// In-memory agent pool β replace with Redis in production (see Day 29)
const agentPool = new Map([
['alice', {
name: 'Alice Chen',
status: 'available', // available | busy | wrap-up | offline
skills: ['billing', 'general', 'english'],
tier: 'senior', // senior | standard
currentCallId: null,
callsHandled: 0,
lastCallEnd: null
}],
['bob', {
name: 'Bob Patel',
status: 'available',
skills: ['technical', 'general', 'english'],
tier: 'standard',
currentCallId: null,
callsHandled: 0,
lastCallEnd: null
}],
['carol', {
name: 'Carol Santos',
status: 'available',
skills: ['billing', 'technical', 'spanish', 'english'],
tier: 'senior',
currentCallId: null,
callsHandled: 0,
lastCallEnd: null
}],
['dave', {
name: 'Dave Kim',
status: 'offline',
skills: ['sales', 'general', 'english'],
tier: 'standard',
currentCallId: null,
callsHandled: 0,
lastCallEnd: null
}]
]);
// ββ GET AVAILABLE AGENTS WITH SKILL ββββββββββββββββββββββββββββ
export function findAvailableAgents(skill, preferSenior = false) {
const available = [];
for (const [username, agent] of agentPool) {
if (
agent.status === 'available' &&
agent.skills.includes(skill)
) {
available.push({ username, ...agent });
}
}
if (available.length === 0) return [];
// Sort: senior agents first if VIP caller, then by fewest calls handled
available.sort((a, b) => {
if (preferSenior) {
if (a.tier === 'senior' && b.tier !== 'senior') return -1;
if (b.tier === 'senior' && a.tier !== 'senior') return 1;
}
return a.callsHandled - b.callsHandled; // least busy first
});
return available;
}
// ββ UPDATE AGENT STATUS ββββββββββββββββββββββββββββββββββββββββ
export function setAgentStatus(username, status, callId = null) {
const agent = agentPool.get(username);
if (!agent) return false;
agent.status = status;
agent.currentCallId = callId;
if (status === 'available') {
agent.lastCallEnd = new Date();
agent.callsHandled += 1;
}
agentPool.set(username, agent);
console.log(`π€ Agent ${username} β ${status}`);
return true;
}
// ββ GET ALL AGENTS (for supervisor dashboard) ββββββββββββββββββ
export function getAllAgents() {
const result = {};
for (const [username, agent] of agentPool) {
result[username] = { ...agent };
}
return result;
}
export default agentPool;
STEP 2 β Build the Business Hours Module
// routing/businessHours.js
const BUSINESS_HOURS = {
timezone: 'Europe/London',
schedule: {
1: { open: '09:00', close: '18:00' }, // Monday
2: { open: '09:00', close: '18:00' }, // Tuesday
3: { open: '09:00', close: '18:00' }, // Wednesday
4: { open: '09:00', close: '18:00' }, // Thursday
5: { open: '09:00', close: '17:00' }, // Friday (early close)
6: null, // Saturday β closed
0: null // Sunday β closed
},
holidays: [
'2024-12-25', // Christmas
'2024-12-26', // Boxing Day
'2025-01-01', // New Year's Day
]
};
export function isWithinBusinessHours() {
const now = new Date();
// Check timezone-aware current time
const localTime = new Intl.DateTimeFormat('en-GB', {
timeZone: BUSINESS_HOURS.timezone,
hour: '2-digit',
minute: '2-digit',
weekday: 'short',
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour12: false
}).formatToParts(now);
const parts = {};
localTime.forEach(({ type, value }) => { parts[type] = value; });
const dayOfWeek = now.toLocaleDateString('en-GB', {
timeZone: BUSINESS_HOURS.timezone,
weekday: 'long'
});
// Map day name to number (0=Sun, 1=Mon...)
const dayMap = {
Sunday: 0, Monday: 1, Tuesday: 2, Wednesday: 3,
Thursday: 4, Friday: 5, Saturday: 6
};
const dayNum = dayMap[dayOfWeek];
// Check bank holiday
const dateStr = `${parts.year}-${parts.month}-${parts.day}`;
if (BUSINESS_HOURS.holidays.includes(dateStr)) {
return { open: false, reason: 'bank-holiday' };
}
// Check if day is open
const todaySchedule = BUSINESS_HOURS.schedule[dayNum];
if (!todaySchedule) {
return { open: false, reason: 'weekend' };
}
// Check time window
const currentTime = `${parts.hour}:${parts.minute}`;
const isOpen = currentTime >= todaySchedule.open &&
currentTime < todaySchedule.close;
return {
open: isOpen,
reason: isOpen ? null : 'outside-hours',
schedule: todaySchedule,
currentTime
};
}
export function getNextOpeningMessage() {
// Returns a human-friendly next-opening message
const now = new Date();
const dayOfWeek = now.getDay();
// Find next working day
for (let i = 1; i <= 7; i++) {
const nextDay = (dayOfWeek + i) % 7;
if (BUSINESS_HOURS.schedule[nextDay]) {
const days = ['Sunday','Monday','Tuesday','Wednesday','Thursday','Friday','Saturday'];
return `We re-open ${days[nextDay]} at ${BUSINESS_HOURS.schedule[nextDay].open}.`;
}
}
return 'Please check our website for opening hours.';
}
STEP 3 β Build the Skills Router
// routing/skillsRouter.js
import { findAvailableAgents } from '../agents/pool.js';
// Maps IVR input digit β required skill
const SKILL_MAP = {
'1': { skill: 'billing', label: 'Billing and Payments' },
'2': { skill: 'technical', label: 'Technical Support' },
'3': { skill: 'sales', label: 'Sales' },
'0': { skill: 'general', label: 'General Enquiries' }
};
export function getSkillFromInput(digit) {
return SKILL_MAP[digit] || SKILL_MAP['0'];
}
// ββ ROUTING DECISION βββββββββββββββββββββββββββββββββββββββββ
export function routeCall({ skill, isVIP = false }) {
const preferSenior = isVIP;
const agents = findAvailableAgents(skill, preferSenior);
if (agents.length > 0) {
const selected = agents[0];
return {
canRoute: true,
agent: selected.username,
agentName: selected.name,
reason: `Matched on skill: ${skill}`
};
}
// Fallback: try "general" skill if specific skill has no agents
if (skill !== 'general') {
const fallback = findAvailableAgents('general', preferSenior);
if (fallback.length > 0) {
return {
canRoute: true,
agent: fallback[0].username,
agentName: fallback[0].name,
reason: 'Fallback to general queue'
};
}
}
return {
canRoute: false,
reason: 'No agents available'
};
}
STEP 4 β Build the Full Routing Webhook
Now wire everything together in server.js. Replace the answer webhook:
// server.js β full routing answer webhook
import { isWithinBusinessHours, getNextOpeningMessage } from './routing/businessHours.js';
import { getSkillFromInput, routeCall } from './routing/skillsRouter.js';
import { setAgentStatus } from './agents/pool.js';
// ββ CALL STATE STORE (use Redis in production) βββββββββββββββββ
const callState = new Map();
// ββ ANSWER WEBHOOK βββββββββββββββββββββββββββββββββββββββββββββ
app.get('/webhooks/answer', (req, res) => {
const { from, to, uuid } = req.query;
console.log(`π Inbound call [${uuid}] from ${from}`);
// Store initial call state
callState.set(uuid, {
from,
to,
startTime: new Date(),
skill: null,
isVIP: isVIPCustomer(from)
});
// ββ STEP 1: Business hours check ββββββββββββββββββββββββββββββ
const hours = isWithinBusinessHours();
if (!hours.open) {
const nextOpen = getNextOpeningMessage();
return res.json([
{
action: 'talk',
text: `Thank you for calling. We are currently closed. ${nextOpen}`,
language: 'en-GB',
style: 1
},
{
action: 'record',
format: 'mp3',
endOnSilence: 3,
beepStart: true,
eventUrl: [`${process.env.BASE_URL}/webhooks/voicemail`]
},
{
action: 'talk',
text: 'Please leave a message after the tone and we will call you back. Goodbye.',
language: 'en-GB'
}
]);
}
// ββ STEP 2: VIP greeting βββββββββββββββββββββββββββββββββββββββ
const state = callState.get(uuid);
const greeting = state.isVIP
? 'Welcome back! As a valued customer, you have priority access.'
: 'Thank you for calling Cloud Call Centre.';
// ββ STEP 3: IVR menu βββββββββββββββββββββββββββββββββββββββββββ
return res.json([
{
action: 'talk',
text: `${greeting} Please select from the following options.`,
language: 'en-GB',
style: 1,
bargeIn: true // Allow caller to press digit before speech ends
},
{
action: 'input',
type: ['dtmf'],
dtmf: {
maxDigits: 1,
submitOnHash: false,
timeOut: 5
},
eventUrl: [`${process.env.BASE_URL}/webhooks/ivr?uuid=${uuid}`],
speech: { active: false }
}
]);
});
// ββ IVR INPUT HANDLER ββββββββββββββββββββββββββββββββββββββββββ
app.post('/webhooks/ivr', async (req, res) => {
const { uuid } = req.query;
const digit = req.body?.dtmf?.digits || '0';
const state = callState.get(uuid) || {};
console.log(`π’ IVR input: digit=${digit} uuid=${uuid}`);
const { skill, label } = getSkillFromInput(digit);
// Save skill to call state
if (state) {
state.skill = skill;
callState.set(uuid, state);
}
// Find an agent
const routing = routeCall({ skill, isVIP: state.isVIP });
if (routing.canRoute) {
console.log(`β
Routing to agent: ${routing.agent} (${routing.reason})`);
// Mark agent as busy
setAgentStatus(routing.agent, 'busy');
return res.json([
{
action: 'talk',
text: `Connecting you to our ${label} team. Please hold.`,
language: 'en-GB',
style: 1
},
{
action: 'connect',
from: state.to,
timeout: 30,
endpoint: [
{
type: 'app',
user: routing.agent
}
],
eventUrl: [`${process.env.BASE_URL}/webhooks/connect-event?uuid=${uuid}&agent=${routing.agent}`]
}
]);
}
// No agents available β offer queue or callback
console.log(`β οΈ No agents available for skill: ${skill}`);
return res.json([
{
action: 'talk',
text: `All our ${label} agents are currently busy. Press 1 to hold, or press 2 to request a callback.`,
language: 'en-GB',
style: 1,
bargeIn: true
},
{
action: 'input',
type: ['dtmf'],
dtmf: { maxDigits: 1, timeOut: 5 },
eventUrl: [`${process.env.BASE_URL}/webhooks/queue-choice?uuid=${uuid}&skill=${skill}`]
}
]);
});
// ββ QUEUE CHOICE HANDLER ββββββββββββββββββββββββββββββββββββββ
app.post('/webhooks/queue-choice', async (req, res) => {
const { uuid, skill } = req.query;
const digit = req.body?.dtmf?.digits;
if (digit === '1') {
// Hold and retry every 30 seconds
return res.json(buildQueueNCCO(uuid, skill, 1));
}
if (digit === '2') {
// Offer callback
return res.json([
{
action: 'talk',
text: 'We will call you back as soon as an agent is available. Thank you for your patience. Goodbye.',
language: 'en-GB'
}
]);
// In production: save callback request to database here
}
// No input β default to hold
return res.json(buildQueueNCCO(uuid, skill, 1));
});
// ββ QUEUE NCCO BUILDER βββββββββββββββββββββββββββββββββββββββββ
function buildQueueNCCO(uuid, skill, attempt) {
const MAX_ATTEMPTS = 6; // 6 Γ 30s = 3 minutes max queue
if (attempt > MAX_ATTEMPTS) {
return [
{
action: 'talk',
text: 'We apologise for the long wait. Please leave a message and we will call you back shortly.',
language: 'en-GB'
},
{
action: 'record',
format: 'mp3',
endOnSilence: 3,
beepStart: true,
eventUrl: [`${process.env.BASE_URL}/webhooks/voicemail`]
}
];
}
return [
{
action: 'talk',
text: attempt === 1
? 'Please hold. Your call is important to us.'
: `Still connecting you. You are number ${attempt} in the queue.`,
language: 'en-GB'
},
{
action: 'stream',
streamUrl: ['https://example.com/hold-music.mp3'], // Replace with your hold music URL
loop: 1
},
{
action: 'input',
type: ['dtmf'],
dtmf: { maxDigits: 1, timeOut: 30 }, // Wait 30s then re-trigger
eventUrl: [`${process.env.BASE_URL}/webhooks/queue-retry?uuid=${uuid}&skill=${skill}&attempt=${attempt + 1}`]
}
];
}
// ββ QUEUE RETRY ββββββββββββββββββββββββββββββββββββββββββββββββ
app.post('/webhooks/queue-retry', async (req, res) => {
const { uuid, skill, attempt } = req.query;
const state = callState.get(uuid) || {};
const routing = routeCall({ skill, isVIP: state.isVIP });
if (routing.canRoute) {
setAgentStatus(routing.agent, 'busy');
return res.json([
{
action: 'talk',
text: 'An agent is now available. Connecting you now.',
language: 'en-GB'
},
{
action: 'connect',
from: state.to || process.env.VONAGE_NUMBER,
timeout: 30,
endpoint: [{ type: 'app', user: routing.agent }],
eventUrl: [`${process.env.BASE_URL}/webhooks/connect-event?uuid=${uuid}&agent=${routing.agent}`]
}
]);
}
// Still no agent β continue queuing
return res.json(buildQueueNCCO(uuid, skill, parseInt(attempt)));
});
// ββ CONNECT EVENT ββββββββββββββββββββββββββββββββββββββββββββββ
app.post('/webhooks/connect-event', (req, res) => {
const { uuid, agent } = req.query;
const event = req.body;
console.log(`π Connect event for ${agent}: ${event.status}`);
if (event.status === 'completed' || event.status === 'failed') {
setAgentStatus(agent, 'wrap-up');
// After 30s wrap-up, set to available
setTimeout(() => {
setAgentStatus(agent, 'available');
}, 30000);
}
if (event.status === 'timeout') {
// Agent didn't answer β set offline and try next agent
setAgentStatus(agent, 'offline');
console.log(`β±οΈ ${agent} timed out β marking offline`);
}
res.status(200).end();
});
// ββ VOICEMAIL HANDLER ββββββββββββββββββββββββββββββββββββββββββ
app.post('/webhooks/voicemail', (req, res) => {
const recording = req.body;
console.log('π¬ Voicemail received:', recording.recording_url);
// In production: save to DB, notify agents via Slack/email
res.status(200).end();
});
// ββ EVENT WEBHOOK ββββββββββββββββββββββββββββββββββββββββββββββ
app.post('/webhooks/event', (req, res) => {
const event = req.body;
console.log(`π [${event.status}] ${event.uuid}`);
if (event.status === 'completed') {
callState.delete(event.uuid);
}
res.status(200).end();
});
// ββ SUPERVISOR ENDPOINT: view agent pool ββββββββββββββββββββββ
app.get('/admin/agents', (req, res) => {
const { getAllAgents } = await import('./agents/pool.js');
res.json(getAllAgents());
});
// ββ HELPER: identify VIP callers βββββββββββββββββββββββββββββββ
function isVIPCustomer(phoneNumber) {
// In production: query your CRM or customer DB
const vipNumbers = [
process.env.TEST_VIP_NUMBER // Set this in .env for testing
];
return vipNumbers.includes(phoneNumber);
}
STEP 5 β Routing Decision Visualised
Here's how a real call flows through the router:
EXAMPLE: VIP customer calls, wants billing help
Call arrives from +44 7700 900001
β
βΌ
Business hours check
β Mon 10:30am GMT β
Open
β
βΌ
Customer lookup: +44 7700 900001
β isVIP = TRUE (found in VIP list)
β
βΌ
IVR plays: "Welcome back! You have priority access.
Press 1 for Billing, 2 for Technical..."
β
βΌ
Caller presses: 1
β
βΌ
getSkillFromInput('1') β skill: 'billing'
β
βΌ
routeCall({ skill: 'billing', isVIP: true })
β findAvailableAgents('billing', preferSenior=true)
β Checks pool:
alice: available, has billing, SENIOR β selected
carol: available, has billing, SENIOR
bob: no billing skill
dave: offline
β Returns: alice (senior, 0 calls handled today)
β
βΌ
NCCO: "Connecting you to our Billing team. Please hold."
NCCO: connect to user:alice
β
βΌ
alice's browser: callInvite fires
Alice answers β WebRTC connected
β
βΌ
Call ends
β setAgentStatus('alice', 'wrap-up')
β After 30s β setAgentStatus('alice', 'available')
Add .env Variables
# .env additions for Day 5
TEST_VIP_NUMBER=+447700900001 # Your personal number for VIP testing
BASE_URL=https://abc123.ngrok.io
π Testing the Router
# Check agent pool status
curl http://localhost:3000/admin/agents
# Expected output:
{
"alice": { "name": "Alice Chen", "status": "available", "skills": ["billing","general","english"], ... },
"bob": { "name": "Bob Patel", "status": "available", "skills": ["technical","general","english"], ... },
...
}
Test scenarios to run:
| Scenario | Expected result |
|---|---|
| Call during business hours β press 1 | Routes to alice (billing, senior) |
| Call from VIP number β press 2 | Routes to carol (technical, senior, VIP priority) |
| Call when all billing agents busy | "All agents busy" β hold or callback |
| Call outside business hours | Closed message + voicemail |
| Agent doesn't answer (timeout) | Agent marked offline, retried |
β Day 5 Checklist
β‘ Agent pool defined with skills and tiers
β‘ Business hours check working (try calling outside hours)
β‘ IVR plays correctly and accepts digit input
β‘ Skill is correctly mapped from digit to agent
β‘ VIP callers get priority routing
β‘ Queue hold music plays when no agents available
β‘ Callback offer works when queue is full
β‘ Wrap-up period set before agent returns to available
β‘ Voicemail recording webhook fires
β‘ /admin/agents endpoint shows live agent status
π What's Next
Day 6 starts Week 2 β Omnichannel. We go deep on voice: PSTN vs WebRTC, codec selection, call recording, DTMF relay, call transfer, and conferencing. Day 5's routing engine will feed directly into these voice flows.
β Day 4: Vonage Client SDK | Day 5 of 30 | Day 6: Voice β PSTN to WebRTC β
Top comments (0)