W Agent is a layered, self-hosted pipeline: a WhatsApp bridge normalizes events into a shared message model; queues make ingest and enrichment durable; storage owns history and outbox state; the agent decides when to act and how to draft; MCP exposes the same capabilities to external clients. Nothing above the bridge depends on Baileys or Meta-specific types.
- Local control of history — chat data lands in your Postgres, not a vendor inbox.
- Human authority over sends — outbound traffic is staged (outbox) unless a chat is explicitly marked auto-send.
- Swappable WhatsApp transport — personal multi-device and official Cloud API share one interface.
- Fail closed on abuse — injection classification and outbound chat locks prefer silence over clever replies.
- Operable in production — watchdog, health endpoints, atomic auth writes, rate limits.
flowchart LR
Phone[Phone / WhatsApp]
Meta[Meta Graph + webhooks]
App[W Agent process]
PG[(Postgres)]
Redis[(Redis)]
LLM[OpenAI APIs]
Humans[Owner + MCP clients]
Phone <-->|Baileys| App
Meta <-->|Cloud API| App
App --> PG
App --> Redis
App --> LLM
Humans --> App
Responsibility: session lifecycle, inbound/outbound I/O, normalization to ProviderMessage.
| Piece | Role |
|---|---|
WhatsAppProvider |
Interface: connect, disconnect, restart, sendText, sendMedia?, sendTyping?, getChats, getHistory, events |
BaileysProvider |
Unofficial multi-device Web session; QR pairing; history sync hooks |
CloudApiProvider |
Official Business Cloud API; webhook verify + POST; Graph text/templates |
createProvider() |
Factory from WHATSAPP_PROVIDER |
| Filters / normalize | Skip status/newsletters; map Baileys or Cloud payloads → ProviderMessage |
| Atomic auth | Baileys multi-file state written via temp + rename + .bak |
The bridge emits:
qr— pairing material (Baileys)connected/disconnected— session healthmessage— normalized inbound (and some outbound) events
Higher layers subscribe with onMessage / attachIngestPipeline. They never import @whiskeysockets/baileys or Graph payload shapes.
Responsibility: durable, retryable work off the hot path of the socket/webhook.
| Queue | Worker | Job |
|---|---|---|
ingest |
ingest-worker |
Persist contact/chat/message; optional media download + enrichment; enqueue embed/summary |
embed |
embed-worker |
Batch OpenAI embeddings → embeddings table |
summary |
summary-worker |
Rolling per-chat memory in agent_memory |
Redis (BullMQ) is the broker. The outbox sender is a lightweight poller in the main process (not a BullMQ worker): it drains approved rows through the send rate limiter and provider.sendText.
Inbound path:
ProviderMessage
→ toIngestJobData (filters)
→ enqueue ingest
→ persistInboundMessage
→ enqueue embed (+ maybe summary)
Responsibility: schema-backed persistence and query APIs used by agent, MCP, and dashboard.
| Domain | Examples |
|---|---|
| Identity | contacts, chats |
| History | messages (unique wa_message_id), media paths |
| Retrieval | embeddings (pgvector), hybrid searchMessages, getChatContext |
| Memory | agent_memory summaries |
| Outbox | outbox statuses: pending → approved → sent / rejected / failed |
| Policy | settings, chat_engagement, chat prefs (auto-send) |
Postgres is the system of record. Redis is ephemeral (queues, bridge status, ops alerts), not the chat archive.
Responsibility: decide whether to engage, run a tool-using model loop, and stage replies safely.
sequenceDiagram
participant In as Inbound message
participant E as Engagement
participant G as Guard
participant A as Agent loop
participant O as Outbox
participant Owner as Owner WhatsApp
In->>E: evaluate allowlist / mention / opt-out
alt silent or escalate
E-->>In: no agent turn
else reply
E->>G: classify injection
alt forced silent
G->>Owner: security alert
else allowed
G->>A: delimited untrusted content
A->>A: tools search / context / summary
A->>O: draft_reply
O->>Owner: "Draft for …" notify
end
end
Owner->>O: approve (dashboard / API)
O->>In: sendText via provider
| Module | Role |
|---|---|
engage.ts |
Allowlists, group mentions, /agent owner commands, debounce |
guard.ts |
Untrusted delimiters, heuristics (+ optional LLM), outbound chat lock, secret redaction |
core.ts |
Model-agnostic tool loop (ModelProvider) |
tools.ts |
Search, context, summary, contacts, draft/send (send gated) |
outbound.ts |
Insert outbox + owner self-chat notification |
ratelimit.ts |
Quotas, quiet hours, typing delay, cooldown, daily cap |
Owner /agent commands short-circuit engagement and run a trusted command path.
Responsibility: expose read and draft capabilities to external MCP clients without a second product surface.
| Surface | Contents |
|---|---|
| Tools | whatsapp_list_chats, whatsapp_get_history, whatsapp_search, whatsapp_get_summary, whatsapp_send_message (draft), whatsapp_contacts |
| Resources | whatsapp://chats, whatsapp://chat/{jid}/history, whatsapp://chat/{jid}/summary |
| Prompts | summarize_chat, draft_reply_in_my_tone, weekly_digest |
| Transports | stdio (pnpm mcp), HTTP+SSE (MCP_PORT + MCP_AUTH_TOKEN) |
MCP whatsapp_send_message uses the same draftReplyAndNotify path as the in-app agent. It does not bypass approval.
Client setup: mcp-clients.md.
Next.js UI over the same Postgres/Redis data: pairing QR (from Redis bridge status), chats, prefs, approvals, health. Authenticated with DASHBOARD_TOKEN. It is a control plane, not a second message store.
| Piece | Role |
|---|---|
| Watchdog | Periodic bridge / Redis / Postgres / queue checks; bridge restart with backoff; owner alert after N failures |
/healthz |
Aggregated status for external uptime monitors |
| Alerts | Structured log + Redis list + WhatsApp when the bridge is up |
WhatsApp access is the most volatile dependency in the system: unofficial sessions can be banned; the official Cloud API cannot sync personal history and requires business verification. Binding the agent, outbox, and MCP to a single SDK would force a rewrite when requirements change.
The WhatsAppProvider interface exists so that:
- Ingest and agent stay stable — they consume
ProviderMessageand callsendText, nothing else. - Risk can be chosen per deployment — Baileys for personal companion use (accept ban risk);
metafor compliant business messaging. - Parity can grow incrementally — Cloud API currently targets inbound text + outbound text/templates; Baileys carries history sync and richer media. Missing methods fail explicitly rather than leaking Baileys types upward.
- Tests prove the seam — mocked providers and the Cloud API suite exercise the same ingest → draft → send lifecycle.
Factory entrypoint: createProvider() in src/bridge/provider.ts. Selection: WHATSAPP_PROVIDER. Detailed trade-offs: providers.md.
| Boundary | Rule |
|---|---|
| Inbound WhatsApp body | Untrusted data; delimited; classified before tool use |
Owner /agent and dashboard actions |
Trusted control plane |
| MCP tools | Same storage + draft APIs; remote HTTP requires bearer token |
| Model tool results | Secret redaction before returning to the model |
| Auto-send | Rate-limited; quiet hours; never owner-notify path |
Threat model detail: security.md.
src/
bridge/ WhatsAppProvider implementations
queue/ BullMQ producers/workers, outbox sender, ingest pipeline
storage/ Postgres access, search, embeddings, outbox, settings
agent/ Engagement, tools, model loop, outbound drafts
safety/ Injection guard, send rate limiter
mcp/ MCP server (stdio + HTTP/SSE)
ops/ Watchdog, healthz, alerts
index.ts Process wiring
dashboard/ Next.js control UI
migrations/ SQL migrations (node-pg-migrate)
tests/ Unit + testcontainers integration
docs/ Architecture, security, MCP, providers
- providers.md — Baileys vs Cloud API
- security.md — injection, approval, ban risk, locality
- mcp-clients.md — connecting Claude Desktop and ChatGPT
- ../CONTRIBUTING.md — local development and PR bar