A self-hosted iMessage bridge with a terminal UI. One Go binary, two roles:
- Server (
herald serve) — runs on a Mac. Reads~/Library/Messages/chat.db(SQLite, strictly read-only), resolves handles to contact names from the AddressBook databases, sends messages by driving Messages.app viaosascript, and exposes a small REST + WebSocket API. Meant to be reached only over your Tailscale tailnet. - Client (
herald, the default command) — a bubbletea TUI that talks to the server from Linux (over the tailnet) or from the Mac itself.
Everything is pure Go (modernc.org/sqlite, no cgo), so the macOS server binary
cross-compiles from Linux. A --mock backend runs the whole system on any OS with
fake conversations — useful for development and for trying the TUI.
Linux box (or the Mac itself) the Mac
┌──────────────────────────────┐ ┌────────────────────────────────────┐
│ herald (TUI) │ │ herald serve │
│ ┌────────────────────────┐ │ │ ┌──────────────┐ ┌────────────┐ │
│ │ internal/tui │ │ │ │ internal/ │ │ store/ │ │
│ │ bubbletea app │ │ │ │ server │──│ macdb │──┼──▶ ~/Library/Messages/chat.db
│ └───────────┬────────────┘ │ │ │ REST + WS │ │ (RO SQL, │ │ (read-only, 1s ROWID poll)
│ ┌───────────┴────────────┐ │ │ │ Bearer auth │ │ poll) │ │
│ │ internal/client │ │ HTTP │ │ │ ├────────────┤ │
│ │ REST + reconnecting WS│──┼──────┼──│ │──│ internal/ │──┼──▶ osascript → Messages.app
│ └────────────────────────┘ │ (over│ └──────────────┘ │ send │ │ (AppleScript send)
└──────────────────────────────┘ tail-│ │ └────────────┘ │
net) │ └── --mock: store/mock │
│ (in-memory, any OS) │
└────────────────────────────────────┘
Reading is chat.db polling (new message.ROWIDs every 1s); sending is AppleScript;
internal/typedstream decodes attributedBody blobs for messages whose text
column is NULL (common on modern macOS).
make build # builds ./bin/herald
./bin/herald serve --mock --listen 127.0.0.1:8787
# a token is generated on first run into ~/.config/herald/token:
cat ~/.config/herald/token
# in another terminal:
HERALD_TOKEN=<token> ./bin/herald login --server http://127.0.0.1:8787
./bin/heraldThe mock backend seeds six conversations and drips scripted incoming replies every 15–25 seconds so the TUI feels alive.
Cross-compile from Linux (or build on the Mac):
make build-mac # bin/herald-darwin-arm64 (Apple silicon) + -amd64 (Intel)
scp bin/herald-darwin-arm64 yourmac:/usr/local/bin/heraldSign it with a real Developer ID Application certificate, not ad-hoc (-s -).
macOS ties the Full Disk Access grant to the code signature; an ad-hoc signature's
hash changes on every rebuild, so the FDA grant silently stops matching and you're
back in System Settings after every single deploy. A Developer ID certificate keeps
a stable Team ID across rebuilds, so the grant survives:
codesign -s "Developer ID Application: <Your Name> (<TEAMID>)" \
--identifier com.ack-ventures.herald --options runtime --force /usr/local/bin/herald(security find-identity -v -p codesigning lists installed identities; create one
via Xcode → Settings → Accounts → Manage Certificates → + → Developer ID
Application, requires an active Apple Developer Program membership.)
This must run in a Terminal attached to the GUI console — not over SSH. The
private key requires interactive confirmation on first use per session, and macOS
flatly refuses the prompt for non-UI sessions (CSSMERR_CSP_NO_USER_INTERACTION),
no matter how many times you've clicked Allow before. Screen Sharing (VNC) counts
as console; plain ssh, even typed by you at the keyboard, does not — what matters
is which session the shell process is attached to, not who's physically at the
machine. make deploy builds and copies the binary (fine over SSH); make sign-restart does the codesign + launchd restart and must be run locally.
- Full Disk Access — needed to read
chat.dband the AddressBook databases (contact names); one grant covers both. System Settings → Privacy & Security → Full Disk Access →+→ press ⌘⇧G and enter the binary's absolute path (/usr/local/bin/herald). Works for bare, non-.app executables. Note: when run from Terminal the binary inherits Terminal's FDA — convenient for a first test, but the grant must be on the binary itself once it runs under launchd. - Automation (control Messages) — cannot be pre-granted. Run the server once
from the GUI console (not over SSH — over pure SSH the send fails with
-1743and no prompt ever appears), send yourself a test message, and click Allow on the "herald wants to control Messages" prompt. After that it works headless for that user's login session.
tailscale ip -4 # → 100.x.y.z
herald serve --listen 100.x.y.z:8787The token is auto-generated on first run into
~/Library/Application Support/herald/token (0600) — print it with
cat. It is never written to the server log. --token overrides, but prefer
$HERALD_TOKEN: flag values are visible to other local users in the
process list. Never expose the port beyond the tailnet — auth is a single
bearer token.
On the client machine:
HERALD_TOKEN=<token> herald login --server http://100.x.y.z:8787
heraldUse a per-user LaunchAgent (not a LaunchDaemon — Apple events can only reach
Messages inside the same user GUI session; the user must be logged in, fast-user-
switched-away is fine). ~/Library/LaunchAgents/com.ack-ventures.herald.plist:
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key><string>com.ack-ventures.herald</string>
<key>ProgramArguments</key>
<array>
<string>/usr/local/bin/herald</string>
<string>serve</string>
<string>--listen</string>
<string>100.x.y.z:8787</string>
</array>
<key>RunAtLoad</key><true/>
<key>KeepAlive</key>
<dict>
<key>SuccessfulExit</key><false/>
</dict>
<key>ThrottleInterval</key><integer>10</integer>
<!-- Log into a user-private dir, NOT /tmp: launchd creates log files
world-readable (umask 022), and /tmp is shared with every local
user. Umask 0077 is belt-and-braces for anything the process
creates. -->
<key>Umask</key><integer>63</integer> <!-- 077 octal -->
<key>StandardOutPath</key><string>/Users/YOU/Library/Logs/herald/out.log</string>
<key>StandardErrorPath</key><string>/Users/YOU/Library/Logs/herald/err.log</string>
<key>ProcessType</key><string>Interactive</string>
</dict>
</plist>mkdir -p ~/Library/Logs/herald
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.ack-ventures.herald.plist # load
launchctl kickstart -k gui/$(id -u)/com.ack-ventures.herald # (re)start
launchctl bootout gui/$(id -u)/com.ack-ventures.herald # unloadGotchas: KeepAlive + ThrottleInterval 10 also covers the login race where the
agent starts before the Tailscale interface is up (bind fails, launchd retries).
Prevent the Mac from sleeping (sudo pmset -a sleep 0 displaysleep 10) or the
bridge goes dark. After re-deploying the binary, re-run make sign-restart locally
at the console — with a Developer ID certificate the FDA grant should survive, but
verify it did (tail ~/Library/Logs/herald/err.log) rather than assuming.
| Key | Action |
|---|---|
Tab |
cycle focus: chat list → messages → compose |
j / k, arrows |
move in the list / scroll messages (when focused) |
Enter |
open selected chat (list) / send (compose) |
Ctrl+J |
insert newline in the compose box |
PgUp |
load older messages (pages backwards) |
/ |
filter the chat list (Esc cancels) |
n |
new message: search contacts by name (or type a raw phone/email), ↑/↓ pick, Enter confirms; then compose + Enter sends |
r |
manual refresh |
q, Ctrl+C |
quit (q only when compose is not focused) |
Status bar shows connection state (● connected / disconnected), server version,
and the last error in red until the next successful event.
All under /v1, all requiring Authorization: Bearer <token>. Non-2xx bodies are
{"error": "..."}.
| Endpoint | Description |
|---|---|
GET /v1/ping |
{"ok":true,"version":"...","backend":"macdb"|"mock"} |
GET /v1/chats?limit=50&offset=0 |
conversations, most recent first (limit ≤ 200) |
GET /v1/contacts?q=&limit=0 |
AddressBook contacts as {"name","handles":[...]}, ordered by name; handles are send-ready (+digits phones, lowercase emails). q filters by name/handle substring (digits-only queries match phones); limit ≤ 0 or absent returns all; [] when contacts are disabled/unavailable |
GET /v1/chats/{guid}/messages?before=0&limit=50 |
messages ascending by date; before pages backwards by rowid (0 = latest); guid must be URL-escaped (it contains ; and +) |
POST /v1/send |
{"chat_guid": "..."} or {"to": "+1555...", "service": "iMessage"} plus "text"; exactly one of chat_guid/to |
GET /v1/events |
WebSocket; JSON frames {"type":"message","message":{...}} for every new message plus {"type":"ping"} keepalives every 30s |
herald is designed for exactly one exposure model: the API port is reachable only over your Tailscale tailnet. Read this before deploying.
- The bearer token is full access. Anyone with it can read your entire
iMessage history and send messages as you. Treat it like a password:
generated with 128 bits of
crypto/rand, stored 0600 in a 0700 config dir, never written to logs. Rotate it by deleting the token file and restarting. - No TLS — Tailscale is the transport security. WireGuard encrypts the
path; the HTTP layer stays plaintext. Never bind to
0.0.0.0, a LAN address, or a public interface — pass--listen <tailscale-ip>:8787(default127.0.0.1for local-only use). If you can't use Tailscale, put the server behind a TLS-terminating proxy you control instead. - The Mac's attack surface grows. The server holds Full Disk Access (granted to the binary) and Automation control of Messages.app. Keep the binary code-signed with a stable Developer ID (see setup) so the grants stay tied to a build you recognize, and keep macOS updated.
- The database is read-only by construction. chat.db and the AddressBook
files are opened
mode=ro+query_only; there is no write path. Sending goes through AppleScript with all user input passed as argv (never interpolated into script source). - Deniability of local users. The token env var (
HERALD_TOKEN) is preferred over--tokenflags, which are visible inpsand shell history. launchd logs go under~/Library/Logs/withUmask 077, not world-readable/tmp. - Scope. Single user, single server, no multi-tenancy, no rate limiting beyond HTTP read timeouts. If your threat model includes hostile tailnet peers, this is not the right shape.
Report vulnerabilities privately via GitHub Security Advisories rather than public issues.
- No attachment transfer — attachments show as
⎘ name (mime)metadata only. - Cannot create new group chats — broken by macOS since Big Sur (
make new chatremoved from the AppleScript dictionary). Start groups from a phone; sending into existing groups works. New 1:1 threads vianwork. - SMS/RCS forwarding untested — SMS chats read fine from chat.db; sending to the SMS service requires Text Message Forwarding and is not battle-tested. AppleScript cannot reliably target RCS.
- macOS version notes — Sonoma/Sequoia/Tahoe all work for chat-guid sends (the sender never derives AppleScript service constants from guid prefixes, which is the thing that actually broke on Tahoe). Sequoia quirk: your own self-chat phone-number thread is not scriptable. Whether buddy-sends (new 1:1 threads) still work on Tahoe is unverified — test early.
- Reading is polling (1s) — near-real-time, not push. Tapbacks and edits appear
as rendered text (
Loved "...",(edited)); live tapback events are not pushed. - Contact names load once at startup — DM titles, unnamed-group titles, and
per-message sender names resolve against
~/Library/Application Support/AddressBook(override with--contacts DIR, disable with--contacts ""). Contacts added while the server runs aren't picked up until restart. Phone matching is exact digits, else last-10-digit suffix (NANP-centric; a contact saved without a country code still matches its E.164 handle). - Single token, no TLS — rely on Tailscale for transport security and never bind beyond the tailnet.
- The Mac must be awake, logged in, and signed into Messages.
make build # Linux/host build → bin/herald
make build-mac # darwin/arm64 + darwin/amd64 cross-compiles
make test # go test ./...
make vet # go vet ./...
make run-mock # build + serve --mock on 127.0.0.1:8787The installed command may point to a stale binary after source changes. Rebuild and install the current workspace binary, then restart any running TUI process:
command -v herald
make build
cp bin/herald ~/.local/bin/heraldLayout: internal/protocol (wire types) and internal/store (backend interfaces) are the contracts; macdb/mock implement store.Store, send/mock implement
store.Sender, server sits on top of both, client + tui consume the API.
Research notes on chat.db, typedstream, and AppleScript sending live in
docs/research/. Everything except the actual Mac send path is unit-tested;
--mock exercises the full server/client/TUI stack on any OS.
