Skip to main content
Glama
Agentic-Delivery

whatsapp-assistant-mcp

whatsapp-assistant-mcp

A stdio MCP server that lets a Claude Code agent read and post in a fixed allowlist of WhatsApp chats.

CI License: Elastic-2.0

A stdio MCP server plus a small background daemon that holds the actual WhatsApp connection. A strict chat allowlist is the only thing standing between "an agent with a WhatsApp connection" and "an agent that can message anyone," and it is a startup error to run without one. Same design lineage as its Microsoft Teams sibling -- see Related project.

Plain JS, ESM, no build step. Node 20+.

Why

  • Allowlist-first safety -- every tool call and every inbound message is checked against a fixed chat allowlist before anything else happens. An empty or missing allowlist is a startup error, never "allow everything."

  • One WhatsApp connection, ever -- Baileys (the WhatsApp library this project uses) refuses a second simultaneous connection on the same auth session, so exactly one process (wa-daemon) is ever allowed to hold it; everything else talks to that process, never to WhatsApp directly.

  • A file-based inbox/outbox protocol -- the MCP server and the daemon are separate processes that never share memory. They hand messages to each other through append-only JSONL files (inbox.jsonl, outbox.jsonl), so either side can restart independently without losing state.

  • A mechanical signature, not a suggestion -- every outgoing message is prefixed with a robot marker (🤖) before it leaves the daemon, so on a shared or personal account, anyone reading the chat can tell a message came from the assistant, not the human.

Related MCP server: WhatsApp MCP Server

Quickstart

Requires Node.js 20+.

git clone https://github.com/Agentic-Delivery/whatsapp-assistant-mcp.git
cd whatsapp-assistant-mcp
npm ci

1. Pair

Prefer a code instead of scanning a QR:

node src/pair.js --code 15551234567   # international format, digits only

or scan the printed QR code with WhatsApp: Settings > Linked Devices > Link a Device:

node src/pair.js

On success this prints PAIRED as <jid> and exits. Auth state is saved under ~/.whatsapp-assistant/auth/ (chmod 700). See Troubleshooting if the code seems to hang for a moment, or the connection closes and reopens right after -- both are normal.

2. Create the allowlist

mkdir -p ~/.whatsapp-assistant
cp wa-mcp.config.example.json ~/.whatsapp-assistant/wa-mcp.config.json

Edit it and replace the placeholder jids with real ones -- individual chats end in @s.whatsapp.net, groups end in @g.us. canPost defaults to (and should stay) false until you mean it. Both wa-daemon and wa-mcp refuse to do anything useful with an empty or missing allowlist.

3. Run the daemon

nohup node src/daemon.js >> ~/.whatsapp-assistant/daemon.out.log 2>&1 &

It reconnects automatically on drops. If the session gets logged out (revoked from the phone, etc.) it exits and tells you to delete the auth directory and re-pair. See Autostart to run it as a proper service instead of nohup.

4. Register the MCP server

Run this from inside your clone, so the path resolves to wherever you actually put it -- never hardcode a home directory:

claude mcp add whatsapp-assistant -- node "$(pwd)/src/mcp-server.js"

Autostart

To have wa-daemon survive reboots and terminal closures, run it as a systemd user service instead of nohup.

~/.config/systemd/user/wa-daemon.service:

[Unit]
Description=whatsapp-assistant-mcp background daemon
After=network-online.target
Wants=network-online.target

[Service]
Type=simple
WorkingDirectory=%h/whatsapp-assistant-mcp
ExecStart=/usr/bin/env node src/daemon.js
Restart=on-failure
RestartSec=5

[Install]
WantedBy=default.target

%h expands to your home directory. If you cloned this repo somewhere other than directly under $HOME, edit WorkingDirectory to match your actual path first. Then:

systemctl --user daemon-reload
systemctl --user enable --now wa-daemon.service
loginctl enable-linger "$USER"   # keep it running after you log out

Under WSL, user services need systemd turned on first. Add to /etc/wsl.conf:

[boot]
systemd=true

