iMessage via BlueBubbles
Use for ANY iMessage work on a Hermes fleet Mac -- sending, reading, searching messages,
or standing up the bridge itself. Also the right skill when iMessage is broken: imsg
failures (permissionDenied, authorization denied (code 23), hangs with no output)
are the signal to move to this bridge.
Why this exists
The older imsg CLI reads Apple's ~/Library/Messages/chat.db directly. macOS governs
that file with TCC, and TCC grants Full Disk Access to an exact interpreter path. So
a routine uv Python patch bump silently revokes access and every scheduled iMessage
job dies with authorization denied (code 23) -- no Hermes version change, no config
change, no log warning. That happened on a-contact a real incident. Add the sandboxd
wedge (imsg hangs forever, zero output) and direct-database access is structurally
fragile. BlueBubbles moves the Apple-facing half into one long-running signed app that
holds its own permission grant. Hermes talks to it over loopback HTTP. Python never
touches chat.db again, so a Python upgrade can no longer break iMessage. That is the
whole point -- a structural fix, not a workaround. Hermes ships a first-class
BlueBubbles platform adapter (gateway/platforms/bluebubbles.py, registered as
Platform.BLUEBUBBLES), so the agent can receive iMessages as a real channel, same as
Telegram.
Two separate capabilities -- do not confuse them
| Capability | Mechanism | Use for |
|---|---|---|
| Inbound channel | Hermes gateway adapter, BLUEBUBBLES_* env, webhook on:8645 |
People messaging the agent and it replying in-thread |
| Outbound tool | scripts/bb.py calling the REST API |
The agent sending or reading messages as part of a task |
Setting the env vars enables the channel. bb.py works independently and needs only
the same two credentials.
Setup
./scripts/setup-bluebubbles.sh # full guided install
./scripts/setup-bluebubbles.sh --verify # health-check an existing install
./scripts/setup-bluebubbles.sh --exposure # check for public tunnel exposure
./scripts/setup-bluebubbles.sh --wire # write BLUEBUBBLES_* to.env only
What cannot be automated, ever
macOS protects the TCC database with SIP. It can be read with sudo for diagnosis,
but it cannot be written -- there is no supported way to grant a permission from a
script. Permission grants are a human click. Do not claim setup is done until a real
API call proves access. Budget a screenshare for a remote machine. The human steps are
exactly three:
- Set a server password in BlueBubbles' first-run wizard.
- Grant Full Disk Access to BlueBubbles when macOS prompts.
- Turn the public tunnel off (see Hardening). On Automation > Messages:
BlueBubbles may also prompt for Automation access to Messages. Grant it if asked. Do
not preemptively send someone to System Settings for it, and do not diagnose a
slow send as a missing Automation grant: sending has been verified working with
no Automation TCC entry present, because an app can inherit the right from an
authorized parent process. Full Disk Access governs reading
chat.db; Automation governs driving Messages.app. They are different permissions, but only the first is reliably required up front. Seereferences/send-path-diagnosis.mdbefore acting on a hang.
Skip the Google / Firebase sign-in -- Hermes does not use it
First-run setup pushes a Google sign-in. That is Firebase Cloud Messaging, which
exists so the BlueBubbles phone app receives push notifications. The Hermes adapter
receives messages by webhook over loopback (127.0.0.1:8645), so FCM is never
consulted. Skip it. If a fleet owner gets stuck on Google auth, that is not a blocker --
it is an optional step for a client we do not run. The same reasoning applies to the
Cloudflare tunnel (see Hardening): both exist for remote phone clients. the operator hit
exactly this on a real incident and it stalled the install for no reason.
Full Disk Access will NOT be in the list until the app asks for it
macOS populates the Full Disk Access list on demand -- an app appears only after it
first attempts to read a protected file. A half-configured BlueBubbles has not tried to
open chat.db yet, so it is genuinely absent from System Settings, and the user has not
missed anything. The order is therefore the reverse of what the setup instructions
imply:
- Finish first-run setup (server password set)
- BlueBubbles attempts to read
chat.db - Then it appears under Full Disk Access
- Enable it, then restart BlueBubbles -- TCC is evaluated at process start, so a running app never picks up a new grant Do not send someone hunting through Settings for an entry that cannot exist yet. Confirm what is actually granted by reading TCC directly:
sudo sqlite3 "/Library/Application Support/com.apple.TCC/TCC.db" \
"select client, service, auth_value from access where client like '%luebubble%';"
auth_value 2 = granted, 0 = denied, no row = never requested.
kTCCServiceAccessibility is the Private API grant (tapbacks, typing);
kTCCServiceSystemPolicyAllFiles is Full Disk Access. They are separate -- having
Accessibility does not imply FDA. But do not treat a missing FDA row as proof of no
access. Verified on the operator's Mac a real incident: BlueBubbles returned the full
chat list with no kTCCServiceSystemPolicyAllFiles entry at all, only
Accessibility. An Electron app launched from an already-authorized parent can inherit
disk access, so the TCC table under-reports. The authoritative test is always a real
chat/query call:
./scripts/bb.py health # 'chat access: ok' is the only proof that counts
Read the TCC table to explain why something is broken; never to conclude that something works or does not.
Gatekeeper: the app refuses to launch until you clear quarantine
BlueBubbles is signed with a Developer ID but not notarized by Apple. macOS tags
every download with com.apple.quarantine, and on an un-notarized app that tag makes it
refuse to open (often as a misleading "damaged" message). The fix is one command, and
setup-bluebubbles.sh now does it automatically:
xattr -dr com.apple.quarantine /Applications/BlueBubbles.app
This clears the download tag on one bundle only. It does not weaken Gatekeeper system-wide. Verified in practice -- the app launched immediately afterward. Two follow-on facts:
spctl -a /Applications/BlueBubbles.appstill reports rejected / Unnotarized Developer ID after the tag is cleared. Expected, and does not block launch. Do not chase it.- GUI fallback: System Settings > Privacy & Security, scroll down, Open Anyway.
The Homebrew cask is deprecated -- prefer the DMG for fleet rollout
brew install --cask bluebubbles works today but is deprecated for failing the
Gatekeeper check and is disabled one occasion. Convenient because it is scriptable, but
not the durable path. For fleet rollout use the DMG from
https://bluebubbles.app/downloads/ plus the quarantine-clear above.
SIP is optional -- default to leaving it ON
| SIP | What works | Who |
|---|---|---|
| Enabled (default) | Send, receive, attachments, read receipts | Non-technical owners. Recommended. |
| Disabled | Adds tapbacks, typing indicators, edit/unsend | Technical hosts already running SIP-off |
Sending and receiving do not need SIP disabled. Only the Private API extras do. Never talk a non-technical owner through disabling SIP for typing indicators -- not worth it, and on Apple Silicon it also disables running iOS apps. Prior fleet notes recording "BlueBubbles needs SIP disabled + ~2hr of pain" describe the Private API tier, not the base install.
Hardening: turn off the public tunnel
BlueBubbles opens a public Cloudflare tunnel by default. On first launch the a first
launch generates a live trycloudflare.com URL pointing at the host. That exists so
phone clients can reach the server from anywhere -- but Hermes talks to it over
loopback and never needs it. Left on, it is a public URL fronting the owner's entire
message history, guarded by one password. Turn it off: BlueBubbles > Settings >
Connection, set Proxy Service to Dynamic DNS with address http://localhost:1234,
or disable the tunnel.
Turning it off headlessly, and the orphaned-process trap
Config is held in memory and flushed on shutdown (set-config / config-update
handlers in app.asar), so a direct DB write while the app runs gets clobbered. Correct
sequence:
# 1. quit cleanly so in-memory config is flushed
osascript -e 'tell application "BlueBubbles" to quit'
# 2. write the DB (values verified from the app bundle: dynamic-dns | lan-url |
# cloudflare | ngrok)
sqlite3 ~/Library/Application\ Support/bluebubbles-server/config.db \
"update config set value='dynamic-dns' where name='proxy_service';
update config set value='http://localhost:1234' where name='server_address';"
# 3. relaunch and verify the values SURVIVED the restart
open -a BlueBubbles
Then kill the orphaned tunnel. Verified in practice: after the config change and a
full app restart, the bundled cloudflared was still running from the previous
session and the public URL still answered HTTP 200. The config was correct and the box
was still exposed.
pgrep -f "BlueBubbles.app.*cloudflared" # BlueBubbles' own tunnel only
kill <pid>
Match on the BlueBubbles.app path, never on cloudflared alone -- the operator may
run their own unrelated tunnels on the same box. Killing those is collateral damage.
Proof is a dead public URL, not a changed setting:
curl -m 25 -o /dev/null -w "%{http_code}\n" "https://<old-url>/api/v1/ping"
# 530 or connection failure = actually closed. 200 = still exposed.
./scripts/setup-bluebubbles.sh --exposure # reads the server's own config
Treat a public server_address on any machine as a finding to fix, not a note to file.
Daily use
scripts/bb.py is the agent-facing replacement for imsg. Plain-text output, one
record per line -- no JSON parsing.
./scripts/bb.py health # server + auth + disk access
./scripts/bb.py chats --limit 20 # recent conversations
./scripts/bb.py find --query "alex" # locate a chat
./scripts/bb.py history --chat "alex" --limit 30 # read a conversation
./scripts/bb.py send --chat "alex" --text "on my way"
--chat accepts a raw GUID or a fuzzy name/number. On an ambiguous match it prints
the candidates and exits without sending -- messaging the wrong person is not
recoverable, so it never guesses.
Rules
- Confirm recipient and content before sending on the user's behalf.
- Read the thread before sending to a recipient you resolved yourself. Stored
contact data conflicts and Apple Contacts genuinely attaches one number to multiple
people. See
references/recipient-verification.md. - Never message an unknown number without explicit approval.
- Rate-limit yourself. No bulk sending.
- Run
healthbefore diagnosing anything. - Never retry a send after a timeout. Re-read the thread to establish whether it landed -- a timeout is absence of information, not failure.
Verification
ping is not sufficient -- it passes with no disk access at all. The real test is
chat/query, which reads the message database:
./scripts/bb.py health # exercises ping, server/info, AND chat/query
chat access: ok is the only line that proves Full Disk Access works. A green ping with
empty chats means the app is running but blind. health exits non-zero in that case, so
a caller can gate on the status code.
Full test suite
Run the suite. It exercises the bridge the way an agent actually uses it and reports PASS/FAIL/SKIP per check.
./scripts/test-bluebubbles.py # read-only, safe, no messages
./scripts/test-bluebubbles.py --send-to "any;-;+15551234567" \
--from-name "YourAgent"
./scripts/test-bluebubbles.py --json # machine-readable summary
Exit 0 = all passed, 1 = failures, 2 = could not run. 16 checks across seven areas: process, auth, disk access, read path, safety guards, network exposure, Hermes wiring, and (opt-in) a live send. Design rules baked into the suite, each earned from a real failure:
- Auth enforcement is tested with a WRONG password. If a bad password is accepted, every other PASS is meaningless.
- Disk access is tested with
chat/query, neverping. Ping succeeds with zero disk access. - Exposure is tested by fetching the public URL, not by reading a config value. Config can say loopback while an orphaned tunnel still serves traffic.
- A live send is confirmed by reading the message back from the server, not by trusting the sender's own success claim.
- The send test is opt-in and requires an explicit GUID, because it texts a real
human. Identify yourself in test messages. Validate the suite itself periodically by
pointing it at a wrong password and a wrong port; both must FAIL. A suite that cannot
fail proves nothing.
bb.py healthremains the quick single check;chat access: okis the only line that proves Full Disk Access.
Fleet rollout
This skill is macOS-only -- BlueBubbles is a Mac app. Delivering it to a Linux host
produces a skill that can never work, which is worse than absent. Resolve the OS per
host before copying (ssh <host> 'uname -s', Darwin only).
Deliver a tarball rather than per-file copies, extract to a staging directory, and only
swap the live directory once SKILL.md is confirmed present -- a partial transfer must
never half-replace a working skill. Back up any existing copy first.
Verify in three layers, because each proves something the others do not:
- Delivery -- sha256 every file against the source. File-present is not file-correct.
- Loader -- resolve the skill BY NAME on the target and confirm exactly one
definition. A duplicate name makes
skill_viewrefuse, so a perfectly delivered skill can still be unloadable. - Body -- assert a distinctive string survived (
chat access: ok,UNKNOWN -- DO NOT RETRY).
When counting name duplicates, separate same-root duplicates (real, breaks the loader) from local-shadows-bundled pairs (normal -- the local copy wins). Any machine whose local skill library overlaps the bundled one shows many of the latter, and reporting them as collisions is a false alarm. Only a same-root duplicate is a defect.
Profile layout trap: on a single-agent host the agent IS the root profile and skills
live at ~/.hermes/skills/, with no profiles/<name>/ directory. Sub-profiles use
~/.hermes/profiles/<name>/skills/.
Pitfalls
A send that times out has NOT necessarily failed -- verify, never retry
The most dangerous failure mode in this skill. AppleScript sends routinely exceed a 20-30s HTTP timeout: the request blocks while Messages.app does the work, but the message still sends. The client gives up; Apple does not. Seen in practice." It had sent. She replied. the operator caught the error. Two rules follow:
- A timeout means UNKNOWN, not failed. Absence of information is not evidence of absence. Never report a send as failed on a timeout alone.
- Verify after a delay, and never retry blind. An immediate re-read races the
in-flight send and returns a false negative. Wait, re-read the thread, match on
message text. Retrying instead of verifying double-texts a real person --
unrecoverable.
bb.py sendhandles this: 120s timeout, then polls the thread for 30s looking for the sent text, printingCONFIRMED deliveredor an explicitUNKNOWN -- DO NOT RETRYthat tells the operator to check Messages.app before retrying. By hand, the same discipline:
# WRONG -- races the send, reports a false negative
./bb.py send --chat X --text "hi";./bb.py history --chat X --limit 3
# RIGHT -- give a slow send time to land before concluding anything
./bb.py send --chat X --text "hi"; sleep 30;./bb.py history --chat X --limit 3
A hang is not proof of a missing permission
A hanging send looks exactly like a blocked Automation prompt, so it is tempting to
conclude the app lacks Automation access to Messages (com.apple.MobileSMS). On the In
testing that inference proved wrong -- sends worked with no such TCC grant; the hang
was pure latency, and the user was nearly sent to System Settings for nothing.
Distinguish by error shape: a missing permission or helper returns an explicit
error (iMessage Private API Helper is not connected!), while latency hangs
silently. Reads working tells you nothing about sends -- they use different mechanisms
-- but neither does a hang tell you a permission is missing.
Name search returns nothing for someone you know exists
iMessage chats are keyed by phone number and email; displayName is null on most 1:1
threads. A name finding nothing is expected and is not evidence the person has no
thread. Search by number fragment, and paginate -- chat/query caps at 1000 rows per
call.
ping succeeds but no chats come back
Full Disk Access is not granted. Grant FDA to BlueBubbles.app, then restart the app -- TCC is evaluated at process start, so a running process never picks up a new grant.
Password shows as set but auth still fails
Check the length, not just presence. First launch can leave an empty password that still appears in config:
sqlite3 ~/Library/Application\ Support/bluebubbles-server/config.db \
"select name, length(value) from config where name='password';"
Length 0 means first-run was never completed. Finish the wizard.
open -a BlueBubbles hangs forever
The Mac is at the login screen. GUI apps cannot launch while locked, and the call blocks
rather than failing. Take a screenshot (screencapture -x) before assuming the app is
broken. Remote fleet Macs must be unlocked and logged in.
Port 1234 already taken
Set BB_PORT and re-run. Preflight distinguishes "held by BlueBubbles" (fine) from
"held by something else" (fatal).
Verifying over SSH proves nothing about scheduled jobs
Inherited from the imsg era, still applies to anything TCC-related: over SSH the
responsible process is sshd, which already holds Full Disk Access, so a probe can pass
while every launchd cron job is denied. Only output from a job that fired on its own
schedule is authoritative. BlueBubbles largely sidesteps this -- the grant belongs to
the always-running app, not to whatever interpreter invokes it -- which is precisely why
it is more robust than imsg.
Migrating off imsg
Run both in parallel for a few days before retiring imsg. Keep the imessage skill
installed during the soak: it documents the sandboxd watchdog and the TCC diagnostic
ladder, still the right tools for a genuinely wedged Messages stack. Retire imsg only
after bb.py health passes from a real scheduled run, not from an interactive shell.
References
references/api-surface.md-- BlueBubbles REST endpoints Hermes usesreferences/landscape-2026.md-- why BlueBubbles over Beeper, pypush, or a paid APIreferences/send-path-diagnosis.md-- sends hang or 500 while reads work; Automation grant, Private API Helper, GUID format, paginationreferences/recipient-verification.md-- confirming you have the right person before an irreversible send