Skip to main content

Recipes

These fragments extend the complete softphone. bc is a connected BabelconnectClient, view is its current AgentView, and callbacks such as renderMessages are your UI functions. They are independent patterns, not one application to concatenate.

Gate surfaces on feature config: calls, messaging, history and phonebook each have enabled; recording uses agent.canRecord. Render server state and send commands from user actions.

A presence selector

const agent = view.agent;
const items = (agent?.presenceOptions ?? []).map(o => ({
label: o.label,
available: o.available,
current: o.name === agent?.presenceName,
onSelect: () => bc.setPresence(o.name),
}));

Keep every configured option. A pause reason's available: false is its meaning, not a reason to hide it. The next agent patch confirms the selection.

An incoming-call card

Use the tutorial's Answer/Reject buttons. For a contact label, skip the literal "recent":

const nameFor = (number: string) => view.agent?.phonebook
.find(e => e.number === number && e.label !== "recent")?.label;

Show Anonymous for call.anonymous, otherwise nameFor(call.from) ?? call.from; include queueName when present. Lifecycle, direction and source are numeric enums: compare named members, and use CallSource[call.source] for a display label. Scheduled callbacks also need explicit answering.

An SMS conversation list

const threads = [...view.sms]
.sort((a, b) => Number(b.lastTs) - Number(a.lastTs))
.map(c => ({
title: c.contactLabel || c.peer, preview: c.lastText, unread: c.unread, open: c.open,
onOpen: async () => {
if (c.id) bc.markConversationRead(c.id);
renderMessages(await bc.getSmsThread(c.id || c.peer));
},
onResolve: c.id ? () => bc.setConversationOpen(c.id, false) : undefined,
}));
const totalUnread = view.sms.reduce((n, c) => n + c.unread, 0);

Filter open for unresolved threads. sendSms(to, text) starts a new thread; its initial summary may lack an ID. Only fetching supports the peer fallback; mark-read and resolve require an ID. Render messages oldest first, aligned by direction, with text, timestamp and a display-only delivery state. Use Number(message.ts) * 1000 for Date: protobuf int64 values are bigint in TypeScript. Catch fetch errors in your UI. setConversationOpen(id, true) reopens a thread.

The wrap-up timer

Store a local deadline from each new wrap-up value, then display Math.max(0, Math.ceil((deadline - Date.now()) / 1000)). Rebase after an extension; don't reset the deadline on unrelated call or presence updates. Keep one timer and clear it on unmount or active: false. A local zero does not confirm the server ended wrap-up.

Show lastCallType; offer bc.wrapUpExtend(30) only with canExtend, and bc.wrapUpCancel() only with canCancel. See wrap-up state.

A line-blocked banner

const why: Record<string, string> = {
unreachable: "A call offer was missed or could not reach you; the line frees itself shortly",
busy: "Your line reported busy",
declined: "A call offer was declined",
dnd: "Your line is on do-not-disturb",
};
const banner = view.agent?.lineBlocked ? {
text: why[view.agent.lineBlockedReason] ?? "Your line is blocked",
reset: () => bc.resetLineStatus(),
} : undefined;

Show it while lineBlocked is true, including during reset. Hide it only when the server clears the flag. Chosen BUSY is different; availability explains why it needs a presence selector instead.

In-call controls

const call = bc.activeCall(); // or select a specific ID from view.activeCalls
if (call) {
const controls = {
mute: () => bc.mute(call.id, !call.muted),
hold: () => bc.hold(call.id, !call.onHold),
record: view.agent?.canRecord ? () => call.recording
? bc.stopRecording(call.id) : bc.startRecording(call.id) : undefined,
hangup: () => bc.hangup(call.id),
transfer: view.config?.calls?.allowTransfer
? (to: string) => bc.transfer(call.id, to) : undefined,
digits: (digits: string) => bc.sendDigits(call.id, digits),
conference: view.config?.calls?.allowConference
? () => bc.startConference(false, call.id) : undefined,
};
}

Read toggle labels from the call flags. startConference(true, call.id) parks that call first. DTMF accepts 09, *, #, AD; command errors still arrive asynchronously.

Outbound dial with a caller-ID picker

Map view.agent?.availableNumbers ?? [] to choices, select the current displayAs, and call bc.setDisplayAs(number) on selection. After the state confirms it, call bc.placeCall(toNumber). For a per-call override, use bc.placeCall(toNumber, { displayAsTo }); displayAsTo is what the consumer sees, while displayAsFrom is what the agent sees. Use E.164 numbers and handle an empty caller-ID list. See dial rejections.

A conference panel

Render each conference's members and gate moderator controls on conf.iAmModerator. Use member.id for kickConferenceMember, holdConferenceMember and muteConferenceMember; avoid offering those controls on your own isMe row. Member onHold is readable, but there is no member muted flag to use for a toggle. Provide explicit Mute/Unmute actions.

addConferenceMember({ agentId }) or addConferenceMember({ number }) adds exactly one target. Show member display/state/failure labels and a badge for isMe; conf.state can indicate ending until removal. endConference() ends it for everyone; leaveConference() drops your own leg. Use in-call controls for your own mute/hold during a conference.

A call-history list

import { CallDirection } from "@babelforce/babelconnect-sdk";

const rows = (await bc.getHistory()).sort((a, b) => Number(b.time) - Number(a.time)).map(c => ({
who: c.contact || (c.direction === CallDirection.INBOUND ? c.from : c.to),
inbound: c.direction === CallDirection.INBOUND,
when: new Date(Number(c.time) * 1000),
durationMs: c.durationMs,
playback: c.hasRecording ? c.recordingUrl : undefined,
}));

History is fetched, not stored in AgentView. TS helpers unwrap response lists. Order isn't assumed here; paging explains page sizes and how to detect the last page. Catch request failures at the UI boundary.

A warm (attended) transfer

bc.addConferenceMember({ number: target, callId: call.id });
// After the target joins and the agent completes the private consultation:
bc.transfer(call.id, target, { warm: true });

The first step parks the customer; completion unholds them and drops the agent's leg. Gate warm transfer on allowConference, blind transfer on allowTransfer. bc.transfer(call.id, target) is a blind transfer without a consult.

A contacts list (dial from the phonebook)

agent.phonebook is a registration snapshot. Refresh/search with await bc.getPhonebook(50, 1, query). Each entry has label and number; show the number for a literal "recent" label. Wire dialing to bc.placeCall(entry.number), and catch fetch errors.

A device selector (where calls ring)

Gate the selector on view.config?.account?.allowDeviceSwitch. Read agent.webrtcEnabled; send bc.setWebrtc(on) for a change. With WebRTC off, calls use agent.number, set by bc.setAgentNumber(number). Keep an audio path configured and confirm changes in state. Control-only setup explains why a new registration requires restoring external-phone routing.

See also

State & events explains the fields; Intents lists commands; Embedding supplies the ready-made UI.