then restart WSL (wsl --shutdown, from Windows) before the systemctl --user commands above will work.

Architecture

   WhatsApp  (Baileys, exactly one socket)
       |
       v
 +--------------+
 |  wa-daemon   |----- creds.json + keys, atomic tmp+rename writes
 +--------------+      ~/.whatsapp-assistant/auth/
   |          ^
 append     watch + drain
   v          |
inbox.jsonl  outbox.jsonl
   ^          |
   |          v
 +--------------+
 |   wa-mcp     |  <-- stdio -->  Claude Code / any MCP client
 +--------------+

Baileys refuses a second simultaneous connection on the same auth session, so exactly one process -- wa-daemon -- is ever allowed to hold it. wa-mcp never opens its own connection: it reads inbox.jsonl for history (which keeps working even while WhatsApp is briefly unreachable) and hands off sends to the daemon through outbox.jsonl, waiting for an acknowledgement.

This isn't just a "the library says so" rule. A second process racing the first for the same session doesn't just fail to connect -- it can corrupt creds.json mid-write, which is a real failure class this project hit during development and has since closed by construction: see src/atomic-auth.js. Every auth-state write goes through a temp-file-then-rename on the same directory, so a crash at any instant leaves either the fully-old file or the fully-new one, never a half-written one. A session lock held by a process that's no longer alive is detected and taken over automatically; a lock held by a live process is refused rather than raced.

Verified sends

Every send/send-file ack is echo-verified: WhatsApp reflects each sent message back to the daemon, and that echo is exactly what recipients receive. The ack therefore reports what arrived, not what was attempted — verified: {fileName, mimetype, bytes} for documents (the echo must carry a file name, a real mimetype and the exact byte count, else ok:false with the reason) and verified: {text:true} for text. If no echo arrives within the timeout (default 15 s, echoTimeoutMs per command) the ack stays ok:true with verified:null and a warning: echo-timeout — a slow echo never fails a real send, but an unverified delivery is always visible as such.

Receiving documents

Incoming document attachments from allowlisted chats are downloaded to ~/.whatsapp-assistant/downloads/<timestamp>-<filename> (filename sanitized — it is untrusted remote input — and size-capped at 25 MB). Each download adds a media-saved line to inbox.jsonl carrying mediaPath, beside the ordinary [document: name] message record; oversize or failed downloads add a media-skipped / media-error line instead, so an absent file is always explained. The allowlist gates downloads exactly as it gates the inbox: messages from non-allowlisted chats are neither stored nor downloaded.

Tools

Tool

What it does

list_chats

Allowlisted chats with jid, name, canPost, and a lightweight recent-activity count

read_chat_messages

Last N stored messages for one allowlisted chat, oldest first -- read from the local inbox log, not a live fetch

send_chat_message

Sends a text message via the daemon and waits up to 15s for delivery confirmation; requires canPost: true

poll_chats

Messages that arrived since the last call, grouped by chat jid; the read position is persisted, so each message is returned exactly once

react_to_message

Puts an emoji reaction on one message (or removes it with an empty emoji) via the daemon and waits for its ack; requires canPost: true

Every tool validates the jid against the allowlist before touching anything else. An unknown jid is refused with an error naming the config file; sending additionally requires canPost: true. There is no bulk-send or broadcast tool -- one call sends one message to one chat.

Reactions

A reaction is the cheapest signal both directions, and this bridge treats it as a first-class record and command.

Inbound. A reaction someone puts on a message arrives in inbox.jsonl as a record of type: "reaction" with reaction.emoji and reaction.targetMsgId, so an agent can see that a message was acknowledged without anyone typing.

Outbound. react_to_message (or a raw {"type":"react", "jid", "nonce", "msgId", "emoji", "participant?", "fromMe?"} line in outbox.jsonl) makes the daemon put the emoji on the target message and ack with reacted: {target, emoji}. In a group the target's sender jid goes in participant; an empty emoji removes the reaction.

A convention that works. When an agent handles messages from real people, react before or instead of replying, and keep the vocabulary tiny so the meaning is stable:

Emoji

Meaning

👀

Seen, being handled

