商业产业 Hacker News Agent 2026-10-18T21:36 正文 ok 2859 words

Show HN:基于 Cloudflare 的开源 WhatsApp MCP 服务器,让 AI Agent 读取与检索聊天记录

英文标题:🔶 Show HN: WhatsApp MCP running free on Cloudflare

开发者开源了 whatsapp-mcp 项目,允许用户将 MCP(Model Context Protocol)服务器部署在自己的 Cloudflare 账户上,使 Claude Code、Claude Desktop 等支持 MCP 的 AI Agent 能够检索和阅读 WhatsApp 聊天记录。该服务通过类似 WhatsApp Web 的多设备配对协议实现,采用纯只读机制、不具备发消息功能,所有聊天数据均保存在 Cloudflare Durable Object 内置的 SQLite 中。这一实践不仅保障了个人通信数据的隐私安全,也为将私有即时通讯数据安全接入大模型 Agent 生态提供了低成本的边缘架构参考。
打开原文相关 3 · 机会 1
media1.4 KB media108.2 KB

中文

让你的 AI Agent 读取你的 WhatsApp 消息。

这是一个运行在你自己的 Cloudflare 账户中的轻量级服务器。你只需将其与手机绑定一次(操作方式与绑定 WhatsApp Web 完全相同),之后任何 MCP 客户端(如 Claude Code、Claude Desktop 或任何支持 MCP 协议的工具)都可以搜索和阅读你的聊天记录。它可以阅读和搜索消息,并记住你为电话号码设置的联系人姓名。它无法发送任何消息。

你可以向 Agent 询问类似这样的问题:

  • “达娜关于航班说了些什么?”
  • “在我的所有聊天中,找出提到租约的所有消息。”
  • “总结一下本周我在家庭群聊里错过了什么内容。”

你的手机始终是唯一真实数据源(source of truth)。该服务器仅作为一个与 Agent 交互的关联设备。

它不会持续保持与 WhatsApp 的连接。当你的 Agent 请求数据时,或者每天定时一次,它会发起连接,拉取自上次以来 WhatsApp 为其排队的更新内容,将其写入自身的数据库,然后断开连接。你的手机照常正常工作,并正常接收通知。

你的消息存储在 Cloudflare 账户中 Durable Object 内置的 SQLite 数据库里。任何数据都不会流向其他地方。

你需要一个处于 Workers Paid 付费计划的 Cloudflare 账户(参见后文说明)、Node 20 或更高版本,以及你的手机。

bash git clone https://github.com/finereli/whatsapp-mcp cd whatsapp-mcp npm install

1. 创建存放登录令牌的位置。将输出的 ID 粘贴到 wrangler.jsonc 中。

npx wrangler kv namespace create OAUTH_KV

2. 为手机绑定页面设置一个密码。需 12 位以上字符。

npx wrangler secret put OWNER_PASSWORD

3. 部署。

npm run deploy

Wrangler 会输出你的 URL,类似于 https://whatsapp-mcp.your-name.workers.dev。打开它,首页会展示剩余的两个步骤:

  1. 关联 WhatsApp。 使用 WhatsApp 扫描二维码(设置 -> 已关联设备 -> 关联设备)。如果你在手机本机上操作且无法扫描屏幕,可改用手机号验证方式。首次关联会导入你的历史记录。页面会显示进度,此时你可以关闭该页面。

  2. 连接你的 Agent。 在 Claude Code 中运行:

    bash claude mcp add --transport http whatsapp https://your-url/mcp

    然后输入 /mcp 并使用密码登录。对于其他客户端:直接将其指向相同的 /mcp URL 即可。

如果你想使用自己的自定义域名,请参考 wrangler.jsonc 底部的注释。

导入体量庞大的历史记录需要一次性解码大量数据,而 Workers 默认的 CPU 配额不足以处理该负载。wrangler.jsonc 调高了这一上限,而提高配额需要付费计划(每月几美元)。我尚未测试过免费计划;如果你使用的是免费计划,请移除 limits 配置块,但首次导入可能会不够稳定。

