Skip to main content

Your first softphone

Build a small browser phone with a dialer and one card per call. Incoming calls wait for an Answer click. Outgoing calls answer the agent's own leg automatically.

You need Node 22.12+, a modern browser, a microphone, a reachable babelconnect-server origin, and an agent bearer token. The account must allow calls and provide an outbound caller ID. Ask the deployment operator to allow http://localhost:5173 in CORS. Microphone access needs HTTPS or localhost; a server alone is not a complete telephony deployment.

1. Install

mkdir my-softphone
cd my-softphone
npm init -y
npm install @babelforce/babelconnect-sdk
npm install --save-dev typescript vite
mkdir src

Create tsconfig.json:

{
"compilerOptions": {
"target": "ES2022", "module": "ESNext", "moduleResolution": "Bundler",
"lib": ["ES2022", "DOM"], "strict": true, "noEmit": true, "skipLibCheck": true
},
"include": ["src"]
}

2. Get a token

Obtain a short-lived test token using Authentication. This local example accepts it in a password field and keeps it in memory. For a deployed app, connect your login flow; never bake tokens or account passwords into source.

Create index.html:

<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>My softphone</title>
</head>
<body>
<h1>My softphone</h1>
<form id="login">
<label>Server <input id="server" type="url" required placeholder="https://agent.example.com"></label>
<label>Token <input id="token" type="password" required autocomplete="off"></label>
<button id="connect">Connect</button>
</form>
<p id="status" role="status">Disconnected</p>
<p id="error" role="alert"></p>
<form id="dial">
<label>Number <input id="number" type="tel" required placeholder="+15551234567"></label>
<button id="call" disabled>Call</button>
</form>
<div id="calls"></div>
<button id="disconnect" disabled>Disconnect</button>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

3. Connect and mirror state

Create src/main.ts. This is the complete application; it defines every helper it uses.

import {
BabelconnectClient, CallDirection, CallLifecycle, CallSource, type AgentView,
} from "@babelforce/babelconnect-sdk";

function element<T extends HTMLElement>(id: string): T {
const node = document.getElementById(id);
if (!node) throw new Error(`Missing element: ${id}`);
return node as T;
}
const login = element<HTMLFormElement>("login");
const dial = element<HTMLFormElement>("dial");
const server = element<HTMLInputElement>("server");
const token = element<HTMLInputElement>("token");
const number = element<HTMLInputElement>("number");
const connect = element<HTMLButtonElement>("connect");
const callButton = element<HTMLButtonElement>("call");
const disconnect = element<HTMLButtonElement>("disconnect");
const status = element("status");
const error = element("error");
const calls = element("calls");
let bc: BabelconnectClient | undefined;
let unsubscribe: (() => void) | undefined;
let usable = false;