👍

Acknowledged, no action needed

Done

A reaction never replaces an answer that was asked for; it tells the human the message did not fall on the floor while the answer is being produced.

Troubleshooting

Four failure modes came up repeatedly during development. All four are already handled by the code -- this section explains what you're seeing and why it's fine.

A pairing code seems to hang for a few seconds. --code only requests a pairing code once the socket signals it's ready, and only after presenting a real desktop browser signature -- WhatsApp's server closes the connection immediately if it sees the library's default signature on this flow. A short pause here is that handshake completing, not a hang.

The connection closes with status 515 right after you enter the code (or scan the QR), then immediately reconnects. This is expected: it's WhatsApp's own protocol telling the client "pairing configured, restart the connection now," not a failure. wa-pair and wa-daemon both honor it automatically, bounded to a few restarts so a genuine connection flap can't loop forever.

"Another WhatsApp process (pid N) holds the session -- refusing a second connection." Exactly one process may ever hold the Baileys auth session. Check ps for a stray node process before assuming anything is broken -- a previous wa-daemon or wa-pair may still be running. If the holder is no longer alive, the next attempt clears the stale lock automatically. The lock lives at ~/.whatsapp-assistant/session.lock.

A torn or empty auth/creds.json. Historically, a process exiting mid-write could truncate this file; the next reader would see "unregistered" and attempt to re-pair, which invalidates the real phone link. This is now prevented by construction (see Architecture) -- every auth write goes tmp-then-rename, so the file is never observed half-written. If you somehow still end up with an empty or corrupt creds.json, treat it like a real logout: delete ~/.whatsapp-assistant/auth and run wa-pair again.

"Not paired (creds absent or unregistered). Run 'node src/pair.js --code ' first." You'll see this from wa-daemon (or anything other than wa-pair) when there's no valid paired session yet. Registration is deliberately wa-pair's job alone -- any other entry point that finds unregistered auth state stops rather than trying to register itself, because a blind registration attempt from the wrong place is exactly what invalidates a real phone link when state was lost or torn. Run wa-pair first, then start the daemon.

Security & Terms of Service

  • Auth keys and the message store live only under ~/.whatsapp-assistant (directory chmod 700, files written 0600) -- never inside this repo, never committed. Treat that directory like a credential: anyone who can read it can act as your WhatsApp account.

  • The chat allowlist in wa-mcp.config.json is the blast-radius control. It is deliberately a startup error to run with an empty one. Only add chats you actually want an agent to read from or post into, and leave canPost off until you mean it.

  • This tool never bulk-sends. There is no broadcast tool and no batch-send path -- every send is one explicit call to one chat.

  • Automating a personal WhatsApp account through this consumer protocol is against WhatsApp's Terms of Service. Use a dedicated number you're prepared to lose, or accept that risk knowingly -- this project does not make that call for you.

teams-assistant-mcp is the Microsoft Teams sibling of this project: same design lineage -- a fixed chat allowlist as the only blast-radius control, a single background process holding the one real connection, and no bulk-send path.

License

Elastic License 2.0. Free to use, copy, modify, and adapt for your own organization, including commercially. You may not sell it or offer it to others as a hosted or managed product.

Available Tools

4 tools
list_chatsList allowed WhatsApp chatsA
Read-only

Lists the WhatsApp chats this server may touch: jid, display name, whether sending is allowed (canPost), and a lightweight recent-activity count. Never lists any other chat.

ParametersJSON Schema
NameRequiredDescriptionDefault

No parameters

TDQS

A4.5/5.0
Behavior4/5

Does the description disclose side effects, auth requirements, rate limits, or destructive behavior?

With readOnlyHint=true already covering the read-only nature, the description adds useful behavioral detail: the exact fields returned (including canPost) and a 'lightweight recent-activity count,' plus a guarantee about scope. This goes beyond the annotation without contradicting it. No side effects or caveats need disclosure for a read-only list tool.

Agents need to know what a tool does to the world before calling it. Descriptions should go beyond structured annotations to explain consequences.

Conciseness5/5