本工具非官方出品。 WhatsApp 并没有用于读取个人消息的官方 API。本项目通过开源库(Baileys)使用 WhatsApp Web 所使用的相同协议。这违反了 WhatsApp 的服务条款。对于一个仅用于读取数据的关联设备而言,实际封号风险很低,但并非为零。请仅在个人账户上使用,切勿用它来发送消息。

以 WhatsApp 发送的数据为准。 首次关联时,系统仅导入 WhatsApp 决定分发给新设备的历史记录。这可能并不涵盖全部历史。每次关联仅执行一次该同步。如需再次导入,请重新关联(已存储的消息会保留,重新导入将补齐缺失内容)。

部分聊天会显示为电话号码。 WhatsApp 不会将所有联系人姓名同步给关联设备,其中大部分是你从未保存过的陌生人发送的一次性消息。对于重要的联系人,你的 Agent 可以解决这个问题:它能列出未命名的聊天,利用其已具备的任何通讯录工具(例如 Google Contacts 或 Gmail 连接器)在你的通讯录中查找号码,并保存姓名。服务器本身绝不会主动连接通讯录服务。参见下文“为聊天命名”。

不下载媒体文件。 照片、语音备忘录和文档将显示为类似 [image] 或 [voice note 12s] 的标签,若附带说明文字(caption)则会保留说明。不会进行语音转录。

仅供单人使用。 一套部署对应一个 WhatsApp 账户和一个所有者。这是特意设计的:每次部署都保存了完整的消息历史和账户的实时登录凭证,因此每个人都应当独立运行属于自己的实例。

重新关联(Relinking)。 如果你的手机提示设备已被移除,或者你想重新开始,可以让你的 Agent 运行 relink,或者打开 /setup 并选择“取消关联并重新扫描”。你需要扫描新的二维码。之前存储的消息将会予以保留。

工具(Tool) 功能描述(What it does)
list_chats 获取聊天列表,按最新时间排序。支持按名称或号码过滤。
get_messages 读取单个聊天。按日期向前或向后翻页。
search_messages 全文搜索所有消息,或在单个聊天内搜索。
unnamed_chats 仅以电话号码显示的聊天,按活跃度排序,并附带对方发送的最后一条消息。
set_contact_names 为电话号码保存姓名(支持任意格式)。姓名会绑定到该号码上,因此在群聊中也能正常显示。
sync_now 立即拉取新消息。
link_status 查看关联状态、上次拉取时间及存储的数据量。
relink 取消关联并重新开始。需要人工重新扫描新的二维码。

所有读取类工具在执行前,若本地存储副本的生成时间已超过 10 分钟,都会先从 WhatsApp 进行一次增量同步。

WhatsApp 只会向该服务器提供其已知的名称。其余联系人则显示为电话号码。为了避免让服务器直接登录通讯录服务(那意味着需要配置云端项目、同意屏幕,对于 Google 还需要应用审核),它将该任务交给了你的 Agent,而 Agent 通常已经具备访问通讯录的途径。

你只需对 Agent 说:“帮我未命名的聊天找到联系人姓名”。Agent 会调用 unnamed_chats,将号码与你的通讯录进行比对,并通过 set_contact_names 保存匹配到的结果。它并不能匹配到所有人:大多数未命名的聊天往往只是陌生人发来的单条消息。

Agent 会自发倾向于执行这一操作。服务器的系统提示词(instructions)中提及了该机制,每次出现“未找到聊天”和“无匹配项”的结果时也同样会进行提示,因为那是 Agent 因名称受阻的关键节点。link_status 同样会显示当前有多少个未命名的聊天。


面向 Agent 以及二次开发人员

以下所有内容适用于任何修改代码的人员,包括在 Fork 仓库上工作的 AI Agent。本文件前半部分面向部署的实际拥有者。

text MCP 客户端 ──OAuth──> Worker (src/index.ts) ├─ /mcp ───────────> mcp.ts:工具集合,每次请求生成全新的 McpServer ├─ /, /setup, /authorize, /login/, /pair/ (着陆页、登录、配对 UI) └─ cron 04:00 UTC ──> pull() │ RPC v WhatsAppStore (Durable Object,单个名为 "owner" 的实例) ├─ SQLite:认证密钥、消息、聊天、联系人、FTS5 索引 └─ Baileys socket,仅在会话期间打开(配对或拉取时) │ WebSocket v WhatsApp 服务器

