TLDR:
launchctl bootstrapfrom a project directory is NOT a persistent install. And leaving a retired.plistin~/Library/LaunchAgents/is how you grow a zombie.
I've been building Apollo — my AI automation system running on Claude Code — around a small fleet of macOS launchd daemons (background jobs that watch calendars, ingest call transcripts, rebuild memory indexes, and so on).
One of those was a pre-call prep agent.
The Setup
Shipped it live on May 13th.
Bootstrapped it straight from the project directory: launchctl bootstrap gui/$UID ~/Developer/Apollo/scanner/com.apollo.precall.plist.
Worked great. My MEMORY.md routing table said LIVE. launchd polls 5 calendars.
For about one day.
What Broke
A reboot happened sometime after May 14th.
The daemon silently vanished. No error. No log. Nothing.
Here's what makes this brutal: the pre-call prep agent's whole job is surfacing missing prep before you realize it's missing. When it dies, the output is just… quiet. Normal-looking quiet. Impossible-to-distinguish-from-working quiet.
I didn't notice for three weeks.
What Actually Happened
launchctl bootstrap gui/$UID /path/inside/your/project/foo.plist loads the job for the current boot only.
If the .plist doesn't also live in ~/Library/LaunchAgents/, it evaporates at the next reboot. macOS doesn't warn you. launchd doesn't warn you. Nobody warns you.
The project directory is your source-of-truth for git.
~/Library/LaunchAgents/ is your source-of-truth for what's actually running.
Those are two different directories. I'd been treating them as one.
The Other Side of the Same Coin
While debugging that, I found something else — a retired scanner daemon whose .plist was still sitting in ~/Library/LaunchAgents/ from May 19th.
I'd unloaded it. Never deleted the file.
It had RunAtLoad=true and StartInterval=3600. One reboot from resurrecting itself and silently burning hourly API calls in perpetuity. A zombie, just waiting.
Retiring a daemon, it turns out, is three steps — not one:
- Record WHY you retired it in the project memory, at retirement time. (When I asked Apollo weeks later what happened to the scanner, the answer required log forensics + git archaeology + semantic RAG search — and still came back partially reconstructed.)
- Unload AND delete the
.plistfrom~/Library/LaunchAgents/. Unloading alone leaves the body in the street. - Update any state docs that claim it's live. A "LIVE" entry that's been false for weeks poisons every session that reads it.
The Fix That Works
Two rules, symmetric:
- Installing: copy the
.plistto~/Library/LaunchAgents/, then bootstrap from THERE. The project copy is for version control. Verify the install withlaunchctl print gui/$(id -u)/<label>after a reboot — not just after bootstrap. - Retiring: unload + delete + document the why, all in the same session. Don't make retirement a two-step you finish "later." You won't.
Why This Matters to Me
Silent death is the worst failure mode for autonomous systems.
A daemon whose job is preventing a problem can fail completely — and look identical to success. The absence of missing prep looks exactly like prep that arrived on time. You never get an alert. You just… stop getting the benefit.
Reboot-verified installs. Documented retirements. Those aren't nice-to-haves. They're the only way to actually trust your automation.
P.S. If you're building any persistent macOS automation with launchd, do yourself a favor:
ls ~/Library/LaunchAgents/right now and audit what's in there. You might find a zombie.