Is the description appropriately sized, front-loaded, and free of redundancy?

Two sentences with no redundant words. The primary action and outcome come first, followed by a compact enumeration of returned fields. The final exclusion sentence earns its place by removing ambiguity about scope.

Shorter descriptions cost fewer tokens and are easier for agents to parse. Every sentence should earn its place.

Completeness5/5

Given the tool's complexity, does the description cover enough for an agent to succeed on first attempt?

For a parameterless read-only list operation, the description fully covers what an agent needs to know to call it correctly: what it returns (fields) and its scope (allowed chats only). No output schema exists, but the description compensates by naming all returned fields. Absent pagination or sorting requirements are not significant for such a lightweight operation.

Complex tools with many parameters or behaviors need more documentation. Simple tools need less. This dimension scales expectations accordingly.

Parameters4/5

Does the description clarify parameter syntax, constraints, interactions, or defaults beyond what the schema provides?

The tool takes zero parameters and schema coverage is 100%, so there is no parameter information to add. The description does not need to explain inputs, and the baseline of 4 for a no-parameter tool is appropriate.

Input schemas describe structure but not intent. Descriptions should explain non-obvious parameter relationships and valid value ranges.

Purpose5/5

Does the description clearly state what the tool does and how it differs from similar tools?

The description uses a specific verb (Lists) and resource (WhatsApp chats this server may touch), and names the exact fields returned: jid, display name, canPost, and recent-activity count. The scope restriction 'Never lists any other chat' further distinguishes it from siblings like read_chat_messages or send_chat_message, which operate on individual chats.

Agents choose between tools based on descriptions. A clear purpose with a specific verb and resource helps agents select the right tool.

Usage Guidelines4/5

Does the description explain when to use this tool, when not to, or what alternatives exist?

The description clearly implies when to use the tool: to discover which WhatsApp chats are available for interaction on this server. The phrase 'this server may touch' sets expectations about scoping, and 'Never lists any other chat' is an exclusion. However, it does not explicitly compare against sibling tools or state when not to use it, so it falls just short of a 5.

Agents often have multiple tools that could apply. Explicit usage guidance like "use X instead of Y when Z" prevents misuse.

poll_chatsPoll for new WhatsApp messagesA
Read-only

Drains messages that arrived since the last poll_chats call, grouped by chat jid. The read position is persisted, so each message is returned exactly once across calls.

ParametersJSON Schema
NameRequiredDescriptionDefault

No parameters

TDQS

A4.4/5.0
Behavior4/5

Does the description disclose side effects, auth requirements, rate limits, or destructive behavior?

Annotations provided readOnlyHint=true, and the description adds valuable behavioral detail: it drains only new messages, groups them by chat jid, persists the read position, and guarantees each message is returned exactly once. This goes beyond the annotation without contradicting it.

Agents need to know what a tool does to the world before calling it. Descriptions should go beyond structured annotations to explain consequences.

Conciseness5/5

Is the description appropriately sized, front-loaded, and free of redundancy?

Two sentences, no filler, with the core action and grouping front-loaded. The second sentence adds the important delivery guarantee without redundancy.

Shorter descriptions cost fewer tokens and are easier for agents to parse. Every sentence should earn its place.

Completeness4/5

Given the tool's complexity, does the description cover enough for an agent to succeed on first attempt?

For a no-parameter polling tool, the description covers purpose, grouping, and delivery semantics. It does not describe the exact response shape or empty-result behavior, but the absence of an output schema and simple tool complexity make this acceptable.

Complex tools with many parameters or behaviors need more documentation. Simple tools need less. This dimension scales expectations accordingly.

Parameters4/5

Does the description clarify parameter syntax, constraints, interactions, or defaults beyond what the schema provides?

Tool has zero parameters, so baseline is 4. Description confirms no input is needed and explains the semantics of the poll position, which is the only relevant 'parameter-like' state.

Input schemas describe structure but not intent. Descriptions should explain non-obvious parameter relationships and valid value ranges.

Purpose5/5

Does the description clearly state what the tool does and how it differs from similar tools?

