TLDR: launchctl bootstrap from a project directory is NOT a persistent install. And leaving a retired .plist in ~/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:

  1. 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.)
  2. Unload AND delete the .plist from ~/Library/LaunchAgents/. Unloading alone leaves the body in the street.
  3. 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 .plist to ~/Library/LaunchAgents/, then bootstrap from THERE. The project copy is for version control. Verify the install with launchctl 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.