Skills

The Chrome extension that puts my CRM inside LinkedIn and WhatsApp

The Chrome extension that puts my CRM inside LinkedIn and WhatsApp

My CRM has exactly one user, so the useful question was never "what should the CRM look like". It was "where am I standing when I need it". The answer, all day, is a LinkedIn profile or a WhatsApp Web chat. So the CRM moved there. This is the whole build written out: the decisions, the rules baked into it, and the parts of the source worth stealing.

It is a Manifest V3 Chrome extension. On a LinkedIn profile or message thread, a pill in the bottom-right corner shows that person's pipeline state at a glance. Click it and a side panel moves the state - status, unqualify with a reason, not-a-lead, next step with a date, a typed note - without leaving the tab. On WhatsApp Web the panel does the same, and follows whichever chat is open.

What it does

  • Pill. Colour is the state. Blue in pipeline, green hot, red unqualified, dashed grey unknown.
  • Status. Rank-guarded, which is rule 1 below.
  • Unqualify. Sets the status and demands a free-text reason. The lead survives in the prospect pool.
  • Not a lead. Deletes the lead and vetoes the contact, so the nightly rebuild cannot resurrect it. Reversible.
  • Follow-up. Writes next step and date, which the backend already turns into a task and a draft email.
  • Note. Only what I type. Never a generated summary.
  • Link. Attaches the profile to a contact I confirm.

What it deliberately does not do

The refusals are the design. Three of them:

  • It never sends anything. No message, no connection request, no email. Sending stays in the outbound engine, behind its caps and its approvals. A tool that both reads a profile and can message it is one bug away from an incident.
  • It never ingests the conversation on screen. LinkedIn DMs already sync on a server with their own dedup keys. A second ingester reading the DOM would write rows it cannot dedup against and double the history.
  • It never matches on a name. More on that below, because it is the single most expensive mistake available here.

Rule 1: a status is never silently moved backwards

Status changes compare funnel rank first. A downgrade returns 409 needs_confirmation, and the panel asks before retrying with an explicit confirm flag.

That guard exists because of a real incident. A review queue once wrote status straight through and demoted five people who had already had real meetings with me. Almost every lead status in my database was put there by hand, by me. Machine-written status that overwrites hand-written status is not a sync, it is data loss with a progress bar.

The rank table is generated from one source file, so the Python side and the TypeScript side cannot drift. There is a test whose entire job is to fail if they do.

If you build one of these, steal this first: before any automated write, ask "is this field usually filled by a human?" If yes, the automation gets a confirmation step, not a merge strategy.

Rule 2: a person is attached on evidence, never on resemblance

Identity is the LinkedIn slug, the email, or the phone number, normalised the same way on both sides. A name is accepted only as a candidate for me to confirm.

Here is the trap that makes this concrete, and it cost me a full day to see. LinkedIn has two kinds of /in/ ID and they are not interchangeable.

  • A profile page gives you the vanity slug: /in/some-person. That is what the CRM stores.
  • A message thread only ever exposes an opaque member ID: /in/ACoAACUvDNcB...

They live in different key spaces. Key a thread the naive way and it matches nothing, so the panel calls someone I have been messaging for months "not in CRM" - and creating them from there mints a duplicate of a contact I already have. The fix is not cleverness, it is a second identity space: opaque IDs get their own field, you link the person once from the thread, and it resolves instantly forever after. Creating a contact from a thread is simply refused.

Worth saying out loud: only about 64% of my leads have a LinkedIn URL at all. So "not in CRM" is roughly a third of profiles, and that is the normal path, not an error state. Every link I confirm shrinks it.

Rule 3: nothing writes anonymously

Every write from the extension lands in the CRM's activity feed tagged with its source, and the extension is a registered agent in the agent registry with a name of its own. If I cannot answer "which agent wrote this row, and when", the row should not exist.

The seam: a new platform is one file

This is the part I would keep in any rebuild. Each platform is one adapter file exporting exactly one function:

identify(url, document) → { platform, key, name, title, context }

Identity comes from the URL path. The DOM is read only for the display name. That single rule is why the pill survives LinkedIn redesigns. From the source:

// DESIGN RULE, and the reason the pill survives LinkedIn redesigns:
// identity comes from location.pathname first. The DOM is consulted ONLY
// for the display name, which is a nicety - when LinkedIn ships a new
// messaging UI the selectors below go stale and the adapter returns a
// null name, but the KEY is still right and every CRM action still
// works. It degrades to "unknown name", never to a wrong person.

The pill is position: fixed inside a shadow root, anchored to nothing in LinkedIn's component tree. Everything above the adapter - panel, background worker, the whole API surface - is platform-agnostic. Adding Gmail is one file and one host permission, and it will resolve close to 100% because an email address is a real key.

WhatsApp was harder, and the reason is instructive

LinkedIn puts identity in the URL. WhatsApp Web has one URL for every chat, forever. So identity there can only come from the page, which changes two things: the pill has to poll the key rather than the URL, and a WhatsApp redesign genuinely can break identification.

