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)