Description uses a specific verb ('Drains') and resource ('messages that arrived since the last poll_chats call'), and specifies grouping by chat jid. It is clearly distinguishable from siblings list_chats, read_chat_messages, and send_chat_message.

Agents choose between tools based on descriptions. A clear purpose with a specific verb and resource helps agents select the right tool.

Usage Guidelines4/5

Does the description explain when to use this tool, when not to, or what alternatives exist?

The description clearly establishes the polling pattern: it returns messages that arrived since the previous poll_chats call, and the persisted read position implies it is meant for repeated incremental fetching. It does not explicitly name alternatives or exclusions, but the usage context is clear.

Agents often have multiple tools that could apply. Explicit usage guidance like "use X instead of Y when Z" prevents misuse.

read_chat_messagesRead WhatsApp chat messagesA
Read-only

Reads the last N stored messages for one allowlisted chat, oldest first. Messages come from the local inbox log, not a live WhatsApp fetch, so this works even when WhatsApp is briefly unreachable.

ParametersJSON Schema
NameRequiredDescriptionDefault
jidYesWhatsApp chat jid, must be on the allowlist
limitNoMax messages, default 30

TDQS

A4.7/5.0
Behavior5/5

Does the description disclose side effects, auth requirements, rate limits, or destructive behavior?

The description adds meaningful behavioral context beyond the readOnlyHint annotation: messages come from the local inbox log, are returned oldest first, and the tool works even when WhatsApp is unreachable. This is substantial, decision-relevant behavior that the agent could not infer from the schema or annotations alone.

Agents need to know what a tool does to the world before calling it. Descriptions should go beyond structured annotations to explain consequences.

Conciseness5/5

Is the description appropriately sized, front-loaded, and free of redundancy?

The description is two concise sentences with no filler. The key operation and scope are front-loaded, and the behavioral caveat about the local log follows immediately.

Shorter descriptions cost fewer tokens and are easier for agents to parse. Every sentence should earn its place.

Completeness5/5

Given the tool's complexity, does the description cover enough for an agent to succeed on first attempt?

For a simple read-only tool with fully covered parameters, a readOnlyHint annotation, and no output schema, the description provides all essential context: what is read, from where, the ordering, and the offline capability. No critical information is missing.

Complex tools with many parameters or behaviors need more documentation. Simple tools need less. This dimension scales expectations accordingly.

Parameters4/5

Does the description clarify parameter syntax, constraints, interactions, or defaults beyond what the schema provides?

Schema coverage is 100%, so the baseline is 3, but the description adds value by clarifying that 'limit' means the last N messages and that output is ordered oldest first. This refines the semantics beyond the schema's simple 'Max messages, default 30' description.

Input schemas describe structure but not intent. Descriptions should explain non-obvious parameter relationships and valid value ranges.

Purpose5/5

Does the description clearly state what the tool does and how it differs from similar tools?

The description uses a specific verb and resource: 'Reads the last N stored messages for one allowlisted chat, oldest first.' It also distinguishes itself from live-fetch tools by explicitly stating the messages come from the local inbox log, not a live WhatsApp fetch.

Agents choose between tools based on descriptions. A clear purpose with a specific verb and resource helps agents select the right tool.

Usage Guidelines4/5

Does the description explain when to use this tool, when not to, or what alternatives exist?

The description gives clear context for when to use the tool: reading stored messages from the local log, and it explicitly notes this is not a live WhatsApp fetch, which implies it is not for live retrieval. It does not name sibling alternatives directly, but the behavioral distinction is enough.

Agents often have multiple tools that could apply. Explicit usage guidance like "use X instead of Y when Z" prevents misuse.

send_chat_messageSend a WhatsApp chat messageA

Sends a text message to an allowlisted chat that has canPost enabled. Handed off to the background daemon -- the only process holding the WhatsApp connection -- and waits up to 15s for delivery confirmation. Real people read this immediately; it cannot be unsent.

ParametersJSON Schema
NameRequiredDescriptionDefault
jidYesWhatsApp chat jid, must be allowlisted with canPost: true
textYesMessage text to send

