Vonage Client SDK: Building Your First Voice App for a Cloud Call Centre
← Day 3: Setup from Scratch | Day 5: Call Routing & Business Rules →
🎯 What You'll Build Today
By the end of this post you will have:
- ✅ A browser-based softphone that receives inbound calls
- ✅ An agent login system using Vonage JWTs
- ✅ Answer / Hang up / Mute controls working in the browser
- ✅ Real-time call status display (ringing, connected, duration)
- ✅ Your first working agent desktop component
No phone hardware. No SIP client. Just a browser tab.
🗺️ What We're Building
┌──────────────────────────────────────────────────────────────────┐
│ TODAY'S ARCHITECTURE │
│ │
│ Caller's Phone │
│ │ │
│ │ dials virtual number │
│ ▼ │
│ ┌──────────┐ webhook ┌──────────────┐ │
│ │ Vonage │──────────────► │ Your Node │ │
│ │ Platform │ │ Backend │ │
│ └────┬─────┘ ◄──── NCCO ───│ server.js │ │
│ │ └──────────────┘ │
│ │ WebRTC audio stream │
│ │ │
│ ▼ │
│ ┌──────────────────────────────────────────┐ │
│ │ AGENT'S BROWSER │ │
│ │ │ │
│ │ Vonage Client SDK (JavaScript) │ │
│ │ │ │
│ │ ┌────────────────────────────────────┐ │ │
│ │ │ SOFTPHONE COMPONENT │ │ │
│ │ │ │ │ │
│ │ │ Status: 🟢 Available │ │ │
│ │ │ Caller: +44 7700 900001 │ │ │
│ │ │ Duration: 00:32 │ │ │
│ │ │ │ │ │
│ │ │ [Answer] [Hang Up] [Mute] │ │ │
│ │ └────────────────────────────────────┘ │ │
│ └──────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────────────┘
🧠 How the Vonage Client SDK Works
Before writing code, understand the authentication flow:
┌──────────────────────────────────────────────────────────────┐
│ CLIENT SDK AUTHENTICATION FLOW │
│ │
│ STEP 1: Agent opens browser │
│ │ │
│ ▼ │
│ STEP 2: Browser requests JWT from your backend │
│ GET /auth/token?agent=alice │
│ │ │
│ ▼ │
│ STEP 3: Your backend generates JWT │
│ signed with your Vonage private.key │
│ JWT payload: │
│ { │
│ sub: "alice", ← agent username │
│ acl: { voice: {} }, ← permissions │
│ exp: now + 86400, ← expires in 24h │
│ application_id: "xxx" ← your app ID │
│ } │
│ │ │
│ ▼ │
│ STEP 4: Browser receives JWT │
│ Vonage Client SDK logs in with JWT │
│ │ │
│ ▼ │
│ STEP 5: SDK connects to Vonage's WebSocket server │
│ Agent is now "online" and reachable │
│ │ │
│ ▼ │
│ STEP 6: Inbound call arrives │
│ SDK fires: client.on('callInvite', ...) │
│ Browser rings / shows notification │
└──────────────────────────────────────────────────────────────┘
The key insight: the JWT ties a username (e.g. "alice") to your Vonage application. When your NCCO routes a call to user: "alice", Vonage knows which browser session to ring.
STEP 1 — Install Dependencies
Add to your existing project from Day 3:
npm install @vonage/jwt express-static
For the frontend, the Vonage Client SDK is loaded via CDN — no npm needed for the browser side.
STEP 2 — Add JWT Generation to Your Backend
Add a new route to server.js that generates agent tokens:
// server.js — add these imports at the top
import { tokenGenerate } from '@vonage/jwt';
import path from 'path';
import { fileURLToPath } from 'url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
// ──────────────────────────────────────────────
// JWT TOKEN ENDPOINT
// Agents call this to get their login token
// ──────────────────────────────────────────────
app.get('/auth/token', (req, res) => {
const agentName = req.query.agent;
if (!agentName) {
return res.status(400).json({ error: 'agent query param required' });
}
// Sanitise agent name — alphanumeric and hyphens only
const sanitised = agentName.replace(/[^a-zA-Z0-9-_]/g, '');
const token = tokenGenerate(
process.env.VONAGE_APPLICATION_ID,
process.env.VONAGE_PRIVATE_KEY_PATH,
{
subject: sanitised,
expiryTime: Math.floor(Date.now() / 1000) + 86400, // 24 hours
acl: {
paths: {
'/*/users/**': {},
'/*/conversations/**': {},
'/*/sessions/**': {},
'/*/devices/**': {},
'/*/image/**': {},
'/*/media/**': {},
'/*/applications/**': {},
'/*/push/**': {},
'/*/knocking/**': {},
'/*/legs/**': {}
}
}
}
);
console.log(`🔑 JWT generated for agent: ${sanitised}`);
res.json({
token,
agent: sanitised,
expiresIn: 86400
});
});
// Serve the agent desktop HTML
app.use(express.static(path.join(__dirname, 'public')));
STEP 3 — Update Your Answer Webhook for Agent Routing
Update the answer webhook to route the call to a specific agent:
// server.js — update the answer webhook
app.get('/webhooks/answer', (req, res) => {
const from = req.query.from;
const to = req.query.to;
const uuid = req.query.uuid;
console.log(`📞 Inbound call from ${from} to ${to} [${uuid}]`);
// In a real system you'd look up which agent to route to
// For now we route to our test agent "alice"
const targetAgent = 'alice';
const ncco = [
{
action: 'talk',
text: `Thank you for calling. Connecting you to an agent now. Please hold.`,
language: 'en-GB',
style: 1
},
{
action: 'connect',
from: to, // Your virtual number as the "from"
endpoint: [
{
type: 'app',
user: targetAgent // Route to this agent username
}
],
timeout: 30, // Ring agent for 30 seconds
eventUrl: [`${process.env.BASE_URL}/webhooks/connect-event`]
}
];
res.json(ncco);
});
// ──────────────────────────────────────────────
// CONNECT EVENT WEBHOOK
// Fired when the connect action changes state
// ──────────────────────────────────────────────
app.post('/webhooks/connect-event', (req, res) => {
console.log('🔗 Connect event:', req.body);
if (req.body.status === 'timeout') {
console.log('⏱️ Agent did not answer — implement voicemail/queue here');
}
res.status(200).end();
});
Add BASE_URL to your .env:
BASE_URL=https://abc123.ngrok.io
STEP 4 — Build the Agent Desktop
Create the public folder and agent desktop:
mkdir public
touch public/index.html
<!-- public/index.html -->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Cloud Call Centre — Agent Desktop</title>
<!-- Vonage Client SDK from CDN -->
<script src="https://cdn.jsdelivr.net/npm/@vonage/client-sdk@latest/dist/vonageClientSDK.js"></script>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: 'Segoe UI', system-ui, sans-serif;
background: #050a14;
color: #e2e8f0;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
}
.container {
width: 400px;
}
.header {
text-align: center;
margin-bottom: 2rem;
}
.header h1 {
font-size: 1.4rem;
color: #38bdf8;
margin-bottom: 0.25rem;
}
.header p {
font-size: 0.8rem;
color: #475569;
}
/* LOGIN PANEL */
.login-panel {
background: #0a1628;
border: 1px solid #1e3a5f;
border-radius: 12px;
padding: 2rem;
}
.login-panel h2 {
font-size: 1rem;
margin-bottom: 1.5rem;
color: #94a3b8;
}
.form-group {
margin-bottom: 1rem;
}
.form-group label {
display: block;
font-size: 0.75rem;
color: #64748b;
margin-bottom: 0.4rem;
letter-spacing: 0.05em;
text-transform: uppercase;
}
.form-group input {
width: 100%;
padding: 0.65rem 0.9rem;
background: #0f1e35;
border: 1px solid #1e3a5f;
border-radius: 8px;
color: #e2e8f0;
font-size: 0.9rem;
outline: none;
transition: border-color 0.2s;
}
.form-group input:focus {
border-color: #38bdf8;
}
/* BUTTONS */
.btn {
width: 100%;
padding: 0.75rem;
border: none;
border-radius: 8px;
font-size: 0.9rem;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
}
.btn-primary {
background: #0ea5e9;
color: white;
}
.btn-primary:hover { background: #0284c7; }
.btn-primary:disabled { background: #1e3a5f; color: #475569; cursor: not-allowed; }
.btn-success {
background: #10b981;
color: white;
}
.btn-success:hover { background: #059669; }
.btn-danger {
background: #ef4444;
color: white;
}
.btn-danger:hover { background: #dc2626; }
.btn-warning {
background: #f59e0b;
color: #0f172a;
}
.btn-warning:hover { background: #d97706; }
.btn-sm {
width: auto;
padding: 0.5rem 1.25rem;
font-size: 0.8rem;
}
/* SOFTPHONE PANEL */
.softphone {
background: #0a1628;
border: 1px solid #1e3a5f;
border-radius: 12px;
padding: 2rem;
display: none;
}
.softphone.visible { display: block; }
/* STATUS INDICATOR */
.status-bar {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 1.5rem;
padding-bottom: 1rem;
border-bottom: 1px solid #1e3a5f;
}
.status-indicator {
display: flex;
align-items: center;
gap: 0.5rem;
}
.status-dot {
width: 10px;
height: 10px;
border-radius: 50%;
background: #64748b;
transition: background 0.3s;
}
.status-dot.available { background: #10b981; box-shadow: 0 0 8px #10b981; }
.status-dot.ringing { background: #f59e0b; box-shadow: 0 0 8px #f59e0b; animation: pulse 1s infinite; }
.status-dot.connected { background: #3b82f6; box-shadow: 0 0 8px #3b82f6; }
.status-dot.offline { background: #ef4444; }
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}
.status-text {
font-size: 0.85rem;
font-weight: 600;
color: #94a3b8;
}
.agent-name {
font-size: 0.75rem;
color: #475569;
}
/* CALL INFO */
.call-info {
background: #0f1e35;
border-radius: 8px;
padding: 1.25rem;
margin-bottom: 1.5rem;
min-height: 90px;
display: flex;
flex-direction: column;
justify-content: center;
}
.call-info .label {
font-size: 0.65rem;
color: #475569;
letter-spacing: 0.1em;
text-transform: uppercase;
margin-bottom: 0.3rem;
}
.call-info .value {
font-size: 1.1rem;
color: #f1f5f9;
font-weight: 600;
}
.call-info .sub {
font-size: 0.75rem;
color: #64748b;
margin-top: 0.2rem;
}
/* CALL CONTROLS */
.call-controls {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: 0.75rem;
margin-bottom: 1rem;
}
/* LOG */
.log {
margin-top: 1.5rem;
padding-top: 1rem;
border-top: 1px solid #1e3a5f;
}
.log-title {
font-size: 0.65rem;
color: #334155;
letter-spacing: 0.1em;
text-transform: uppercase;
margin-bottom: 0.5rem;
}
.log-entries {
max-height: 120px;
overflow-y: auto;
}
.log-entry {
font-size: 0.72rem;
color: #475569;
padding: 0.2rem 0;
border-bottom: 1px solid #0f1e35;
}
.log-entry .time {
color: #334155;
margin-right: 0.5rem;
}
.log-entry.info { color: #38bdf8; }
.log-entry.warn { color: #f59e0b; }
.log-entry.error { color: #ef4444; }
.log-entry.success { color: #10b981; }
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>☁️ Cloud Call Centre</h1>
<p>Agent Desktop — Day 4 Demo</p>
</div>
<!-- LOGIN PANEL -->
<div class="login-panel" id="loginPanel">
<h2>Agent Login</h2>
<div class="form-group">
<label>Agent Username</label>
<input type="text" id="agentName" value="alice" placeholder="e.g. alice" />
</div>
<button class="btn btn-primary" id="loginBtn" onclick="loginAgent()">
Connect to Call Centre
</button>
<div id="loginStatus" style="margin-top:1rem; font-size:0.8rem; color:#475569;"></div>
</div>
<!-- SOFTPHONE PANEL -->
<div class="softphone" id="softphone">
<div class="status-bar">
<div class="status-indicator">
<div class="status-dot available" id="statusDot"></div>
<span class="status-text" id="statusText">Available</span>
</div>
<span class="agent-name" id="agentLabel">agent: —</span>
</div>
<div class="call-info" id="callInfo">
<div class="label">Waiting for calls</div>
<div class="value" id="callerNumber">—</div>
<div class="sub" id="callDuration"></div>
</div>
<div class="call-controls">
<button class="btn btn-success" id="answerBtn" onclick="answerCall()" disabled>
📞 Answer
</button>
<button class="btn btn-danger" id="hangupBtn" onclick="hangupCall()" disabled>
📵 Hang Up
</button>
<button class="btn btn-warning" id="muteBtn" onclick="toggleMute()" disabled>
🎤 Mute
</button>
</div>
<button class="btn btn-sm" style="background:#1e3a5f;color:#94a3b8;margin-top:0.5rem;" onclick="logout()">
Logout
</button>
<div class="log">
<div class="log-title">Activity Log</div>
<div class="log-entries" id="logEntries"></div>
</div>
</div>
</div>
<script>
// ── STATE ──────────────────────────────────────────────
let vonageClient = null;
let currentCall = null;
let isMuted = false;
let callTimer = null;
let callSeconds = 0;
// ── LOGGING ────────────────────────────────────────────
function addLog(message, type = 'info') {
const entries = document.getElementById('logEntries');
const entry = document.createElement('div');
const time = new Date().toLocaleTimeString();
entry.className = `log-entry ${type}`;
entry.innerHTML = `<span class="time">${time}</span>${message}`;
entries.prepend(entry);
}
// ── STATUS HELPERS ─────────────────────────────────────
function setStatus(state, label) {
const dot = document.getElementById('statusDot');
const text = document.getElementById('statusText');
dot.className = `status-dot ${state}`;
text.textContent = label;
}
function updateCallInfo(number, subText) {
document.getElementById('callerNumber').textContent = number || '—';
document.getElementById('callDuration').textContent = subText || '';
document.querySelector('.call-info .label').textContent =
number ? 'Incoming call from' : 'Waiting for calls';
}
function setButtons(state) {
const answerBtn = document.getElementById('answerBtn');
const hangupBtn = document.getElementById('hangupBtn');
const muteBtn = document.getElementById('muteBtn');
answerBtn.disabled = state !== 'ringing';
hangupBtn.disabled = state !== 'connected';
muteBtn.disabled = state !== 'connected';
}
// ── CALL TIMER ─────────────────────────────────────────
function startTimer() {
callSeconds = 0;
callTimer = setInterval(() => {
callSeconds++;
const m = String(Math.floor(callSeconds / 60)).padStart(2, '0');
const s = String(callSeconds % 60).padStart(2, '0');
document.getElementById('callDuration').textContent = `Duration: ${m}:${s}`;
}, 1000);
}
function stopTimer() {
clearInterval(callTimer);
callTimer = null;
}
// ── LOGIN ──────────────────────────────────────────────
async function loginAgent() {
const agentName = document.getElementById('agentName').value.trim();
const loginBtn = document.getElementById('loginBtn');
const statusEl = document.getElementById('loginStatus');
if (!agentName) {
statusEl.textContent = '⚠️ Please enter an agent username.';
return;
}
loginBtn.disabled = true;
statusEl.textContent = 'Fetching credentials…';
try {
// 1. Get JWT from your backend
const res = await fetch(`/auth/token?agent=${encodeURIComponent(agentName)}`);
const data = await res.json();
statusEl.textContent = 'Connecting to Vonage…';
// 2. Initialise the Vonage Client SDK
vonageClient = new VonageClient();
// 3. Log in with JWT
await vonageClient.createSession(data.token);
// 4. Register call event listeners
registerCallListeners();
// 5. Show softphone UI
document.getElementById('loginPanel').style.display = 'none';
document.getElementById('softphone').classList.add('visible');
document.getElementById('agentLabel').textContent = `agent: ${agentName}`;
setStatus('available', 'Available');
addLog(`Logged in as ${agentName}`, 'success');
} catch (err) {
loginBtn.disabled = false;
statusEl.textContent = `❌ Login failed: ${err.message}`;
console.error('Login error:', err);
}
}
// ── CALL EVENT LISTENERS ───────────────────────────────
function registerCallListeners() {
// Inbound call arrives
vonageClient.on('callInvite', (callId, from, channelType) => {
console.log('📞 Incoming call:', { callId, from, channelType });
currentCall = callId;
isMuted = false;
setStatus('ringing', 'Ringing…');
updateCallInfo(from, 'Incoming call…');
setButtons('ringing');
addLog(`📞 Incoming call from ${from}`, 'warn');
});
// Call was answered
vonageClient.on('callAnswered', (callId) => {
console.log('✅ Call answered:', callId);
setStatus('connected', 'Connected');
setButtons('connected');
startTimer();
addLog('✅ Call connected', 'success');
});
// Call ended
vonageClient.on('callHangup', (callId, callQuality, reason) => {
console.log('📵 Call ended:', { callId, reason });
stopTimer();
currentCall = null;
isMuted = false;
setStatus('available', 'Available');
updateCallInfo(null);
setButtons('idle');
addLog(`📵 Call ended — ${reason || 'completed'}`, 'info');
});
// Connection state changes
vonageClient.on('sessionError', (err) => {
addLog(`⚠️ Session error: ${err.message}`, 'error');
});
}
// ── CALL CONTROLS ──────────────────────────────────────
async function answerCall() {
if (!currentCall) return;
try {
await vonageClient.answer(currentCall);
addLog('Answered call', 'success');
} catch (err) {
addLog(`Failed to answer: ${err.message}`, 'error');
}
}
async function hangupCall() {
if (!currentCall) return;
try {
await vonageClient.hangup(currentCall);
addLog('Hung up call', 'info');
} catch (err) {
addLog(`Failed to hang up: ${err.message}`, 'error');
}
}
async function toggleMute() {
if (!currentCall) return;
try {
isMuted = !isMuted;
await vonageClient.mute(currentCall, isMuted);
const muteBtn = document.getElementById('muteBtn');
muteBtn.textContent = isMuted ? '🔇 Unmute' : '🎤 Mute';
addLog(isMuted ? 'Microphone muted' : 'Microphone unmuted', 'info');
} catch (err) {
addLog(`Mute error: ${err.message}`, 'error');
}
}
function logout() {
if (vonageClient) vonageClient.deleteSession();
location.reload();
}
</script>
</body>
</html>
STEP 5 — Test the Full Flow
5.1 Start everything
Terminal 1 — server:
node server.js
Terminal 2 — ngrok:
ngrok http 3000
5.2 Open the agent desktop
Navigate to http://localhost:3000 in your browser.
┌──────────────────────────────────────────────┐
│ Cloud Call Centre │
│ Agent Desktop — Day 4 Demo │
│ │
│ Agent Login │
│ ┌──────────────────────────────────────┐ │
│ │ Agent Username: alice │ │
│ └──────────────────────────────────────┘ │
│ │
│ [Connect to Call Centre] │
└──────────────────────────────────────────────┘
5.3 Log in as "alice"
Click Connect to Call Centre. The page fetches a JWT for alice and connects to Vonage. You'll see:
┌──────────────────────────────────────────────┐
│ 🟢 Available agent: alice │
│ │
│ Waiting for calls │
│ — │
│ │
│ [Answer ✗] [Hang Up ✗] [Mute ✗] │
│ │
│ Activity Log │
│ 09:12:01 Logged in as alice │
└──────────────────────────────────────────────┘
5.4 Call your virtual number
Call your Vonage number from your phone. Watch the browser:
┌──────────────────────────────────────────────┐
│ 🟡 Ringing… agent: alice │
│ │
│ Incoming call from │
│ +44 7700 900001 │
│ Incoming call… │
│ │
│ [Answer ✅] [Hang Up ✗] [Mute ✗] │
│ │
│ Activity Log │
│ 09:12:44 📞 Incoming call from +44770... │
│ 09:12:01 Logged in as alice │
└──────────────────────────────────────────────┘
Click Answer — the caller hears the hold music stop and you're connected via WebRTC audio in the browser.
How the Data Flows
CALL FLOW WITH CLIENT SDK
Your Phone Vonage Your Server Browser (alice)
│ │ │ │
│── dials number ─────────►│ │ │
│ │── GET /answer ──────►│ │
│ │◄── NCCO (connect ───│ │
│ │ to user:alice) │ │
│ │ │ │
│ │── callInvite ───────────────────────────►│
│ │ │ │
│ │ │ user clicks Answer
│ │◄──────────────────────────────── answer()│
│ │ │ │
│◄──── WebRTC audio ───────────────────────────────────────────────────│
│ established │ │ │
│ │ │ │
│ conversation happening │ │ │
│ │ │ │
│ │◄────── hangup() ────────────────────────│
│── call ends ────────────►│ │ │
│ │── POST /event ──────►│ │
│ │ {completed} │ │
🛠️ Troubleshooting
ISSUE: "callInvite" never fires in browser
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Fix 1: Check the NCCO connect action uses type: "app"
and user: "alice" (must match the JWT subject)
Fix 2: Check the JWT was generated with the correct
applicationId from your .env
Fix 3: Open browser devtools console — SDK errors appear here
────────────────────────────────────────────────────────────
ISSUE: No audio after answering
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Fix: Browser must have microphone permission granted
Check: Site Settings → Microphone → Allow
The SDK needs mic access for WebRTC
────────────────────────────────────────────────────────────
ISSUE: JWT token error 401
━━━━━━━━━━━━━━━━━━━━━━━━━
Fix: Verify VONAGE_APPLICATION_ID and
VONAGE_PRIVATE_KEY_PATH match the application
that owns your phone number
✅ Day 4 Checklist
□ JWT token endpoint /auth/token working
□ Agent login succeeds in browser
□ Status dot shows green "Available"
□ Inbound call triggers "Ringing" status
□ Answer button connects audio via WebRTC
□ Caller hears agent and vice versa
□ Hang Up ends the call cleanly
□ Mute toggles microphone
□ Activity log shows events in real time
□ Call events logged in server terminal
🚀 What's Next
Day 5 covers the routing engine — skills-based routing, queues, business hours, and priority. We'll extend today's agent desktop to support multiple agents with different skills and route calls intelligently between them.
← Day 3: Setup from Scratch | Day 4 of 30 | Day 5: Call Routing →
Top comments (0)