chatbubbles is a small https api for iMessage over tailscale.
it runs on a Mac, talks to imsg, and gives paired clients a clean way to read chats, stream events, send messages, and manage webhooks without exposing the machine to the public internet.
today, chatbubbles supports:
- tls on first boot with a pinned self-signed fingerprint
- token-based auth with direct pairing and delegated web sessions
- read endpoints for server info, chats, and message history
- a websocket event stream backed by
imsg watch - local admin over a unix socket with
chatbubbles-cli - webhook registration and delivery
- api-based message and attachment sending
still landing:
- a more polished install story for running it as a background service
- a homebrew tap, with notes in
docs/homebrew.md
- macOS 14 or newer
- Messages signed in and working
- full disk access for the terminal or daemon
imsg:brew install imsg- tailscale installed and connected
for local bridge work, the daemon accepts CHATBUBBLES_IMSG_BIN=/path/to/imsg or -imsg-bin /path/to/imsg if you need to point at a patched checkout before upstream catches up.
make fmt
make test
make build
make build-cli
make runthe server listens on :8443 by default.
on first boot it will:
- create a self-signed cert in the data dir
- log the tls fingerprint used for pairing
- create a short-lived bootstrap pairing code if no clients exist yet
after that, local admin happens over the unix socket at ~/.local/share/chatbubbles/chatbubbles.sock.
there are really two client modes today:
- direct clients: cli tools, native apps, and anything that can live with the bridge's self-signed tls plus the pairing fingerprint
- browser clients: the web app shell, which needs a browser-trusted https host and should go through tailscale serve
direct clients can talk to the bridge itself on :8443.
the normal path is:
chatbubbles-cli pairthat command mints a pairing code, prints the server fingerprint, and renders a terminal qr that direct clients can scan.
common local commands:
chatbubbles-cli status
chatbubbles-cli pair
chatbubbles-cli clients
chatbubbles-cli revoke c_01examplefor local bridge work, you can also point the daemon at a patched imsg checkout:
CHATBUBBLES_IMSG_BIN=/path/to/imsg make runor:
./bin/chatbubbles -imsg-bin /path/to/imsgthe browser should not connect to the bridge's direct :8443 listener.
that listener uses a self-signed cert, which is fine for pinned native flows but not for a normal browser.
for browsers, use:
- cloudflare pages for the static shell
- tailscale serve for the bridge traffic
- the
*.ts.nethostname as the saved browser host
make runor your built binary:
./bin/chatbubblesif your bridge is listening on 127.0.0.1:8443 or :8443 with its current self-signed cert, this is the simplest setup:
tailscale serve --https=443 https+insecure://127.0.0.1:8443
tailscale serve statusthat gives you a browser-trusted tailscale https endpoint, usually something like:
https://bridge-name.your-tailnet.ts.net
the web app is just a static frontend:
cd web
npm install
npm run buildthen deploy web/dist to cloudflare pages.
cloudflare is only hosting the shell. it is not proxying message traffic.
open the cloudflare-hosted web app and use either:
pair with qrapproval code
for the browser host, use the plain *.ts.net host:
bridge-name.your-tailnet.ts.net
not:
bridge-name.your-tailnet.ts.net:8443
100.x.y.z:8443
https://100.x.y.z:8443
the web app stores:
https://bridge-name.your-tailnet.ts.netfor api requestswss://bridge-name.your-tailnet.ts.netfor live events
web clients can also use the delegated session flow instead of qr pairing:
curl -sk https://127.0.0.1:8443/v1/sessions \
-H 'Content-Type: application/json' \
-d '{"client_name":"Chrome","client_type":"web"}'the browser polls the returned session_id, and an already-paired client approves it.
the short version:
- use
chatbubbles-cli pairfor direct/native clients - use the cloudflare-hosted web shell plus your
*.ts.nethost for browser clients - do not use
:8443as the browser host
main endpoints:
GET /healthzGET /v1/serverGET /v1/chatsGET /v1/chats/{id}/messagesPOST /v1/pairPOST /v1/sessionsGET /v1/sessions/{id}POST /v1/sessions/{id}/approveGET /v1/eventsPOST /v1/messagesPOST /v1/attachmentsGET /v1/attachments/{id}GET /v1/webhooksPOST /v1/webhooksDELETE /v1/webhooks/{id}
example send request:
curl -sk https://127.0.0.1:8443/v1/messages \
-H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' \
-d '{"to":"+15551234567","text":"hi from chatbubbles","service":"auto"}'example webhook list request:
curl -sk https://127.0.0.1:8443/v1/webhooks \
-H "Authorization: Bearer $TOKEN"example attachment send request:
curl -sk https://127.0.0.1:8443/v1/attachments \
-H "Authorization: Bearer $TOKEN" \
-F to=+15551234567 \
-F text='photo attached' \
-F file=@./photo.jpgwebhook targets must use https:// and cannot resolve to loopback, private, link-local, or metadata addresses.
if desktop works but mobile chrome fails, check these first:
- the saved browser host must be your
*.ts.nethostname with no:8443 tailscale serve statusshould show port443serving the bridge- if you just deployed the web shell, do one hard refresh so the browser picks up the latest service worker and bundle
if the bridge log shows:
tls: unknown certificate
or:
tls handshake error ... EOF
the browser is almost always talking to the bridge's direct self-signed tls listener instead of the tailscale serve host.
- the daemon keeps its state in
~/.local/share/chatbubbles/ - the api is meant for tailscale clients, not direct public exposure
- websocket auth uses
?token=because browser websocket clients cannot set custom auth headers - for now, the browser story assumes tailscale serve and a
*.ts.nethostname. direct self-signed tls is for native/direct clients.
if you want the deeper architecture and rollout plan, that lives in the repo notes rather than this README.