One Go module, one binary, two roles:
- Server (
herald serve) — runs on a Mac. Reads~/Library/Messages/chat.db(SQLite, read-only), sends messages by shelling out toosascript, and exposes a small REST + WebSocket API. Intended to be reached only over Tailscale.--mockswaps the Mac backend for an in-memory fake so the whole system runs on Linux. - Client (
herald, the default command) — a bubbletea TUI that talks to the server. The same binary/TUI runs on Linux (over the tailnet) and on the Mac itself (pointing at localhost).
Everything is pure Go (modernc.org/sqlite, no cgo) so the darwin/arm64 server binary
cross-compiles from Linux: GOOS=darwin GOARCH=arm64 go build.
cmd/herald/ main: flag parsing, config, wiring (integration)
internal/protocol/ wire types — WRITTEN, DO NOT MODIFY
internal/store/ Store/Sender interfaces — WRITTEN, DO NOT MODIFY
internal/store/macdb/ chat.db reader implementing store.Store
internal/store/mock/ in-memory Store + Sender for dev/demo
internal/typedstream/ attributedBody → plain text extractor
internal/send/ osascript Sender implementation
internal/server/ HTTP + WebSocket API on top of Store/Sender
internal/client/ Go client for the API (REST + reconnecting WS)
internal/tui/ bubbletea app on top of internal/client
docs/research/ reference docs for chat.db / typedstream / sending
Rules for implementers:
- Touch only your own package (plus its
_test.gofiles).internal/protocolandinternal/store/store.goare frozen contracts. - Do not edit
go.mod/go.sum. Available deps: bubbletea v1, bubbles, lipgloss, modernc.org/sqlite, github.com/coder/websocket, stdlib. Need something else → say so in your report instead of adding it. - Your package must compile alone (
go build ./internal/<pkg>) and have unit tests for the logic that can be tested without a Mac. - Read the relevant
docs/research/*.mdbefore writing code.
Config dir: os.UserConfigDir()/herald/ (i.e. ~/.config/herald on Linux,
~/Library/Application Support/herald on macOS).
Server (herald serve):
--listen(default127.0.0.1:8787) — bind address. README documents binding to the Tailscale IP.--db(default~/Library/Messages/chat.db) — chat.db path.--mock— use the mock backend (works on any OS).- Token: read from
<configdir>/token; if absent, generate 32 hex chars, write the file (0600), and log it once.--tokenoverrides.
Client (herald):
--serverURL and--token; defaults read from<configdir>/client.json({"server_url": "...", "token": "..."}). On first run with flags provided, offer nothing fancy — just use flags;herald login --server URL --token Twrites client.json.
GET /v1/ping→protocol.PingResponse.GET /v1/chats?limit=50&offset=0→protocol.ChatsResponse. Ordered by last activity desc. limit capped at 200. Each chat carriesunread_countandlast_unread(permessage.is_read; tapbacks, event rows, and retracted messages excluded). is_read flips only when the chat is read on an Apple device — the server never writes chat.db — so clients keep their own read watermark and flag a chat only whenunread_count > 0andlast_unreadpostdates it. Each chat also carriesjunk(Apple's junk bucket:chat.is_filtered = 2orchat.is_blackholed) andunknown_sender(no participant resolves to a contact; without a contact source,chat.is_filtered = 1).junkwins: a junk chat is never alsounknown_sender.categoryis"transactions"or"promotions"when iOS's SMS filter tagged the sender — recovered from the handle suffix it bakes into chat.db identifiers ((smsft…)/(smsfp…), e.g.86434(smsft_fi)); display names strip the suffix.GET /v1/contacts?q=&limit=0→protocol.ContactsResponse. Contacts from the server's contact source (AddressBook), ordered by name, handles in send-ready form (usable asSendRequest.To).qfilters by name/handle substring, case-insensitive; a digits-only reading ofqalso matches phone handles.limit<= 0 or absent = no cap. Backends without contacts return{"contacts":[]}, not an error.GET /v1/chats/{guid}/messages?before=0&limit=50→protocol.MessagesResponse. Ascending by date within the page;beforepages backwards by RowID (0 = latest). guid is URL-escaped by the client (it contains;and+).POST /v1/sendbodyprotocol.SendRequest→ 200protocol.SendResponseor 4xx/5xxprotocol.ErrorResponse. Validation: text non-empty (whitespace-only counts as empty); exactly one of chat_guid / to.GET /v1/events— WebSocket (coder/websocket). Server → client frames areprotocol.EventJSON text messages: every new message fromStore.Watch(fan-out to all connected clients), plus{"type":"ping"}every 30s. Client frames are ignored. Auth via the same Bearer header on the handshake request.- Unknown auth → 401 before any handler logic. Non-2xx bodies are always
protocol.ErrorResponse.
server.New(store store.Store, sender store.Sender, token, version, backendName string) *Server
with func (s *Server) Handler() http.Handler and func (s *Server) Run(ctx, listenAddr) error.
One goroutine consumes Store.Watch and fans out to subscribers; WS write errors
drop that subscriber. Slow subscribers must not block the fan-out (small buffered
channel per subscriber, drop-oldest or disconnect on overflow — disconnect is fine,
client reconnects). Log requests + watch errors with log/slog.
macdb.Open(path string) (*DB, error) implementing store.Store. Follow
docs/research/chatdb.md exactly (dates, WAL read-only open, queries). Text
resolution: message.text when non-empty else typedstream.ExtractText(attributedBody).
Render tapbacks per research doc. Watch = poll MAX(message.ROWID) every 1s
(config const), emit new rows joined to their chat. Never write to the db;
open with mode=ro and query_only pragma. Unit tests: build a tiny fixture
chat.db in-memory (create the needed tables yourself in the test, insert rows,
point the reader at it) — schema DDL for fixtures is in the research doc.
send.NewOSAScript() store.Sender. Follow docs/research/sending.md: pass text and
target via on run argv (no string interpolation), map common osascript failures to
readable errors, support chat-guid sends and direct-handle sends. Runs osascript
via exec; on non-darwin return a clear "server must run on macOS" error at Send time
(construction succeeds so the package is testable anywhere). Unit-test the argv
construction and error mapping with a fake exec (inject the command runner).
mock.New() *Mock implementing both store.Store and store.Sender. Seed ~6 chats
(mix of DMs/groups) and a few dozen messages with believable timestamps. Send
appends the outgoing message and emits it on Watch; a background goroutine (started
by Start(ctx), stopped by ctx) emits a scripted incoming reply into a random chat
every 15–25s so the TUI demo feels alive. Deterministic under test (allow injecting
the tick interval).
client.New(baseURL, token string) *Client with methods mirroring the API:
Ping, Chats(limit, offset), Messages(guid, before, limit), Send(req),
and Events(ctx) (<-chan protocol.Event, <-chan error) — a WebSocket consumer that
reconnects with capped exponential backoff (1s→30s) forever until ctx is done, and
surfaces connection state changes as synthetic events: {"type":"_connected"} /
{"type":"_disconnected"} (client-side only, not from the wire). Unit tests against
httptest.Server + a real coder/websocket handler.
tui.Run(c *client.Client) error. bubbletea v1 + bubbles + lipgloss. Layout:
┌─ chats (30 cols) ─┬─ messages ────────────────────────────┐
│ ▍Mom 12:01 │ scrollback viewport │
│ Group: fam 11:48 │ [Mom] hey │
│ +1555… Tue │ [me] omw │
│ ├───────────────────────────────────────┤
│ │ compose textarea (3 lines) │
└───────────────────┴───────────────────────────────────────┘
status bar: ● connected · server · keys hint
- Chat list: bubbles/list, filterable (
/), shows display name + relative time. - Messages: viewport; render
[sender] textwith lipgloss (me vs them colors, timestamps on hover-less TUI: dim HH:MM prefix). Attachments render as⎘ name (mime). Wrap to width. - Compose: bubbles/textarea, 3 lines. Enter sends; Shift+Enter is not reliably detectable in terminals, so: Enter sends, Ctrl+J inserts newline.
- Keys: Tab cycles focus (list ↔ compose), j/k or arrows scroll when viewport
focused, PgUp loads older page (before=oldest RowID),
nprompts for a new recipient handle (simple textinput modal) then sends viato,rmanual refresh,fcycles the sender bucket (all-but-junk → known → unknown → transactions → promotions → junk, skipping empty buckets; junk, unknown-sender, and categorized rows render dim in the mixed view),q/ Ctrl+C quits (q and f only when compose not focused). - Live events: new message in the open chat appends + autoscrolls (only if already
at bottom); other chats bump in the list with a
●unread marker (cleared on open)._connected/_disconnectedflip the status-bar indicator. - Unread markers: seeded at startup and on every chat-list fetch from the
server's
unread_count/last_unread, gated by a per-chat read watermark persisted in<configdir>/readstate.json(opening a chat, or a message arriving while it is open, advances the watermark). The watermark exists because the server cannot mark chat.db read: without it, chats read only in the TUI would be re-flagged by every fetch and every restart. Reading a chat on an Apple device (is_read flips →unread_count0) clears its marker here on the next fetch. - Errors (send failure, fetch failure) show in the status bar, red, until next event.
- Must handle terminal resize.
Testability: keep Update/View pure over an injected client interface
(define a narrow api interface in the tui package so tests can fake it);
test key flows with bubbletea's Program-less Update calls (table tests on Msg →
Model transitions). No golden-file rendering tests required.
Subcommands: none (default = TUI), serve, login, version. Plain flag
package with a subcommand switch, no cobra. Wire: mock vs macdb by flag;
send.NewOSAScript() vs the mock as Sender. Graceful shutdown on SIGINT/SIGTERM.
Cross-package compilation depends on these exact signatures; do not deviate:
// internal/typedstream
func ExtractText(b []byte) string // never panics; "" when unparseable
// internal/store/macdb
func Open(path string) (*DB, error) // *DB implements store.Store
// internal/store/mock
func New() *Mock // implements store.Store AND store.Sender
func (m *Mock) Start(ctx context.Context)
// internal/send
func NewOSAScript() store.Sender
// internal/server
func New(st store.Store, snd store.Sender, token, version, backend string) *Server
func (s *Server) Handler() http.Handler
func (s *Server) Run(ctx context.Context, addr string) error
// internal/client
func New(baseURL, token string) *Client
func (c *Client) Ping(ctx context.Context) (protocol.PingResponse, error)
func (c *Client) Chats(ctx context.Context, limit, offset int) ([]protocol.Chat, error)
func (c *Client) Messages(ctx context.Context, chatGUID string, beforeRowID int64, limit int) ([]protocol.Message, error)
func (c *Client) Send(ctx context.Context, req protocol.SendRequest) error
func (c *Client) Events(ctx context.Context) (<-chan protocol.Event, <-chan error)
// internal/tui
func Run(c *client.Client) errorGo 1.25. gofmt clean, go vet clean. Errors: wrap with %w, messages
lower-case. Logging: log/slog (server side only; the TUI must never write to
stdout/stderr while running — route errors into the UI). No global state.
Contexts flow from main. Keep it small: this is a personal tool, not a product —
prefer the direct implementation over abstraction, but never at the cost of
correctness around concurrency or the frozen contracts.