单个 Durable Object 承载所有数据。其内置的 SQLite 既作为 Baileys 的认证凭据存储,也作为消息存储。除了 OAuth 提供方用于存储令牌和短期“待处理登录”记录的 KV 命名空间外,Worker 本身是无状态的。

文件 职责
src/index.ts 路由入口。OAuth 提供方接线、登录、配对端点、/debug、定时任务(cron)。
src/auth.ts 定义谁是所有者。包含两种模式:密码模式或登录服务模式。同时管理所有者的浏览器会话。
src/phone.ts 将任意格式的电话号码转换为 WhatsApp JID。
src/whatsapp-do.ts Durable Object 实现。运行 Baileys 会话,向 Worker 暴露 RPC 方法。
src/store.ts 数据库 Schema 定义、将 Baileys 事件摄入 SQLite 以及所有查询逻辑。
src/auth-state.ts 基于 DO 内置 SQLite 的 Baileys 认证状态实现(替代 useMultiFileAuthState)。
src/mcp.ts MCP 工具实现,以及引导 Agent 使用命名工具的提示逻辑。
src/pages.ts, src/landing.ts, src/privacy.ts, src/tos.ts, src/ui.ts 所有 HTML 页面。ui.ts 包含通用的 Design Token。
src/ws-shim.ts, src/lru-shim.ts, src/polyfills.ts 使 Baileys 能够在 workerd 上运行的兼容层(垫片)。详见后文。
scripts/vendor-bridge.mjs 从 Baileys 的 Rust bridge 中提取 wasm。在 npm install 时运行。
local-export/ 独立的单次运行脚本,可将聊天记录导出为 JSON 文件(无需服务器)。不属于部署的一部分。

所有 WhatsApp 流量都在 whatsapp-do.ts 中的 runSession() 内处理。会话分为两类:

  • 配对(Pairing)(startPairing):尚未获取凭证。Baileys 产出二维码字符串,保存在内存中并通过 pairStatus() 渲染为 SVG。扫码后,WhatsApp 会关闭 Socket 并返回状态码 515(需要重启),随后会话利用新凭证重新连接。历史记录以 messaging-history.set 数据块的形式送达,并伴随 progress 百分比进度。当 WhatsApp 报告 100% 且数据流静默数秒后,会话结束。
  • 拉取(Pull)(pull):使用存储的凭证重新连接。WhatsApp 下发离线期间排队的所有内容,随后发送 receivedPendingNotifications 信号。静默数秒后会话结束。

调用方共享同一个会话:如果在拉取正在进行时发起了第二次 pull(),将复用同一个 Promise。

配对会话的生命周期长于触发它的 HTTP 请求。startPairing 会设置一个 Durable Object 警报(Alarm),并且 alarm() 会 await 该会话 Promise。正是运行中的 Alarm 处理器维持了 Object 的存活。配对页面仅用于观察;关闭该页面不会中止导入过程。

空闲检测仅统计数据事件(messaging-history.set、messages.upsert),而不统计网络连接层面的心跳通信。如果将所有通信都计算在内,会话将永远无法关闭,因为在线状态(presence)和联系人更新始终不会停止。

所有数据均存储在 Durable Object 的 SQLite 中(参见 store.ts、initSchema):

  • messages(chat, id, ts, from_me, sender, push_name, type, body),主键为 (chat, id)。媒体文件在 body 中表现为文本标签。消息编辑会更新 body;消息撤回会将其置为 [deleted]。
  • messages_fts:基于 body 构建的 FTS5 索引,通过触发器保持同步。
  • chats(jid, name, is_group)、contacts(jid, name, notify) 以及 custom_names(jid, name)。显示名称的解析优先级依次为:WhatsApp 群聊名 -> WhatsApp 联系人名 -> 自定义保存名称 -> 用户自身的推送昵称(push name) -> 电话号码。custom_names 是 set_contact_names 写入的目标表。
  • alias(lid, pn):WhatsApp 正在向匿名的 @lid 标识符迁移。该表将其映射到电话号码 JID,且 addAlias 会将现有记录合并为电话号码形式,确保同一联系人聚合在单个聊天中。
  • auth(k, v):Baileys 凭证与 Signal 密钥,存储为通过 Baileys 的 BufferJSON 编码后的 JSON。
  • meta(k, v):元数据,目前仅包含 last_pull。

