Setting up Morse
Morse is one binary on one Mac. There is no account, no number and no service to sign in to. It reads the Messages database that macOS already keeps on disk, watches a single conversation in it, and replies through Messages the same way you would.
Install
macOS 14 or newer, on the machine you want to reach.
brew install juracich/tap/morse
morse setupsetup asks for two macOS permissions and will not work without them. Both are granted in System Settings and both are worth understanding before you click:
| Full Disk Access | Privacy & Security | Lets it read ~/Library/Messages/chat.db. This is your entire message history, so grant it deliberately. |
| Automation → Messages | Privacy & Security | Lets it send the reply. Without this it can read a command and run it but never tell you what happened. |
Then install it as a login agent so it survives reboots. It holds no ports open and makes no outbound connections of its own.
morse install # writes a LaunchAgent, starts it now
morse status # thread, last command, permissionsThe thread
Morse watches exactly one conversation: the one where you are both sender and recipient. On an Apple ID with iMessage this already exists whether or not you have used it, and setup finds it for you.
$ morse setup
Found your note-to-self thread:
you · isaac@icloud.com · iMessage
633 messages · last active today
Watch this thread? [y/N] y
✓ watching. Send ".ping" from your phone to test.Every other conversation on the machine is invisible to it. Morse filters on that one chat id at the query level, so messages from other people are never read into memory, never classified, and never logged.
If you would rather keep commands out of your personal scratchpad, add a second address to your Apple ID and point Morse at that thread instead with morse setup --thread you+morse@icloud.com.
Talking to it
A message is a command when it starts with a dot. Nothing else in the thread is looked at twice. The dot is the default because it lives on the iPhone letter keyboard, unlike a slash; change it with prefix in the config if you want something else.
| named action | .deploy web | Matched against morse.toml. Runs verbatim. |
| raw command | .run pnpm test | Runs in the default cwd. Always confirms first. |
| anything else | .why is the build slow? | Handed to whichever agent CLI you configured, with only the tools you allowed. |
| no dot | call the accountant | Ignored completely. No reply, no log entry, no model call. |
Replies are trimmed to fit a message. Long output is truncated with a one-line summary; send .more for the next chunk or .log for the path to the full run on disk.
Choosing the model
Whatever isn't a named action goes to an agent CLI on your own machine, signed into your own subscription. Two are built in and tested against real runs:
brain = "claude" # Claude Code, the default
brain = "codex" # OpenAI Codex CLIAnything else works through brain_command, which takes an argv list and treats whatever the tool prints as the answer. There is no per-vendor parsing, which is the point: a parser written against release notes rather than a real run is worse than none.
brain_command = ["gemini", "-p"] # prompt on stdin
brain_command = ["cursor-agent", "-p", "{prompt}"]
brain_command = ["ollama", "run", "llama4"]
brain_command = ["/Users/you/bin/ask.sh"] # your own scriptA {prompt} element is substituted; with none, the prompt is piped in on stdin, which is what most of these prefer and what sidesteps argv limits. It is an argv list rather than a command string on purpose, so nothing in it is word-split or glob-expanded. Gemini CLI, Cursor, opencode, Amp, aider and local models via Ollama all fit this shape, though only Claude and Codex are tested here.
Passthrough, if you want no dot at all
Set passthrough = true and the prefix rule is dropped entirely: every message in the thread becomes a prompt. It is the nicest way to use Morse and the least safe, so it is off by default and worth understanding before you turn it on.
The dot was doing two jobs. Separating commands from notes was the obvious one. The other was keeping Morse from reading its own replies as new questions, since a self-thread gives it no reliable way to tell its own text from yours. Without the prefix, that guard is gone and only the hourly reply budget stands between you and an agent talking to itself.
passthrough = true
replies_per_hour = 60 # the only hard stop left; never set it to 0
thinking_notice = true # says "thinking…" while the model runsIn passthrough the control words lose their dots too: stop, pause and resume are bare. If the budget is ever hit, Morse stops and pauses itself rather than continuing, on the assumption that hitting it means something has gone wrong.
Defining actions
Drop a morse.toml in a project root, or in ~/.morse/ for actions that apply everywhere.
[action.deploy]
match = ".deploy {target}"
run = "./scripts/deploy.sh {target}"
cwd = "~/Projects/juracich"
reply = "{stdout | last 3 lines}"
timeout = "10m"
[action.caches]
match = ".clear the caches"
run = "rm -rf ~/Library/Caches/*"
confirm = true{target} captures one word and is shell-escaped before substitution, so a command can never be injected through the message body. Multi-word capture is {target...} and is escaped the same way.
confirm = true holds the command and describes what it is about to do. It runs only on a .yes within five minutes, and expires silently otherwise.
Permissions
The agent starts with nothing allowed except the actions you declared. Anything broader is opt-in, in ~/.morse/config.toml.
prefix = "."
[allow]
read = ["~/Projects/**", "~/Downloads"]
write = ["~/Projects/juracich/**"]
shell = false # .run is refused entirely
net = false # no outbound calls from actions
[confirm]
always = ["rm", "git push --force", "pkill"]There is no remote administration surface, so this file is the only way the allowlist changes. Nobody can widen it with a message, including you.
Other machines
Only a Mac can read the thread, so exactly one machine listens. Everything else you own is reached through it. Prefix a command with .@name and the listener dispatches over SSH using a host you defined.
.@edge deploy web # runs on the Hetzner box
.@tower render walkthrough
.machines # list, with last-seenA machine that is offline queues nothing. You get an immediate edge unreachable rather than a command that fires hours later.
The listening Mac has to be awake and logged in. A sleeping Mac reads nothing, and commands sent while it slept are treated as stale on wake rather than run in a burst.
Stopping it
Send .stop to halt everything immediately. It is handled before matching and before any queued confirmation, so it works while a command is mid-flight.
.stop # halt everything now
.pause 2h # ignore commands until later
morse uninstall # remove the agent, the log and the configUninstalling leaves your Messages history exactly as it was. Morse only ever read from it.
How it really works
Three details matter enough to write down, because they are the reason this is harder than it looks and the reason it is safe.
Every self-message is stored twice. A single message you send yourself lands in chat.db as two rows with the same timestamp: the sent copy, and a loopback echo marked as received.
ROWID is_from_me is_sent destination_caller_id
364046 1 1 isaac@icloud.com
364047 0 0 mailto:isaac@icloud.comSo is_from_metells you nothing about who typed a message. Your command and Morse's own reply produce the same pair. Anything naive enough to answer received rows would answer itself forever. Morse takes the sent row, discards the echo, and acts only on the dot prefix, which its own replies never carry. Two independent guards, either one sufficient.
The message text is usually not in the text column. Modern Messages writes an attributedBodyblob in Apple's typedstream format and leaves text null. Morse decodes the blob directly rather than shelling out, which is why it does not need Messages.app running to read a command, only to send the answer.
Finding the account to send from is not a one-liner. Every AppleScript example on the internet opens with 1st account whose service type = iMessage. On a normal Mac that fails, and so does reading the property across all accounts at once:
1st account whose service type = iMessage -1728 can't get
service type of every account -10000 handler failed
service type of account 5 iMessage ✓A Mac signed into iMessage, SMS relay and RCS carries six accounts, and several of them cannot answer the property at all, which poisons any query that touches every one. Reading it from a single account works. So Morse walks the accounts one at a time inside a try and takes the first enabled iMessage one. The tidier version type-checks, reads correctly, and silently never sends.
Not built yet
Being straight about the beta: attachment replies, scheduled standing orders, per-action rate limits, and the SSH dispatch to non-Mac machines are designed but not shipped. Reactions are a maybe, since Messages has no scripting API for them and the only route is a named accessibility action, which Apple can move at any point.