function report(message: string): void { error.textContent = message; }
function button(label: string, action: () => void): HTMLButtonElement {
const node = document.createElement("button");
node.type = "button";
node.textContent = label;
node.onclick = action;
node.disabled = !usable;
return node;
}
function render(view: AgentView): void {
// subscribe() immediately supplies a possibly empty cache, before any network reply.
callButton.disabled = !usable || !view.config?.calls?.enabled || !view.agent?.displayAs;
if (usable) status.textContent = view.agent?.id
? `${view.agent.name}: ${view.agent.presenceLabel || view.agent.presenceName}`
: "Connecting…";
calls.replaceChildren();
for (const call of view.activeCalls) {
const card = document.createElement("section");
const title = document.createElement("p");
const peer = call.direction === CallDirection.INBOUND ? call.from : call.to;
title.textContent = `${call.anonymous ? "Anonymous" : peer}${CallLifecycle[call.state]}`;
card.append(title);
const needsAnswer = call.state === CallLifecycle.RINGING &&
(call.direction !== CallDirection.OUTBOUND || call.source === CallSource.CALLBACK);
if (needsAnswer && call.webrtcOffer) {
card.append(button("Answer", () => { void bc?.answerCall(call.id).catch(e => report(String(e))); }));
}
card.append(button(call.state === CallLifecycle.RINGING ? "Reject" : "Hang up",
() => bc?.hangup(call.id)));
if (call.state === CallLifecycle.IN_PROGRESS || call.state === CallLifecycle.BRIDGED) {
card.append(button(call.muted ? "Unmute" : "Mute", () => bc?.mute(call.id, !call.muted)));
}
calls.append(card);
}
}
function connectionLost(message: string): void {
usable = false;
status.textContent = "Disconnected or stale — disconnect, then connect again";
report(message);
if (bc) render(bc.view);
}
async function close(): Promise<void> {
usable = false;
unsubscribe?.();
unsubscribe = undefined;
const previous = bc;
bc = undefined;
try { await previous?.close(); }
catch (e) { report(String(e)); }
finally {
calls.replaceChildren();
status.textContent = "Disconnected";
callButton.disabled = disconnect.disabled = true;
connect.disabled = false;
}
}
login.onsubmit = event => {
event.preventDefault();
if (bc) return;
report("");
usable = true;
connect.disabled = true;
disconnect.disabled = false;
try {
bc = BabelconnectClient.connect({
serverUrl: server.value.replace(/\/+$/, ""), token: token.value,
onError: e => e.code === "disconnected"
? connectionLost(e.message)
: report(`${e.code}${e.callId ? ` (${e.callId})` : ""}: ${e.message}`),
onGap: () => connectionLost("State updates were missed."),
});
token.value = "";
unsubscribe = bc.subscribe(render);
bc.register();
} catch (e) { report(String(e)); void close(); }
};
dial.onsubmit = event => {
event.preventDefault();
if (bc && !callButton.disabled) { report(""); bc.placeCall(number.value.trim()); }
};
disconnect.onclick = () => { void close(); };
window.addEventListener("pagehide", () => { void close(); });

Check and serve it:

npx tsc
npx vite --host localhost --port 5173 --strictPort

Open http://localhost:5173, enter the server origin and token, then Connect. Registration loads feature settings and caller IDs and requests WebRTC reachability. The SDK queues early intents until the stream starts. Rendering only reads state; it never answers calls.

4. Place a call

Enter an E.164 number and click Call. The SDK's default browser media handles the offer and auto-answers your own outbound leg. Grant microphone permission and check that both parties can hear each other. The Call button stays disabled until calls are enabled and agent.displayAs is populated; use a caller-ID picker if the account requires a selection. The server may still reject dialing for presence or line status.

5. Answer an inbound call

Call the agent from another phone. The card displays Answer and Reject; neither happens until you click. Scheduled outbound callbacks also need explicit acceptance. A ringing call without a WebRTC offer cannot be answered by this browser. See where calls ring.

6. During the call, and hanging up

Mute sends an intent; the next state update changes the label. Hang up ends the call. Disconnect detaches the renderer and awaits close() to release the stream and media. pagehide also starts cleanup, though browsers do not wait for asynchronous unload work. Closing does not revoke the token; see Logout.

The same in Go

Use the complete Go terminal example. Check its availability and transport limits first; its default media sends silence rather than using a microphone.

Troubleshooting your first call

SymptomCheck
No soundMicrophone permission, HTTPS/localhost, autoplay policy, and reachable STUN/TURN.
No incoming cardRegistration, WebRTC routing and the agent's availability.
Dial rejectedRead the displayed error; select an allowed caller ID and an available presence.
Connection lostDisconnect, obtain a fresh token if needed, and reconnect. This closes live media.

The troubleshooting guide covers each case. This example deliberately uses manual reconnection; it does not promise uninterrupted audio or automatic token refresh.

Where to go next

Recipes adds presence, transfer, messaging and contacts. Errors & reconnects explains recovery limits. Use the TypeScript API for method signatures or embed the agent app to use its ready-made interface.