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)