Help us shape the future of Go! Take the 2026 Go Developer Survey

google-messages-multidevice-bridge

module
v0.1.2 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Oct 21, 2026 License: AGPL-3.0

README

Google Messages Multi-Device Bridge

CI

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

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 pass integration.

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:

  1. Download /pairing-helper.zip, extract it, and load that directory from chrome://extensions using Developer mode and Load unpacked.
  2. Choose Start browser sign-in in the bridge. Copy the displayed bridge origin and short-lived pairing ticket into the extension's setup tab.
  3. Use the helper's sign-in button, complete Google sign-in, return to the setup tab, and explicitly choose Connect this Google account.
  4. 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_required with 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; preview marks 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. A requestable attachment 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
api
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.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL