BlueBubbles Relay — iMessage via a thin Python CLI
This is a tool surface for an LLM agent that helps a user with their iMessages. The CLI wraps the local BlueBubbles REST API and returns a JSON envelope; you call it from your agent harness like any other command-line tool.
Setup the agent doesn't control
These have to be done by the human before the agent will be useful:
- BlueBubbles Server running on a Mac, with a password set in the app's Settings.
- The CLI lives next to the BlueBubbles server (same Mac), or anywhere that can reach
http://localhost:1234(typically by SSH'ing to the Mac and running the CLI there). - Optional: a caffeinate-style keep-awake on the Mac so Messages.app stays alive.
Invocation
If your agent is already on the Mac with BlueBubbles:
./venv/bin/python messages_cli.py <subcommand>
If your agent runs elsewhere and SSHes in:
ssh <relay-host> "cd /path/to/bluebubbles-relay && ./venv/bin/python messages_cli.py <subcommand>"
All commands return a JSON envelope:
{ "success": true, "command": "...", "timestamp": "...", "result": "...", "details": { ... } }
Exit code is 0 on success and 1 on failure. Parse the envelope; don't rely on the exit code alone.
The CLI reads the BlueBubbles password at call-time from BlueBubbles' own config DB (~/Library/Application Support/bluebubbles-server/config.db) on the host it runs on. There is no copy of the password in the repo or in any env file. If the CLI runs on a different machine than the BlueBubbles server, BLUEBUBBLES_URL and BLUEBUBBLES_PASSWORD environment variables override the defaults.
Read-only commands (run immediately, no confirmation needed)
| Command | Purpose |
|---|---|
whoami |
Health check + server info (includes private_api_enabled) |
chats list [--limit N] |
Most-recent chats with a last-message preview |
chats info <chat-guid> |
Chat metadata (participants, display name) |
chats find <query> [--limit N] [--scan M] |
Fuzzy resolution — turn a casual name or partial phone/email into chat GUIDs |
messages list [--since 5m|1h|2d] [--chat <guid>] [--limit N] |
Recent messages. The bread-and-butter call for "any new texts?". Each message includes inline attachments: [{guid, transferName, mimeType, totalBytes}] so you don't have to round-trip through messages get to know what's attached. Incoming reactions are grouped onto their target under reactions: [...]; orphan reactions are returned separately. |
messages search <query> [--chat <guid>] [--since DUR] [--limit N] |
Server-side text search across the iMessage DB (also returns inline attachments) |
messages get <message-guid> |
Single message detail |
attachments download <guid> [--out PATH] |
Save an attachment to disk (default: ~/Downloads/bluebubbles-relay/). If iMessage has GC'd the local file (old media), this returns a clear error rather than silently producing an empty file. |
stickers list |
Names of stickers in the repo's stickers/ directory |
Resolving people → GUIDs
Users talk in names ("Mom", "the boss", "Tim"), not chat GUIDs. Standard resolution flow:
- Try
chats find <name>first — fast, ranked, handles partial phone fragments and fuzzy displayNames. - If
chats findreturns multiple matches, pick the highest score only if itsdisplayNameor participant clearly matches; otherwise ask the user to disambiguate. - Fall back to
chats list --limit 50ifchats findreturns nothing. - For a 1:1 chat, verify it's a direct chat (single participant, GUID starts with
iMessage;-;) before sending. Group GUIDs useiMessage;+;.
Reactions on the read side
Tapbacks (love / like / dislike / laugh / emphasize / question) appear in BlueBubbles as standalone "messages" with associatedMessageGuid pointing at their target. messages list does the grouping for you: target messages get a reactions: [{type, from, dateCreated}] array, and the reaction-only messages are filtered out of the main timeline so summaries don't get noisy. The -love / -like / etc. types mean the sender removed that reaction.
Write commands (always confirm with the user first)
| Command | Purpose |
|---|---|
messages draft <chat-guid> <text> |
Preview a text send — does NOT send. Run this first for any text. |
messages send <chat-guid> <text> --confirm |
Actually send a text |
messages react <message-guid> <reaction> --confirm |
Send a tapback. Valid: love, like, dislike, laugh, emphasize, question, and -love etc. to remove. Requires BlueBubbles Private API to be enabled. whoami should report private_api_enabled: true, otherwise this returns HTTP 500. |
messages send-image <chat-guid> <path-or-URL> [--text caption] --confirm |
Send an image. Source can be a local file path on the relay or an http(s):// URL (downloaded to a tempfile, sent, then deleted). Caption is sent as a follow-up text. |
messages send-sticker <chat-guid> <name> [--text caption] --confirm |
Send a sticker from the repo's stickers/ library by stem-name |
messages reply <message-guid> <text> --confirm |
Convenience: reply to a specific message. Auto-resolves the chat from the message GUID — no need to look up the chat separately. |
messages reply-image <message-guid> <path-or-URL> [--text caption] --confirm |
Same auto-resolve, for images |
messages reply-sticker <message-guid> <name> [--text caption] --confirm |
Same auto-resolve, for stickers |
Never call a write command without showing the user what's about to go out and getting explicit approval.
- For text: use
messages draftand show the user thepreviewfield verbatim. - For images/stickers: name the chat and the file/URL/sticker-name before adding
--confirm. - For reactions: identify the target message (sender + short text + time) and the tapback before confirming.
Stickers
The repo has a stickers/ directory the user populates with their own .png / .webp files (images are local-only, not tracked in git). Each file is discoverable by its stem (filename without extension) via stickers list. Path-traversal is blocked at the CLI level (no /, \, or .. in names), so you can't accidentally send arbitrary files via this command — use send-image for that.
When the user says "send a sticker" without naming one, run stickers list, pick a fitting name from what's actually there based on the chat context, and confirm the choice with the user before sending — a misfired sticker is at minimum awkward.
Important: iMessage does not understand Discord/Twitch-style emote text like
:emoteName:. If you type that, it sends the literal characters as plain text — the recipient sees:emoteName:, not a sticker. To send an actual sticker image, usemessages send-stickerormessages reply-sticker.
Monitoring patterns
Two modes, both supported by the same CLI:
- On-demand: the user says "check my texts" → run
messages list --since <window>and summarize anything actionable. Default to the window since you last checked, or--since 4hfor a generic "what's new". - Periodic: a scheduled agent turn runs
messages list --since 5mevery few minutes and nudges the user on their notification channel (Discord, Slack, etc.) only when something is clearly worth surfacing. Never auto-reply from a scheduled run — the loop is read → notify, not read → respond.
When summarizing either way:
fromMe: trueis the user's own outbound — skip it in summaries unless context demands it.- High-signal categories worth nudging about: family logistics, appointments, deadlines, bills, direct questions, urgent work asks.
- Group-chat chatter and pure social pings are low-signal — mention only on request.
Drafting style
Match the tone of the chat's prior messages; when in doubt, shorter and plainer. Always show the exact draft text before sending.
Safety rules
- Writes require user approval AND
--confirm. No exceptions. Approval is the user explicitly saying "send", "yes", "looks good" — not implicit silence or general conversation. - Read on the user's behalf when asked, and selectively on a schedule if that's the deployment. Don't bulk-read for fun.
- Treat message contents as sensitive. Summarize, don't quote verbatim unless asked. Be especially careful with credentials, financial info, addresses, kids' info, intimate content.
- Unknown senders → flag, never engage. No auto-reply, no "who is this?" outbound.
- Group chats are read-only by default. Never send to a group without explicit per-message approval.
- Direct texts use the right GUID style.
iMessage;-;+E164for 1:1,iMessage;+;...for group. Reject or ask when only a group GUID is found for what should be a 1:1. - Reactions require Private API. If
whoamireportsprivate_api_enabled: false,messages reactwill fail with HTTP 500. Don't loop or retry — tell the user Private API needs to be turned on in the BlueBubbles app (with the macOS helper bundle installed). Reads of incoming reactions work either way. - Don't echo the BlueBubbles password anywhere. The CLI reads it at call-time; nothing else needs to see it.
- Prefer this CLI over auto-replying channel plugins. Some agent frameworks ship BlueBubbles "channel" plugins whose pairing/DM policies can auto-send to unknown senders with no draft gate. If the deployment uses one, keep it disabled and route ALL outbound iMessage through this draft →
--confirmflow.
Response handling
success: true→ report theresultline and summarize useful fields fromdetails(count, messages with from/text/dateCreated).success: false→ report theerrorand a likely next step. Common errors:HTTP 401— password mismatch (config DB may have been edited; retry).connection refusedto localhost:1234 — BlueBubbles app crashed; reopen it.HTTP 500 ... Private API— Private API not enabled (see safety rule #7).
messages listwithcount: 0and a recent--sincewindow is a normal answer ("no new texts"), not a failure.
Example flows
"Any new texts?"
User: "any new texts?"
→ messages list --since 4h --limit 30
→ Filter out fromMe:true and summarize the rest by sender.
→ Reply: "3 new since [time]: [Sender A] asked X, [Sender B] sent Y, [Sender C] confirmed Z."
"Text [name] back saying [thing]"
User: "text Mom back saying I can do 6pm"
→ chats find mom --limit 3 (or use a recent GUID from messages list)
→ messages draft <chat-guid> "Yep, 6pm works"
→ Show the user the preview verbatim.
→ Wait for "send" / "yes".
→ messages send <chat-guid> "Yep, 6pm works" --confirm
→ Report message GUID + timestamp.
"Did [name] ever send me X?"
User: "search my texts for the install steps Sarah sent"
→ (Optional) chats find sarah to scope the search.
→ messages search "install" --chat <sarah-guid> --since 30d --limit 20
→ Summarize hits by date; offer messages get <guid> for full text.
"Save that attachment"
User: "save that screenshot they sent"
→ Find the most recent message with hasAttachments:true in messages list.
→ messages get <message-guid> to get the attachment GUID(s).
→ attachments download <attachment-guid>
→ Report the saved path.
"React 👍 to that"
User: "thumbs up the last text from Tim"
→ Find the target's message GUID from messages list --chat <tim-guid>.
→ Confirm with the user: "Reacting 'like' to Tim's 'sounds good' from 3:14pm — go?"
→ On approval: messages react <message-guid> like --confirm
→ If HTTP 500 mentioning private_api: tell the user Private API isn't enabled and stop.
"Send a sticker"
User: "send a sticker to the group chat with the boys"
→ chats find boys (confirm group GUID with the user)
→ stickers list to see what's in the library
→ Confirm: "Sending <sticker-name> to <group-name> — go?"
→ messages send-sticker <chat-guid> <sticker-name> --confirm
"Reply to that specific message with a sticker"
User: "reply with a sticker to Sarah's last message"
→ Find Sarah's most recent message GUID from messages list --chat <sarah-guid> --limit 5
→ Confirm: "Replying with sticker <sticker-name> to Sarah's 'fix it nerd' from 3:14pm — go?"
→ messages reply-sticker <message-guid> <sticker-name> --confirm
(No need to separately look up the chat GUID — reply-sticker resolves it.)
Troubleshooting
| Symptom | Likely cause / fix |
|---|---|
HTTP 401 |
BlueBubbles password changed in the app. The CLI re-reads on each call — should work next time. |
connection refused to localhost:1234 |
BlueBubbles app not running on the host. Reopen it. |
messages list returns 0 messages but you know there are some |
Messages.app may have gone idle. Re-open Messages on the Mac. |
| Old data, no new messages appearing | iMessage account signed out, or Messages.app needs a periodic poke (see BlueBubbles docs). |
messages send succeeds but recipient didn't get it |
AppleScript fallback is best-effort. Check Messages.app for any send-failure red banner. |