TLDR: Don't build a long-lived AI agent process. Spawn one subprocess per message, return text, die. Decouple everything else.
the setup
I wanted Apollo — my personal AI agent — reachable from anywhere.
Not just at my desk. From my iPhone, waiting for coffee, on the road. From any device, any time.
The solution was simpler than I expected.
Every iMessage I send to myself via Note-to-Self gets intercepted by a chat.db poller with a ROWID watermark. That poller triggers a dispatcher. The dispatcher does exactly one thing:
spawn: claude -p "<message>" --allowedTools ...
wait for output
AppleScript-send the reply back
That's the whole architecture. Stateless. Each message spawns a fresh claude CLI subprocess, returns its text, and dies. No long-lived session. No hanging process. Clean isolation.
All 10 steps shipped in one evening. The plan was saved to the project directory with an auto-generated codename — maybe my favorite I've ever seen on a project file.
the catch
Here's the thing about stateless: every call starts completely blank.
Which means memory doesn't exist by default. Every conversation starts fresh.
So I bolted on a 3-tier file-based memory — a MEMORY.md routing table, per-topic markdown files, and on-demand semantic search. The agent reads what it needs at call time. Statefulness lives in files, not in the process.
It's a tradeoff, not a flaw. Clean isolation is worth the extra work.
where it broke — my friend's version
When my friend asked me to build him the same thing, I handed him the pattern.
His iMessage listener went completely silent.
A test text — just "what's up" — logged in the terminal. The agent saw it. But it never replied.
We dug in. The root cause: my friend's new listener was coupled to his existing email-processing agent. Two long-running processes, fighting over the same surface. The listener tried to fire, got tangled, and died quietly.
I had done this right in Apollo without realizing it — the iMessage listener lives in its own launchd (macOS background job runner) job, its own log file, completely isolated from the hourly inbox scanner. Different cadence. Different process. Never wired together.
my friend had wired them together.
the fix
We rebuilt his agent as a general-purpose chat agent that runs email-processing as an on-command script — not a background process. User types "process emails," the agent runs the script, returns results.
One listener. One job. Nothing coupled.
Also recommended a dedicated Mac Mini for continuous operation — my friend's laptop goes offline whenever he travels (he's heading to Australia). Lesson: your always-on agent needs always-on hardware.
why this matters to me
The word "stateless" sounds like a limitation. It's actually the whole point.
One process per message. Return text. Die. No shared state, no ghost processes, no silent failures because two things are fighting over the same resource.
The memory, the email logic, the tools — all of it attaches to the message, not to the process.
The one place this breaks is when you fuse something long-lived onto the listener and think it'll hold.
It won't.