iMessage Triage
An always-on agent that answers my texts in my own voice — but only while a Focus mode is on.
## the problem
When I am heads-down, the texts do not stop, and I only had two bad options. Ignore people for hours, which is rude and means I forget to circle back, or break focus every time the phone buzzes, which defeats the entire point of focus. But most of what comes in does not actually need me. It needs an answer. I wanted something that could clear the easy stuff in my own voice and only pull me out for the messages that genuinely need a human — and I wanted it off by default, because letting a model speak as me is not something I will leave running unless I have explicitly switched it on.
A dumb autoresponder would have been worse than silence. To be worth running, it had to sound like me, adjust to who it was talking to, disclose that it was an assistant, and — most importantly — know when not to answer at all. It could never commit me to a meeting, a price, or a promise, and it could never text the wrong person. Those are not features you hope a prompt remembers. They are rules that have to hold every single time.
So the real problem was never the wording. It was building a background process that behaves against macOS: finding where the message text is actually stored, reading an on/off signal that will not stay readable to a daemon, sending through an app without opening an injection hole, and getting a language model to return a machine-readable decision on every message instead of a friendly paragraph. The design goal was to push the dangerous rules down below the model, into code, where nothing it generates can override them.
## what I built
A small Python process on a Mac mini, kept alive by launchd, that polls the local Messages database read-only every few seconds. While a Focus mode is on, every new inbound text runs a gauntlet of filters, and whatever survives goes to Claude, which returns exactly one decision: reply, escalate, or ignore. Flip the Focus off and it stands down, drops anything pending, and advances its baseline so it never blasts the backlog that piled up while it was idle.
Two rules live in the dispatch layer, below the model, because they were too important to trust to a prompt. It acts only while the Focus is on — a check I later had to make fail-closed, after a launch bug I describe below — and it waits a random 30 to 120 seconds before every send, long enough to feel human, and the same window debounces a rapid burst of texts into a single considered reply instead of five robotic ones. A hard never-text list blocks specific numbers on every code path, reply and escalation alike, matched on the last ten digits so country code and formatting cannot sneak around it.
The model's freedom is bounded by a strict JSON contract: an action, the message to send, a priority, and a one-line summary for my handoff log. reply means it handled the message fully. escalate means it pings me and still sends a warm holding reply so the person is not left hanging, without committing me to anything. ignore means do nothing. Any error or unparseable response fails safe to escalate. It is an internal tool with no public URL.
## how Claude was actually used
- 01
Wrote the persona as a spec, not a vibe
I had Claude Code write the system prompt as a real document: how to mirror my cadence and punctuation, when to disclose itself, the exact line between what it handles and what it escalates, and a short list of hard boundaries it can never cross. The persona is a specification the runtime model follows on every message, not a loose instruction to act like me.
- 02
Turned the model's output into a contract
Instead of free text, Claude defined a strict JSON decision schema — action, message, priority, summary — so the Python layer can act deterministically on whatever the model returns. It also wrote a tolerant parser that extracts the first JSON object and ignores anything after it, because even when told to return only JSON, a model occasionally appends a stray trailing line that would otherwise crash a naive parse.
- 03
Reverse-engineered the Messages database
Claude Code worked out that modern macOS often stores the message body in an archived attributedBody blob rather than the plain text column, and wrote a decoder that pulls the string out and handles both the one-byte and two-byte length-prefix forms. The database is opened read-only so the bot never fights the Messages app for the write lock.
- 04
Got the focus signal wrong, then fixed it the right way
The on/off flag syncs through iCloud so I can flip it from my phone, but a background process usually cannot pull the evicted iCloud file back down, so reads fail at random. My first fix cached the last good read and fell back to it — which sounds safe until the file is unreadable while the real state is off. Falling back to the last-known on meant it replied when it should have stayed silent. The fix was to invert the default to fail-closed: it returns on only on a fresh, positive read, and treats any failure, timeout, or unreadable file as off. The dangerous mistake is texting people while the focus is off, so when in doubt it stays quiet.
- 05
Hardened the send path against the inbound text
Replies go out through AppleScript, which means a hostile incoming message could try to inject commands. Claude passed the message to the script as an argument instead of building it into the script source, so a malicious text is inert data, not code. The same send function checks the never-text list first, so a blocked number cannot be messaged on any path.
- 06
Built the filter gauntlet so it answers the right people
Claude wrote the layers that drop group threads, automated senders like verification codes and delivery and marketing short-codes, and any number with no prior message history — strangers are left for me to handle myself. Surviving threads are rebuilt into clean, strictly alternating turns before the model ever sees them, because a raw phone thread is messy.
- 07
Made it fail toward me, and disclose itself
Every uncertain or broken path ends at escalate, never at an unsupervised send. And on the first reply in a thread the model discloses that it is my AI assistant, exactly once, then talks normally — and if anyone asks it outright whether it is a bot, it always says yes.
- 08
Ran it under launchd, not a terminal
Claude documented the launchd setup that keeps it alive (run at load plus keep alive) and the macOS permissions a background job actually needs — Full Disk Access to read the database and Automation access to send through Messages — which are granted to the daemon's own binary, not to Terminal. A small smoke test checks the API path end to end.
- 09
Shipped it, then caught it running when it should have been off
Early on it only ran while I kept a terminal open, so on and off meant starting and killing it by hand. Moving it under launchd let it run unattended — and running it the way it actually lives is exactly what surfaced the real failure. With a focus I had it gated on, switching that focus off did not stop it, and it kept replying for about two days before I noticed. What it sent was contained — short, lowercase holding notes and escalations to me, more 'haha yeah i'm heads-down today, i'll get back to you tonight' than anything that committed me to a plan — but it should not have happened at all. Once I knew where to look, the fix took under five minutes.
## stack
## results (the verifiable kind)
- ✓Internal tool, no public deployment. It runs as a launchd background job on a Mac mini and sends through the Messages app.
- ✓It shipped with a real bug. For about two days after launch, turning off the focus it was gated on did not stop it, and it kept replying. It is now fail-closed: it sends only on a fresh, positive read that the focus is on, and stays silent on any failed or uncertain read. When the focus is off it drops anything pending and advances its baseline, so it never replays the backlog when the focus comes back on.
- ✓It never auto-replies to a number with no prior message history, never to group threads, and never to automated senders such as verification codes, delivery and account alerts, or marketing short-codes — all filtered before the model is ever called.
- ✓Specific numbers are hard-blocked on every send path, matched on the last ten digits, so country code and formatting cannot get around the block.
- ✓Every send is delayed a random 30 to 120 seconds, and that same window collapses a rapid burst of texts into one reply instead of several.
- ✓On any model error or unparseable response it escalates to me instead of sending — verified in the dispatch code, which routes every failure to a heads-up rather than a guess.
- ✓It reads the Messages database read-only, and inbound text is passed to AppleScript as an argument rather than built into the script, so a hostile message cannot inject commands.
## what I learned
- →With a tool like this, the model is the easy part. The hard work was the plumbing around it: where macOS hides the message text, an iCloud signal that will not stay readable to a background process, and a send path that could be attacked.
- →Put the rules you cannot afford to get wrong below the model, not inside the prompt. 'Only run while the Focus is on' and 'never text these numbers' are enforced in Python, so no clever inbound message can talk its way past them.
- →Fail toward the human. Every unknown or broken path ends at escalate, never at guess-and-send. For an agent that speaks as me, a missed reply is cheap and a wrong send is expensive.
- →Disclosure-once is a small detail that matters a lot. Say it every message and it reads like a robot; say it never and it is a lie. Once per thread is the line.
- →A reply agent is only as good as the voice samples behind it. Until real examples of how I actually text are loaded, it runs on a sensible polished-casual default, not a true match — a known gap, documented rather than dressed up.
- →Fail-closed is not optional for an agent that talks as me. My first 'safe' fallback returned the last-known state when it could not read the flag — the one default I could not afford, because the last-known state was on while the real one was off. The correct default is silence: act only on a fresh, positive confirmation, and treat any uncertainty as off.
- →Shipping surfaced what my testing did not. The failure only appeared once it ran unattended under launchd, the way it really lives, not while I was watching it from a terminal. I caught it about two days in and fixed it in under five minutes once I knew where to look — the cost was in not noticing sooner, not in the repair.
$ follow --the-build
Watch it happen, don't take my word for it
Every build on this site gets documented as it happens — the prompts, the dead ends, the results. No course at the end of this funnel. There is no funnel.
follow on x →