WhatsApp

End-to-end WhatsApp channel: Signal Protocol pairing, inbound message bridge, outbound send/reply/reaction/media tools, optional voice transcription.

Source: crates/plugins/whatsapp/ (thin wrapper over the whatsapp-rs crate).

Topics

DirectionSubjectNotes
Inboundplugin.inbound.whatsappLegacy single-account
Inboundplugin.inbound.whatsapp.<instance>Multi-account routing
Outboundplugin.outbound.whatsappLegacy single-account
Outboundplugin.outbound.whatsapp.<instance>Multi-account routing

During pairing the plugin also publishes qr lifecycle events on the inbound topic so the wizard can render the QR.

Config

# config/plugins/whatsapp.yaml
whatsapp:
  enabled: true
  session_dir: ""            # empty → per-agent default
  media_dir: ./data/media/whatsapp
  instance: default
  acl:
    allow_list: []           # empty + empty env = open ACL
    from_env: WA_AGENT_ALLOW
  behavior:
    ignore_chat_meta: true
    ignore_from_me: true
    ignore_groups: false
  bridge:
    response_timeout_ms: 30000
    on_timeout: noop         # noop | apology_text
  transcriber:
    enabled: false
    skill: whisper
  public_tunnel:
    enabled: false
    only_until_paired: true

Key fields:

FieldDefaultPurpose
session_dirper-agentSignal Protocol state. Each account needs its own dir.
instanceNoneLabel for multi-account routing. Unlabelled keeps the legacy bare topic.
allow_agents[]Agents permitted to publish from this instance. Empty = accept any agent holding a resolver handle. Defense-in-depth for the per-agent credentials binding.
acl.allow_list[]Bare JIDs allowed to reach the agent. Empty + empty env = open.
behavior.ignore_chat_metatrueSkip muted / archived / locked chats on the phone.
behavior.ignore_from_metrueDrop the agent's own replies to prevent loops.
behavior.ignore_groupsfalseSkip group chats entirely when true.
bridge.response_timeout_ms30000Per-message handler deadline.
bridge.on_timeoutnoopnoop (no reply) or apology_text.
transcriber.enabledfalseVoice → text via skill.
public_tunnel.enabledfalseExpose /whatsapp/pair through a Cloudflare tunnel.
public_tunnel.only_until_pairedtrueTear down the tunnel after Connected.

Pairing

Pairing is setup-time only. The runtime refuses to start without paired credentials.

sequenceDiagram
    participant U as Operator
    participant W as agent setup
    participant WA as whatsapp-rs Client
    participant P as Phone

    U->>W: setup pair whatsapp --agent ana
    W->>WA: new_in_dir(session_dir)
    WA-->>W: QR image
    W-->>U: render QR (Unicode blocks)
    U->>P: Settings → Linked Devices → scan
    P->>WA: pair
    WA-->>W: Connected
    W->>W: persist creds to session_dir/.whatsapp-rs/creds.json
  • Credentials at <session_dir>/.whatsapp-rs/creds.json
  • Daemon-collision check at <session_dir>/.whatsapp-rs/daemon.json blocks a second process on the same account
  • Multi-account via Client::new_in_dir() — no XDG_DATA_HOME mutation
  • Credential expiry mid-run (401 loop) → operator must re-pair; no runtime QR fallback

Tools exposed to the LLM

ToolSignatureNotes
whatsapp_send_message(to, text)Send to arbitrary JID.
whatsapp_send_reply(chat, reply_to_msg_id, text)Quote a specific inbound message.
whatsapp_send_reaction(chat, msg_id, emoji)Emoji tap-back.
whatsapp_send_media(to, file_path, caption?, mime?)File attachment.

All tools honor the per-binding outbound_allowlist.whatsapp — empty list = unrestricted, populated = hard allowlist.

Event shapes

Inbound payloads (on plugin.inbound.whatsapp[.<instance>]):

// message
{
  "kind": "message",
  "from": "573000000000@s.whatsapp.net",
  "chat": "573000000000@s.whatsapp.net",
  "text": "hi",
  "reply_to": null,
  "is_group": false,
  "timestamp": 1714000000,
  "msg_id": "3EB0..."
}

// media_received
{
  "kind": "media_received",
  "from": "...",
  "chat": "...",
  "msg_id": "...",
  "local_path": "./data/media/whatsapp/abc.jpg",
  "mime": "image/jpeg",
  "caption": null
}

// qr  (pairing only)
{"kind": "qr", "ascii": "...", "png_base64": "...", "expires_at": ...}

// lifecycle
{"kind": "connected" | "disconnected" | "reconnecting" | "credentials_expired"}

// observability
{"kind": "bridge_timeout", "msg_id": "...", "waited_ms": 30000}

Gotchas

  • Shared session_dir across agents = cross-delivery. Each agent should point at its own <workspace>/whatsapp/default. The wizard does this automatically; manual configs need care.
  • ignore_chat_meta: true silently skips muted/archived chats. If a user archives a chat on the phone, the agent never sees it again until they unarchive.
  • Credential expiry is irreversible without re-pair. whatsapp-rs will loop on 401. Watch for credentials_expired lifecycle events and alert.

See Setup wizard — WhatsApp pairing.