The only genuine PII that slipped through is the personal phone number in the SQL gate condition. Everything else — dial.sh, Telnyx, ElevenLabs, Apollo, all technical numbers — stays per the rules. Here is the cleaned post:


TLDR: Texting "call me" to my AI agent now rings my actual phone. Getting there required three non-obvious decisions I'll make again on every agent I build.

the setup

I wanted Apollo (my AI agent) reachable from anywhere — not just the terminal, not just my laptop.

So I built an iMessage bridge: a Python listener that polls ~/Library/Messages/chat.db (the SQLite database Messages.app keeps locally) every 5 seconds, finds new messages in my Note-to-Self chat (the special iMessage thread where you message yourself), and dispatches them to Claude.

That part worked.

The next ask: text "call me" and have Apollo actually phone me back — via Telnyx (my telephony provider) and ElevenLabs TTS.

That part needed some thinking.

the trust problem comes first

The moment an agent listens on a live message surface, you have a security problem.

Anyone who has my number could — in theory — drive my agent. So the very first filter isn't "does this look like a command?" It's: is this message from me, in my Note-to-Self chat, over iMessage?

The listener gates on chat_identifier == +1XXXXXXXXXX AND is_from_me = 1 AND service = 'iMessage'. All three conditions. Not two. If any one fails, the message is dropped — silent, no reply.

Without this, the bridge is an open endpoint to anyone who texts me.

the self-reply loop

Here's a fun one.

Apollo sends a reply via AppleScript → Messages.app → Note-to-Self. That message lands in chat.db. The listener polls 5 seconds later, finds a new message in Note-to-Self, and dispatches it. Apollo replies to itself. Forever.

The fix is a ZWJ (zero-width joiner — a hidden Unicode character, U+200D) prepended to every outbound message. The listener filters any message starting with ZWJ before dispatch. The agent never sees its own output.

Unglamorous. Completely necessary.

intent vs. token — the decision that stuck

For my CONFIRM path — where Apollo queues a staged outbound draft and waits for me to approve — I use a hard trigger token. One secret word. You either send it exactly or nothing fires.

I almost did the same thing for /call.

But "call me" is a human request. "Call me when you're done." "Can you ring me on the other number?" "Give me a call." All of those should work. A hard token kills the whole point. So /call dispatch runs a full LLM intent-detection round-trip: the model reads the current message, decides if it's asking for a phone call, sanitizes the payload, and fires dial.sh.

Phone rings ~10–30 seconds after I text.

One more gate I added and won't remove: the call always goes to my number. The iMessage surface cannot override the --to target. A remote surface that can redirect who gets called is a remote-controlled phone. HARD no.

why this matters to me

The rule I'll carry everywhere now: natural language for the convenient path, a hard token for the irreversible one.

/call intent → LLM. The outbound CONFIRM → one secret word.

It sounds obvious once you write it down. But I'd been treating all actions the same — and the moment you're building agents that send messages, make calls, or move money, that assumption WILL bite you.

Irreversible actions get a hard gate. Quality-of-life features get natural language. That line isn't arbitrary — it's the difference between a useful agent and a liability.