<?xml version="1.0" encoding="UTF-8"?><rss version="2.0"
	xmlns:content="http://purl.org/rss/1.0/modules/content/"
	xmlns:wfw="http://wellformedweb.org/CommentAPI/"
	xmlns:dc="http://purl.org/dc/elements/1.1/"
	xmlns:atom="http://www.w3.org/2005/Atom"
	xmlns:sy="http://purl.org/rss/1.0/modules/syndication/"
	xmlns:slash="http://purl.org/rss/1.0/modules/slash/"
	>

<channel>
	<title>Home - Mobali Solutions</title>
	<atom:link href="https://mobali.com/feed/" rel="self" type="application/rss+xml" />
	<link>https://mobali.com/</link>
	<description>Power Platform, Dynamics 365 &#38; Custom Software Consulting</description>
	<lastBuildDate>Fri, 10 Jul 2026 07:15:59 +0000</lastBuildDate>
	<language>en-US</language>
	<sy:updatePeriod>
	hourly	</sy:updatePeriod>
	<sy:updateFrequency>
	1	</sy:updateFrequency>
	<generator>https://wordpress.org/?v=7.0.1</generator>
	<item>
		<title>A browser softphone that also lives inside Dynamics 365</title>
		<link>https://mobali.com/a-browser-softphone-that-also-lives-inside-dynamics-365/</link>
		
		<dc:creator><![CDATA[ville]]></dc:creator>
		<pubDate>Fri, 10 Jul 2026 07:00:33 +0000</pubDate>
				<category><![CDATA[Tech Blog]]></category>
		<category><![CDATA[CIF]]></category>
		<category><![CDATA[Dynamics 365 CRM]]></category>
		<category><![CDATA[Integration]]></category>
		<category><![CDATA[Twilio]]></category>
		<guid isPermaLink="false">https://mobali.com/?p=21</guid>

					<description><![CDATA[<p>A working reference for a Twilio browser softphone that runs standalone and, dropped into a Dynamics 365 CIF panel, transparently gains screen pop, call logging, and click-to-dial — one build, host detected at runtime.</p>
<p>The post <a href="https://mobali.com/a-browser-softphone-that-also-lives-inside-dynamics-365/">A browser softphone that also lives inside Dynamics 365</a> appeared first on <a href="https://mobali.com">Mobali Solutions</a>.</p>
]]></description>
										<content:encoded><![CDATA[<blockquote><p><em><strong>About Mobali Solutions.</strong> We build telephony integrations for CRM platforms. Over the years we&#8217;ve delivered many phone integrations into Dynamics 365 CRM — including <strong><a href="https://www.genesys.com/genesys-cloud" target="_blank" rel="noopener">Genesys Cloud</a></strong> and <strong><a href="https://www.enreach.com/" target="_blank" rel="noopener">Enreach Voice</a></strong>, among others — and we&#8217;ve built telephony integrations for <strong><a href="https://www.pipedrive.com/" target="_blank" rel="noopener">Pipedrive</a></strong> and <strong><a href="https://www.hubspot.com/" target="_blank" rel="noopener">HubSpot</a></strong> as well. This post distills that experience into a compact, working reference: a <a href="https://www.twilio.com/" target="_blank" rel="noopener">Twilio</a> browser softphone wired into Dynamics 365 through the Channel Integration Framework.</em></p></blockquote>
<p>&nbsp;</p>
<p>Link to Github repo: <a href="https://github.com/mobali/twilio-d365-integration">https://github.com/mobali/twilio-d365-integration</a></p>
<blockquote><p>&nbsp;</p></blockquote>
<p>Contact-center and inside-sales agents spend their day toggling between two windows: a phone (or a physical handset) and a CRM. A customer calls, the agent reads the number off a screen, types it into a search box, finds the account, and starts talking — all before the greeting is done. Outbound is the mirror image: find the record, read the number, dial it by hand. Every one of those manual hops is a chance to mis-key a digit, forget to log the call, or lose the context that was on screen a moment ago.</p>
<p>This post walks through a working integration that removes those hops. We build a <strong>browser softphone</strong> on top of the Twilio Voice SDK, and we make the <em>same build</em> light up inside <strong>Dynamics 365</strong> through the Channel Integration Framework (CIF): inbound calls pop the matching contact, finished calls are logged as Phone Call activities, and click-to-dial on a record places the call. Crucially, the softphone also runs perfectly well on its own — open it in a browser tab and it&#8217;s just a phone. There is no separate &#8220;D365 build&#8221;; the app detects its host at runtime.</p>
<p>The code is organized as three npm workspaces, and everything below is grounded in the real source. Where the implementation makes a deliberate simplification (hold, for example) I call it out rather than paper over it.</p>
<p>&nbsp;</p>
<h2>1. Architecture overview</h2>
<p>&nbsp;</p>
<p>Three packages, each with one job:</p>
<ul>
<li><strong><code>server/</code></strong> — a small Node/Express backend. It mints short-lived Twilio Voice access tokens and answers Twilio&#8217;s <code>/voice</code> webhook with TwiML. This is the <em>only</em> component that touches your Twilio account credentials.</li>
<li><strong><code>webphone/</code></strong> — the browser softphone (Vite + TypeScript). It fetches a token from the server, registers a Twilio <code>Device</code>, and renders a dialpad and call controls. It contains the CIF adapters but has no build-time knowledge of whether D365 is present.</li>
<li><strong><code>d365/</code></strong> — the Dynamics-side glue: web resources (form scripts, click-to-dial, an optional iframe host) and the solution/provider setup docs.</li>
</ul>
<p>The key design decision is the <strong>standalone-vs-CIF duality</strong>. The webphone never imports a D365-specific entry point. On startup it inspects <code>window.Microsoft.CIFramework</code> and picks an adapter: a real CIF 1.0 or 2.0 adapter when hosted in Dynamics, or a no-op adapter when running alone. The telephony code and the UI don&#8217;t know or care which one they got — they talk to a <code>CifAdapter</code> interface.</p>
<pre><code>                          POST /token  (JWT access token)
   ┌───────────────┐  &#x25c0;───────────────────────────────  ┌──────────────┐
   │   webphone    │                                     │   server/    │
   │ (browser)     │  ────────────────────────────&#x25b6;    │ Express      │
   │               │       (token request)               │              │
   │  TwilioDevice │                                     │  createVoice │
   │  EventBus     │                                     │  Token       │
   │  CifAdapter   │                                     │  buildVoice  │
   └───────┬───────┘                                     │  Response    │
           │  @twilio/voice-sdk                          └──────▲───────┘
           │  (media + signaling)                               │ POST /voice
           ▼                                                    │ (TwiML)
     ┌───────────┐   webhook when a call starts                 │
     │  Twilio    │  ──────────────────────────────────────────┘
     │  edge      │
     └───────────┘

   Host detection (runtime, in the browser):

     window.Microsoft.CIFramework?
       ├─ absent .................. NoopAdapter  (standalone phone)
       ├─ present ................. Cif1Adapter  (single-session, default)
       └─ present + ?cifversion=2   Cif2Adapter  (multisession, opt-in)</code></pre>
<p>The rest of the post follows the data flow: backend first, then the standalone phone, then the D365 layer on top.</p>
<p>&nbsp;</p>
<h2>2. The token / TwiML backend</h2>
<p>&nbsp;</p>
<p>The browser must never hold your Twilio Account SID and Auth Token — anything shipped to the client is readable by the client. Instead the backend mints a <strong>short-lived access token</strong> (a JWT) that authorizes exactly one browser client for one hour, and nothing else.</p>
<p>Two things are worth understanding before the code.</p>
<p><strong>Why an API Key, not the Auth Token.</strong> Twilio access tokens are <em>signed</em> with an API Key SID/Secret pair, not with your account&#8217;s Auth Token. An API key can be rotated or revoked independently of the account, so a leaked signing key is a contained incident. Config reflects this — the server requires the API key pair and refuses to start without it:</p>
<pre class="ts"><code>// server/src/config.ts
const REQUIRED = [
  "TWILIO_ACCOUNT_SID", "TWILIO_API_KEY_SID", "TWILIO_API_KEY_SECRET",
  "TWILIO_TWIML_APP_SID", "TWILIO_CALLER_ID",
] as const;

export function loadConfig(env: NodeJS.ProcessEnv): AppConfig {
  const missing = REQUIRED.filter((k) =&gt; !env[k]);
  if (missing.length &gt; 0) {
    throw new Error(`Missing required environment variables: ${missing.join(", ")}`);
  }
  // ...builds a validated AppConfig
}</code></pre>
<p><strong>Why a TwiML App.</strong> The token grants the browser the right to place outbound calls <em>through a specific TwiML App</em>. A TwiML App is just a named pointer to a Voice Request URL. When the browser SDK places a call, Twilio looks up that app, calls its Voice URL (our <code>/voice</code> endpoint), and does whatever TwiML comes back. This indirection is what lets the <em>server</em> decide how each call is dialed, even though the <em>browser</em> initiated it.</p>
<p>Minting the token:</p>
<pre class="ts"><code>// server/src/token.ts
const { AccessToken } = twilio.jwt;
const { VoiceGrant } = AccessToken;

export function createVoiceToken(config: AppConfig, identity?: string): string {
  const who = identity ?? config.agentIdentity;
  const token = new AccessToken(
    config.accountSid,
    config.apiKeySid,
    config.apiKeySecret,
    { identity: who, ttl: 3600 },
  );
  token.addGrant(
    new VoiceGrant({
      outgoingApplicationSid: config.twimlAppSid,
      incomingAllow: true,
    }),
  );
  return token.toJwt();
}</code></pre>
<p>The <code>VoiceGrant</code> says two things: outbound calls route through <code>outgoingApplicationSid</code> (our TwiML App), and <code>incomingAllow: true</code> lets Twilio ring this browser client by its <code>identity</code>. That&#8217;s the whole basis for browser-to-PSTN and PSTN-to-browser calling.</p>
<p>When a call actually starts, Twilio POSTs to <code>/voice</code> and we return TwiML. Here is a gotcha that cost us a real debugging session: <strong>both</strong> outbound (browser → PSTN) and inbound (PSTN → your Twilio number) calls hit the <em>same</em> TwiML App Voice URL, so this one handler has to tell them apart itself.</p>
<p><strong>The bug we hit first.</strong> The obvious implementation is &#8220;if there is a <code>To</code>, dial it.&#8221; It works for outbound and <em>silently breaks inbound</em>: on an inbound call <code>To</code> is <em>your own Twilio number</em>, so &#8220;dial <code>To</code>&#8221; makes Twilio call itself and the agent&#8217;s browser never rings. The reliable discriminator is <code>From</code> — a browser-SDK call arrives with <code>From="client:&lt;identity&gt;"</code> (dial the <code>To</code> it carries), while an inbound PSTN call arrives with <code>From=&lt;caller&gt;</code> and must instead ring the agent&#8217;s client:</p>
<pre class="ts"><code>// server/src/twiml.ts
export function buildVoiceResponse(config: AppConfig, params: VoiceParams): string {
  const response = new VoiceResponse();
  const to = params.To?.trim();
  const isOutboundFromClient = (params.From?.trim() ?? "").startsWith("client:");

  if (isOutboundFromClient &amp;&amp; to) {
    const dial = response.dial({ callerId: config.callerId });
    if (isPhoneNumber(to)) dial.number(to);
    else dial.client(to);
  } else {
    const dial = response.dial();
    dial.client(config.agentIdentity);
  }
  return response.toString();
}</code></pre>
<p>Two branches fall out:</p>
<ul>
<li><strong>Outbound</strong> (<code>From="client:…"</code>): the <code>To</code> we passed from the browser is the real target. A phone number → <code>&lt;Dial&gt;&lt;Number&gt;</code> it with <code>TWILIO_CALLER_ID</code> as the caller ID (<code>isPhoneNumber</code> is a <code>/^[+\d]/</code> test on the trimmed target); anything else → <code>&lt;Dial&gt;&lt;Client&gt;</code> for browser-to-browser.</li>
<li><strong>Inbound</strong> (anything else): we must <strong>not</strong> dial <code>To</code> — that would be dialing our own Twilio number, and the inbound call never connects. We ring the agent&#8217;s browser with <code>&lt;Dial&gt;&lt;Client&gt;agentIdentity&lt;/Client&gt;&lt;/Dial&gt;</code>.</li>
</ul>
<p>The Express layer wires these into routes. Note that <code>/voice</code> reads form-encoded fields (Twilio posts <code>application/x-www-form-urlencoded</code>), and <code>/token</code> reads JSON:</p>
<pre class="ts"><code>// server/src/routes.ts
app.post("/token", (req, res) =&gt; {
  const identity = typeof req.body?.identity === "string" ? req.body.identity : undefined;
  const token = createVoiceToken(config, identity);
  res.json({ token, identity: identity ?? config.agentIdentity });
});

app.post("/voice", (req, res) =&gt; {
  const xml = buildVoiceResponse(config, {
    To: req.body?.To, From: req.body?.From, Direction: req.body?.Direction,
  });
  res.type("text/xml").send(xml);
});</code></pre>
<p>There&#8217;s also a permissive CORS shim (so the Vite dev server can call <code>/token</code>) and a <code>GET /health</code>. See <code>server/src/routes.ts</code> for the full file.</p>
<p>&nbsp;</p>
<h2>3. The standalone softphone</h2>
<p>&nbsp;</p>
<p>The webphone is deliberately decoupled: a <strong>typed event bus</strong> sits between the Twilio device, the UI, and (later) the CIF layer, so none of them import each other directly.</p>
<p>The bus is a tiny typed pub/sub. The event map is the contract:</p>
<pre class="ts"><code>// webphone/src/events.ts
export type CallStatus = "idle" | "connecting" | "ringing" | "in-call" | "on-hold";

export interface PhoneEventMap {
  statusChanged: { status: CallStatus };
  incoming: { from: string };
  connected: { direction: "inbound" | "outbound"; peer: string };
  disconnected: { direction: "inbound" | "outbound"; peer: string; durationSec: number };
  error: { message: string };
}</code></pre>
<p><code>EventBus.on/off/emit</code> are generic over <code>keyof PhoneEventMap</code>, so a handler for <code>"disconnected"</code> is statically known to receive <code>{ direction, peer, durationSec }</code> and nothing else.</p>
<p><code>TwilioDevice</code> wraps the Twilio Voice SDK and translates its callbacks into those bus events. Initialization fetches a token, constructs the <code>Device</code>, wires callbacks, and registers:</p>
<pre class="ts"><code>// webphone/src/device.ts
async init(): Promise {
  const token = await this.fetchToken();
  this.device = new Device(token);
  this.device.on("incoming", (call: Call) =&gt; this.onIncoming(call));
  this.device.on("error", (e: { message?: string }) =&gt;
    this.bus.emit("error", { message: e?.message ?? "Device error" }));
  this.device.on("tokenWillExpire", async () =&gt; {
    try {
      const fresh = await this.fetchToken();
      this.device?.updateToken(fresh);
    } catch (e) {
      this.bus.emit("error", { message: e instanceof Error ? e.message : "Token refresh failed" });
    }
  });
  await this.device.register();
  this.bus.emit("statusChanged", { status: "idle" });
}</code></pre>
<p>The token refresh is itself wrapped: if <code>fetchToken</code> throws (network blip, server rejects the request), the failure surfaces as an <code>error</code> event on the bus instead of an unhandled rejection — the device keeps running on its current token rather than crashing the call.</p>
<p>Placing a call is <code>device.connect({ params: { To: target } })</code> — those <code>params</code> are exactly the form fields Twilio later posts to <code>/voice</code>, which is how the browser&#8217;s target reaches our TwiML builder. Each call is &#8220;attached&#8221; so its lifecycle drives the bus:</p>
<pre class="ts"><code>// webphone/src/device.ts
private attachCall(call: Call, peer: string): void {
  this.call = call;
  call.on("accept", () =&gt; {
    this.startedAt = Date.now();
    this.bus.emit("statusChanged", { status: "in-call" });
    this.bus.emit("connected", { direction: this.direction, peer });
  });
  call.on("disconnect", () =&gt; {
    const durationSec = this.startedAt ? Math.round((Date.now() - this.startedAt) / 1000) : 0;
    this.bus.emit("disconnected", { direction: this.direction, peer, durationSec });
    this.bus.emit("statusChanged", { status: "idle" });
    this.reset();
  });
}</code></pre>
<h3>Mute and hold</h3>
<p>Mute is straightforward: it mutes the transport. <strong>Hold, in this build, is realized as a persistent mute plus a distinct status</strong> — not a true carrier-side hold. The device keeps two independent booleans and derives the actual transport state from both:</p>
<pre class="ts"><code>// webphone/src/device.ts
setMuted(muted: boolean): void {
  this.muted = muted;
  this.applyMute();
}

/** Reflects the user's mute toggle. Hold mutes the transport independently. */
isMuted(): boolean {
  return this.muted;
}

/** Hold is realized as a persistent mute with a distinct status (documented). */
setHold(hold: boolean): void {
  this.held = hold;
  this.applyMute();
  this.bus.emit("statusChanged", { status: hold ? "on-hold" : "in-call" });
}

/** Transport is muted when the user muted OR the call is on hold. */
private applyMute(): void {
  this.call?.mute(this.muted || this.held);
}</code></pre>
<p>Note the split responsibility: <code>isMuted()</code> returns the <em>user&#8217;s</em> mute toggle, while <code>applyMute()</code> computes <code>this.muted || this.held</code> for the wire. So hold silences the transport but doesn&#8217;t flip the user-facing mute state. This is a deliberate simplification — the wrap-up covers how you&#8217;d swap in a real TwiML <code>&lt;Enqueue&gt;</code>-based hold.</p>
<h3>The controller and UI</h3>
<p><code>PhoneController</code> mediates UI intents and the device, holding its own mute/hold toggle state:</p>
<pre class="ts"><code>// webphone/src/controller.ts
toggleMute(): void {
  this.muted = !this.muted;
  this.device.setMuted(this.muted);
}
toggleHold(): void {
  this.held = !this.held;
  this.device.setHold(this.held);
}</code></pre>
<p>The UI is plain DOM. <code>renderDialpad</code> builds a keypad, a number field, and a Call button; <code>renderCallControls</code> wires Answer/Hangup/Mute/Hold to the controller; <code>renderStatus</code> subscribes to the bus and shows a live call timer. None of them know Twilio exists — they only touch the controller and the bus.</p>
<p>Everything is assembled in <code>main.ts</code> — and the exact shape here is load-bearing for the D365 case. The UI renders <em>immediately</em>, but the controller is created only after the CIF host is detected, so the UI drives it through a lazy indirection (clicks before it is ready are harmless no-ops):</p>
<pre class="ts"><code>// webphone/src/main.ts
const app = document.getElementById("app")!;
const bus = new EventBus();
const tokenUrl = import.meta.env.VITE_TOKEN_ENDPOINT ?? "http://localhost:3001/token";

const device = new TwilioDevice(bus, { tokenUrl });

// The controller is created only after the CIF host is detected (below), so
// the UI drives it through this indirection.
let controller: PhoneController | undefined;
const actions: CallActions = {
  answer: () =&gt; controller?.answer(),
  hangup: () =&gt; controller?.hangup(),
  toggleMute: () =&gt; controller?.toggleMute(),
  toggleHold: () =&gt; controller?.toggleHold(),
};

renderStatus(app, bus);
renderDialpad(app, (n) =&gt; void controller?.dial(n));
renderCallControls(app, actions);

// Wait for D365 to inject Microsoft.CIFramework before choosing the adapter
// (section 4); standalone falls through after a timeout.
void (async () =&gt; {
  await waitForCifFramework(window);
  const cif = createCifAdapter(window);
  controller = new PhoneController(bus, device, cif);
  await controller.start();
})().catch((e) =&gt;
  bus.emit("error", { message: e instanceof Error ? e.message : String(e) }));</code></pre>
<p><strong>Running it alone:</strong> start the server (<code>npm run dev --workspace server</code>), then <code>npm run dev --workspace webphone</code>, and open <code>http://localhost:5173</code>. Dial an E.164 number or another client identity and call. Because <code>createCifAdapter</code> returns a <code>NoopAdapter</code> when D365 is absent, the phone is fully functional on its own — no Dynamics required.</p>
<p>&nbsp;</p>
<h2>4. Adding D365 CIF</h2>
<p>&nbsp;</p>
<p>Now the interesting part: making the same app integrate with Dynamics when it happens to be hosted there. But before any adapter code can run, two real-world hurdles have to be cleared that the tutorials gloss over: getting the CIF library loaded at all, and waiting for it to be ready.</p>
<h3>Loading the CIF client library</h3>
<p>This was the single biggest surprise of the whole project. In the tutorial version of CIF, Dynamics injects <code>Microsoft.CIFramework</code> into the panel iframe for you. That injection only happens when the iframe is served from the <em>same origin</em> as Dynamics. Our softphone is hosted on its own static host — a <strong>different origin</strong> — so D365 injects nothing, detection sees nothing, and we silently fall back to the <code>NoopAdapter</code>: no screen pop, no logging, no click-to-dial, and no error anywhere to tell you why.</p>
<p>The fix is documented but easy to miss: the widget has to load <code>msdyn_ciLibrary.js</code> <em>from the D365 org itself</em>, which then bridges to the parent window and exposes <code>Microsoft.CIFramework</code> inside our iframe. So <code>index.html</code> ships a tiny inline loader that auto-detects the framing org (from <code>ancestorOrigins</code>, falling back to <code>document.referrer</code>, overridable with <code>?crmurl=</code>) and injects the library with the <code>data-crmurl</code> / <code>data-cifid="CIFMainLibrary"</code> attributes CIF expects. It only runs when actually framed, so the standalone phone is untouched:</p>
<pre class="html"><code>&lt;!-- webphone/index.html --&gt;
&lt;script&gt;
  (function () {
    function originOf(u) { try { return new URL(u).origin; } catch (e) { return ""; } }
    var params = new URLSearchParams(window.location.search);
    var crmUrl = params.get("crmurl") || "";
    var lib = params.get("ucilib") || "";
    if (!crmUrl &amp;&amp; !lib) {
      var ao = window.location.ancestorOrigins;
      if (ao &amp;&amp; ao.length) crmUrl = ao[0];
      else if (document.referrer) crmUrl = originOf(document.referrer);
    }
    if (!lib &amp;&amp; crmUrl) lib = crmUrl.replace(/\/+$/, "") + "/webresources/Widget/msdyn_ciLibrary.js";
    if (!crmUrl &amp;&amp; lib) crmUrl = originOf(lib);
    if (lib &amp;&amp; crmUrl &amp;&amp; window.self !== window.top) {
      var s = document.createElement("script");
      s.type = "text/javascript";
      s.src = lib;
      s.setAttribute("data-crmurl", crmUrl);
      s.setAttribute("data-cifid", "CIFMainLibrary");
      document.head.appendChild(s);
    }
  })();
&lt;/script&gt;</code></pre>
<p>The symptom when this is missing is maddening precisely because nothing errors: the panel renders, the phone works standalone, and every CIF feature just quietly no-ops.</p>
<h3>Waiting for CIF to be ready (the <code>CIFInitDone</code> event)</h3>
<p>That library loads <em>asynchronously</em>, so <code>Microsoft.CIFramework</code> is usually <em>not</em> present at the instant the app boots. Pick the adapter synchronously on startup (as an earlier version did) and you race the loader and lose — you get the <code>NoopAdapter</code> again. The library raises a documented <code>CIFInitDone</code> event on <code>window</code> once its APIs are ready, so we wait on that, with a poll-and-timeout fallback that also covers the race where the event fires before we subscribe and provides the standalone exit (no CIF host ever appears):</p>
<pre class="ts"><code>// webphone/src/cif/detect.ts
export function waitForCifFramework(
  w: Window,
  timeoutMs = 8000,
  intervalMs = 250,
): Promise&lt;void&gt; {
  if (w.Microsoft?.CIFramework) return Promise.resolve();
  return new Promise((resolve) =&gt; {
    const start = Date.now();
    let settled = false;
    const finish = () =&gt; {
      if (settled) return;
      settled = true;
      w.removeEventListener("CIFInitDone", finish);
      clearInterval(timer);
      resolve();
    };
    // Primary: the documented readiness event.
    w.addEventListener("CIFInitDone", finish);
    // Fallback: poll for the library and enforce the timeout.
    const timer = setInterval(() =&gt; {
      if (w.Microsoft?.CIFramework || Date.now() - start &gt;= timeoutMs) finish();
    }, intervalMs);
  });
}</code></pre>
<p>This is why <code>main.ts</code> (section 3) renders the UI first and only creates the <code>PhoneController</code> inside the <code>await waitForCifFramework(window)</code> continuation: the agent sees a working dialpad instantly, and adapter selection happens the moment CIF signals it is ready — or after the timeout, standalone.</p>
<p>With the library loaded and ready, the rest of the integration hangs off one interface:</p>
<pre class="ts"><code>// webphone/src/cif/types.ts
export interface CifAdapter {
  readonly version: "1.0" | "2.0" | "none";
  init(): Promise;
  /** Search for a contact by phone number and open the record. */
  screenPop(phoneNumber: string): Promise;
  /** Create a Phone Call activity in D365 for a finished call. */
  logCall(log: CallLog): Promise;
  /** Subscribe to D365 click-to-dial (onclicktoact) events. */
  onClickToDial(handler: (phoneNumber: string) =&gt; void): void;
}</code></pre>
<h3>Feature detection</h3>
<p>Detection is a two-step: is CIF present at all, and if so which version? Our first instinct was to sniff the version from a capability — &#8220;if <code>getFocusedSession</code> exists it must be 2.0.&#8221; <strong>That is wrong, and it is worth knowing why:</strong> the shared <code>msdyn_ciLibrary.js</code> exposes the session-management methods in <em>both</em> 1.0 and 2.0, so their presence cannot tell the versions apart. Instead we default to single-session <strong>1.0</strong> (the model-driven-app case) and let a multisession deployment (Omnichannel / Copilot Service workspace) opt in explicitly with <code>?cifversion=2</code> on the widget URL:</p>
<pre class="ts"><code>// webphone/src/cif/detect.ts
/** Explicit override in the widget URL, e.g. `?cifversion=2`. */
function forcedVersion(w: Window): "2.0" | undefined {
  return /(?:^|[?&amp;])cifversion=2(?:\.0)?(?:&amp;|$)/.test(w.location?.search ?? "")
    ? "2.0"
    : undefined;
}

export function detectCifVersion(w: Window): "1.0" | "2.0" | "none" {
  if (!w.Microsoft?.CIFramework) return "none";
  return forcedVersion(w) ?? "1.0";
}</code></pre>
<p>A factory turns that into a concrete adapter:</p>
<pre class="ts"><code>// webphone/src/cif/factory.ts
export function createCifAdapter(w: Window): CifAdapter {
  const version = detectCifVersion(w);
  const api = w.Microsoft?.CIFramework;
  if (version === "none" || !api) return new NoopAdapter();
  return version === "2.0" ? new Cif2Adapter(api) : new Cif1Adapter(api);
}</code></pre>
<p>The <code>NoopAdapter</code> implements every method as a logged no-op, which is exactly what makes standalone mode work without any conditionals in the controller.</p>
<h3>BaseCifAdapter: shared behavior</h3>
<p>The real CIF work lives in a base class the two version adapters extend:</p>
<pre class="ts"><code>// webphone/src/cif/adapter.ts
export abstract class BaseCifAdapter implements CifAdapter {
  abstract readonly version: "1.0" | "2.0";
  protected constructor(protected api: CIFramework) {}

  async init(): Promise {
    await this.api.setClickToAct?.(true);
  }

  /** Version-specific work before opening a record (e.g. focus the session). */
  protected async beforeScreenPop(): Promise {}

  async screenPop(phoneNumber: string): Promise {
    await this.beforeScreenPop();
    const query = buildContactPhoneQuery(phoneNumber);
    try {
      await this.api.searchAndOpenRecords("contact", query, false);
    } catch (err) {
      console.warn("[cif] screenPop failed", err);
    }
  }

  async logCall(log: CallLog): Promise {
    const data = {
      subject: log.subject,
      phonenumber: log.phoneNumber,
      directioncode: log.direction === "outbound",
      actualdurationminutes: Math.max(1, Math.ceil(log.durationSec / 60)),
      description: `${log.direction} call, ${log.durationSec}s`,
    };
    try {
      await this.api.createRecord("phonecall", JSON.stringify(data));
    } catch (err) {
      console.error("[cif] logCall failed", err);
    }
  }

  onClickToDial(handler: (phoneNumber: string) =&gt; void): void {
    this.api.addHandler("onclicktoact", (eventData: string) =&gt; {
      try {
        const parsed = JSON.parse(eventData) as { value?: string };
        if (parsed.value) handler(parsed.value);
      } catch {
        /* ignore malformed payloads */
      }
    });
  }
}</code></pre>
<p>The screen-pop query earns its own function, because a naive lookup was the second gotcha we hit in a live tenant. Our first version ran a single exact match — <code>telephone1 eq '&lt;number&gt;'</code> — and it <em>missed constantly</em>. Real contacts keep their number in <code>mobilephone</code>, or store it with formatting (spaces, dashes, parentheses), or in national rather than E.164 form. When the exact match misses, <code>searchAndOpenRecords</code> opens the multi-result search page instead of the contact, so the agent still has to click. We broadened it to search <code>telephone1</code>, <code>mobilephone</code>, <code>telephone2</code>, and <code>telephone3</code>, each by three variants: the exact E.164 value, the contiguous digit string, and the last-7-digit national tail:</p>
<pre class="ts"><code>// webphone/src/cif/adapter.ts
const PHONE_FIELDS = ["telephone1", "mobilephone", "telephone2", "telephone3"];

export function buildContactPhoneQuery(phoneNumber: string): string {
  const esc = (v: string) =&gt; v.replace(/'/g, "''");
  const e164 = phoneNumber.trim();
  const digits = e164.replace(/\D/g, "");
  const tail = digits.slice(-7);
  const clauses: string[] = [];
  for (const field of PHONE_FIELDS) {
    clauses.push(`${field} eq '${esc(e164)}'`);
    if (digits) clauses.push(`contains(${field},'${esc(digits)}')`);
    if (tail.length &gt;= 6) clauses.push(`contains(${field},'${esc(tail)}')`);
  }
  return `?$select=fullname,telephone1,mobilephone&amp;$filter=${clauses.join(" or ")}`;
}</code></pre>
<p>Each value is still single-quote-escaped (OData escapes a literal quote by doubling it) before interpolation. One limitation survives even so: a number stored with <em>internal</em> spaces (e.g. <code>"040 123 4567"</code>) will not match the <code>contains</code> on a spaceless digit string, because OData <code>contains</code> is a literal substring test — normalizing stored data, or a fuzzier server-side search, is the next step.<br />
A few details worth pausing on:</p>
<ul>
<li><strong>The screen-pop query</strong> is built by <code>buildContactPhoneQuery</code> (above) and passed to <code>searchAndOpenRecords("contact", query, false)</code> — the <code>false</code> means &#8220;don&#8217;t search-only; open the matched record.&#8221; That is the pop.</li>
<li><strong>Call logging</strong> creates a <code>phonecall</code> activity. <code>directioncode</code> is a boolean (<code>true</code> for outbound). <code>actualdurationminutes</code> uses <code>Math.max(1, Math.ceil(durationSec / 60))</code> — call seconds are rounded <strong>up</strong> to whole minutes, and any answered call is at least one minute, so a 5-second call still logs as 1 minute rather than 0. (Dynamics stores activity duration in whole minutes.)</li>
<li><strong>Click-to-dial</strong> subscribes to CIF&#8217;s <code>onclicktoact</code> event and pulls the dialed number out of the JSON payload&#8217;s <code>value</code> field, ignoring anything malformed.</li>
<li><strong>Both CIF calls are wrapped in try/catch</strong> that logs and resolves rather than rejecting, so a CIF API failure (a stale session, a transient D365 error) degrades to a logged no-op instead of breaking the call flow — the same standalone-friendly posture the <code>NoopAdapter</code> gives you when CIF isn&#8217;t present at all.</li>
</ul>
<h3>Wiring CIF to the call lifecycle</h3>
<p>The controller connects the bus events to the adapter. This is where inbound calls pop records, finished calls get logged, and CIF click-to-dial places calls — all without the device or UI knowing CIF exists:</p>
<pre class="ts"><code>// webphone/src/controller.ts
async start(): Promise {
  await this.device.init();
  await this.cif.init();

  this.cif.onClickToDial((n) =&gt; void this.dial(n));

  this.bus.on("incoming", ({ from }) =&gt; void this.cif.screenPop(from));
  this.bus.on("disconnected", ({ direction, peer, durationSec }) =&gt;
    void this.cif.logCall({
      direction, phoneNumber: peer, durationSec,
      subject: `${direction === "outbound" ? "Outbound" : "Inbound"} call with ${peer}`,
    }));
}</code></pre>
<p>Read that top to bottom and you have the whole integration: an inbound call emits <code>incoming</code> → <code>screenPop(from)</code>; a click-to-dial event calls <code>dial(n)</code>; a <code>disconnected</code> event logs the call. In standalone mode the same wiring runs, but every CIF call is a no-op.</p>
<p>&nbsp;</p>
<h2>5. CIF 1.0 vs CIF 2.0</h2>
<p>&nbsp;</p>
<p>The two versions differ in one architectural way: <strong>sessions</strong>. CIF 1.0 is single-session — there&#8217;s one active context, and a screen pop just targets the main form. CIF 2.0 (Omnichannel / Copilot Service workspace) is multisession — an agent may have several conversations open at once, so before you pop a record you should make sure you&#8217;re acting on the <em>focused</em> session.</p>
<p>The adapters encode exactly that difference and nothing more. CIF 1.0 adds no behavior over the base:</p>
<pre class="ts"><code>// webphone/src/cif/cif1.ts
export class Cif1Adapter extends BaseCifAdapter {
  readonly version = "1.0" as const;
  constructor(api: CIFramework) { super(api); }
}</code></pre>
<p>CIF 2.0 overrides the <code>beforeScreenPop</code> hook to focus the current session first:</p>
<pre class="ts"><code>// webphone/src/cif/cif2.ts
export class Cif2Adapter extends BaseCifAdapter {
  readonly version = "2.0" as const;
  constructor(api: CIFramework) { super(api); }

  protected override async beforeScreenPop(): Promise {
    if (!this.api.getFocusedSession || !this.api.requestFocusSession) return;
    try {
      const sessionId = await this.api.getFocusedSession();
      if (sessionId) await this.api.requestFocusSession(sessionId);
    } catch {
      /* session APIs only work in multisession apps; ignore elsewhere */
    }
  }
}</code></pre>
<p>Because <code>screenPop</code> lives entirely in the base class and only calls <code>beforeScreenPop()</code> as a hook, the multisession concern is isolated to this one override. The base class&#8217;s own <code>beforeScreenPop</code> is an empty method, so CIF 1.0 &#8220;just works&#8221; and CIF 2.0 gets its focus step — a clean template-method split.</p>
<p>&nbsp;</p>
<h2>6. Wiring it into Dynamics</h2>
<p>&nbsp;</p>
<p>The <code>webphone/dist/</code> build is a static bundle; Dynamics loads it into a channel panel iframe. The D365 side is configuration plus a few web resources. Full, step-by-step instructions live under <code>d365/</code> — here&#8217;s the shape of it.</p>
<p><strong>1. Deploy the build.</strong> Run <code>npm run build --workspace webphone</code> and host <code>webphone/dist/</code> over HTTPS (CIF requires HTTPS). Note the exact origin — you&#8217;ll need it for the trusted domain. Make sure <code>VITE_TOKEN_ENDPOINT</code> (baked in at build time) points at a reachable <code>server/</code> instance.</p>
<p><strong>2. Register a channel provider.</strong> Which CIF version you get is decided by which provider you register, not by the build:</p>
<ul>
<li><strong>CIF 1.0</strong> — install the &#8220;Dynamics 365 Channel Integration Framework&#8221; app from AppSource and create a provider record with the webphone URL as the <strong>Channel URL</strong>. See <code>d365/provider-config/cif1-provider.md</code>.</li>
<li><strong>CIF 2.0</strong> — install the &#8220;Channel Integration Framework 2.0&#8221; solution and create an <code>msdyn_channelprovider</code> record with the webphone URL as the <strong>Landing URL</strong>. See <code>d365/provider-config/cif2-provider.md</code>.</li>
</ul>
<p><strong>3. Set the trusted domain.</strong> In both cases the trusted domain (CIF 1.0) / trusted domains (CIF 2.0, a list) must be the webphone&#8217;s <strong>origin</strong> — scheme + host, no path. If CIF&#8217;s trusted-domain list doesn&#8217;t include the origin it&#8217;s loading, the panel won&#8217;t render.</p>
<p><strong>4. Add the web resources.</strong> Four files ship in the solution (<code>d365/webresources/</code>):</p>
<ul>
<li><code>clicktodial.js</code> — self-registers with <code>Microsoft.CIFramework</code>, enabling click-to-act and giving you a CRM-side hook on <code>onclicktoact</code> for logging/validation. (The softphone in the panel subscribes to the same event independently.)</li>
<li><code>formIntegration.js</code> — a Contact form <strong>OnLoad</strong> handler, <code>TwilioCif.onFormLoad</code>, that reads <code>telephone1</code> and raises a <code>contactloaded</code> custom event the panel can listen for. Attach it with &#8220;pass execution context as first parameter&#8221; checked, since it calls <code>executionContext.getFormContext()</code>.</li>
<li><code>cifSession.js</code> — CIF 2.0 session helpers (<code>focusCurrentSession</code>), no-ops in single-session apps.</li>
<li><code>softphone.html</code> — an optional iframe host; you can also point the provider&#8217;s Channel/Landing URL straight at the deployed webphone and skip this file.</li>
</ul>
<p><strong>5. Package and import the solution.</strong> <code>d365/solution/README.md</code> walks through bundling the web resources plus the provider record into an unmanaged solution under a <code>twilio</code> publisher prefix, wiring <code>TwilioCif.onFormLoad</code> to the Contact form, exporting the <code>.zip</code>, and importing it into the target environment.</p>
<p><strong>6. Reload the app.</strong> CIF reads provider registration once per app session, so fully reload (or close and reopen) the model-driven app before the Twilio panel appears.</p>
<p>&nbsp;</p>
<h2>7. Testing and running</h2>
<p>&nbsp;</p>
<p>The project ships two Vitest suites, run together with <code>npm test</code> from the repo root:</p>
<ul>
<li><strong><code>server/</code>: 14 tests</strong> across <code>config</code>, <code>token</code>, <code>twiml</code>, and <code>routes</code> — they cover config validation (missing env vars throw), token minting (a valid JWT with the right grant), the TwiML branch table (PSTN number vs. client vs. inbound), and the HTTP endpoints.</li>
<li><strong><code>webphone/</code>: 25 tests</strong> across <code>events</code>, <code>device</code>, <code>controller</code>, <code>dialpad</code>, <code>cif-detect</code>, and <code>cif-adapter</code> — they cover the typed event bus, the device&#8217;s event translation, controller wiring, dialpad DOM behavior, version detection, and the base adapter&#8217;s screen-pop/log/click-to-dial logic (including the round-up-to-minutes rule).</li>
</ul>
<p>Because the interesting logic (TwiML branching, duration rounding, version detection, event translation) is in pure functions and thin wrappers, it&#8217;s all unit-testable without a live Twilio account or a Dynamics environment.</p>
<p><strong>Exposing <code>/voice</code> with ngrok.</strong> Twilio calls <code>/voice</code> over the public internet, so <code>localhost:3001</code> isn&#8217;t reachable from Twilio in local dev. Tunnel it:</p>
<pre class="bash"><code>ngrok http 3001</code></pre>
<p>Then set the resulting <code>https://&lt;subdomain&gt;.ngrok-free.app/voice</code> as your TwiML App&#8217;s <strong>Voice Request URL</strong>. Only <code>/voice</code> needs to be internet-reachable — <code>/token</code> and <code>/health</code> are called by your own browser.</p>
<p><strong>Manual smoke test (inside D365).</strong> After importing the solution and registering the provider, verify the three integration points end to end (checklist in <code>d365/README.md</code>):</p>
<ol>
<li><strong>Click-to-dial</strong> — open a Contact with a <code>telephone1</code>, trigger click-to-dial, confirm the panel places the call.</li>
<li><strong>Inbound → screen pop</strong> — call the Twilio number from an external phone, confirm it rings in the panel and pops the matching Contact.</li>
<li><strong>Hang up → activity logged</strong> — end the call, confirm a Phone Call activity is created against the Contact with direction, number, and duration.</li>
</ol>
<h3>Deploying to production</h3>
<p>Everything above assumes <code>localhost</code> plus an ngrok tunnel. For a real deployment, the server needs a public HTTPS host — e.g. <strong>Azure App Service</strong> — because Twilio&#8217;s cloud calls the <code>/voice</code> webhook directly, not just your own browser. The softphone, by contrast, is just a static bundle (<code>webphone/dist/</code>) that belongs on a static host such as <strong>Azure Static Web Apps</strong>, served over HTTPS and embedded inside the D365 CIF panel via an iframe. That iframe embedding only works if the static host sends a <code>Content-Security-Policy: frame-ancestors https://*.dynamics.com</code> header (and no conflicting <code>X-Frame-Options</code>) — otherwise the CIF panel renders blank. See <code>docs/DEPLOYMENT.md</code> for the full hosting guide, including Azure-specific steps and a <code>staticwebapp.config.json</code> example.</p>
<p>&nbsp;</p>
<p><img fetchpriority="high" decoding="async" class="alignnone wp-image-40 size-large" src="https://mobali.com/wp-content/uploads/2026/07/2026-07-09_20-18-24-1024x596.png" alt="" width="1024" height="596" srcset="https://mobali.com/wp-content/uploads/2026/07/2026-07-09_20-18-24-1024x596.png 1024w, https://mobali.com/wp-content/uploads/2026/07/2026-07-09_20-18-24-300x175.png 300w, https://mobali.com/wp-content/uploads/2026/07/2026-07-09_20-18-24-768x447.png 768w, https://mobali.com/wp-content/uploads/2026/07/2026-07-09_20-18-24-1536x894.png 1536w, https://mobali.com/wp-content/uploads/2026/07/2026-07-09_20-18-24-2048x1191.png 2048w" sizes="(max-width: 1024px) 100vw, 1024px" /></p>
<p>&nbsp;</p>
<h2>8. Wrap-up and hardening</h2>
<p>&nbsp;</p>
<p>What we built: one browser softphone that is a complete phone on its own and, when dropped into a Dynamics 365 CIF panel, transparently gains screen pop, call logging, and click-to-dial — with the CIF-1.0-vs-2.0 difference reduced to a single overridden hook. The seam that makes this work is runtime host detection plus a <code>CifAdapter</code> interface the rest of the app codes against.</p>
<h3>Lessons from wiring it into a live tenant</h3>
<p>Almost everything above that is not boilerplate came from the gap between &#8220;compiles and passes tests&#8221; and &#8220;works inside a real Dynamics org.&#8221; Five things bit us, in roughly the order we hit them:</p>
<ul>
<li><strong>One webhook, two directions.</strong> Inbound and outbound share the <code>/voice</code> URL; discriminating on <code>To</code> instead of <code>From</code> makes Twilio dial your own number and inbound never connects (section 2).</li>
<li><strong>The library must be loaded from the org.</strong> Because the widget lives on a different origin, D365 will not inject <code>Microsoft.CIFramework</code> — you have to pull <code>msdyn_ciLibrary.js</code> from the org yourself, or every CIF feature silently no-ops (section 4).</li>
<li><strong>CIF loads asynchronously.</strong> Select the adapter before <code>CIFInitDone</code> fires and you race the loader straight into the no-op adapter (section 4).</li>
<li><strong>The version cannot be feature-detected.</strong> The session APIs exist in both 1.0 and 2.0; default to 1.0 and opt into 2.0 explicitly with <code>?cifversion=2</code> (section 4).</li>
<li><strong>Exact phone matches miss.</strong> Real numbers live in <code>mobilephone</code> and carry formatting, so a single <code>telephone1 eq</code> query opens the search page instead of the contact (section 4).</li>
</ul>
<p>For production, two things in this codebase are deliberately kept simple and should be hardened:</p>
<ul>
<li><strong>Authentication on <code>/token</code>.</strong> As written, anyone who can reach <code>/token</code> can mint a Voice token for your account. In production, put it behind your identity provider (e.g. validate a D365/Entra ID session or a bearer token) and derive the <code>identity</code> from the authenticated user rather than trusting the request body.</li>
<li><strong>Real hold.</strong> Hold here is a client-side mute with an <code>on-hold</code> status (see <code>applyMute()</code> in <code>device.ts</code>) — the caller hears silence, not hold music, and the call isn&#8217;t parked server-side. A production implementation would use TwiML to <code>&lt;Enqueue&gt;</code> the call into a hold queue (with wait-music/<code>&lt;Play&gt;</code>) and dequeue it on resume, so hold survives independently of the browser.</li>
</ul>
<p>Other natural next steps: validating Twilio&#8217;s request signature on <code>/voice</code>, richer activity fields (regarding-object linkage, timestamps), and normalizing stored phone numbers so the screen pop also matches values saved with internal spaces — the one case <code>buildContactPhoneQuery</code> still cannot reach.</p>
<h3>Reference</h3>
<ul>
<li><code>server/README.md</code> — creating the API key and TwiML App, endpoints, ngrok.</li>
<li><code>webphone/README.md</code> — dev/build, CIF auto-detection, hosting <code>dist/</code>.</li>
<li><code>d365/README.md</code> and <code>d365/provider-config/*.md</code> — provider registration, trusted domains, the smoke test.</li>
<li><code>docs/DEPLOYMENT.md</code> — hosting the server and softphone in production (Azure App Service, Azure Static Web Apps, CIF iframe embedding).</li>
</ul>
<p>Link to Github repo: <a href="https://github.com/mobali/twilio-d365-integration">https://github.com/mobali/twilio-d365-integration</a></p>
<p>The post <a href="https://mobali.com/a-browser-softphone-that-also-lives-inside-dynamics-365/">A browser softphone that also lives inside Dynamics 365</a> appeared first on <a href="https://mobali.com">Mobali Solutions</a>.</p>
]]></content:encoded>
					
		
		
			</item>
	</channel>
</rss>
