WhatsApp as a bridge
Move the primary SmolPaws channel off the root process and onto the durable Message Relay — a standalone apps/whatsapp that starts like the Slack bridge, talks to the new TypeScript agent-server, and loses none of what makes WhatsApp the main channel.
STATUS: PROPOSED bead smolpaws-kxa · P0 epic smolpaws-zlo
1Why move at all
WhatsApp is the oldest SmolPaws channel, and it shows: it lives in the repository root (src/index.ts), it boots as the app itself, and it predates the bridge abstraction the newer channels use. Every other channel — Discord, Slack, email — is an apps/<name> plugin that the host starts and stops independently. WhatsApp is the exception, and being the exception is now the problem.
Two forces make this a P0:
- The cutover to the new transpiled stack. We are proving the new TypeScript OpenHands agent-server with Slack (
paws) as the guinea pig. When it is proven, the primary channel has to follow — but only if WhatsApp can talk to that server the same durable way Slack does. - One shape, many front doors. When WhatsApp is a bridge like the others, the whole system has one mental model: a thin front door per platform, one durable relay, one agent-server. Less bespoke code to reason about, and the eight-minute-understandable property survives.
2WhatsApp today
The current channel is a Baileys multi-device client wrapped in a poll loop, with a set of concerns that the thin bridges simply do not carry. From src/:
| Concern | Where it lives now | What it does |
|---|---|---|
| Connection | src/index.ts · connectWhatsApp() | Baileys socket, auth reload, reconnect, transient-error swallowing so the CDN socket dropping never crashes the process. |
| Versioning | whatsapp-version.ts | Resolves a working WhatsApp web version. |
| Auth | whatsapp-auth.ts | Multi-file auth state in ~/.smolpaws/whatsapp/auth. |
| Identity | whatsapp-jid.ts | JID / LID resolution for outbound targets. |
| Store | ~/.smolpaws/whatsapp/messages.db | SQLite record of all messages, both directions, per group. |
| Scopes | scope.ts · control-scope.ts | Which chat maps to which group folder, control-scope privileges, per-group config, triggerFree groups. |
| Scheduler | task-scheduler.ts | Cron / interval / once tasks, per scope, that fire prompts back into the agent. |
| Media | src/index.ts media helpers | Download inbound media, voice-note playback, image passthrough, outbound voice outbox. |
| Trigger | src/index.ts trigger logic | @smolpaws gating, except in triggerFree groups. |
The important detail: WhatsApp already has a durable SQLite store and a coordinator. It is not starting from nothing — it is on the legacy dispatch path (the older turnClient / /turns route into the agent-server), while Slack has already moved to the durable Message Relay.
3The Slack pattern (the target we copy)
Slack (apps/slack, the live paws app) is kind: standalone. It deliberately does not use BaseBridgeAdapter, bridgeRegistry, turnClient, or /turns. It runs as its own process and drives the durable relay directly. Its own AGENTS.md is blunt about it:
Slack Socket Mode
-> SlackBridge / slackHandler
-> SlackRelayRuntime.accept()
-> MessageRelay durable intake (SQLite)
-> TypeScript OpenHands agent-server (:8790)
-> agent EventLog
-> OutboundRelay.syncDeliveryOutbox()
-> durable delivery outbox
-> DeliveryDispatcher
-> SlackDeliveryTarget
-> chat.postMessage
The relay pieces are shared code in src/coordinator/, not Slack-specific:
| Component | Owns |
|---|---|
MessageRelay | Lane binding, durable intake, agent-server integration, syncDeliveryOutbox(). |
OutboundRelay | Catches agent events up into the durable delivery outbox, repeatedly. |
DeliveryDispatcher | The external side-effect boundary — the one place a reply actually leaves the building. |
<Platform>RelayRuntime | Runs the intake + outbound workers for one platform. |
<Platform>DeliveryTarget | Performs the platform-specific send. |
4Target: apps/whatsapp
Mirror the Slack layout. Same file names, WhatsApp guts.
apps/whatsapp/
plugin.json # kind: "standalone" (NOT loaded by the bridge loader)
package.json
src/
index.ts # process entry: wire config -> runtime -> start
config.ts # env + ~/.smolpaws paths, allowlist, relay server URL
adapter.ts # WhatsAppBridge: owns the Baileys connection + poll loop
handler.ts # trigger logic, scope resolution, media -> IncomingMessage
relayRuntime.ts # WhatsAppRelayRuntime: intake + outbound workers
deliveryTarget.ts # WhatsAppDeliveryTarget: Baileys sendMessage / media
__tests__/
The plugin.json mirrors Slack's, declaring kind: "standalone" so the bridge loader ignores it (the loader only auto-starts kind: "bridge" manifests). WhatsApp is started on its own, like paws.
| New file | Comes from | Disposition |
|---|---|---|
adapter.ts | src/index.ts connect + poll loop | move lift connectWhatsApp(), reconnect, error-swallowing verbatim |
handler.ts | src/index.ts trigger + scope.ts | move produce IncomingMessage after trigger + scope checks |
deliveryTarget.ts | src/index.ts send + media helpers | move the send side-effect boundary |
relayRuntime.ts | apps/slack/src/relayRuntime.ts | adapt copy the Slack runtime, swap the delivery target |
config.ts | src/config.ts | move WhatsApp-relevant env + paths |
5What must not be lost
The enyst.io WhatsApp page already names why WhatsApp never became a bridge: it "carries concerns the bridges don't: the SQLite store, the scope/permission model, the scheduler, and media pipelines." Those concerns are the acceptance criteria for this rewrite. Nothing here gets dropped — it gets rehomed into apps/whatsapp.
| Concern | Plan | Status |
|---|---|---|
SQLite message store (messages.db) | Keep as-is; the coordinator relay DB is separate and additive. The message DB stays the channel's own record. | keep |
| Scope / permission model | Move scope.ts, control-scope.ts, registered_groups.json, triggerFree into the handler path. Scope resolution runs before durable intake, so only accepted messages create work. | move |
| Scheduler | The trickiest piece — see open questions. Scheduled tasks fire prompts into the agent without an inbound message. Options: keep the scheduler in apps/whatsapp and have it enqueue synthetic relay intakes, or lift the scheduler into shared coordinator code so any channel can use it. | decide |
| Media pipelines | Inbound download + outbound (images, voice notes) move into handler.ts / deliveryTarget.ts. Voice-note playback for Bossy stays host-side. | move |
| Connection resilience | The transient-error swallowing that keeps Baileys from crashing the process moves verbatim into adapter.ts. | move |
| Auth / version / JID | Move the three helper modules unchanged. | move |
6The relay flow
Durable boundaries carry over directly from the Slack design: coordinator SQLite keyed by the stable WhatsApp message identity is the idempotency authority; a process-local dedup gate is only a short-lived optimization and never the source of truth. Once a delivery row is marked send_attempted, a failure is delivery_unknown — never blindly re-send a message that may already have landed. WhatsApp gets its own versioned relay DB and conversation namespace (e.g. whatsapp-relay:v1); it must not reuse legacy conversation IDs, or it could rediscover and re-send historical replies.
7Start like a bridge, not like the app
Today WhatsApp boots because it is src/index.ts — the thing you run. After the move, the host starts apps/whatsapp as a separate process, the same way paws is started:
./scripts/run-local-smolpaws.sh npm --prefix apps/whatsapp run start
This is the one intended behavioral difference Engel called out. It also unlocks the operational wins: WhatsApp can be restarted without touching the agent-server, it can run against a local or remote agent-server by config, and the repo root stops being a single channel's home.
smolpaws-1zk.2 already covers keeping the host alive via a LaunchAgent and bootstrapping the agent-server when needed. The bridge move should land with a supervision story, not leave the main channel to be started by hand.8Migration plan
- Prerequisite. The new agent-server is proven by
pawsend-to-end (epicsmolpaws-zlo, blocked-bysmolpaws-b1randsmolpaws-c6q). - Scaffold
apps/whatsappfrom the Slack layout —plugin.json,config.ts, empty runtime/target with tests. - Lift the front door. Move connect/poll/reconnect into
adapter.ts; move trigger + scope + media intohandler.ts; move send intodeliveryTarget.ts. No behavior change. - Wire the relay. Copy
relayRuntime.tsfrom Slack, point it at the WhatsApp delivery target and a freshwhatsapp-relay:v1coordinator DB. - Resolve the scheduler. Decide keep-local vs lift-to-shared; wire scheduled prompts as synthetic intakes.
- Prove it locally with the same six-point canary Slack uses: ingress accepted → intake row → agent run → outbox row → dispatcher settled → reply in the right chat. A visible reply alone is not a pass.
- Add to the live test queue for the running smolpaws, behind the current channel, until confidence is high.
- Cut over the live primary channel; keep the old root path as a documented rollback until the bridge has soaked.
9Risks & open questions
| Item | Note |
|---|---|
| scheduler Where does it live? | The scheduler is the one concern with no Slack precedent. Keeping it in apps/whatsapp is the smaller change; lifting it into shared coordinator code (bead smolpaws-0de) is the cleaner long-term shape but a bigger blast radius. Proposal: keep it local first, lift later. |
| history Reused conversation IDs | WhatsApp has a long history of conversation IDs on the legacy path. The new relay must use a fresh namespace so it cannot rediscover and re-send old replies. This is the same trap the Slack design flagged. |
| messages.db One store or two? | Does the channel keep its own messages.db and let the coordinator hold intake/outbox separately (proposed), or does the message record fold into the coordinator? Proposed: keep them separate — the message DB is the channel's ledger, the relay DB is the work queue. |
| supervision Who keeps it up? | The primary channel must not depend on a human running a command. Tie the cutover to smolpaws-1zk.2. |
| control scope Admin privileges | The main control scope has elevated rights (managing groups, scheduling for other groups). Confirm the relay path preserves control-scope semantics end to end. |