SmolPaws · design proposal

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 goal is not a behavior change for the user. WhatsApp should feel identical. The single intended difference is operational: it starts as its own process, on demand, instead of being the process.

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/:

ConcernWhere it lives nowWhat it does
Connectionsrc/index.ts · connectWhatsApp()Baileys socket, auth reload, reconnect, transient-error swallowing so the CDN socket dropping never crashes the process.
Versioningwhatsapp-version.tsResolves a working WhatsApp web version.
Authwhatsapp-auth.tsMulti-file auth state in ~/.smolpaws/whatsapp/auth.
Identitywhatsapp-jid.tsJID / LID resolution for outbound targets.
Store~/.smolpaws/whatsapp/messages.dbSQLite record of all messages, both directions, per group.
Scopesscope.ts · control-scope.tsWhich chat maps to which group folder, control-scope privileges, per-group config, triggerFree groups.
Schedulertask-scheduler.tsCron / interval / once tasks, per scope, that fire prompts back into the agent.
Mediasrc/index.ts media helpersDownload inbound media, voice-note playback, image passthrough, outbound voice outbox.
Triggersrc/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:

ComponentOwns
MessageRelayLane binding, durable intake, agent-server integration, syncDeliveryOutbox().
OutboundRelayCatches agent events up into the durable delivery outbox, repeatedly.
DeliveryDispatcherThe external side-effect boundary — the one place a reply actually leaves the building.
<Platform>RelayRuntimeRuns the intake + outbound workers for one platform.
<Platform>DeliveryTargetPerforms the platform-specific send.
So the WhatsApp rewrite is not an invention. It is: keep WhatsApp's front-door code, drop the legacy dispatch, and plug into the same shared relay Slack already uses.

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 fileComes fromDisposition
adapter.tssrc/index.ts connect + poll loopmove lift connectWhatsApp(), reconnect, error-swallowing verbatim
handler.tssrc/index.ts trigger + scope.tsmove produce IncomingMessage after trigger + scope checks
deliveryTarget.tssrc/index.ts send + media helpersmove the send side-effect boundary
relayRuntime.tsapps/slack/src/relayRuntime.tsadapt copy the Slack runtime, swap the delivery target
config.tssrc/config.tsmove 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.

ConcernPlanStatus
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 modelMove 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
SchedulerThe 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 pipelinesInbound download + outbound (images, voice notes) move into handler.ts / deliveryTarget.ts. Voice-note playback for Bossy stays host-side.move
Connection resilienceThe transient-error swallowing that keeps Baileys from crashing the process moves verbatim into adapter.ts.move
Auth / version / JIDMove the three helper modules unchanged.move

6The relay flow

Baileyspoll loop handlertrigger+scope MessageRelaydurable intake agent-server:8790 · EventLog OutboundRelaydelivery outbox Dispatcher+ WA target WhatsAppsendMessage
Inbound is gated by trigger + scope before it ever becomes durable work. Outbound leaves the building in exactly one place: the Dispatcher.

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.

The LaunchAgent question. WhatsApp is the primary channel and is expected to stay up. Bead 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

  1. Prerequisite. The new agent-server is proven by paws end-to-end (epic smolpaws-zlo, blocked-by smolpaws-b1r and smolpaws-c6q).
  2. Scaffold apps/whatsapp from the Slack layout — plugin.json, config.ts, empty runtime/target with tests.
  3. Lift the front door. Move connect/poll/reconnect into adapter.ts; move trigger + scope + media into handler.ts; move send into deliveryTarget.ts. No behavior change.
  4. Wire the relay. Copy relayRuntime.ts from Slack, point it at the WhatsApp delivery target and a fresh whatsapp-relay:v1 coordinator DB.
  5. Resolve the scheduler. Decide keep-local vs lift-to-shared; wire scheduled prompts as synthetic intakes.
  6. 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.
  7. Add to the live test queue for the running smolpaws, behind the current channel, until confidence is high.
  8. Cut over the live primary channel; keep the old root path as a documented rollback until the bridge has soaked.

9Risks & open questions

ItemNote
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 IDsWhatsApp 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 privilegesThe main control scope has elevated rights (managing groups, scheduling for other groups). Confirm the relay path preserves control-scope semantics end to end.
Bottom line. The rewrite is a lift-and-replug, not a redesign. WhatsApp keeps its store, scopes, scheduler, and media; it drops only the legacy dispatch and its "I am the app" boot; it gains the durable relay and the same shape as every other channel. The proof bar is the Slack six-point canary, run for WhatsApp.