Turning WhatsApp Web into a customer-service CRM

Originally published as a LinkedIn post, expanded here.

I built an Israeli bike shop a Chrome extension that adds CRM behaviour to WhatsApp Web: it reads the open conversation, shows the customer’s context from Shopify, drafts replies in Hebrew, and summarises the conversation onto the customer record. It only reads — it never writes to WhatsApp, never sends, never touches the API. That is how they got what they wanted from a CRM without giving up the normal interface, and without paying for another system.

The choice WhatsApp forces on a business

I got to the shop and was sent up to the top floor. Amid a maze of bicycle boxes sits a small desk with a computer and a pile of receipts. The manager sits there with the computer and the phone, personally answering every customer on both. Attentive service is the core value of that business, and you can see it. It also eats far too much of a manager’s day.

They had already tried moving customer service to a CRM running on the WhatsApp API, and got burned. They hit what most owners discover too late: WhatsApp makes you choose — the classic interface, or the API. Not both. Pick the API and the business is locked into the CRM’s interface; the owner can no longer take the wheel from regular WhatsApp on his own phone or desktop. That is deeply inconvenient when personal service is what makes you different.

A read-only extension sidesteps the choice. It is not an unofficial client, it does not log in on your behalf, and it does not call the API — so the normal interface keeps working on every device.

A side panel, not an injection

The panel is a Chrome Side Panel — a browser surface next to the tab, not markup inside WhatsApp’s page. The content script reads DOM that WhatsApp has already rendered, roughly the way a screen reader does: no DOM writes, no synthetic events, no poking at internal modules.

A few small decisions are what hold it together:

  • A MutationObserver on the app root, debounced at 400ms. #main is replaced on every chat switch, so you cannot observe it directly; the debounce keeps a deliberately broad observation cheap. A hash suppresses unchanged snapshots, and observation stops when the tab is hidden.
  • The last 40 messages only. The reader trims the window to bound the payload that leaves the machine. That is a privacy limit and a cost limit at the same time.
  • Every selector in one block at the top of the file. WhatsApp changes its markup without notice, and when it does the panel goes empty. None of this is a stable interface; at least give yourself one place to fix.

What is absent matters as much: no code path types into WhatsApp and none sends. A suggestion is copied to the clipboard; the agent pastes and presses send.

Why there is a local proxy in the middle

You cannot call Bedrock from the extension — that would put AWS credentials inside the browser, where anyone can pull them out. So a small Node server runs locally, holds the keys, and exposes four endpoints: lookup, suggestions, summary, health.

Two things contain its exposure. It binds to 127.0.0.1 rather than 0.0.0.0; otherwise anyone on the same network could run up a Bedrock bill on your account. And it checks the request Origin against an allow-list, returning a 403 before it calls the model — loopback binding alone is not enough, since any web page you visit can issue requests to 127.0.0.1 from your browser. What that is not: by default the allow-list accepts any chrome-extension:// origin unless you pin one, and there is still no authentication and no rate limit on the proxy. It contains the hole rather than closing it.

The default region is Frankfurt, so the messages stay in the EU. These are private messages from people who installed nothing and asked for nothing; where they get processed should be a decision, not an accident of a default.

Three replies, not one

The agent does not try to be the rep. It gets the conversation so far plus the customer record, and returns three Hebrew drafts from different angles: a direct answer; one that gently surfaces a relevant upgrade; an empathetic one for a hesitation or a complaint. The proxy prepends one deterministic reply anchored in the customer’s own record, so the panel shows three or four. The human picks.

At the end there is a “summarise” button: two to four sentences in the third person — what the customer wanted, what was agreed, what is still open — appended to the customer record automatically.

The system prompts are frozen byte-for-byte so they can sit behind a cache breakpoint, and parsing the model’s output runs through five defensive stages, the fourth of which salvages complete quoted strings out of a reply truncated mid-array. The fifth is a plain line split.

The more interesting half: where the data comes from

I like building agents that do not need a long training process — find real data instead, push it into Claude Code, and let it do the work. It still needs a human reviewing; it is just far easier once you have the data.

So I looked for a way to export the conversations out of WhatsApp in bulk. Of course there isn’t one. So I built a second Chrome extension whose only job is recording: press start, click through the chats one by one while it reads in the background, get a single JSON file at the end. Instead of exporting chat by chat at roughly 30 seconds each, it came down to about a second per chat — with one caveat: it captures what WhatsApp has actually rendered. For older history you scroll up in the chat before stopping. This is not the full-history export WhatsApp’s own does.

The hard part was not the recording, it was the stitching. WhatsApp renders only what is in view — old rows unload once you scroll away — so any single scan is a partial window. The recorder merges overlapping windows by message id rather than replacing them, and when a fresh window shares no message with what was already captured, scroll direction decides whether it is older or newer history. A heuristic, documented as one.

Direction comes from the bubble’s tail icon, which stays correct in an RTL layout. The selectors hang off metadata WhatsApp uses for its own copy/paste, the most stable hook I found. Phone numbers are largely gone: WhatsApp no longer puts the JID in the page, so a number is captured only when displayed as text — that is, only for an unsaved contact.

The control lives in a closed shadow root inside a single element with no id and no class — not cleverness, but because a toolbar popup closes the instant you click a chat.

What does not work

  • The selectors depend on WhatsApp’s markup, which changes without notice. When it does, this breaks.
  • Text only. Images, voice notes and documents are not read, and the recorder skips them too.
  • The suggestions are drafts. A model fed a partial conversation will confidently invent stock, prices and delivery dates.
  • The public version ships with a mocked store connection so it runs without credentials; swapping in a real store is isolated to a single lookup function.

What I learned

An agent does not have to talk to the customer. For now that is still clunky. But it can raise the performance of the human rep, which is a much more gradual adoption path: staff get an aid, managers get oversight, and anyone can still take the wheel from their phone.

A good agent is an agent wired to context. Historical data for the “training” — which in the end is prompt writing, but let’s be respectful about it — and live data for the answer itself, here the record from Shopify.

Sometimes you have to get creative to extract the data. The lazy move is to tell the client this is “a process that requires training”. Finding the solution myself, rather than making the client do the work, is part of the job.

If you are stuck at that point — a tool the business cannot adopt without giving up something that already works — this is how I approach it: how I work.

Questions people ask

Can you add a CRM to WhatsApp without switching to the WhatsApp API?

Yes, as long as you give up automated sending. I built a Chrome extension that sits next to WhatsApp Web, reads the open conversation from the page, pulls customer context from the store, and drafts replies — but never writes to WhatsApp and never sends anything itself. The business keeps normal WhatsApp on phone and desktop instead of being locked into the CRM's interface.

How do you export many WhatsApp conversations at once?

There is no built-in bulk export — WhatsApp Web exports one chat at a time. I built a Chrome extension that passively records every chat you open while recording is on, then writes everything to a single JSON file. It captures only what WhatsApp actually rendered, so for older history you scroll up in the chat before stopping.

Is a Chrome extension that reads WhatsApp Web against the terms of service?

The one I built is read-only: it reads DOM that has already been rendered, the way a screen reader does, and never dispatches synthetic events, touches WhatsApp's internal APIs, or sends messages. Every message that leaves the account was sent by a person. That is the design intent, not a ruling — WhatsApp is the only party that decides what passes, and each business should weigh that itself.