数据摄入具备幂等性。重新导入历史记录执行的是 upsert(更新插入)操作:它会补全缺失的 sender 或 push_name,绝不会产生重复数据。这也使得重新关联操作变得安全可靠。

动手修改前需知

排查以下问题花费了大量实际时间。在改动涉及 Baileys 的部分之前,请务必仔细阅读:

  1. Baileys 需要三个垫片(shim)才能在 workerd 上运行。
    • ws:该依赖包需要 Node 原生 Socket。ws-shim.ts 对 Workers WebSocket 进行了封装,采用 fetch() 配合 Upgrade 头发起连接,因为这是设置 WhatsApp 校验所需的 Origin 头的唯一方式。
    • whatsapp-rust-bridge:该库将 wasm 打包为 base64 并在运行时动态编译,而 workerd 禁止此类行为。vendor-bridge.mjs 会将 wasm 提取为独立文件,并修改加载逻辑使其作为 CompiledWasm 模块导入。vendor/ 目录属于自动生成,不提交至版本库。
    • 定时器(Timers):Node 原生定时器具备 .unref() 方法,而 workerd 没有。polyfills.ts 补充了该方法。它必须作为 Worker 和 Durable Object 模块中的首个 import 引入。
  2. lru-cache 的自动清理机制会导致首次导入崩溃。 Baileys 在创建缓存时启用了 ttlAutopurge: true,这会为每个缓存项启动一个 setTimeout。workerd 允许每个 Object 最多存在 10,000 个挂起状态的定时器,首次历史记录同步会轻易超出该上限并抛出 QuotaExceededError。lru-shim.ts 关闭了自动清理。过期的缓存项仍会在访问时惰性删除。
  3. 扫码配对绝不会设置 creds.registered。 该标志位仅适用于移动端注册流程。判断“是否已关联”应通过 creds.me?.id。
  4. 历史数据在每次关联时仅下发一次。 如果首次导入在途中失败,这部分历史数据就会丢失,除非重新关联。在针对真实关联环境进行测试之前,切勿直接发布对摄入代码的修改。
  5. 群聊发送者字段出现在两个不同位置。 实时消息位于 key.participant,而历史同步消息则位于 msg.participant。两者都必须进行读取。
  6. 严格锁定 Baileys 的版本号。 该项目尚处于预发布阶段,其 API 在各个 Release Candidate 版本之间经常变更。
  7. 部署不会直接踢掉(evict)处于忙碌状态的 Durable Object。 拥有活跃会话的 Object 会继续运行旧代码,直至会话终止。即使通过迁移(migration)重命名类也无法强制淘汰。如果你发布了修复补丁但 Object 行为依旧异常,请停止向其发送请求并等待 3 到 5 分钟。包括 /debug 在内的任何探测都会重置其空闲计时器,持续轮询只会让旧版 Object 一直保活。此外,Baileys 在 Socket 关闭时可能会泄漏 interval 计时器;whatsapp-do.ts 中的 finish() 会清理它们,以确保 Object 能够真正进入空闲状态。
  8. 注销登录分为两个步骤。 logout() 首先建立连接并调用 Baileys 的 logout(),以便该设备从手机的关联列表中移除,然后再清除本地凭据。如果第一步失败,调试事件中会输出提示,届时用户必须手动在手机端移除该设备。
  9. WhatsApp 会在联系人姓名处存储占位符。 对于没有保存联系人的聊天,其名称会直接以号码显示(有时带掩码,如 +972∙∙∙∙∙∙∙20),并同时出现在聊天名和联系人名中。store.ts 中的 numberLike 会将这类情况视为“无名称”,且数据摄入逻辑会拒绝存储它们。在该修复之前,占位符的优先级曾错误地高于用户真实保存的自定义姓名。
  10. 不要调用 markOnlineOnConnect。 该项已被特意置为 false。将关联设备标记为在线会导致手机端停止推送新消息通知。
