DEV Community

Shashi Kiran
Shashi Kiran

Posted on

Series: Building Cloud Call Centres with Vonage APIs — Day 5 of 30

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       │  │
│  └─────────────────────────────────────────────────────────┘  │
└───────────────────────────────────────────────────────────────┘
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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.';
}
Enter fullscreen mode Exit fullscreen mode

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'
  };
}
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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')
Enter fullscreen mode Exit fullscreen mode

Add .env Variables

# .env additions for Day 5
TEST_VIP_NUMBER=+447700900001   # Your personal number for VIP testing
BASE_URL=https://abc123.ngrok.io
Enter fullscreen mode Exit fullscreen mode

📊 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"], ... },
  ...
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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