使用说明
WhatsApp MCP Server
A self-hosted WhatsApp data platform with a Model Context Protocol (MCP) interface. A Go bridge captures every WhatsApp event into PostgreSQL, Python enrichment workers add embeddings and transcriptions, and a Python FastMCP server exposes the data and send actions to any MCP/LLM host.
Architecture
6-Layer Model
L1 External Edge
go.mau.fi/whatsmeow (WhatsApp protocol; external dependency)
L2 Capture & Gateway
bridge/client/ event handler, QR auth, send
bridge/connection/ state machine, keepalive, backoff
bridge/api/ 26-route HTTP API (chi router)
bridge/media/ upload, download, OGG/Opus analysis
bridge/indexer/ URL/link extraction
bridge/config/ env-based configuration
bridge/embed/ outbound client -> L3 embedder
bridge/analyzer/ outbound client -> L3 transcriber
L3 Persistence & Enrichment
bridge/store/ PostgreSQL read/write via pgx pool
bridge/migrations/ 12 golang-migrate pairs through 000013 (000001, 000003-000013; no 000002)
bridge/mediaretry/ media re-download (retry) worker
embedder/ poll worker + on-demand /embed server
transcriber/ poll worker + on-demand /analyze server
L4 Tool / Contract
mcp-server/ Python FastMCP: 21 tools, stdio/SSE/HTTP
L5 Intelligence & Control
Claude Code / Desktop / any MCP client (external)
L6 Operations & Observability
docker-compose.yml 8-service stack (including Redis + session controller)
dashboard/ FastAPI + HTMX read-only view
Makefile / scripts/ build, systemd, deploy
Data-Flow Diagrams
Inbound capture path (L1 -> L3)
WhatsApp network
|
| (encrypted protocol)
v
whatsmeow (L1)
| events.Message / events.HistorySync / ...
v
bridge/client/events.go (L2: event handler)
|
|-- INSERT messages, chats, contacts
|-- INSERT messages_media (media rows, local_path NULL)
v
PostgreSQL (L3)
|
|-- embedder polls (embedded_at IS NULL, content <> '')
| model: paraphrase-multilingual-MiniLM-L12-v2
| writes message_embeddings.embedding vector(384)
|
|-- transcriber polls (messages_media, audio, transcribed_at IS NULL)
| calls whisper.cpp /inference
| writes messages_media.transcription
v
Enriched persistent store
Outbound tool path (L5 -> L1)
Claude / MCP client (L5)
|
| MCP (stdio / SSE / streamable-HTTP)
v
FastMCP server: mcp-server/ (L4)
|
| httpx -> bridge REST
v
bridge/api/ (L2, :8080)
|
|-- send / reaction / edit / revoke -> whatsmeow -> WhatsApp (L1)
|
|-- semantic_search flow:
| POST /api/search {query: "..."}
| bridge calls POST http://embedder:8000/embed (L3 embedder)
| <- {embedding: [384 floats]}
| bridge runs hybrid RRF SQL (FTS + pgvector) in PostgreSQL
| <- ranked SearchResult list
|
|-- analyze_media flow:
| MCP: analyze_media(chat_jid, message_id)
| POST /api/media/analyze (L2, thin proxy, 190s timeout)
| POST http://transcriber:8500/analyze (L3 transcriber)
| bridge /api/download -> WhatsApp CDN -> disk
| ffmpeg: keyframes (1 fps, max 8) + 16kHz mono WAV
| whisper.cpp /inference -> transcript
| <- {frame_paths, transcription, duration, frame_count}
v
Response returned to MCP client
Components
| Component | Layer | Role |
|---|---|---|
bridge/ |
L2 / L3 | Go bridge: QR auth, event capture, 26-route HTTP API, 11 migration pairs (001, 003–012; no 002), single-writer PostgreSQL store, hybrid FTS + pgvector RRF search |
mcp-server/ |
L4 | Python FastMCP server: 21 tools, stdio / SSE / streamable-HTTP, per-tool telemetry middleware |
embedder/ |
L3 | Poll worker + on-demand HTTP server: embeds messages into vector(384) using paraphrase-multilingual-MiniLM-L12-v2 |
transcriber/ |
L3 | Poll worker + on-demand HTTP server: transcribes voice notes via whisper.cpp, on-demand video frame extraction via ffmpeg |
dashboard/ |
L6 | Read-only FastAPI + HTMX ops view, Tailwind v3.4 / Tremor design language, dark/light toggle, self-hosted FontAwesome |
session-controller/ |
L5 / L6 | Mandatory Redis-backed schema-v2 worker with persistent, lazy Pi sessions and controlled compaction |
Prerequisites
- Go 1.26+
- Python 3.11+
- uv (Python package manager)
- PostgreSQL 17 with the
pgvectorextension (the bridge runs migrations at startup) - Docker + Docker Compose (recommended — provisions the full stack)
- A WhatsApp account with multi-device support
- A running whisper.cpp
whisper-server(optional; required only for voice-note transcription andanalyze_media) - For bare-metal controller commands, exact Node.js
22.19.0or24.12.0(the container remains pinned to the22.19.0baseline)
Setup
Option A: Docker Compose (recommended)
Create an operator-owned .env with BRIDGE_NOTIFY_SECRET, exact BRIDGE_NOTIFY_GROUP_ALLOWLIST, exact BRIDGE_NOTIFY_OPERATOR_JIDS, SESSION_CONTROLLER_PI_PROVIDER, SESSION_CONTROLLER_PI_MODEL, SESSION_CONTROLLER_SAFE_WORKING_WINDOW_TOKENS, PI_AGENT_DIR, optional WORKSPACE_PATH, and optional raw POSTGRES_PASSWORD plus percent-encoded POSTGRES_PASSWORD_URI overrides. Do not commit it. Start from these intentionally value-free password entries; when overridden, both represent the same password:
POSTGRES_PASSWORD=
POSTGRES_PASSWORD_URI=
The raw variable is used only for PostgreSQL initialization. Every client DSN uses the URI variable, whose reserved characters must be percent-encoded. Both compatibility defaults remain whatsapp. Build and validate without activation:
docker compose build
docker compose config --quiet
After separately authorized schema-v1 backlog review/retirement and receiver-capability checks, set BRIDGE_NOTIFY_ENABLED=false in the operator-owned Compose environment and first start only PostgreSQL, Redis, and the controller. The controller intentionally remains unready while waiting for a bridge, but its running service namespace is required by network_mode: service:session-controller:
BRIDGE_NOTIFY_ENABLED=false docker compose up -d postgres redis session-controller
docker compose ps
For initial or repaired pairing only, run the bridge attached in that existing controller namespace with exactly:
docker compose run --rm --no-deps -e BRIDGE_MANAGED=false -e BRIDGE_NOTIFY_ENABLED=false -e BRIDGE_LOG_LEVEL=info bridge
The command-local BRIDGE_NOTIFY_ENABLED=false is mandatory and overrides any conflicting Compose .env value, so private pairing cannot deliver controller notifications. Because this command also sets exact BRIDGE_MANAGED=false, the entrypoint preserves child BridgeHoldExit=78; docker compose run therefore reports a pairing timeout or hold as exit 78 instead of converting it to success. Scan the QR in that operator-only foreground terminal, wait for successful pairing, then stop the foreground run normally. Never use debug logging, detach the pairing run, redirect or capture its output, paste the QR, or expose it in a shared terminal, CI log, screen recording, or support transcript. Treat the QR as temporary authentication material and clear private terminal scrollback according to operator policy. Do not run docker compose down, down -v, rm, or remove named volumes: retain PostgreSQL, bridge authentication, Redis, controller sessions, and every volume. Then start the managed bridge while delivery is still disabled, followed by the remaining services:
BRIDGE_NOTIFY_ENABLED=false docker compose up -d bridge
BRIDGE_NOTIFY_ENABLED=false docker compose up -d embedder transcriber mcp-server dashboard
docker compose ps
curl --fail --silent http://127.0.0.1:8644/ready
If retained bridge state is already paired, skip only the foreground QR step; still establish the controller namespace before managed bridge startup. Before any authorization to set BRIDGE_NOTIFY_ENABLED=true, an operator must invoke the exact non-prompting inventory command while delivery remains disabled:
BRIDGE_NOTIFY_ENABLED=false docker compose run --rm --no-deps session-controller --check-pi-inventory
The controller image ENTRYPOINT already invokes node /app/dist/src/main.js; do not append another node dist/src/main.js. Bare-metal and systemd execution still invoke the resolved Node binary, the installed dist/src/main.js, and --check-pi-inventory directly.
Require exact stdout {"status":"pi_inventory_ready"} and exit 0. Exact stderr {"status":"pi_inventory_failed"} with exit 1 blocks cutover; investigate privately without exposing child output or credentials. This command is narrowly authorized to read configured Pi auth/model files, apply configured provider/model selection, create/remove one owned temporary tree, and start/bind/dispose the existing MCP child for inventory using validated command/args/cwd. Unlike normal full mode, preflight uses owned temporary cwd, agentDir, and HOME/.agents resource/package roots, project-untrusted in-memory settings, and an owned public ResourceLoader with only Pi's MCP built-in plus the owned whatsapp_session_controller registration. It restores the exact prior HOME state after disposal on success or failure; configured project/agent/global roots are never scanned, their extensions, skills, templates, themes, context, system files, hooks/startup handlers, and unrelated MCP servers are neither loaded nor run, and child/startup stdout/stderr is not relayed. It does not prompt a model, execute a tool, access Redis/database/backlog, consume a bridge event, start scheduling/webhook intake, or perform a WhatsApp action, and it does not itself authorize service activation or schema-v2 delivery. Only after this check and all other gates pass may a separately authorized cutover set BRIDGE_NOTIFY_ENABLED=true and recreate the managed bridge.
Controller compaction windows default to 262,144 tokens in repository configuration, with 15% headroom and 8,192 fixed reserve tokens; a smaller model-reported context remains the effective cap. For a specifically diagnosed orphaned compaction_failed/prepared checkpoint, fully stop the normal controller first (including stopping the user/system systemd service and confirming it is inactive; do not rely on restart backoff), then run the same image or binary once with --recover-compaction-failures. Normal startup and recovery contend for the same application lease. Recovery renews both application and repair-chat ownership through file proof, then fully drains heartbeat renewal before running the final work check and durable abort within validated lease headroom; no renewal can remain queued across terminal lease deletion. It performs two immediate strict file proofs with an identical device/inode/size/mtime/ctime generation and rejects every session entry at or after checkpoint creation; an exact matching compaction must be reconciled by normal startup and can never be aborted. Deterministic failures before Redis commits the terminal abort release repair ownership while retaining prepared plus compaction_failed. If a durable commit response is lost, the outcome is ambiguous and requires aggregate/state reinspection before another operator action; replay remains fenced and safe. Confirmed success records terminal aborted and clears only that pause. Streams and exact PEL consumer/delivery/idle metadata, events, sequences, cursors, and mappings are not mutated; recovery uses initialize:false, creates no namespace/stream/group, initializes no Pi model or MCP, performs no prompt/provider/tool/bridge/WhatsApp operation, and emits only fixed aggregate JSON status.
New prepared checkpoints carry a durable versioned binding to the exact event ID, immutable event fingerprint, stream ID, closed session binding, and exact stable device/inode/size/mtime/ctime file generation captured immediately before durable prepare. Recovery requires the current generation to equal that bound generation. Older restored records remain readable and reconcilable, but generic recovery never trusts an unbound legacy prepared record. For the one explicitly diagnosed legacy case, set SESSION_CONTROLLER_COMPACTION_RECOVERY_MANIFEST to an absolute path in controller-owned state/config storage, ensure its parent already exists and is private, stop the service, and run --create-compaction-recovery-manifest. The command acquires the application lease and exact repair-chat fence, requires the legacy file's mtime and ctime to be strictly before checkpoint creation, performs two stable orphan proofs, accepts exactly one eligible tuple, and writes/fsyncs a private mode-0600 staged manifest containing only a fixed version and cryptographic digest over that closed tuple plus generation; no identifiers are printed. It then fully drains heartbeat renewal, explicitly releases the repair fence within validated lease headroom, and atomically publishes the manifest without replacing an existing path. A failed or ambiguous release never publishes the staged file, so it cannot authorize later recovery even if best-effort pending-file cleanup fails. Run --recover-compaction-failures with the same path. Recovery securely consumes/deletes the manifest, re-proves the file, and recomputes the tuple-plus-generation digest before abort; any tuple or generation change fails closed. Never place identifiers or the digest on process argv or output, copy the manifest into logs, or hand-edit a repair in Redis.
The controller is mandatory for the schema-v2 deployment path. Normal service startup first listens with readiness false, then polls the literal-loopback bridge status until the API reports both state=CONNECTED and live is_connected=true; only then does it start scheduling, acquire the application lease, and report ready. The bridge status state remains the state-machine value, while is_connected is the underlying WhatsMeow IsConnected() && IsLoggedIn() truth. Pi/model auth/MCP remain lazy until first claimed work during normal startup; /ready intentionally excludes them. While operational it periodically enforces that same exact bridge status contract; one timeout, transport failure, non-OK response, malformed body, or non-exact status drops readiness, synchronously fails the coordinator's application fence, aborts scheduling and heartbeat renewal, makes already-claimed workers observe appCurrent=false before another prompt, requests exit 1, and cleans up. Ordinary SIGINT/SIGTERM instead preserves graceful stopScheduling plus unbounded drain. Compose makes the bridge depend on Redis health and controller service_started (not controller health), while the controller depends only on Redis health, so loopback polling introduces no readiness cycle.
PostgreSQL initialization uses the raw ${POSTGRES_PASSWORD:-whatsapp} value; every client DSN uses ${POSTGRES_PASSWORD_URI:-whatsapp}. If overridden, POSTGRES_PASSWORD_URI must be the percent-encoded URI representation of the same raw password used to initialize the role. An existing postgres-data volume keeps its initialized password; changing either .env variable does not rotate that role. Rotation remains a separately authorized and verified database operation: rotate the database credential first, then change both matching representations and verify every client.
Compose has no portable infinite stop duration. Never use docker compose down while a turn may be active. Drain the controller first:
controller_id="$(docker compose ps -q session-controller)"
docker stop --time=-1 "$controller_id"
docker compose stop bridge dashboard transcriber embedder mcp-server
docker compose stop redis postgres
docker stop --time=-1 is an explicit manual stop, so it suppresses restart: unless-stopped, sends the controller its normal stop signal, and waits indefinitely while a Pi turn settles. Investigate rather than force-killing.
Option B: Bare-metal with systemd
make install
make setup
make whisper-unit # optional
These commands require exact Node.js 22.19.0 or 24.12.0, install artifacts, preserve existing templates/environment/state, and statically verify temporary copied units only; they do not run daemon-reload and never enable, start, restart, or stop services. The generated controller unit pins the same resolved Node executable, and the installer runs npm's resolved CLI through that validated executable. Controller installation copies only package.json, package-lock.json, tsconfig.json, src/**/*.ts, and the three named operator-neutral templates into a fresh same-filesystem staging directory; tests and environment/auth/model/runtime/session/local files cannot enter the release. It runs locked install/typecheck/build/prune there, makes the complete staged tree read-only, atomically publishes the immutable versioned release under ~/.local/share/whatsapp-session-controller/releases/, and switches current only after success; the active release is never mutated. Old releases are retained. Removing them is a manual, separately authorized operation performed only after confirming they are inactive. Both installers run with umask 077, reject symlink/non-directory managed directories and symlink/non-regular managed files, preserve content, enforce 0700 configuration/state directories and 0600 files, and never print values. The controller installer validates the Redis state directory with secure_dir before writing redis.conf or state. Review transitional shared compatibility ~/.config/whatsapp-bridge/env, bridge-only ~/.config/whatsapp-bridge/relay.env, shared notification-auth/admission ~/.config/whatsapp-notifications/env, controller-only ~/.config/whatsapp-session-controller/env, and all three templates. The current bridge, MCP server, embedder, transcriber, and dashboard units still share ~/.config/whatsapp-bridge/env; it is explicitly transitional compatibility configuration until a later migration, not a final ownership split. Its exact accepted union is DATABASE_URL, PG_DSN, BRIDGE_ADDR, BRIDGE_DATA_DIR, BRIDGE_LOG_LEVEL, BRIDGE_LOG_STATUS_REQUESTS, EMBEDDER_URL, ANALYZER_URL, MEDIA_RETRY_ENABLED, MEDIA_RETRY_INTERVAL, MEDIA_MAX_DOWNLOAD_ATTEMPTS, MEDIA_RETRY_BATCH_SIZE, BRIDGE_URL, LOG_LEVEL, MCP_TRANSPORT, MCP_HOST, MCP_PORT, WORKSPACE_DIR, WORKSPACE_HOST_PATH, EMBEDDING_MODEL, EMBED_BATCH_SIZE, EMBED_POLL_INTERVAL, EMBED_HTTP_ADDR, WHISPER_URL, TRANSCRIBE_BATCH_SIZE, TRANSCRIBE_POLL_INTERVAL, ANALYZER_HTTP_ADDR, DASHBOARD_HOST, DASHBOARD_PORT, and CONFIG_PATH. BRIDGE_NOTIFY_ENABLED, BRIDGE_NOTIFY_URL, and optional documented relay tuning belong only in relay.env; BRIDGE_NOTIFY_SECRET, BRIDGE_NOTIFY_GROUP_ALLOWLIST, and BRIDGE_NOTIFY_OPERATOR_JIDS belong only in the shared notification file. The controller unit reads notification+controller only, never reads either bridge file, and explicitly applies UnsetEnvironment=DATABASE_URL. After explicit activation approval:
make reload-units
make start
make status
make logs
The default start, stop, restart, status, and logs targets deliberately exclude optional whatsapp-whisper.service, so the normal stack does not fail when that unit is absent. If Whisper is installed and separately authorized, manage it explicitly with make start-whisper, make stop-whisper, make status-whisper, and make logs-whisper; make whisper-unit remains optional and performs setup/static verification only.
The managed bridge unit sets BRIDGE_MANAGED=true, RestartPreventExitStatus=78, StandardOutput=null, and StandardError=journal, and keeps the transitional compatibility, bridge-only relay, and shared notification files. Its exact ExecStart=/usr/bin/env BRIDGE_ADDR=127.0.0.1:8080 BRIDGE_LOG_LEVEL=info %h/.local/bin/whatsapp-bridge makes managed loopback/info authoritative even when an environment file conflicts; the documentation explicitly states that EnvironmentFile= values override Environment= assignments regardless of directive order. Managed mode selects discard-only dependency loggers before SQL device-store migration and WhatsMeow construction; the SQL store, WhatsMeow, and waLogAdapter cannot emit or later replay variable dependency details. The separate application logger remains authoritative for fixed classes. Managed permanent handling calls the terminal handler first and emits exactly one events-layer permanent_disconnect only for its winner, with no reason/message/code/expire values; foreground preserves each current event-specific detailed permanent record byte-for-byte and in order before calling that handler. Atomic markConnected holds the transition lock across permanent rejection, Connected state/log, retry reset, and nested connected-channel publication; foreground retains exact QR code scanned successfully and success return only after that whole operation succeeds. Managed mode never requests, enters, or prints a QR. Managed unpaired startup emits only bridge_managed_unpaired and returns exported BridgeHoldExit=78; an active cooldown, wrapped ErrQRTimeout, wrapped startup permanent disconnect, and the manager's one-shot post-connect permanent-disconnect event likewise end at 78 without exposing dependency details or restarting. Status 78 is reserved for these application holds so Go runtime's panic-like status 2 remains an ordinary restartable failure. Ordinary/context-cancellation failures end at 1 unless an actual signal owns shutdown, and signals end at 0. Initial or repaired bare-metal pairing requires a separately approved private foreground bridge run in an operator-only terminal with exact BRIDGE_MANAGED=false BRIDGE_LOG_LEVEL=info (never debug), followed by the separately approved managed-service start. Foreground retains its existing dependency logs and QR behavior. Compose uses the exact attached docker compose run --rm --no-deps -e BRIDGE_MANAGED=false -e BRIDGE_NOTIFY_ENABLED=false -e BRIDGE_LOG_LEVEL=info bridge procedure above only after PostgreSQL, Redis, and the controller namespace exist with delivery disabled; the command-local notification disablement overrides conflicting .env. Do not redirect, record, share, or persist that QR. Bridge stderr remains journaled for fixed application operational/error classes; stdout suppression does not suppress those diagnostics.
make stop stops controller intake and drains active Pi/MCP work indefinitely before stopping bridge and Redis. Added units are whatsapp-session-redis.service and whatsapp-session-controller.service; controller shutdown uses TimeoutStopSec=infinity and KillMode=mixed. The Redis unit starts the preserved redis.conf and then appends exact --bind 127.0.0.1 ::1 --protected-mode yes; these final command-line values prevent retained configuration from exposing bare-metal Redis. The controller unit executes exact ${NODE_BIN} %h/.local/share/whatsapp-session-controller/current/dist/src/main.js and uses WorkingDirectory through the atomic current symlink; --preserve-symlinks-main is deliberately absent. Normal unit startup supplies no CLI flag and remains lazy; operators run the documented inventory flag as an explicit one-shot command, never by adding it to the service unit. The fixed-safe direct-entry guard realpaths both fileURLToPath(import.meta.url) and process.argv[1], returns false without variable diagnostics if either lookup fails, enters once through current, and remains side-effect free on ordinary import. Node's default main-module resolution pins import.meta.url and both package and relative dynamic imports to the concrete immutable release selected at process start even if a later install atomically retargets current.
Option C: stdio transport (Claude Code / Desktop)
Add to your .mcp.json (Claude Code) or claude_desktop_config.json (Claude Desktop):
{
"mcpServers": {
"WhatsApp": {
"command": "uv",
"args": [
"--directory",
"/path/to/WhatsApp-MCP-Server/mcp-server",
"run",
"whatsapp-mcp"
],
"env": {
"BRIDGE_URL": "http://localhost:8080"
}
}
}
}
The bridge must be running separately before the MCP server starts.
Persistent WhatsApp sessions
The mandatory schema-v2 controller accepts authenticated schema-v2 metadata and gives each configured group one persistent Pi session; configured PN/LID aliases share one operator-private session. A newly bound, active-branch-empty session may begin its first real turn even when Pi has not reported usage yet; the settled real prompt must then provide known usage before completion, without a synthetic prompt or compaction. A prompt exception before any current-event mutation preserves its controller classification (or becomes session_failed for a non-controller error); an active mutation remains mutation_uncertain, while a failure after a confirmed mutation is session_failed with the confirmation retained to block replay, and none of these failures is automatically retried. Redis 7.2+ stores durable ordered work with AOF, appendfsync always, and noeviction; Pi JSONL files are separate durable session state. Compose Redis publishes no host port and uses its isolated project network; protected-mode no is limited to that container network so the controller can connect without inventing credentials. Bare-metal Redis is different: its unit appends --bind 127.0.0.1 ::1 --protected-mode yes after the preserved configuration path, making those safety values authoritative even if retained config bytes disagree. This supports controlled local restarts, not universal zero loss under host/storage failure.
For prompt troubleshooting, the controller emits one dedicated stderr JSON record with event="prompt_failed" and fixed stage/category values. It intentionally omits IDs, paths, messages, stacks, provider/model details, tool arguments, and results.
During normal service execution, the shared Pi SDK loader and model runtime are initialized once, lazily on the first claimed turn; each active turn then owns and disposes its own AgentSession and keep-alive WhatsApp MCP stdio child. /ready intentionally does not claim Pi/model/MCP readiness. The controller uses its pinned pi-mcp-adapter dependency in Pi's replaceable mcp extension slot, not Pi's built-in MCP runtime. It supplies one isolated in-memory adapter snapshot for the WhatsApp child, with direct tools, strict arguments, bounded output, no ambient adapter config discovery, no inherited child environment, and explicit bridge/workspace variables. Normal loadNativeSdk full mode snapshots global and trusted-project settings into scope-preserving session memory, forces compaction off there, and retains standard project/agent discovery plus every other configured package/extension. Before injecting the controller-owned factory it removes exact npm:pi-mcp-adapter package declarations and explicit plain extension paths whose realpath's nearest valid package.json has exact name pi-mcp-adapter; it does not import paths while qualifying them, and profile settings bytes are never rewritten. Before cutover, the explicit operator-only --check-pi-inventory mode instead performs a separate temporary, non-prompting connect/bind/dispose cycle through an owned public-interface loader containing only that adapter factory and WhatsApp server snapshot, with every skill/template/theme/context surface empty. Hostile configured extensions/startup hooks and unrelated MCP servers cannot load, run, start, or emit; no prompt or tool executes and no startup/child output is forwarded. Both paths fail closed unless the mcp__whatsapp_session_controller__ prefix contains exactly the 21 production-derived qualified names with no missing, duplicate, or extra 22nd tool, every member has direct exposure and source path <inline:mcp> plus source inline, and all 21 exact names are active; hidden, inactive, same-name shadowed, and wrong-provenance tools are rejected. Generic mcp, mcpScript, and per-server namespace proxy tools must not be active or callable; a generic gateway may remain registered only while inactive and non-callable, and it does not count toward the 21-tool inventory.
In Compose, Redis is attached only to the dedicated controller-internal network, the controller is attached to both that internal network and default, and the bridge inherits both through network_mode: service:session-controller; MCP, enrichment, and dashboard services remain only on default. Compose sets exact BRIDGE_MANAGED=true and runs the bridge through its signal-forwarding entrypoint adapter: only then is bridge child BridgeHoldExit=78 normalized to container exit 0 to stop restart: on-failure; foreground pairing with exact BRIDGE_MANAGED=false, or an unset managed variable, preserves child 78 so docker compose run reports the timeout/hold failure. Child 0 remains 0, panic-like child status 2 remains 2 and therefore restarts, and every other nonzero status is preserved. The bridge webhook, startup status probe, and controller MCP child use literal loopback (127.0.0.1:8644 and 127.0.0.1:8080); the unauthenticated bridge API is never published by the controller. The controller image is built with APP_UID/APP_GID from the same ${UID:-1000}/${GID:-1000} values used by runtime user, so the named /sessions volume receives correctly owned image seed directories and the process never runs as root. This does not alter bind-mount ownership: operators must ensure ${WORKSPACE_PATH} and ${PI_AGENT_DIR} are readable/writable as required by that numeric UID/GID before activation. Under systemd the controller is Type=simple, starts its listener before the bridge, and reports readiness only after WhatsApp connection and application-lease acquisition. Its unit runs from ~/.local/share/whatsapp-session-controller/current, the atomically switched symlink to one immutable release, and sets WORKSPACE_DIR=%h/workspace, so the inherited stdio MCP media path is the same bare-metal workspace configured as SESSION_CONTROLLER_PI_CWD=${HOME}/workspace and writable by the bridge for media downloads. The bridge has Wants= on the controller, Requires= on Redis, and After= on both, so explicitly stopping the controller does not stop the bridge before drain.
Clean cutover and rollback
Do not replay schema-v1 work into the controller. Keep bridge delivery disabled and prior relay workers stopped while a separately authorized database-administration channel reviews v1 pending/leased aggregates and opaque IDs. Obtain separate write authorization for the selected retain/expire/retire action and recheck that no unintended v1 work remains. Repository automation embeds no destructive SQL or retirement command.
Before enabling schema v2, verify exact allowlists, shared HMAC authentication, private/loopback routing, effective receiver capabilities, and normal controller readiness, then run exact BRIDGE_NOTIFY_ENABLED=false docker compose run --rm --no-deps session-controller --check-pi-inventory and require {"status":"pi_inventory_ready"} with exit 0. The operator's invocation authorizes configured auth/model reads and provider/model selection plus that isolated owned-resource MCP inventory lifecycle with validated command/args/cwd; resource/package discovery is confined to owned temporary cwd, agentDir, and HOME/.agents, exact prior HOME is restored afterward, and configured project/agent/global roots are never scanned. It grants configured extensions/hooks/servers no execution and grants no prompt, tool call, WhatsApp action, backlog/database/Redis access, service activation, or delivery enablement. Routine rollback disables delivery first, drains the controller while the bridge remains available, and retains additive PostgreSQL, Redis, and session state. Down migrations, queue/key deletion, session deletion, credential rotation, and forced termination need separate authorization. No executable destructive SQL is included. Section 14 of the architecture remains binding: reuse the existing bridge and existing WhatsApp MCP; neither the preflight nor this plan creates a replacement gateway, Reactor path, or new action authority.
Bridge Configuration
All bridge settings are read from environment variables at startup.
| Variable | Default | Description |
|---|---|---|
DATABASE_URL |
(required) | PostgreSQL DSN. Example: postgres://user:pass@localhost:5432/whatsapp?sslmode=disable |
BRIDGE_ADDR |
127.0.0.1:8080 |
HTTP bind address. Warning logged if not loopback — the API has no auth. |
BRIDGE_DATA_DIR |
./data |
Directory for downloaded media files. |
BRIDGE_LOG_LEVEL |
info |
Log verbosity: debug, info, warn, error. |
BRIDGE_LOG_STATUS_REQUESTS |
true |
Exact lowercase true/false. When false, suppress successful GET /api/status access records only; errors, non-GET requests, and all other paths remain logged. |
EMBEDDER_URL |
http://embedder:8000 |
Base URL of the L3 embedder (query-time embedding). |
ANALYZER_URL |
http://transcriber:8500 |
Base URL of the L3 transcriber (on-demand media analysis). |
MEDIA_RETRY_ENABLED |
true |
Enable the background media-retry worker. |
MEDIA_RETRY_INTERVAL |
3m |
Poll cadence / per-row backoff. |
MEDIA_MAX_DOWNLOAD_ATTEMPTS |
3 |
Attempts before a row is marked permanently failed. |
MEDIA_RETRY_BATCH_SIZE |
10 |
Rows retried per cycle. |
BRIDGE_MANAGED |
false |
Exact lowercase true/false; managed mode never emits pairing QR output. |
BRIDGE_NOTIFY_ENABLED |
false |
Opt-in metadata-only Hermes relay; no auto-reply or action grant. |
BRIDGE_NOTIFY_URL |
(required when enabled) | Private HTTPS or literal-loopback HTTP /webhooks/<route> endpoint. |
BRIDGE_NOTIFY_SECRET |
(required when enabled) | At least 32 bytes of operator-managed HMAC key material; byte-counted, never generated, printed, or persisted in events. |
BRIDGE_NOTIFY_GROUP_ALLOWLIST |
(required when enabled) | Exact comma-separated canonical group JIDs. |
BRIDGE_NOTIFY_OPERATOR_JIDS |
(required when enabled) | Exact configured PN/LID aliases for the operator-private session. |
BRIDGE_NOTIFY_TIMEOUT |
5s |
Per-request timeout. |
BRIDGE_NOTIFY_POLL_INTERVAL |
1s |
Enabled dispatcher poll cadence. |
BRIDGE_NOTIFY_MIN_INTERVAL |
2s |
Global minimum spacing between attempts. |
BRIDGE_NOTIFY_BATCH_SIZE |
20 |
Maximum sequential claims per cycle. |
BRIDGE_NOTIFY_MAX_ATTEMPTS |
10 |
Maximum committed claims per event. |
BRIDGE_NOTIFY_MAX_AGE |
24h |
Notification expiry from original receipt. |
BRIDGE_NOTIFY_RETENTION |
7d |
Terminal-state metadata retention. |
Hermes incoming notifications documents the metadata-only outbox, validation bounds, identifier privacy, mandatory actual read-only receiver capability gate, backlog retirement and rollback safety. Deployment remains PAUSED; enabling, migration execution and configuration changes require separate operator approval. Disabled mode loads no relay secret and performs only bounded minute-wise local cleanup with maintenance defaults.
MCP Server Configuration
| Variable | Default | Description |
|---|---|---|
BRIDGE_URL |
http://localhost:8080 |
Bridge REST base URL. |
MCP_TRANSPORT |
stdio |
Transport: stdio, sse, or http (streamable-HTTP). |
MCP_HOST |
0.0.0.0 |
Bind host (SSE / HTTP transports only). |
MCP_PORT |
3000 |
Bind port (SSE / HTTP transports only). |
WORKSPACE_DIR |
/workspace |
Container path of the shared workspace mount. |
WORKSPACE_HOST_PATH |
(unset) | Host path corresponding to WORKSPACE_DIR; used to translate file paths for send_media. |
LOG_LEVEL |
INFO |
Python log verbosity: DEBUG, INFO, WARNING, ERROR. |
MCP Tools
21 tools across five modules (mcp-server/src/whatsapp_mcp/tools/) after the schema-v2 context lookup is implemented: messages.py:11, groups.py:4, media.py:3, contacts.py:2, search.py:1.
| Tool | Module | Description |
|---|---|---|
get_message |
messages | Retrieve one exact message by actual source chat JID and message ID |
send_message |
messages | Send plain-text to an individual; supports quote, mentions |
check_new_messages |
messages | Poll for messages since a Unix ms timestamp |
get_messages |
messages | Retrieve recent messages from a chat |
get_unread_chats |
messages | List all chats with unread messages and previews |
get_unread_messages |
messages | Flat list of all unread messages across all chats |
get_group_context |
messages | Compact recent-turn window for a group (strips own messages) |
send_reaction |
messages | React to a message with an emoji; empty string removes it |
edit_message |
messages | Edit the text of a previously sent message |
revoke_message |
messages | Revoke (delete for everyone) a message |
check_triggers |
messages | Batch trigger check across multiple chats using server-side watermarks |
list_groups |
groups | List all group chats the account belongs to |
get_group |
groups | Full group metadata including participant list |
send_group_message |
groups | Send plain-text to a group; supports quote, mentions |
send_auto_message |
groups | Send to any JID — auto-routes to individual or group by suffix |
send_media |
media | Send image, video, audio, or document; auto-detects type from extension |
download_media |
media | Download media from a received message to the workspace directory |
analyze_media |
media | Sample video frames (ffmpeg) + transcribe audio (Whisper); returns frame paths + transcript |
list_contacts |
contacts | Return all contacts from the bridge store |
get_contact |
contacts | Metadata for a single contact by JID |
semantic_search |
search | Hybrid (full-text + vector RRF) message search; query is embedded at query time by the bridge |
Bridge HTTP API
26 routes registered in bridge/api/server.go, including exact-message lookup. The API binds to 127.0.0.1:8080 by default. It has no authentication and must not be exposed off-host.
GET /api/status
GET /api/messages
GET /api/messages/{id}
GET /api/messages/{id}/context
GET /api/messages/{id}/similar
PUT /api/messages/{id}/embedding
POST /api/search
GET /api/chats
POST /api/chats/search
GET /api/chats/{jid}
GET /api/contacts
GET /api/contacts/similar
GET /api/contacts/{jid}
GET /api/groups
GET /api/groups/{jid}
GET /api/unread
GET /api/check
POST /api/check/triggers
POST /api/download (200 on hit; 410 MEDIA_PERMANENTLY_UNAVAILABLE terminal / 503 DOWNLOAD_RETRY_PENDING transient; records a download attempt)
POST /api/telemetry/tool
POST /api/media/analyze (190s route timeout; proxies L3 transcriber)
POST /api/send (rate-limited: ~2/s sustained, burst 5)
POST /api/send/media (rate-limited)
POST /api/send/reaction (rate-limited)
POST /api/send/edit (rate-limited)
POST /api/send/revoke (rate-limited)
Enrichment & Storage
PostgreSQL + pgvector
PostgreSQL 17 with the pgvector extension is the single source of truth. The bridge runs golang-migrate migrations at startup automatically.
Migrations: 11 pairs — bridge/migrations/000001, 000003–000012 (001, 003–012; no 002).
Key tables:
| Table | Purpose |
|---|---|
chats |
Chat metadata: JID, name, group flag, unread count |
contacts |
Contact name, notify, phone |
messages |
All messages with FTS column (content_fts tsvector) and embedded_at watermark |
messages_media |
Media metadata: URL, local path, transcription, download tracking |
message_embeddings |
embedding vector(384) per message |
links |
URL/link index extracted from messages |
telemetry_daily |
Daily counters: sent, received, media, links |
telemetry_tool_calls |
Per-tool name, duration_ms, success, error |
watermarks |
Per-chat last_seen for check_triggers |
Embedder (L3)
Located in embedder/. Runs two concurrent functions:
- Poll loop — reads
messageswhereembedded_at IS NULL AND content <> '', encodes in batches, writesmessage_embeddings.embedding vector(384), stampsmessages.embedded_at. - On-demand HTTP server — listens on
EMBED_HTTP_ADDR(default127.0.0.1:8000):POST /embed{"text":"..."}->{"embedding":[384 floats]}GET /health->{"status":"ok","model":"...","dim":384}
Model: paraphrase-multilingual-MiniLM-L12-v2 (sentence-transformers). Dimension: 384 — matches the vector(384) schema column.
| Variable | Default | Description |
|---|---|---|
DATABASE_URL |
(required) | PostgreSQL DSN |
EMBEDDING_MODEL |
paraphrase-multilingual-MiniLM-L12-v2 |
sentence-transformers model name |
EMBED_BATCH_SIZE |
100 |
Messages per embedding batch |
EMBED_POLL_INTERVAL |
30 |
Seconds between idle polls |
EMBED_HTTP_ADDR |
127.0.0.1:8000 |
Bind address for on-demand /embed + /health |
Transcriber (L3)
Located in transcriber/. Runs two concurrent functions:
- Poll loop — reads
messages_mediawheremedia_type = 'audio' AND transcribed_at IS NULL AND NOT download_permanently_failed, re-downloads missing files via the bridge if needed, posts raw audio to whisper.cpp/inference, writesmessages_media.transcription. - On-demand HTTP server — listens on
ANALYZER_HTTP_ADDR(default127.0.0.1:8500):POST /analyze{"chat_jid":"...","message_id":"..."}->{"frame_paths":[...],"transcription":"...","duration":<float>,"frame_count":<int>}GET /health->{"status":"ok"}
The /analyze handler downloads the video via the bridge, extracts up to 8 keyframes at 1 fps with ffmpeg, demuxes audio to 16 kHz mono WAV, and transcribes with Whisper. Videos longer than 600 seconds are rejected.
| Variable | Default | Description |
|---|---|---|
DATABASE_URL |
(required) | PostgreSQL DSN |
BRIDGE_URL |
http://bridge:8080 |
Bridge base URL (for media re-download) |
WHISPER_URL |
http://127.0.0.1:8443 |
whisper.cpp server base URL; audio posted to {WHISPER_URL}/inference |
WHISPER_MODEL |
large-v3 |
Informational only; the model is loaded by whisper-server |
WHISPER_LANGUAGE |
ur |
Informational; the whisper-server auto-detects; set in compose only |
TRANSCRIBE_BATCH_SIZE |
20 |
Rows per cycle |
TRANSCRIBE_POLL_INTERVAL |
30 |
Seconds between idle polls |
ANALYZER_HTTP_ADDR |
127.0.0.1:8500 |
Bind address for on-demand /analyze + /health |
Media-retry worker (L3)
A bridge-co-resident Go worker (bridge/mediaretry/, co-resident because re-download needs the live whatsmeow session). It polls messages_media for previously-requested media that failed to download (download_attempts > 0, requested-only — captured-but-never-requested media is left untouched), retries at the MEDIA_RETRY_INTERVAL cadence up to MEDIA_MAX_DOWNLOAD_ATTEMPTS, then sets download_permanently_failed. Scope is non-audio media (image, video, sticker, document); audio stays with the transcriber.
Hybrid Search
POST /api/search accepts {"query":"...","chat_jid":"...","limit":20}.
When the caller supplies only query text, the bridge calls POST http://embedder:8000/embed to obtain a query vector at query time, then runs hybrid Reciprocal Rank Fusion over:
- PostgreSQL full-text search (
plainto_tsquery('simple', ...)oncontent_fts) - pgvector cosine distance (
<=>) onmessage_embeddings
If the embedder is unreachable (2-second timeout), search degrades to FTS-only without erroring the request. Results carry a match_type field: "fts", "semantic", or "both".
Media Analysis (analyze_media)
Full flow for the analyze_media MCP tool:
MCP client calls analyze_media(chat_jid, message_id)
|
v
mcp-server POST /api/media/analyze (L4 -> L2)
|
v
bridge/api/handlers_analyze.go (thin proxy, 190s chi timeout)
|
v
bridge/analyzer/client.go POST http://transcriber:8500/analyze
|
v
transcriber/worker.py analyze_video()
|-- bridge POST /api/download -> WhatsApp CDN -> disk
|-- ffprobe duration gate (max 600s)
|-- ffmpeg: up to 8 keyframe JPEGs at 1 fps
|-- ffmpeg: 16kHz mono WAV
|-- POST {WHISPER_URL}/inference -> transcript
|
v
{"frame_paths":["/abs/path/frame_001.jpg",...],
"transcription":"...",
"duration":42.0,
"frame_count":8}
The MCP tool returns the result verbatim. The LLM host must Read each path in frame_paths (they are absolute image files) to inspect visual content.
Voice Note Duration Fix
bridge/media/ogg.go parses the OGG Opus container in-process (no external commands) to derive the real audio duration and a 64-sample waveform from the granule position. Previously sent voice notes reported a hardcoded 30-second duration; they now report the real duration.
Observability
Dashboard
dashboard/ is a FastAPI + HTMX application running on port 9090 (default). It is read-only: all SQL is SELECT-only via psycopg2.
Recent restyle: Tailwind v3.4 built with the standalone Tailwind CLI (no Node required), Tremor design language color tokens, dark/light mode toggle with no-flash script (respects OS preference and localStorage.theme), self-hosted FontAwesome icons served from /static/fontawesome/.
Views and HTMX partials (auto-refresh every 30 seconds):
| Path | Content |
|---|---|
GET / |
Full page: bridge status, groups, recent messages, telemetry, links, tool call history |
GET /partials/status |
Bridge connection state |
GET /partials/messages |
Recent messages table |
GET /partials/telemetry |
Daily counters |
Dashboard env vars:
| Variable | Default | Description |
|---|---|---|
DATABASE_URL |
(required) | PostgreSQL DSN |
BRIDGE_URL |
http://127.0.0.1:8080 |
Bridge base URL for status polling |
DASHBOARD_HOST |
127.0.0.1 |
Bind host |
DASHBOARD_PORT |
9090 |
Bind port |
Telemetry Tables
The MCP server's TelemetryMiddleware records every tool call to POST /api/telemetry/tool on the bridge, which writes to PostgreSQL:
telemetry_daily— daily rolling counters:messages_sent,messages_received,media_downloaded,media_sent,links_indexed.telemetry_tool_calls— per-call rows:tool_name,duration_ms,success,error_msg,called_at.
Connection Handling
The Go bridge implements a supervised connection state machine in bridge/connection/:
- Exponential backoff reconnection: 1s, 2s, 4s, 8s, 16s, 32s, 60s (repeating).
- Handles
TemporaryBan,ClientOutdated,ConnectFailure, andStreamReplacedevents from whatsmeow. - The 10-minute cooldown marker returns exported
BridgeHoldExit=78, the only supervisor hold status; panic-like or ordinary status 2 remains a restartable failure. - Keepalive monitor (
bridge/connection/keepalive.go) forces reconnect after 3 consecutive timeouts. - Outbound send routes are rate-limited (token-bucket: ~2 sends/s sustained, burst 5, up to 300 ms human-timing jitter) and are never retried.
Build Verification
# Go (L2/L3)
cd bridge && go build ./... && go vet ./...
# Python syntax check (L3/L4/L6)
python -m py_compile <path/to/changed/file.py>
Contributor Guide
See CLAUDE.md for the 6-layer model, the directory-to-layer map, where to place new code, and the hard rules (PostgreSQL-only, schema bridge-owned, no DB driver in L4, send routes never retried).
Acknowledgements
The work done in PR #1 is inspired by the open-source asimzeeshan/WhatsApp-bridge, reimplemented and extended for this stack.
License
MIT License. See LICENSE for details.
