README
¶
Google Messages Multi-Device Bridge
One always-on Google Messages or WhatsApp connection, available to all your devices.
Each instance serves exactly one network and owns one encrypted bbolt database.
Google Messages remains the default. Run a separate instance with
serve --network whatsapp for WhatsApp linked-device support.
Google Messages Multi-Device Bridge is a personal messaging service built on
mautrix-gmessages/pkg/libgm.
It does not run a Matrix server or require a Matrix account. The libgm source is
pinned to e6cc29974f92 and carried in third_party/mautrix-gmessages with narrow
reliability patches; see its patch notes.
WhatsApp uses pinned go.mau.fi/whatsmeow as a linked device. Link from the web
client using a rotating, locally rendered QR or a phone-number pairing code.
The primary phone does not need to remain online for ordinary messaging.
Signal sessions, identity keys and app-state keys use the same encrypted bbolt database as messages; there is no plaintext SQLite sidecar. Person/chat identity uses stable LIDs, with E.164 addresses when known. Unknown phone-to-LID mappings delay publication rather than creating duplicate chats. See ADR 0003.
Implemented: app-state contacts and names, group metadata, durable history
ingestion, older-history requests, text/media sends, reactions, read receipts,
typing, edits/revokes, readable ephemeral/view-once content, attachment downloads,
and conversation/contact search with q and limit. WhatsApp uses one attachment
per durable request; the web client splits multiple files into separate requests.
WhatsApp verification: the operator has linked a device, and read-only API inspection confirmed a connected session. That inspection also exposed incomplete history, empty protocol records and missing contacts. The fixes for message filtering, history metadata and app-state recovery are tested with fakes, not yet live-verified. No real messages were sent during this work. Historical coverage, delivery, group creation and media transfers still need live verification. History completeness, interactive polls/calls/payments and disappearing-message erasure from the local archive are not promised.
bin/google-messages-multidevice-bridge serve --network whatsapp \
--db data/whatsapp.db --listen 127.0.0.1:8788 \
--storage-key-pass-entry services/whatsapp-bridge/storage-key \
--api-token-pass-entry services/whatsapp-bridge/api-token
Use separate secrets, databases and listen addresses for separate instances. The database rejects a mismatched network. Both networks retain the schema-1 API; the API contract documents network-specific states and limitations. Nix deployment supports named instances without changing the existing Google service.
Google Messages milestone
- Guided pairing and re-pairing from the web app using a bundled Chromium helper. The user does not export cookies or give the helper an API bearer token.
- Encrypted pairing state, provider-independent message snapshots, durable outbox, local uploads, downloaded attachments, and private media descriptors.
- Text and attachment sends, new conversations, reaction updates, outgoing typing, explicit mark-read, queued-operation cancellation, and incoming typing/reactions.
- Resumable history jobs for inbox, archive, spam, and each discovered conversation, in addition to bounded recent-history reconciliation.
- An authenticated schema-1 API, resumable SSE, a multi-client web app served from the bridge or bundled into the desktop shell, and a connection supervisor that retains the local service while reconnecting.
- An installable app: a web app manifest, icons, and a service worker that caches the static shell and delivers Web Push notifications while no window is open.
- A Nix package and Home Manager user service with direct
passintegration.
This is a live-verified integration on one phone, not yet a replacement for Google Messages. Verified against a paired Android phone on 2026-09-12: guided Google sign-in, helper credential handoff, emoji confirmation, paired-session persistence across restarts, initial history sync, inbox history import across hundreds of conversations, SSE replay, attachment download, full-media requests, and, in a conversation with the owner's own number over RCS, conversation creation, text sends, image sends, a caption queued as a second message, a reaction, typing, and mark-read, each confirmed through Google's history echo and delivery status. A message composed elsewhere on the phone appeared in a running client within seconds over the live stream, which is the same event path incoming messages use. Not yet live-verified: messages received from other parties, SMS/MMS sends to non-RCS recipients, multi-SIM selection, RCS group creation, and authentication expiry handling. Structured libgm authentication errors and revoked-session events are covered by synthetic tests, but have not been induced against the live phone. Automated tests use fake providers, synthetic protocol messages, and local HTTP servers. No messages were sent to other people during implementation.
The history importer follows the pages exposed by the current private protocol. A
job marked complete means that response stream ended; it is not proof of a complete
phone archive. The bridge does not promise exactly-once processing or sending. Your
phone must remain online for new traffic, while stored history remains readable
without it. Upstream authentication or protocol changes can require pairing again.
Development
Requires Go 1.26.7 or newer. Dependencies are pinned in go.mod and go.sum.
The Nix flake provides the matching Go toolchain and development tools:
direnv allow
# or: nix develop
Format, lint, test with the race detector, and build with:
just check
just build
nix build produces the packaged service, while nix flake check verifies the
package and flake outputs. Tests never connect to either upstream network. See
docs/adr/0001-use-go-for-the-bridge-service.md
for the language decision.
Secrets
Provide two runtime secrets, either through the environment or the corresponding
pass entry flags:
GOOGLE_MESSAGES_MULTIDEVICE_BRIDGE_STORAGE_KEY: base64 encoding of exactly 32 random bytes, or--storage-key-pass-entry NAME.GOOGLE_MESSAGES_MULTIDEVICE_BRIDGE_API_TOKEN: a random bearer token of at least 32 characters, or--api-token-pass-entry NAME.
Neither value belongs in the repository, command-line arguments, or a plaintext environment file. Replacing the storage key is not key rotation: the existing database will no longer open. Keep the original key in a backed-up password manager.
Database payloads use AES-256-GCM with fresh nonces and record-specific associated data. Record identifiers, sizes, and event counts remain visible. The database is created mode 0600; backups need the same protection as the database and storage key. Upstream logging is disabled because it can contain credentials and message bodies.
Serve and pair
Start the bridge before it has a Google session:
bin/google-messages-multidevice-bridge serve \
--db data/google-messages-multidevice-bridge.db
The default address is 127.0.0.1:0, so the OS chooses an unused port and the
service prints it. Open that address, enter the API token, and choose Pair / Re-pair:
If you have paired before, choose Reconnect phone and confirm the emoji in Google Messages on your phone. The bridge reuses the Google sign-in stored in its encrypted session, so you do not need the browser helper again unless that sign-in has also expired. If reconnecting fails, expand Use browser sign-in:
- Download
/pairing-helper.zip, extract it, and load that directory fromchrome://extensionsusing Developer mode and Load unpacked. - Choose Start browser sign-in in the bridge. Copy the displayed bridge origin and short-lived pairing ticket into the extension's setup tab.
- Use the helper's sign-in button, complete Google sign-in, return to the setup tab, and explicitly choose Connect this Google account.
- Return to the bridge and choose the displayed emoji on the phone.
Leave Enable background recovery checked during that handoff to enroll the helper as a pairing agent. After enrollment, Reconnect phone can be initiated from any bridge client. The helper checks once a minute for a pending pairing ticket and supplies fresh Google credentials in the background. Chrome only needs to be running and signed into the Google account; the setup page can remain closed.
The ticket is a 43-character, one-use capability scoped to credential handoff and
expires after ten minutes. It is not the API token. The helper requests Google and
bridge host access only on the explicit Connect click and reads only the allowlisted
Google cookies required by libgm. Without background recovery it removes the granted
host permissions after the handoff. With background recovery it retains those host
permissions and stores the bridge origin plus a random pairing-only token in Chrome
extension storage. The bridge stores only the token's encrypted SHA-256 digest. The
token can fetch a current pairing ticket but cannot read messages, send texts, or use
other authenticated API routes. The extension never stores Google cookies. Bridge
URLs must use HTTPS, except loopback or a literal Tailscale 100.64.0.0/10 address
may use HTTP.
Starting a re-pair immediately cancels queued outbox operations. Canceling or failing that pairing attempt does not advance the entity epoch, clear provider upload descriptors, or reset history jobs, although the canceled outbox records stay canceled. Only successfully saving the new paired session advances the epoch, clears private provider upload, download, and history descriptors, and resets existing history jobs to paused with no checkpoint. Stored entities from the previous session remain readable but cannot be mutation targets until the new connection observes them. Messages from earlier pairings are labeled read-only, and status reports their conversation/message counts. Locally cached attachment bytes remain available as stored history. Review recipients and start fresh history imports after successful pairing. If the bridge restarts mid-pairing, it keeps the last saved session, invalidates the old ticket, and reports that the attempt was interrupted.
serve --offline exposes stored history and permits durable operations to be queued
without starting Google. Pairing is intentionally disabled in this mode and the API
returns 400 for a pairing start. Restart without --offline to pair or reconnect.
For a stable service, pass-backed flags, Home Manager configuration, network
guidance, and offline-consistent backups, see deployment.
Web client and API
The browser keeps the bearer token only in that tab's memory; locking or reloading
requires it again. After unlocking, the client loads one snapshot of each record kind
and then applies durable SSE events in place, so the view stays current without
polling or re-rendering the whole list. Image attachments render as inline previews that are discarded on
lock. An opt-in toggle raises browser notifications for new incoming messages in
hidden tabs or unselected conversations; replayed history never notifies. Web assets
and the pairing-helper ZIP are public. Every /v1/ route requires bearer
authentication except the ticket-only POST /v1/pairing/credentials handoff and
the pairing-agent-token-only GET /v1/pairing/agent/pending route.
The client lives in client/, outside the Go tree. The bridge embeds it so a browser
can install the app straight from the bridge, and the desktop app bundles the same
files. Only the browser copy is same-origin with the API; the desktop copy is a
foreign origin, so /v1/ answers CORS preflights and allows exactly two origins —
the bridge's own, and the fixed Tauri origin the desktop app runs at. Any other
origin is refused at the preflight and again on the request itself.
The web app can create conversations from E.164 phone numbers, queue text or up to ten attachments totaling 20 MiB, paste clipboard images directly into the composer, react with any emoji and take a reaction back, send typing indicators, mark a conversation read, download available attachments, and manage history jobs. Hovering a reaction names everyone behind it. Each conversation row previews the newest message as soon as it arrives, rather than waiting for Google's own conversation update, labels it "You" or names the group member who sent it, and moves its unread badge with the same message. An open conversation clears that badge itself: while its thread is on screen the client sends a read receipt for the newest message, which a settings toggle disables for anyone who would rather send them by hand. Local upload bytes are encrypted before they are needed by the provider; provider media descriptors remain private.
Conversation creation may receive Google's CREATE_RCS response. In that case the
provider sends exactly one explicit unnamed-group confirmation as the second RPC in
the same durable attempt. A transport error or unclear response makes the attempt
ambiguous; neither RPC is automatically replayed.
When offline, durable message, conversation, and reaction operations remain queued
until a connected provider can prepare them. Typing and mark-read are immediate,
non-durable operations and require a connected phone. accepted means Google
accepted a request, not that it was delivered. confirmed means a matching sent
message appeared in stored history. ambiguous means the outcome is unknown: inspect
the phone before intentionally authorizing another operation. The bridge never
automatically retries an attempted ambiguous mutation.
If an HTTP response is lost while submitting an outbox operation, Retry reuses its idempotency key. That safely rechecks the local durable record; it does not replay a provider attempt. Incoming typing expires after five seconds, and opening a conversation does not itself send a read receipt.
See the schema-1 API contract for routes, request limits, outbox and history states, pagination, pairing, and SSE replay.
Command line and MCP
gmessages, shipped in the same package, is a client for a running bridge meant
for scripts and agents. It reads the bridge URL and token the same way the desktop
client does (GOOGLE_MESSAGES_BRIDGE_URL, GOOGLE_MESSAGES_BRIDGE_TOKEN_FILE), or
from named profiles in ~/.config/gmessages/config.json or
/etc/gmessages/config.json, which the Nix modules write from cli.profiles:
{
"default_profile": "default",
"profiles": {
"default": {
"url": "https://bridge.example.ts.net:8443",
"token_file": "/run/agenix/bridge-token"
},
"whatsapp": {
"url": "https://bridge.example.ts.net:8444",
"token_pass_entry": "bridge/whatsapp"
}
}
}
gmessages conversations --query bank
gmessages messages "Lisa" # ID, part of a name, or a number
gmessages search --query code --since 15m
gmessages wait --query code --timeout 2m # exits 2 if nothing arrives
gmessages send --to +15125550111 "On my way"
gmessages --profile whatsapp --json search --since 1h
gmessages mcp serves the same operations as MCP tools over stdio:
bridge_status, list_conversations, read_messages, search_messages,
wait_for_message, and list_contacts. send_message is offered only under
gmessages mcp --allow-send, so registering the server never by itself lets an
agent text people.
Desktop app
desktop/ is a small Tauri shell that bundles the same
client/ tree the bridge serves. The window never loads code over the network: it
runs the local client, which talks to the bridge over /v1/ alone. It asks for the
bridge URL once, keeps the bridge token in the OS keyring (or a private file under
the app config directory when no secret service is running), unlocks automatically
on launch, shows a tray icon with the unread count in the window title and badge,
closes to the tray, and raises desktop notifications for new incoming messages while
it is running.
Because the client is bundled, the shell's native commands — the keyring, OS notifications, and opening links externally — are reachable only from local content. No remote origin is granted them. The tradeoff is that the desktop app carries its own client build, so a bridge upgrade that changes the API needs a matching app upgrade; the browser client, still served by the bridge, updates with it.
nix build .#desktop
./result/bin/google-messages-desktop
The package is Linux only. On other platforms, or without Nix, cd desktop && cargo build --release needs the usual Tauri prerequisites (WebKitGTK, GTK 3, libsoup 3,
libayatana-appindicator, and D-Bus on Linux). Locking from the menu forgets the stored
token; the tray menu can change the bridge URL.
To install it on every Nix host, apply the flake overlay and set
services.google-messages-multidevice-bridge.client.enable = true via the Home
Manager module (per user) or the NixOS module (system-wide). Only enable the bridge
service itself on the host that owns the database. client.bridgeUrl skips the
setup screen, and client.apiTokenPassEntry (or client.apiTokenFile) unlocks
the client without typing the token; only the entry name ever lands in the store.
See deployment for the snippets.
Reliability and protocol boundaries
- Recent reconciliation is deliberately bounded to 30 inbox conversations and 50 recent messages per conversation. Durable history jobs page beyond that window, but private-protocol omissions can still prevent a complete archive.
- History cursors are opaque and scoped to the job. Invalid cursors, cursor-bytes-only responses unsupported by the mapped protocol, and repeated cursors fail the job without looping. Transient phone failures preserve the checkpoint and retry after 30 seconds.
- A history response cannot overwrite an entity observed locally after that fetch began. Original message timestamps cannot order later receipt/reaction changes, and absence from a bounded response does not imply deletion.
- Incoming callback ACK admission now follows successful bridge persistence through libgm's error-aware handler. This reduces one loss window but is not exactly-once: ACK queues and deduplication are memory-only, Google can redeliver, response ACKs are independent, and crashes can still lose ACK state.
- The outbox is durable before provider mutation. Interrupted attempts are ambiguous; only matching transaction and conversation IDs can confirm a message. Matching text or timestamps is insufficient. Media upload is a separate preflight, so an uncertain upload can be repeated before the single message-send attempt is claimed.
- Re-pair generation checks prevent a canceled attempt from replacing a newer ticket. Mutation admission is serialized so queueing, claiming, mark-read, and typing do not cross the start of a re-pair.
- The supervisor retries transient connection failures with bounded exponential
backoff. Authentication failure is reported as
authentication_requiredwith a machine-readable reason and waits for explicit restart or re-pair unless WhatsApp automatic pairing recovery is enabled. Stored history and the HTTP service remain available unless storage itself fails. - Patched libgm joins client-owned poll, ACK, ping, recovery, and post-connect workers on disconnect. Foreground pairing and request/media calls remain caller-owned and must be canceled and joined by the bridge. A transport that ignores context can still delay shutdown. See the exact patch limits.
- A local event cursor is not a Google cursor. Restoring an older backup can require clients to reload. Remote deletion snapshots do not erase older event versions or cached media from this encrypted local archive.
- Attachment downloads prefer the full upload, then its thumbnail, then the inline
preview;
previewmarks the latter two. Google serves web clients a compressed rendition, so downloaded bytes can be smaller than the declared size and HEIC originals arrive as JPEG. Arequestableattachment can ask the phone to upload its full media; the message updates when the phone answers, which older MMS may never do.
Synthetic browser fixture
For development only, an opt-in test starts a server with fake conversations and no Google connection:
BRIDGE_BROWSER_TEST=1 go test ./internal/api -run '^TestBrowserFixture$' -v -timeout 30m
It binds all interfaces on a fresh OS-selected port and prints the port. The token
is synthetic-browser-test-token-only; the fixture contains no real secrets or
messages. Sends only enter its temporary local queue. Normal tests skip it.
Set BRIDGE_BROWSER_RECOVERY_TEST=1 as well to render an expired-session state
with synthetic previous-pairing records for recovery UI review.
To check composer sizing and typing with the 1,500-message fixture, open its printed port in a fresh Playwright CLI session and run:
playwright-cli -s=compose open http://127.0.0.1:PORT --browser chrome
playwright-cli -s=compose run-code "$(node --input-type=module -e 'import check from "./tools/check-compose.mjs"; console.log(check.toString())')"
playwright-cli -s=compose close
The check covers wrapping, trailing newlines, the height limit, window resizing,
draft restoration, and clearing on lock. It reports input-handler timing without
a machine-dependent pass threshold. Run with --browser webkit as well when that
browser is installed to cover the desktop app's rendering engine.
tools/check-performance.mjs runs against the same fixture. It checks live
message updates, lazy reaction menus, background import updates, cached navigation,
pending sends in empty threads, and attachment cleanup. It also measures redraws
with 1,500 messages and search with 2,000 synthetic conversations:
playwright-cli -s=performance open http://127.0.0.1:PORT --browser chrome
playwright-cli -s=performance run-code "$(node --input-type=module -e 'import check from "./tools/check-performance.mjs"; console.log(check.toString())')"
playwright-cli -s=performance close
Use a dedicated browser session: this check disables the fixture's service worker and injects measurement hooks into its client. These hooks are not part of the shipped application. Timings are diagnostic, without hardware-dependent thresholds.
tools/check-sync.mjs checks interrupted history fetches, live events arriving
while snapshots reload, renamed preview senders, stream reconnection, and recovery
from a restored database whose event cursor moved backward. It uses synthetic
stream events and never sends upstream messages:
playwright-cli -s=sync open http://127.0.0.1:PORT --browser chrome
playwright-cli -s=sync run-code "$(node --input-type=module -e 'import check from "./tools/check-sync.mjs"; console.log(check.toString())')"
playwright-cli -s=sync close
License
AGPL-3.0-or-later. This project depends on mautrix-gmessages; retain its source and license notices with distributed builds.
The application icon is derived from the Numix icon theme (GPL-3.0-only, https://numixproject.github.io). It is redistributed here under the GPL-3.0, which AGPL-3.0-or-later permits combining with.
WhatsApp can opt into automatic phone-code pairing after logout/session expiry from Pair / Re-pair. Recovery is bounded to three attempts, survives process restart, and pauses on cancellation; phone approval remains manual. The saved phone number is encrypted with the database. Web Push can announce a ready code; EVA can consume the authenticated pairing API. See recovery setup and the API contract. This controller is fake-tested, not live-verified.
Directories
¶
| Path | Synopsis |
|---|---|
|
Package client carries the web client.
|
Package client carries the web client. |
|
cmd
|
|
|
gmessages
command
Command gmessages reads and sends messages through a running bridge, and serves the same operations to agents over MCP (`gmessages mcp`).
|
Command gmessages reads and sends messages through a running bridge, and serves the same operations to agents over MCP (`gmessages mcp`). |
|
internal
|
|
|
client
Package client is a typed HTTP client for the bridge API, shared by the command-line client and its MCP server.
|
Package client is a typed HTTP client for the bridge API, shared by the command-line client and its MCP server. |
|
model
Package model defines the bridge's versioned, provider-independent wire records.
|
Package model defines the bridge's versioned, provider-independent wire records. |
|
provider
Package provider adapts an upstream messaging connection to the bridge's records.
|
Package provider adapts an upstream messaging connection to the bridge's records. |
|
push
Package push delivers Web Push notifications to subscribed browsers.
|
Package push delivers Web Push notifications to subscribed browsers. |