Send iMessages from anywhere. Without a Mac on the public internet.
A small, opinionated bridge that lets an inexpensive Linux/cloud "producer" (or your favorite openclaw π¦) enqueue messages securely and async over AMQP 1.0 into a managed broker, and a tiny agent on your Mac pulls them out and sends them through Messages.app. No inbound ports to attack. No SAS keys. No PATs.
Quick start Β· Architecture Β· Standards & portability Β· Install Β· Troubleshooting Β· Security Β· Contributing
iMessage is a walled garden. If you want to send an iMessage programmatically, you need a Mac signed into iCloud running Messages.app. That Mac shouldn't be exposed to the internet, and you shouldn't be juggling long-lived secrets to talk to it.
imessage-bridge solves both:
- Producer-anywhere, consumer-on-Mac β the producer is just a cmdline tool call for humans or agents to send a message securely like you would want to in a claw; the Mac only makes outbound calls to the broker and then forwards to iMessage via
Messages.app. Nothing inbound. No port forwarding. No tunnels. - OAuth + Microsoft Entra identities β no SAS connection strings, no PATs, no
.envfiles full of secrets, or other things to get complicated and get Pwnd. Both ends authenticate viaDefaultAzureCredential. Don't let Entra scare you β it works with Free Azure accounts and consumer outlook.com accounts. Or just take this pattern and use the ecosystem you prefer for identity and cloud hosting. No biggie. - Standards under the hood β the wire protocol is AMQP 1.0 (OASIS / ISO/IEC 19464), not a vendor-proprietary format. Your queue is just a queue. See Standards & portability below.
- Built in the openclaw π¦ spirit β own your data, own your infra, ship one more "claw" into a walled-garden ecosystem so your agents can act on your behalf. Each
skills/entry follows the openclaw skill format so this folder drops cleanly into any openclaw-style runtime (or any other agent framework that consumesSKILL.md). - Tiny surface area β ~100 lines of producer + ~150 lines of agent. Easy to read, easy to fork, easy to trust.
The 60-second demo. Once you've done the one-time Azure setup below, sending an iMessage from anywhere is one line:
npx imessage-bridge@alpha send --to "+15555550100" --body "hi from the bridge π©"That's it. npx fetches the package on demand and runs it β nothing to install, nothing to clean up. (The @alpha tag is pinned through the v0.2 prerelease β once we ship stable, drop the @alpha.)
You'll need: Node.js 22+ LTS (recommend nvm install 24), an Azure subscription (free tier is fine), the az CLI, and a Mac signed into iMessage. The Mac can be anywhere with internet access (it makes outbound calls to the broker β no inbound exposure needed). Or if your producer is on the same private network, you can swap Service Bus for a local broker (Redis + the Dapr extension, RabbitMQ, ActiveMQ Artemis, β¦) β see Standards & portability.
This project ships both a terminal CLI (via npm) and a set of operational skills under skills/. Pick whichever matches how you work β they do exactly the same things.
| Goal | π₯οΈ Terminal | π¬ Or just say to your agent |
|---|---|---|
| Send a message | npx imessage-bridge@alpha send --to "+15555550100" --body "hi" |
"send 'hi' to +15555550100 via the bridge" β send-message |
| Send a Signal message | npx imessage-bridge@alpha signal-send --to "+15555550100" --body "hi" |
"send 'hi' to +15555550100 via Signal" |
| Run the Mac receiver (foreground) | npx imessage-bridge@alpha agent |
"run the imessage-bridge receiver" |
| Install on a Linux/cloud/openclaw producer | npm i -g imessage-bridge@alpha (or just npx) |
"install the imessage-bridge producer on this box" β install-producer |
| Install on the receiving Mac (as a daemon) | follow step 6 below | "install imessage-bridge on this Mac as a daemon" β install-mac |
| Health check | npx imessage-bridge@alpha doctor |
"is the imessage-bridge healthy?" / "doctor" β doctor |
| Find a message in the logs | grep <uuid> logs/agent.log |
"did the iMessage to +1β¦ go through?" / "show me the bridge logs" β logs |
π¦ Skill consumers: Copilot CLI, Cursor, Claude Code, and any openclaw runtime pick up the
skills/folder automatically βcdinto the repo (Copilot CLI / Claude Code), include it in your workspace (Cursor), or symlink:ln -s "$(pwd)/skills" ~/.openclaw/skills/imessage-bridge. EachSKILL.mdhastrigger_phrasesfrontmatter so the right runbook surfaces on the right intent.
The rest of this section is the first-time setup walkthrough: provision the queue once, grant two RBAC roles, drop a tiny config.json next to where you'll run the CLI, and you're done. After that, every send is npx imessage-bridge@alpha send β¦.
The bridge runs on any AMQP 1.0 broker; Azure Service Bus is the immediate default implementation because it's the cheapest managed AMQP-with-OAuth/Entra option (~$0/month at our volume) and zero ops. To use a different broker (RabbitMQ, ActiveMQ Artemis, Solace, β¦), see Standards & portability below β the producer/agent stay the same.
RG=imessage-bridge
NS=$RG-$(whoami) # namespace must be globally unique
QUEUE=imsg-queue
LOC=westus2
az group create -n $RG -l $LOC
az servicebus namespace create -g $RG -n $NS --sku Basic
az servicebus queue create -g $RG --namespace-name $NS -n $QUEUEEach machine logs in as its own Azure AD identity and gets only the role it needs. No shared identities, no Service Principals, no secrets.
SCOPE=$(az servicebus queue show -g $RG --namespace-name $NS -n $QUEUE --query id -o tsv)On the producer machine (sends only):
az login --use-device-code # log in as the producer identity
ME=$(az ad signed-in-user show --query id -o tsv)
az role assignment create --assignee $ME --role "Azure Service Bus Data Sender" --scope $SCOPEOn the Mac (receives only):
az login # log in as the Mac identity
ME=$(az ad signed-in-user show --query id -o tsv)
az role assignment create --assignee $ME --role "Azure Service Bus Data Receiver" --scope $SCOPEπ Identity-only auth. This project never uses Service Principals, client secrets, certificates, SAS keys, or PATs. Both sides authenticate with
az login(Azure AD user identity);DefaultAzureCredentialdiscovers the cached token. See SECURITY.md.
The npm package reads ./config.json from the current directory (or $IMSG_CONFIG if set). No clone required.
After you provisioned the namespace in step 1, determine the namespace FQDN and put it into config.json. The namespace FQDN is simply:
<your-namespace>.servicebus.windows.net
Example: if you created NS=$RG-$(whoami) and that evaluated to imessage-bridge-yourname, the namespace FQDN is:
imessage-bridge-yourname.servicebus.windows.net
Create the config file from the shell (safe / scriptable):
NS=imessage-bridge-yourname # or whatever you picked above
FQDN="$NS.servicebus.windows.net"
cat > config.json <<JSON
{
"namespace_fqdn": "$FQDN",
"queue": "imsg-queue",
"poll_interval_s": 3,
"log_path": "./logs/agent.log",
"message_prefix": "[m365]",
"signature": "β‘",
"allowed_recipients": [
"+15555550100"
]
}
JSONPrefer to keep config out of the working dir? Set IMSG_CONFIG=~/.config/imessage-bridge.json and the CLI will pick it up.
Notes:
- config.json is gitignored. Do not commit it.
namespace_fqdnmust exactly match the Service Bus namespace FQDN (no protocol, no trailing slash).allowed_recipientsis an optional E.164 allowlist enforced by the Mac consumer. A queued message addressed to another number is dead-lettered before it can reach Messages.app.message_prefixandsignaturelabel every outbound message at the consumer boundary. The producer cannot bypass them.- If youβre unsure what the namespace name is, you can list namespaces with:
az servicebus namespace list -g $RG -o tableand inspect the name column β append .servicebus.windows.net to form the FQDN.
az login # opens browser; tokens cached in ~/.azure
# OR for headless boxes:
az login --use-device-codeThat's it for auth. DefaultAzureCredential picks up the cached az tokens automatically. No connection strings, no PATs, no SAS keys.
# from anywhere (Linux, Mac, cloud) β enqueue:
npx imessage-bridge@alpha send --to "+14255551234" --body "hey from the bridge π"
# on the Mac β start the consumer in the foreground:
npx imessage-bridge@alpha agentThe Mac picks it up within a few seconds and Messages.app sends it. β¨
Add signal_queue to the same producer configuration:
{
"namespace_fqdn": "your-namespace.servicebus.windows.net",
"queue": "imsg-queue",
"signal_queue": "signal-queue"
}Then enqueue through the independent Signal queue:
npx imessage-bridge@alpha signal-send --to "+14255551234" --body "Signal smoke test"The producer needs Azure Service Bus Data Sender scoped to signal-queue.
signal-send does not require a Signal account or signal-cli; those are only
needed by the receiving Signal consumer. To configure that receiver, including
its Signal account, signal-cli, and Azure Service Bus Data Receiver role,
follow the Mac deployment guide.
The safe path is one clear arc: foreground-test β install β verify β done.
β οΈ Do the foreground run first. macOS must show the Automation prompt forMessages.app, and you must click Allow. Under launchd, macOS cannot show that prompt β the agent will just fail with "Not authorized."
npx imessage-bridge@alpha agent # ctrl-C after you click Allow on the Automation promptFor the persistent LaunchAgent installer, clone the repo (it ships the plist template + installer):
gh repo clone paulyuk/imessage-bridge && cd imessage-bridge
./mac/launchd/install.sh # detects node, installs deps, renders plist, registers with launchd, starts nowlaunchctl print gui/$(id -u)/com.imessage-bridge.agent | head -20 # status
tail -F logs/agent.log # follow app logsThe installer is idempotent, so re-run it after git pull to pick up
new code. Full daemon setup and common commands: INSTALL.md.
Troubleshooting starts with the two log files: TROUBLESHOOTING.md.
The default osascript path can cause macOS to repeatedly prompt for Messages automation because it is an unbundled command-line process. On a Mac with Xcode Command Line Tools, install the included signed local helper before the first foreground send:
./mac/automation/install-helper.shAdd the printed path to automation_helper_path in config.json, then run one foreground smoke test and approve iMessage Bridge when macOS asks to control Messages. The helper has the stable local identifier com.paulyuk.imessage-bridge.automation, so the Automation approval persists across daemon restarts.
flowchart LR
subgraph anywhere["π» Anywhere β Linux / cloud / your bot"]
P["Producer<br/><code>npx imessage-bridge send</code>"]
end
subgraph azure["βοΈ Cloud β managed AMQP 1.0 broker (Azure Service Bus)"]
AAD[("π Azure AD")]
SB["Service Bus Queue<br/><b>imsg-queue</b><br/><sub>AMQP 1.0 over TLS</sub>"]
end
subgraph mac["π₯οΈ Your Mac β signed into iMessage"]
A["Agent<br/><code>npx imessage-bridge agent</code>"]
M["Messages.app"]
end
iMsg(["π¬ iMessage recipient"])
P -- "OAuth token" --> AAD
A -- "OAuth token" --> AAD
P == "send (AMQP 1.0) β Data Sender role" ==> SB
SB == "long-poll receive (AMQP 1.0) β Data Receiver role" ==> A
A -- "signed local helper" --> M
M -. "send" .-> iMsg
classDef az fill:#0078d4,stroke:#005a9e,color:#fff
classDef host fill:#f4f4f5,stroke:#a1a1aa,color:#18181b
classDef ext fill:#fff,stroke:#10b981,color:#065f46
class SB,AAD az
class P,A,M host
class iMsg ext
- Producer authenticates with
DefaultAzureCredentialand has only theAzure Service Bus Data Senderrole on the queue. - Mac agent authenticates the same way and has only the
Azure Service Bus Data Receiverrole. - Two distinct Azure AD identities, one role each β least privilege, no shared secrets, no Service Principals.
- The Mac only makes outbound calls β no inbound ports, no tunnels, no exposed surface.
- Service Bus tier: Basic. ~$0/month at our volume (no base fee, ~$0.05/M ops).
sequenceDiagram
autonumber
participant P as Producer
participant SB as Service Bus
participant A as Mac Agent
participant M as Messages.app
participant R as Recipient
P->>SB: send(payload, message_id)
Note over SB: durable, retry-friendly
A->>SB: receive (long-poll)
SB-->>A: message (peek-lock)
A->>M: osascript "send"
alt success
M-->>R: iMessage delivered
A->>SB: complete(message)
else automation helper fails
A->>SB: abandon(message) β retry
else bad payload
A->>SB: dead_letter(message)
end
This project is built on standards, not vendor primitives. The default deployment uses Azure Service Bus because it's a cheap, managed AMQP broker with first-class Azure AD auth β but the wire format and patterns are portable.
| Layer | Standard / Spec | Why it matters |
|---|---|---|
| Wire protocol | AMQP 1.0 (OASIS, ISO/IEC 19464) | Same protocol RabbitMQ, ActiveMQ Artemis, Solace, IBM MQ, AWS MQ, and Azure Service Bus all speak. Your queue is just a queue. |
| Auth | OAuth 2.0 + OpenID Connect (Microsoft Entra as the IdP) | No SAS keys, no PATs, no client secrets. Token-based, instantly revocable. |
| Phone format | E.164 | The same format Twilio, Telegram, WhatsApp, and SMS gateways all expect. |
| Message shape | JSON, AMQP message_id for idempotency |
Trivial to interop with anything. |
| Skill format | SKILL.md frontmatter + sections, as used by openclaw π¦ |
The whole skills/ folder drops cleanly into any openclaw runtime β install-mac, install-producer, send-message, doctor, logs are all reusable claws. |
The default flow uses azure-servicebus (an AMQP 1.0 client). To run against a different AMQP 1.0 broker (e.g. RabbitMQ, ActiveMQ Artemis, Apache Qpid), swap the client library β the producer/consumer logic stays the same. Roughly ~30 lines change, mostly imports and connection setup.
For full broker portability without swapping client libraries, run the producer behind a Dapr sidecar and use the pubsub building block. Dapr ships pluggable pubsub components for Service Bus, RabbitMQ, Kafka, NATS, Redis, AWS SNS/SQS, GCP Pub/Sub, and ~15 others β same producer code, change one config file to switch broker. A examples/dapr/ reference implementation is on the roadmap.
TL;DR: This isn't an Azure-only toy. Azure Service Bus is the default because it's the cheapest AMQP-with-AAD-OAuth broker on the market for low volumes (~$0/month at our scale). Everything else is standards.
Auth model: identity-only. No long-lived secrets, anywhere, ever.
- β
DefaultAzureCredentialeverywhere β backed byaz login(Azure AD user identity). - β Producer and consumer (Mac) are two distinct Azure AD users, each with only one RBAC role on the queue (Sender on the producer host, Receiver on the Mac). True least privilege.
- β
Tokens cached + refreshed by Azure CLI in
~/.azure. Revoke instantly viaaz logoutor by removing the role assignment. - β No Service Principals. No client secrets, no certificate-as-secret, no federated credentials with PATs.
- β No SAS connection strings. Anywhere.
- β No PATs. GitOps uses
gh auth login(OAuth web flow) only. - β No secrets committed. PRs are PII-scanned for phone numbers, emails, and key patterns.
Upgrade path β only these are acceptable when user identity isn't enough:
- Managed Identity (workload runs in Azure)
- Workload Identity Federation (workload outside Azure, federated to AAD without secrets)
- Azure Arc (bring the host into Azure as a managed resource)
See SECURITY.md for the full rule + threat model.
imessage-bridge/
βββ producer/
β βββ __init__.py
β βββ cli.py # enqueue CLI β runs anywhere
βββ mac/
β βββ agent.py # long-running consumer
β βββ send_applescript.py # osascript wrapper
β βββ requirements.txt
β βββ launchd/
β βββ com.imessage-bridge.agent.plist # LaunchAgent template
β βββ install.sh # render + bootstrap
β βββ uninstall.sh # bootout + remove
βββ infra/
β βββ azure-quickstart.md # az cli provisioning
βββ extensions/ # opt-in sibling packages β see extensions/README.md
β βββ dapr/ # Dapr pubsub variant (Redis / Service Bus / Kafka / β¦)
βββ .squad/ # Brady Gaster Squad config
βββ AGENTS.md # binding rules for human + bot agents
βββ README.md # you are here
βββ config.example.json
Want a different broker?
extensions/dapr/ships a Dapr-pubsub variant that runs over Redis / Azure Service Bus / Kafka / RabbitMQ / AWS SNS+SQS / GCP Pub/Sub via a config swap. Source-only for v0.2.x β clone the repo and followextensions/dapr/README.md.
PRs welcome. This repo runs the Brady Gaster Squad β every PR triggers squad validation (PII flag + schema check).
- Default branch:
main. Nevermaster. - DevRel agent owns this README. Improvements to docs are extra-welcome.
- Read
CONTRIBUTING.mdfor the PR flow.
MIT β see LICENSE.
π ABB