配置项(Name) 类型(Kind) 用途(Purpose)
OWNER_PASSWORD secret 密码模式(默认)。必须为 12 位以上字符,否则拒绝登录。
OWNER_EMAIL var 登录服务模式:对应允许访问的邮箱。

English / 原文

Let your AI agent read your WhatsApp messages.

This is a small server that runs in your own Cloudflare account. You link your phone to it once, the way you'd link WhatsApp Web, and from then on any MCP client (Claude Code, Claude Desktop, anything that speaks MCP) can search and read your chats. It can read and search your messages, and remember names you give it for phone numbers. It can't send anything.

Ask your agent things like:

  • "What did Dana say about the flights?"
  • "Find every message where someone mentioned the lease, across all my chats."
  • "Summarize what I missed in the family group this week."

Your phone stays the source of truth. The server is a linked device that your agent talks to.

It doesn't stay connected to WhatsApp. When your agent asks for something, and once a day, it connects, collects whatever WhatsApp queued for it since last time, writes that to its own database, and hangs up. Your phone keeps working normally and keeps getting notifications.

Your messages live in a SQLite database inside a Durable Object in your Cloudflare account. Nothing goes anywhere else.

You need a Cloudflare account on the Workers Paid plan (see the note below), Node 20 or later, and your phone.

git clone https://github.com/finereli/whatsapp-mcp
cd whatsapp-mcp
npm install

# 1. A place for the sign-in tokens. Paste the id it prints into wrangler.jsonc.
npx wrangler kv namespace create OAUTH_KV

# 2. A password for the page that links your phone. 12+ characters.
npx wrangler secret put OWNER_PASSWORD

# 3. Deploy.
npm run deploy

Wrangler prints your URL, something like https://whatsapp-mcp.your-name.workers.dev. Open it. The front page has the two remaining steps:

  1. Link WhatsApp. Scan the QR code with WhatsApp (Settings, Linked Devices, Link a Device). If you're on the phone itself and can't scan a screen, use the phone number option instead. The first link imports your history. The page shows progress, and you can close it.

  2. Connect your agent. In Claude Code:

    claude mcp add --transport http whatsapp https://your-url/mcp
    

Then type /mcp and sign in with the password. Other clients: point them at the same /mcp URL.

If you want your own domain, see the comments at the bottom of wrangler.jsonc.

Importing a large history decodes a lot of data in one go, and the default CPU limit on Workers is too small for it. wrangler.jsonc raises it, and raising it needs the paid plan, which is a few dollars a month. I haven't tried the free plan. If you're on it, remove the limits block and expect the first import to be fragile.

This isn't official. WhatsApp has no API for reading your own personal messages. This uses the same protocol WhatsApp Web uses, through an open source library (Baileys). That's against WhatsApp's terms. For a linked device that only reads, the practical risk is low, but it isn't zero. Use it on your own account, and don't use it to send messages.

You get what WhatsApp sends. The first link imports whatever history WhatsApp decides to hand a new device. It may not be everything. It only does this once per link. To import again, relink (your stored messages are kept, and a re-import fills in anything missing).

Some chats show up as phone numbers. WhatsApp doesn't give a linked device every contact name, and most of these are one-off messages from people you never saved. For the ones that matter, your agent can fix it: it lists the unnamed chats, looks the numbers up in your contacts with whatever contacts tool it already has (a Google Contacts or Gmail connector, for example), and saves the names. The server never connects to a contacts service itself. See "Naming chats" below.

Media isn't downloaded. Photos, voice notes and documents appear as labels like [image] or [voice note 12s], with the caption if there was one. Nothing is transcribed.

It's for one person. One deployment is one WhatsApp account and one owner. That's deliberate: each deployment holds a full message history and a live login to the account, so everyone should run their own.

