Building tools. Learning to build tools. Learning to build learning tools.
How a missing API and an exposed debug protocol create an opportunity for custom theming.
CDP Arch
Microsoft Teams ships with exactly three appearance options: Light, Dark, and High Contrast. There is no theme API, no extension point, no CSS override mechanism, and no user-facing way to tweak colors. If you find the white background too bright, the dark mode too stark, or you simply want your chat app to match the VS Code theme you stare at all day — you’re out of luck. Or so it seems.
The “new” Teams (the Electron-to-WebView2 rewrite Microsoft shipped in late 2023) changed the runtime architecture in a way that opens the door to customization. Understanding that architecture is the key to everything that follows.
Classic Teams was an Electron app — Node.js + Chromium bundled together. The
new Teams replaces Electron with WebView2, Microsoft’s embeddable
Chromium control. The native shell (written in C++ / .NET) hosts a WebView2 instance
that renders the same React/Fluent UI web application you’d see in a browser at
teams.microsoft.com.
WebView2 is built on Chromium, and Chromium exposes the Chrome DevTools Protocol (CDP) — a JSON-over-WebSocket API that lets external tools inspect, debug, and manipulate a running browser. CDP is how Chrome DevTools, Playwright, and Puppeteer work. If you can enable it on the Teams WebView2 instance, you gain the same level of access to Teams’ DOM that DevTools gives you on a regular web page.
The Chrome DevTools Protocol exposes “domains” like
Runtime, Page, DOM, and Network.
Each domain has methods you can call over a WebSocket. For theme injection, we only need
two: CDP.List() to discover page targets, and
Runtime.evaluate() to run JavaScript inside the page context.
WebView2 doesn’t expose CDP by default. You have to opt in by passing
--remote-debugging-port=PORT to the Chromium engine at startup.
For standalone apps like Teams and Outlook, Microsoft provides a per-application
registry key mechanism:
# Registry path for per-app WebView2 arguments
$regPath = "HKCU:\Software\Policies\Microsoft\Edge\WebView2\AdditionalBrowserArguments"
# Assign dedicated ports to each app
New-Item -Path $regPath -Force | Out-Null
Set-ItemProperty -Path $regPath -Name "ms-teams.exe" -Value "--remote-debugging-port=9222"
Set-ItemProperty -Path $regPath -Name "olk.exe" -Value "--remote-debugging-port=9223"
Each value name is the executable filename. When ms-teams.exe launches,
the WebView2 loader reads this key and appends the flag. After a restart, Teams listens
on localhost:9222 for CDP connections.
An older approach used the WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS
environment variable. This is a global setting — every WebView2 app
on your system reads it. If Teams and Outlook both try to use port 9222, one will
fail to start. The per-app registry key approach avoids this entirely.
The setup.ps1 script in the project automatically removes the legacy
env var if it finds one.
Once the debug port is active, you can enumerate available targets by calling
CDP.List(). This returns an array of objects describing each browsing
context — pages, service workers, iframes. We filter for type: "page"
to find the main Teams window:
const CDP = require("chrome-remote-interface");
// List all page targets on the Teams debug port
const targets = await CDP.List({ port: 9222 });
const pages = targets.filter(t => t.type === "page");
// Each target has:
// {
// id: "E3F2A1...",
// title: "Microsoft Teams",
// url: "https://teams.microsoft.com/v2/...",
// type: "page",
// webSocketDebuggerUrl: "ws://localhost:9222/devtools/page/E3F2A1..."
// }
The webSocketDebuggerUrl is the WebSocket endpoint for CDP commands.
The chrome-remote-interface npm package wraps this into a clean async API.
In the codebase, the shared cdp.js module handles port scanning,
target identification, and connection:
// From cdp.js — identifying which app a target belongs to
function identifyApp(target) {
const url = (target.url || "").toLowerCase();
const title = (target.title || "").toLowerCase();
if (url.includes("teams.microsoft") || url.includes("teams.live")) return "Teams";
if (url.includes("outlook.office") || url.includes("outlook.live")) return "Outlook";
if (title.includes("teams")) return "Teams";
if (title.includes("outlook") || title.includes("mail")) return "Outlook";
return "WebView2";
}
The module also implements a fallback port scan. If the configured ports yield no targets, it probes ports 9222–9231 looking for any WebView2 instance. This handles cases where the debug port was assigned a non-default value.
With a target identified, we open a CDP session and use Runtime.evaluate()
to execute arbitrary JavaScript inside the Teams page context:
// From cdp.js — connecting to the first Teams target
async function connectToTeams() {
const targets = await findTargets("Teams");
if (targets.length === 0) {
throw new Error("No Teams target found. Is CDP enabled?");
}
// Prefer the primary window (title contains "|")
const target =
targets.find(t => t.title && t.title.includes("|")) || targets[0];
const client = await CDP({ port: target._port, target });
const { Runtime, Page } = client;
await Page.enable();
return { client, Runtime, Page, target };
}
With this connection, any JavaScript you pass to Runtime.evaluate() runs
as if you typed it in the browser console. You have full access to the DOM, computed
styles, localStorage, and every API the page uses.
The core idea is simple: create a <style> element, fill it with CSS
custom property overrides, and append it to <head>. The injected
element gets an ID so it can be found and replaced on subsequent injections:
// From injector.js — building the injection expression
function buildInjectionExpression(css) {
const escaped = css.replace(/\\/g, "\\\\").replace(/`/g, "\\`");
return `
(() => {
let el = document.getElementById('custom-teams-theme');
if (el) el.remove();
const style = document.createElement('style');
style.id = 'custom-teams-theme';
style.textContent = \`${escaped}\`;
document.head.appendChild(style);
})();
`;
}
This pattern is idempotent — calling it again replaces the previous theme rather than stacking overrides. Removal is equally straightforward:
function buildRemoveExpression() {
return `
(() => {
const el = document.getElementById('custom-teams-theme');
if (el) { el.remove(); return 'removed'; }
return 'not found';
})();
`;
}
.fui-FluentProvider and !important
If you try the naïve approach — setting CSS custom properties on
:root — nothing happens. Teams uses
Fluent UI React v9, which applies its design tokens (CSS custom
properties) on the .fui-FluentProvider element, not on :root.
The FluentProvider wrapper sits near the top of the React tree and scopes all token
values to its subtree.
Fluent UI v9 uses a library called Griffel for CSS-in-JS. Griffel
generates atomic CSS classes at build time and applies them with high specificity.
Its styles are injected into <style> tags in
<head>, and they reference the custom properties set on
.fui-FluentProvider. Our overrides must target the same element
with enough specificity to win. The !important flag ensures our
values take precedence over Griffel’s injected styles.
A theme CSS file targets the FluentProvider and overrides every token:
/* Generated theme file — targets the Fluent UI provider */
.fui-FluentProvider {
--colorNeutralBackground1: #d4cfc7 !important;
--colorNeutralBackground2: #cac5bd !important;
--colorNeutralBackground3: #c0bbb3 !important;
--colorNeutralForeground1: #1a1a1a !important;
--colorNeutralForeground2: #2e2e2e !important;
/* ... 490+ more token overrides ... */
}
The complete injection flow involves four components communicating over two protocols:
The injector doesn’t just inject once — it watches the theme CSS file for
changes using fs.watch(). When you edit and save the theme file, the
injector reads the new CSS, rebuilds the injection expression, and pushes it to all
connected Teams windows. Changes appear in under a second, making iterative theme
development fast and visual.
// From injector.js — file watcher for live reload
watcher = fs.watch(themeFile, { persistent: true }, (eventType) => {
if (eventType === "change") {
const css = fs.readFileSync(themeFile, "utf-8");
expression = buildInjectionExpression(css);
injectAll(); // Push to all connected clients
console.log(` Re-injected (file changed)`);
}
});
Teams can have multiple WebView2 page targets — the main window, pop-out chats,
meeting windows, and the calling overlay. The injector connects to all of them
via connectToAllTeams() and periodically scans for new targets that may
have appeared since startup:
// Periodic scan for new targets (pop-outs, meeting windows)
setInterval(async () => {
const targets = await findTargets("Teams");
for (const t of targets) {
if (!connectedTargetIds.has(t.id)) {
// New target discovered — connect and inject
const client = await CDP({ port: t._port, target: t });
await client.Page.enable();
await client.Runtime.evaluate({ expression, returnByValue: true });
connectedTargetIds.add(t.id);
}
}
}, TARGET_SCAN_INTERVAL_MS);
The theme must be re-injected each time Teams navigates internally (e.g., switching between Chat and Calendar). The injector handles this by polling and re-injecting on a 5-second interval, ensuring the theme persists across navigation events.
Since dimmed themes are derived from the light palette, they look wrong if Teams is in Dark mode. The injector detects the native mode and can automatically switch Teams to the preferred mode by driving the Settings UI via CDP:
// From injector.js — detect native mode by temporarily removing injected style
async function checkMode(Runtime) {
const result = await Runtime.evaluate({
expression: `
(() => {
const injected = document.getElementById('custom-teams-theme');
if (injected) injected.remove();
// Force style recalc
document.body.offsetHeight;
const p = document.querySelector('.fui-FluentProvider');
let mode = 'unknown';
if (p) {
const bg = getComputedStyle(p)
.getPropertyValue('--colorNeutralBackground1').trim();
// ... parse and check luminance ...
mode = brightness > 0.5 ? 'light' : 'dark';
}
return mode;
})()
`,
returnByValue: true,
});
return result.result.value;
}
The trick: temporarily remove the injected style, force a layout recalculation, read the native background color, and then re-inject. This gives the true underlying mode rather than the visually rendered one.
CDP.List() discovers page targets; Runtime.evaluate() runs JS<style id="custom-teams-theme"> element overrides Fluent UI tokens.fui-FluentProvider with !important beats Griffel’s CSS-in-JSThe approach is established. But what CSS values do we actually inject? We can’t just guess — Teams uses nearly 500 design tokens. In the next section, we’ll build an observation pipeline that extracts every token from a live Teams instance.