TDQS

A4/5.0
Behavior5/5

Does the description disclose side effects, auth requirements, rate limits, or destructive behavior?

The description adds substantial behavior beyond the annotations: the message is handed to a background daemon, the call waits up to 15 seconds for delivery confirmation, and the message cannot be unsent because real people may read it immediately. This is exactly the type of context annotations alone do not provide.

Agents need to know what a tool does to the world before calling it. Descriptions should go beyond structured annotations to explain consequences.

Conciseness5/5

Is the description appropriately sized, front-loaded, and free of redundancy?

Three tight sentences convey the action, the precondition, the execution path, the timeout behavior, and the irreversibility. Every sentence earns its place, and the core action is front-loaded.

Shorter descriptions cost fewer tokens and are easier for agents to parse. Every sentence should earn its place.

Completeness4/5

Given the tool's complexity, does the description cover enough for an agent to succeed on first attempt?

For a simple two-parameter tool, the description covers the key operational context: prerequisites, async handoff, delivery wait, and human impact. It does not describe the return value or failure behavior on timeout, which is a minor gap given there is no output schema.

Complex tools with many parameters or behaviors need more documentation. Simple tools need less. This dimension scales expectations accordingly.

Parameters3/5

Does the description clarify parameter syntax, constraints, interactions, or defaults beyond what the schema provides?

Schema coverage is 100% and both required parameters are described in the schema. The description adds no new parameter-level detail beyond restating the allowlist/canPost condition already present in the jid schema description.

Input schemas describe structure but not intent. Descriptions should explain non-obvious parameter relationships and valid value ranges.

Purpose4/5

Does the description clearly state what the tool does and how it differs from similar tools?

The description clearly states the verb and resource: sends a text message to a chat, with the specific precondition that the chat must be allowlisted and have canPost enabled. It is clearly distinct from sibling tools like list_chats and read_chat_messages, but it does not explicitly name or contrast those alternatives.

Agents choose between tools based on descriptions. A clear purpose with a specific verb and resource helps agents select the right tool.

Usage Guidelines3/5

Does the description explain when to use this tool, when not to, or what alternatives exist?

The description implies when to use the tool by framing it as sending a message and requiring an allowlisted chat with canPost enabled. However, it does not explicitly state when to use this tool versus siblings, nor does it mention alternatives like read_chat_messages for reading conversations.

Agents often have multiple tools that could apply. Explicit usage guidance like "use X instead of Y when Z" prevents misuse.

Tool Schema Changelog

Recent tool additions, removals, and schema changes observed during successful MCP inspections.

  1. 4 tool updatesv0.1.0
    • First observedlist_chats
    • First observedpoll_chats
    • First observedread_chat_messages
    • First observedsend_chat_message

TDQS

A4.4/5.0

Scored across 4 tools

Disambiguation5/5

Each tool has a clearly distinct purpose: listing chats, reading stored message history, sending a new message, and polling for newly arrived messages. The one potential overlap between read_chat_messages and poll_chats is resolved by their explicit focus on historical versus incremental messages.

Naming Consistency5/5

All four tool names follow the same verb_noun snake_case pattern: list_chats, read_chat_messages, send_chat_message, and poll_chats. There are no mixed conventions or inconsistent verb styles.

Tool Count5/5

Four tools is a well-scoped size for a WhatsApp assistant: list, read, send, and poll cover the core needs without unnecessary redundancy. Each tool earns its place.

Completeness4/5

The toolset covers the main assistant workflow: discovering conversations, reading previous messages, sending replies, and receiving new messages. It lacks richer operations like media sending, message search, or read receipts, but those are reasonable omissions for this focused server.

Maintenance

ActivityMaintained
ResponsivenessUnresponsive

Related MCP Connectors

Related MCP Servers

  • A
    license
    Not graded
    quality
    D
    maintenance
    Enables Claude Code to interact with WhatsApp for reading messages, sending replies, and searching contacts through the Model Context Protocol. It uses whatsapp-web.js to facilitate local connection management with QR code authentication and session persistence.
    4
    MIT