Relinking. If your phone says the device was removed, or you want to start over, ask your agent to run relink, or open /setup and choose "Unlink and scan again". You scan a new QR code. Stored messages are kept.

Tool What it does
list_chats Chats, most recent first. Filter by name or number.
get_messages Read one chat. Page back or forward by date.
search_messages Full-text search across everything, or within one chat.
unnamed_chats Chats known only by phone number, busiest first, with the last thing the other person said.
set_contact_names Save names for phone numbers, in any format. Names stick to the number, so they also show up in groups.
sync_now Pull new messages right now.
link_status Is it linked, when did it last pull, how much is stored.
relink Unlink and start over. A human has to scan the new QR code.

Every read tool first tops up from WhatsApp if the stored copy is more than ten minutes old.

WhatsApp only gives this server the names it knows. The rest show up as phone numbers. Rather than have the server log in to a contacts service (which means a cloud project, consent screens and, for Google, an app review), it hands the job to your agent, which usually already has a way to reach your contacts.

Say "find names for my unnamed chats". The agent calls unnamed_chats, matches the numbers against your contacts, and saves what it finds with set_contact_names. It won't find everyone: most unnamed chats are a single message from a stranger.

Agents get nudged toward this on their own. The server's instructions mention it, and so does every "no chat found" and "no matches" result, because that's the moment an agent is stuck on a name. link_status also says how many chats are unnamed.


For agents, and people adapting this

Everything below is for whoever is changing the code, including an AI agent working on a fork. The top of this file is for the person who owns the deployment.

