← Home

SmolPaws WhatsApp Entry Point live

How the WhatsApp ingress — SmolPaws' primary channel — receives messages over Baileys and dispatches to the agent server.
Source: src/  ·  Entry: src/index.ts

Overview

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
    

Why a Poll Loop?

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.

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

Message Flow

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
    

Trigger Logic

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

Scopes

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.

CapabilityControl scope (main)Other scopes
Respond without @mentionyesonly if triggerFree
Send to other chatsany chatown chat only
Register / refresh groupsyesno
See scheduled tasksallown scope only
List available groupsyesno

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.

Media

The ingress downloads attached media to ~/.smolpaws/whatsapp/media/ and passes it to the agent in the form the model can use:

TypeHandling
ImagesRead back as a base64 data URL and sent inline to the LLM (cap 10 MB)
Audio / voice notesSaved to disk; path passed as has_audio + audio_path for local transcription (Whisper)
DocumentsText extracted and embedded as an <attachment> block (truncated if large)

Sending Replies

Text replies go out directly via sock.sendMessage, prefixed with the assistant name. Two extra paths exist:

Connection Resilience

Configuration

VariableDefaultPurpose
ASSISTANT_NAMEsmolpawsReply prefix and @mention trigger word
SMOLPAWS_HOME / WHATSAPP_DIR~/.smolpaws/…Auth state, media, voice outbox, SQLite db
TZsystemTimezone for the scheduler

Registered chats and their scopes live in data/registered_groups.json (folder, name, triggerFree).

File Layout

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

Relationship to the Bridges

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.

← Home