WhatsApp is where SmolPaws started, and it is still the primary channel. Unlike the Slack and Discord bridges (which live under apps/ and self-register with the bridge loader), WhatsApp is the root process in src/: it owns the WhatsApp connection, the message store, the scheduler, and the dispatch loop.
It connects to WhatsApp via Baileys (@whiskeysockets/baileys), the multi-device Web protocol — no Business API, no phone number rental. Authentication is a one-time QR pairing; the session persists to disk. Rather than reacting to a push stream, the process persists every message to SQLite and polls for new ones every 2 seconds, which makes restarts and missed-message recovery trivial.
flowchart LR
A["WhatsApp\n(multi-device)"] <-->|Baileys WS| B["src/index.ts\n(makeWASocket)"]
B -->|"messages.upsert"| DB["SQLite\nstore (db.ts)"]
B -.->|"poll every 2s"| DB
B -->|"runAgentRuntime()"| C["local-agent-server\n127.0.0.1:8788"]
C -->|runs agent| D["OpenHands\nruntime"]
D -->|turn result +\noutbound msgs| C
C --> B
B -->|"sock.sendMessage"| A
Baileys delivers messages via a messages.upsert event, but SmolPaws does not dispatch straight off that event. Instead it writes incoming messages into SQLite, then a separate loop reads new rows on a fixed interval.
getMessagesSince), so the prompt carries the full recent conversation, not just the triggering line.POLL_INTERVAL = 2000ms // src/config.ts
loop:
messages = getNewMessages(jids, lastTimestamp, ASSISTANT_NAME)
for each chat with new messages:
processMessage(latest) // pulls full context since last run
sequenceDiagram
participant U as WhatsApp User
participant W as Baileys Socket
participant L as Poll Loop (index.ts)
participant DB as SQLite
participant A as local-agent-server
participant R as Agent Runtime
U->>W: message (text / image / audio / doc)
W->>L: messages.upsert
L->>DB: storeMessage() (+ download media)
loop every 2s
L->>DB: getNewMessages()
DB-->>L: pending rows
end
L->>L: trigger? (control scope / triggerFree / @smolpaws)
L->>DB: getMessagesSince() — full context
L->>W: setTyping(true)
L->>A: runAgentRuntime(scope, prompt, images)
A->>R: create/resume conversation
R-->>A: result + outbound messages
A-->>L: AgentRuntimeOutput
L->>W: setTyping(false)
L->>W: sock.sendMessage(reply)
W->>U: sees reply
Whether a message wakes the agent depends on the scope of the chat:
shouldRespondWithoutTrigger(folder, triggerFree) = isControlScope(folder) || triggerFree === true // otherwise require an @mention: TRIGGER_PATTERN = /(^|\W)@smolpaws\b/i
main group) — responds to every message, no mention needed. This is Engel's private line to the cat.triggerFree: true), also respond to all messages. Used for the Hunting group.@smolpaws mention (matched in text or image caption).Each registered chat maps to a scope — an isolation boundary for conversation state, working directory, and permissions. The control scope (main) is privileged; other scopes are sandboxed to themselves.
| Capability | Control scope (main) | Other scopes |
|---|---|---|
| Respond without @mention | yes | only if triggerFree |
| Send to other chats | any chat | own chat only |
| Register / refresh groups | yes | no |
| See scheduled tasks | all | own scope only |
| List available groups | yes | no |
Conversation continuity is per-scope: sessions.json maps each scope to its agent-server conversation id, so a chat resumes its own conversation across turns.
The ingress downloads attached media to ~/.smolpaws/whatsapp/media/ and passes it to the agent in the form the model can use:
| Type | Handling |
|---|---|
| Images | Read back as a base64 data URL and sent inline to the LLM (cap 10 MB) |
| Audio / voice notes | Saved to disk; path passed as has_audio + audio_path for local transcription (Whisper) |
| Documents | Text extracted and embedded as an <attachment> block (truncated if large) |
Text replies go out directly via sock.sendMessage, prefixed with the assistant name. Two extra paths exist:
send_message tool produces current_thread_message items, delivered to the chat in order before the final reply.{jid, oggPath} lines to a voice-outbox.jsonl; the loop drains it each cycle (atomic rename → send as ptt: true Opus → requeue on failure). This is how the cat speaks in Engel's voice (TTS → OGG/Opus).useMultiFileAuthState; credentials persist under ~/.smolpaws/whatsapp/.DisconnectReason).ConnectionGuards debounce reconnect storms and surface a clean "I'm up" ping on the main scope after a healthy reconnect.| Variable | Default | Purpose |
|---|---|---|
ASSISTANT_NAME | smolpaws | Reply prefix and @mention trigger word |
SMOLPAWS_HOME / WHATSAPP_DIR | ~/.smolpaws/… | Auth state, media, voice outbox, SQLite db |
TZ | system | Timezone for the scheduler |
Registered chats and their scopes live in data/registered_groups.json (folder, name, triggerFree).
src/
├── index.ts # WhatsApp socket, poll loop, dispatch, voice outbox
├── config.ts # constants (poll interval, trigger, paths)
├── db.ts # SQLite store — messages, chat metadata, sync state
├── scope.ts # ExecutionScope from a registered group
├── control-scope.ts # control / trigger-free / permission rules
├── message-loop.ts # collapse messages to latest per chat
├── connection-guards.ts # reconnect debounce + health ping
├── whatsapp-jid.ts # outbound JID resolution (lid ↔ chat)
├── document-text.ts # document text extraction
├── outbound-reply-policy.ts # when to send the final reply after outbound msgs
├── task-scheduler.ts # cron / interval / once scheduled tasks
└── agent-runtime/
├── index.ts # runAgentRuntime() → local-agent-server
└── local-agent-server.ts # turn submission + monitoring
WhatsApp predates the bridge abstraction, so it does not extend BaseBridgeAdapter or register with the bridge loader. But it terminates at the same backend: runAgentRuntime() submits to the local agent-server on 127.0.0.1:8788 — the very server that hosts the Slack and Discord bridges. One agent, one runtime, many front doors.
flowchart TB
subgraph "Front doors"
WA["WhatsApp\nsrc/ (root process)"]
SL["Slack\napps/slack (bridge)"]
DC["Discord\napps/discord (bridge)"]
GH["GitHub\napps/github"]
end
subgraph "Backend"
A["agent-server\n127.0.0.1:8788"]
R["OpenHands runtime"]
end
WA --> A
SL --> A
DC --> A
GH --> A
A --> R
Why not make WhatsApp a bridge too? It could be, but it carries concerns the bridges don't: the SQLite store, the scope/permission model, the scheduler, and media pipelines. Those are first-class to the primary channel. The bridges are deliberately thinner — they lean on the shared turn client and let the agent-server own state.