MCP client ──OAuth──> Worker (src/index.ts)
                        ├─ /mcp ───────────> mcp.ts: tools, one fresh McpServer per request
                        ├─ /, /setup, /authorize, /login/*, /pair/*   (landing, sign-in, pairing UI)
                        └─ cron 04:00 UTC ──> pull()
                                   │ RPC
                                   v
              WhatsAppStore (Durable Object, one instance named "owner")
                ├─ SQLite: auth keys, messages, chats, contacts, FTS5 index
                └─ Baileys socket, opened only during a session (pairing or pull)
                                   │ WebSocket
                                   v
                              WhatsApp servers

One Durable Object holds everything. Its SQLite is both the Baileys auth store and the message store. The Worker is stateless apart from a KV namespace the OAuth provider uses for tokens and a short-lived "pending sign-in" record.

File Job
src/index.ts Routes. OAuth provider wiring, sign-in, pairing endpoints, /debug, cron.
src/auth.ts Who counts as the owner. Two modes: a password, or a login service. Also the owner's browser session.
src/phone.ts Phone numbers in any format to WhatsApp jids.
src/whatsapp-do.ts The Durable Object. Runs Baileys sessions, exposes RPC methods for the Worker.
src/store.ts Schema, ingesting Baileys events into SQLite, and every query.
src/auth-state.ts Baileys auth state backed by the DO's SQLite (replaces useMultiFileAuthState).
src/mcp.ts The MCP tools, and the hint that points agents at the naming tools.
src/pages.ts, src/landing.ts, src/privacy.ts, src/tos.ts, src/ui.ts All HTML. ui.ts has the shared design tokens.
src/ws-shim.ts, src/lru-shim.ts, src/polyfills.ts Make Baileys run on workerd. See below.
scripts/vendor-bridge.mjs Extracts the wasm from Baileys' Rust bridge. Runs on npm install.
local-export/ A separate one-off script that dumps your history to JSON files with no server. Not part of the deployment.

All WhatsApp traffic happens inside runSession() in whatsapp-do.ts. There are two kinds:

  • Pairing (startPairing). No credentials yet. Baileys emits QR strings, kept in memory and rendered to SVG by pairStatus(). After a scan WhatsApp closes the socket with code 515 (restart required), and the session reconnects with the new credentials. History arrives as messaging-history.set chunks with a progress percentage. The session ends when WhatsApp reports 100 percent and the stream has been quiet for a few seconds.
  • Pull (pull). Reconnects with stored credentials. WhatsApp delivers whatever it queued while we were away, then signals receivedPendingNotifications. The session ends after a few quiet seconds.

Callers share one session: a second pull() while one is running gets the same promise.

A pairing session outlives the HTTP request that started it. startPairing sets a Durable Object alarm, and alarm() awaits the session promise. An in-flight alarm handler is what keeps the object alive. The pairing page only watches; closing it doesn't stop the import.

Idle detection counts only data events (messaging-history.set, messages.upsert), not connection chatter. Counting everything keeps a session open forever, because presence and contact updates never stop.

All in the Durable Object's SQLite (store.ts, initSchema).

  • messages(chat, id, ts, from_me, sender, push_name, type, body), primary key (chat, id). Media is a label in body. Edits update body; deletes set it to [deleted].
  • messages_fts, an FTS5 index over body, kept in sync by triggers.
  • chats(jid, name, is_group), contacts(jid, name, notify) and custom_names(jid, name). Display names resolve by WhatsApp's chat name, then WhatsApp's contact name, then a saved name, then the person's own push name, then the number. custom_names is what set_contact_names writes.
  • alias(lid, pn). WhatsApp is moving to anonymous @lid identifiers. This maps them to phone-number JIDs, and addAlias folds existing rows into the phone-number form so one person is one chat.
  • auth(k, v), Baileys credentials and signal keys, JSON with Buffers encoded by Baileys' BufferJSON.
  • meta(k, v), currently just last_pull.

Ingest is idempotent. Re-importing history upserts: it fills in a missing sender or push_name and never duplicates. That is what makes relinking safe.

What to know before changing things

These all cost real time to find. Read them before touching the Baileys parts.

  1. Baileys needs three shims to run on workerd.
    • ws: the package needs Node sockets. ws-shim.ts wraps the Workers WebSocket, opened with fetch() and an Upgrade header because that's the only way to set the Origin header WhatsApp checks.
    • whatsapp-rust-bridge: it ships its wasm as base64 and compiles it at runtime, which workerd forbids. vendor-bridge.mjs writes the wasm to a file and patches the loader to import it as a CompiledWasm module. The vendor/ folder is generated, not committed.
    • Timers: Node timers have .unref() and workerd's don't. polyfills.ts adds it. It must be the first import in both the Worker and the Durable Object module.
  2. lru-cache autopurge breaks the first import. Baileys creates caches with ttlAutopurge: true, which starts one setTimeout per entry. workerd allows 10,000 pending timers per object, and the first history sync blows through that with QuotaExceededError. lru-shim.ts turns autopurge off. Expired entries are still dropped lazily on access.
  3. QR pairing never setscreds.registered. That flag belongs to the mobile registration flow. "Is this linked" is creds.me?.id.
  4. History comes once per link. If a first import fails halfway, that history is gone until you relink. Don't ship a change to ingest code without testing it against a real first link.
  5. Group senders arrive in two places. Live messages have key.participant. History messages put it on msg.participant. Read both.
  6. Pin Baileys exactly. It's a prerelease and its API moves between release candidates.
  7. Deploys don't evict a busy Durable Object. An object with an active session keeps running old code until the session ends. Renaming the class with a migration didn't evict it either. If you've shipped a fix and the object still misbehaves, stop sending it requests and wait three to five minutes. Every probe, including /debug, resets its idle timer, so polling a stale object keeps it stale. Baileys also leaks interval timers when a socket closes; finish() in whatsapp-do.ts clears them so the object can go idle at all.
  8. Logging out takes two steps. logout() first connects and calls Baileys' logout(), so the device disappears from the phone's list, then clears local credentials. If the first step fails it says so in the debug events, and the user has to remove the device by hand.
  9. WhatsApp stores a placeholder where a name should be. Chats it has no contact for are named after the number, sometimes masked (+972∙∙∙∙∙∙∙20), in the chat name and the contact name. numberLike in store.ts treats those as no name, and ingest refuses to store them. Before that fix a placeholder outranked a real saved name.
  10. Don't callmarkOnlineOnConnect. It's set to false on purpose. Marking the linked device online makes the phone stop showing notifications.
Name Kind Purpose
OWNER_PASSWORD secret Password mode (default). 12+ characters or sign-in refuses.
OWNER_EMAIL var Login-service mode: the one email allowed in.
OPERATOR_NAME var, optional Shown on the privacy page as who runs this server.
DEFAULT_COUNTRY_CODE var, optional Country code (digits, no plus), used when your agent saves a number written without one, e.g. 972.
LOGIN_ORIGIN var Set this to switch to login-service mode.
ALLOWED_ORIGINS var Login-service mode: comma-separated origins whose tokens are accepted. Production should list only production.
PIN secret, optional 32+ random characters. Enables the /debug routes via an x-pin header. Leave it unset to leave them off.
OAUTH_KV binding Token store for the OAuth provider and pending sign-ins.
WA binding The Durable Object.

Keep a deployment's own values (domain, KV id, email) in wrangler.local.jsonc, which is gitignored, and deploy with npm run deploy:local. wrangler.jsonc is the template other people start from.

  • Add a tool. Register it in mcp.ts and put the SQL in store.ts. Read tools should call fresh() first. Keep tools read-only unless you mean otherwise. If a failure leaves an agent stuck, say in the tool result what to try next, the way NAME_HINT does for missing names.
  • Change who can sign in. auth.ts picks a mode (authMode) and the routes in index.ts follow it. To add another identity provider, add a mode there and a callback route. The rest of the code only sees an opaque bearer token.
  • Make it multi-user. Today the Durable Object is addressed by the fixed name owner (wa() in index.ts, handleMcp in mcp.ts), and userId is the constant 'owner' in the OAuth props. To go multi-user, derive both from the signed-in identity and key the object on it. Think about whether you want to hold other people's message history first.
  • Other ways to fill in names. Anything that can produce { jid, name } rows can call store.setCustomNames. A CSV upload, a CardDAV pull or a scheduled job would all work. The agent route exists so the server needs no credentials for any of them.
  • Sending messages. Not built, on purpose. Think hard before adding it.

With PIN set, these return JSON and need the header x-pin: <PIN>:

  • /debug: session phase, linked or not, counts, and the last events from the session (connection changes, history chunk sizes, errors).
  • /debug/pull: run a pull now.
  • /debug/chats?q=, /debug/messages?chat=, /debug/search?q=, /debug/types: read the store without going through MCP.
  • /debug/unnamed: the chats unnamed_chats would return.
  • /debug/sql?q=: one read-only SELECT against the store, for finding out why a name or a count looks wrong.
  • /debug/logout: the same as relink.

Local development: npm run dev with a .dev.vars containing OWNER_PASSWORD=... (12+ characters). It runs a local Durable Object with its own empty storage, so you can exercise the sign-in flow and see a real QR code, but linking a phone from local dev makes a second linked device.

To test the pairing UI states without a phone, serve the page with a mocked /pair/status. Everything the page needs is that one JSON shape: paired, running, progress, qrSvg, messages, chats.

  1. npm run typecheck, then npm test. The test runs the real store code against Node's built-in SQLite (Node 22.13 or later), including the migration from older versions. That SQLite has no FTS5, so search itself isn't covered.
  2. Deploy, then GET /debug with the PIN. It should come back with phase: "idle" and paired: true.
  3. GET /debug/pull. A quiet account should return reason: "idle" in about ten seconds.
  4. Call list_chats, then get_messages on a Hebrew or emoji-heavy chat, then search_messages. Then unnamed_chats, and set_contact_names on one number, and confirm the chat shows the name.
  5. If you touched ingest, relink and confirm the stored message count doesn't grow (re-import is idempotent) and that group messages show real sender names.
  • Chats with no name in WhatsApp and none saved show as phone numbers. Nothing refreshes names by itself: they only get saved when an agent (or you) does it.
  • If the daily pull finds the session logged out, nothing tells you. link_status shows it the next time your agent asks.
  • Edits and deletes are applied when they arrive as protocol messages. That path hasn't been tested against real ones.
  • It's only been exercised against one real account.

MIT