Connect WhatsApp to Claude, not Claude to WhatsApp

Originally published as a LinkedIn post, expanded here.

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.

Questions people ask

How do I connect WhatsApp to Claude?

I built a local daemon that owns a WhatsApp Web session (on top of the open-source whatsapp-web.js library), a CLI called wa that talks to it over a private unix socket, and a skill that teaches Claude Code how to use the commands. Everything runs on your own machine — no third-party server sits between you and WhatsApp.

Is it safe to give an AI agent access to my WhatsApp?

In my setup reading is unrestricted and sending is bounded: every send is checked against an allow-list inside the daemon, and widening that list requires clicking Approve in a native OS dialog. It is not a sandbox — everything runs under your own user account — but it raises the bar from 'the agent can quietly widen its own permissions' to 'someone has to click on screen'.

Why bring WhatsApp into Claude instead of putting Claude inside WhatsApp?

Claude Code is useful because it has the full context of your workspace — files, mail, tools. Moving Claude's text box into WhatsApp gives you the same model in a worse interface with none of that context. The other direction breaks a data silo and adds a source the agent can actually reason over.

Can Claude schedule WhatsApp messages for me?

Yes. I added a schedule command that takes an absolute time, a relative delay, or a daily, weekly, or monthly recurrence. Scheduling goes through the same two-step confirmation, and the allow-list is re-checked at the moment the job actually fires, not only when you created it.