So it fails closed. No confident key means the panel says it cannot identify the chat. It never falls back to the contact's display name, because a saved contact's name is whatever I once typed into my phone book, and matching on that is exactly the wrong-person write that rule 2 exists to prevent.

The real key is the JID that WhatsApp hangs off its own message rows:

data-id="false_972500000000@c.us_3EB0..."   // 1:1 chat
data-id="false_120363...@g.us_3EB0..."      // group

The part before the @ is the phone number - the same phone the CRM stores on the contact. Deterministic, not inferred.

One more thing about WhatsApp, and it is a product lesson rather than a technical one. There is no pill on WhatsApp. I had it, I moved it once off the send button, and it still covered the conversation, because WhatsApp Web fills its window edge to edge and has no dead corner the way LinkedIn does. So it was removed. The docked panel already follows the open chat by itself, so nothing was lost. The comment in the source records why, including my own complaint verbatim, so nobody adds it back in six months as an improvement.

The Manifest V3 trap that cost me two fixes

I asked twice for the same thing: I do not want to click reload on this extension every time I change a file. The first fix was a watcher that fingerprinted the source and reloaded when it changed. It never fired. Not once.

The reason is the single most useful MV3 fact I know. Chrome tears the service worker down after about 30 seconds of idle. My watcher kept its baseline fingerprint in a module-level variable, so every scheduled check compared today's files against a baseline that had just been re-read from today's files. It compared the present to itself, forever, and reported no change. Its own comment cheerfully described the teardown as "the behaviour we want".

The working version keeps the baseline in chrome.storage and writes it before calling reload. That write ordering is the loop guard.

The rule that falls out of it: under MV3, anything that must outlive 30 seconds of idle belongs in chrome.storage, never in a variable. If your background worker "remembers" something, it does not.

Auth, and why it is not cookies

The obvious design is to reuse the CRM's session cookie. It does not work, and the reason is worth knowing before you spend an afternoon on it: the session cookie is SameSite=Lax, so the browser will never send it from a request originating on linkedin.com. Correct behaviour, and fatal to the shortcut.

So the extension holds a bearer token instead, with three constraints:

  • The token lives only in the background service worker and extension storage. It never touches a content script, because a content script runs in the page's world alongside whatever else that page is running.
  • Server-side auth fails closed. A missing or malformed token is a rejection, never a fallthrough to "treat as anonymous".
  • Every request is authenticated at the edge, before any handler logic runs.

Treating a session cookie as what it actually is

One optional feature: the extension can hand a fresh LinkedIn session cookie to my server every few hours, because the server's LinkedIn jobs authenticate with a session that expires on LinkedIn's schedule, and when it does everything stops until someone logs in by hand. My browser is always logged in. So it parks a fresh cookie and the server picks it up.

That cookie is a bearer credential. Holding it is being me on LinkedIn - no password, no 2FA. So it is treated that way: it goes only to the one configured URL, it is never written to extension storage, it is never logged (lengths only, never a prefix), exactly one row is kept, the table is service-key only with row level security on and anonymous access revoked, and the receiving script refuses anything implausibly short rather than overwriting a working session with junk.

Most "small internal tools" leak because somebody moved a credential around without ever writing down that it was one.

How it is tested, and the cleanup that nearly ate the history

Two suites, because they catch different things:

  • A shell script with 23 assertions against a live API, which creates and cleans up its own throwaway contact. This proves the API.
  • A Python script with 13 assertions that drives the real UI in a real Chromium - the options page and every panel button. This proves the buttons, and it is the only thing that catches UI state bugs.

The war story is in the cleanup. That self-test used to end with an unqualified delete of every row the extension had ever written, in order to remove the two rows the test itself created. It would have destroyed the extension's entire history to tidy up after itself. Read the teardown of any test before you run it against something real. Setup code gets reviewed. Teardown code gets skimmed.

What is not in this post

The CRM's base URL, the API paths, and the token. Not because the code is secret - the interesting parts are above - but because an endpoint map plus an auth description is a starting point for someone, and there is no reason to publish one. If you are building the same thing, none of what I withheld would have helped you anyway.

What to take from this

  • Put the tool where the person already is. The best CRM UI I have built is one I never navigate to.
  • One function per platform. If adding a platform means touching more than one file, the seam is in the wrong place.
  • Get identity from the URL when you can. DOM scraping for identity breaks on every redesign. DOM scraping for a display label degrades gracefully.
  • Fail closed on identity. "I do not know who this is" is a fine answer. A confident wrong answer writes to the wrong person's record.
  • Guard human-entered fields. Rank-check before you overwrite, and ask.
  • Tag every write with its author. You will want it the first time something looks wrong.

None of this is exotic. It is a few hundred lines of judgment about identity and consent, wrapped around a very ordinary API client. That ratio - small code, careful rules - is what makes an internal tool safe enough to leave running.

Want your pipeline where your team already works, instead of in a tab nobody opens?

Book a call
← Back to the blog