I built a connector that brings WhatsApp into Claude Code rather than putting Claude into WhatsApp: a local daemon that owns a WhatsApp Web session, a CLI called wa that talks to it over a private unix socket, and a skill that teaches the agent to drive it. Reading is completely open — any chat, any group, search across conversations. Sending is limited to an allow-list the agent cannot widen without a click on my screen.
What I learned building it is that the direction of the integration is the whole argument, and that the interesting design decision was not how a message gets sent but who holds the key to the recipient list.
The other direction does not solve a problem
I never understood the appeal of taking the text box you use to talk to Claude out of Claude’s own app and putting it inside WhatsApp. What problem does that solve? Maybe I missed something, but I could not find the logic. You get the same model, in a worse interface, without your files and without your tools.
The opposite direction makes sense. Claude Code — or Cowork, its more consumer-facing form — can change the working life of anyone who spends their day at a computer. All it needs in order to help is the full context of your workspace.
Most platforms have caught up and shipped official connectors: mail, Slack, and so on. WhatsApp is a great example of a data silo you have to break open yourself. In every Israeli business I have worked with it is the main channel, not a side one — clients, suppliers, work groups, old leads, all of it lives there.
What I actually built
The foundation is an open-source library that reverse-engineered the WhatsApp Web API. Above it sit three layers:
- A daemon that runs continuously and owns the session. It is the only thing that talks to WhatsApp.
- A CLI,
wa, that reaches the daemon over a local unix socket and nothing else. - A skill that tells the agent what each command returns, when to wait, and what it must not do.
That separation is not aesthetics. Every limit I want to be deterministic lives in the daemon, where the agent cannot talk its way around it by rephrasing the request.
The read side is broad: list chats, read a chat’s recent messages, search, list group members, resolve a name to a contact, download an attachment. A single read is capped at 200 messages (30 by default), with an --all flag that pulls everything currently synced into the session — very useful, and very heavy on the model’s context window.
One small detail I like: mark-read works on any chat. It writes no message and creates no content — at most it flips the blue ticks on messages I actually read — so there is no reason to fence it. A fence should block what reaches other people, not every action that sounds dangerous.
The allow-list is the design decision
My rule is: Claude can read everything, and can send only to chats on a list it cannot widen without a click on my screen. That is bounded in three places — two the daemon enforces, one the skill only instructs.
Sending is two steps
wa send sends nothing. It returns a token and a preview of the exact text. Only wa send-confirm with that token actually transmits, and the token lives about two minutes. The skill instructs the agent to show me the preview and get an explicit yes in between.
The check lives in the daemon
Every send, media send, reaction, and scheduled job is checked against the allow-list file at execution time, inside the daemon. The second half of a two-step send is re-checked too, not just the first. And a scheduled job is checked again at the moment it fires — if I removed a chat from the list after scheduling a message to it, the message does not go out.
Only a human widens the list
wa allow add does not write to the file. It makes the daemon raise a native OS dialog on my screen listing the chats being requested, with Deny as the default button, and blocks for up to a minute waiting. A denial, a timeout, a machine with no screen, or any failure in the dialog all count as “not approved”, and every request and outcome goes to the log.
Inside that dialog is a small piece of paranoia that surfaced during the build: a group’s name is free text controlled by someone else. So before a name is displayed, the daemon strips control, bidi, and zero-width characters from it and truncates it to 60 characters. Otherwise naming a group with a newline inside it would be enough to forge an extra line in my own approval dialog, or push the real chat id off the edge of the screen.
Errors come back as short codes with instructions attached: NOT_ALLOWED means ask me about adding the chat, NOT_APPROVED means I said no. A denial is final — no retry, no touching the file. An agent that retries after I said no is not a diligent agent, it is a bug.
Something else that fell out of the separation: all of that logic — the list, the approval, the tokens — is covered by a test suite that runs in plain Node with no WhatsApp connection at all, so I can change a gating rule and know within seconds whether I broke it.
What it is not
It is not a sandbox, and I wrote that into the README explicitly so I would not forget. Everything runs under my user account. A local process with my privileges could edit the allow-list file directly, restart the daemon pointed at a different file, or — if I hand an agent control of my screen — click the dialog itself. The protection that ships in the agent harness — deny rules on editing that file — is the harness’s guarantee, not the daemon’s.
What the dialog buys is a raised bar: from “the agent can quietly widen its own permissions” to “this requires a click on my screen or visible tampering”. If someone needs a hard boundary, run the daemon under a separate OS user. That is a distinction worth saying out loud to a client rather than hiding behind the word “secure”.
Most of the work is not the features
What took longest was not the commands but the behaviour of the connection itself. WhatsApp Web syncs slowly after a restart, and messages that queued while the daemon was down keep streaming in after it already reports itself connected. So the daemon does not declare itself ready until that stream goes quiet; until then every command fails with an explicit not ready (state: syncing) rather than answering from a half-synced chat.
There is also an irritating case where authentication succeeds but the session never becomes ready. The daemon recycles itself a few times reusing the session on disk — no new QR code — and escalates to a fresh re-link only if that keeps failing. Worth knowing too: after a restart the available history depth resets and refills gradually, so a “read everything” call can return two messages in a busy chat. That is the kind of detail that, if the agent does not know it, produces a confident report that the chat is empty.
What it is good for day to day
- Summarizing what is happening in high-traffic groups.
- Going over every chat and working through old leads.
- A daily pass across all chats to surface open tasks and unfinished conversations.
- Keeping in touch with clients, employees, and friends.
I also added scheduled messages — at an absolute time, after a relative delay, or on a daily, weekly, or monthly recurrence. The scheduler refuses to send late: a job that missed its grace window is recorded as missed rather than fired. A “good morning” message going out at three in the morning because the machine was asleep is not a charming bug.
The general lesson
When someone offers you “AI inside X”, it is worth stopping to ask what problem that solves. Usually the thing that actually helps is the reverse: bring X into the agent, alongside all your other tools, and then carefully fence only the actions that reach real people. Read access is what makes an agent useful. Write access is what you have to design.
If you are looking at a silo like this in your own business and are not sure where the fence belongs, that is exactly the process I work through.