# My Outreach Schedule Got Rewritten to 2:31 AM Without Me: Building a launchd Guard That Self-Heals in Under 10 Minutes

> Source: <https://dev.to/bokuwalily/my-outreach-schedule-got-rewritten-to-231-am-without-me-building-a-launchd-guard-that-self-heals-4bii>
> Published: 2026-09-03 00:00:06+00:00

Six months after a layoff left me with zero take-home pay, I had built an autonomous Claude Code setup that now brings in a steady ¥1.2M a month. The whole thing rests on one assumption: outbound sales automation that runs at midnight, on weekends, without me touching it. I took "never stops" for granted right up until the early hours of August 23, 2026.

When solo developers want to grow revenue, most jump straight into "working faster." Send DMs quicker, process bigger lists, write copy with a higher reply rate. All of that matters. But somewhere past ¥600K a month, the bottleneck stopped being the speed of the work and became **the robustness of the environment**.

My outreach (sales DMs) is fully automated by four LaunchAgents.

| Agent | Sends per day | Schedule |
|---|---|---|
| com.lily.outreach-ig | 8 | 8:20–22:20 (every 2 hours) |
| com.lily.outreach-th | 7 | 9:38–21:38 (every 2 hours) |
| com.lily.followers-outreach | 8 | 8:35–22:35 (every 2 hours) |
| com.lily.outreach-yt | 6 | 10:52–20:52 (every 2 hours) |

That's 29 runs a day, firing automatically every time the Mac is up, with no input from me. As long as this runs normally, I can focus on content production.

The problem: **these configuration files (plists) got rewritten from outside**. That was a real incident.

It's right there in the comment at the top of the script.

``` php
# 2026-08-23 12:04 に outreach-ig が 8回/日 -> 3回/日(2:31/10:31/18:31)、
# outreach-th が 7回/日 -> 3回/日 に一斉に書き換えられ、
# 深夜帯に営業DMを送る設定になっていた。
```

(Translation: at 12:04 on 2026-08-23, outreach-ig was changed from 8 runs/day to 3 runs/day at 2:31/10:31/18:31, and outreach-th from 7 runs/day to 3 runs/day, all at once, leaving the setup sending sales DMs in the middle of the night.)

It wasn't just that 8 runs got cut to 3. The times had shifted to include a 2:31 AM slot. An account that keeps sending DMs in the dead of night gets flagged by platform spam detection. Worst case, the account is frozen, and the sales lists and followers I'd built up are gone.

I never identified the culprit. Maybe some script I'd installed at the time, maybe a side effect of a system update. But the important thing **isn't finding the culprit. It's building a mechanism that detects the change immediately and reverts it**.

"Fix it when I notice" is over. The machine fixes it before a human notices.

outreach-ig's normal schedule is 8 runs a day. Drop that to 3 and daily touchpoints fall by 62.5%. Factoring in my DM-to-meeting conversion rate, leaving the schedule broken for just half a day wipes out dozens of meeting opportunities on a monthly basis.

On top of that, when a 2:31 AM send is detected, the risk of the account being flagged isn't a vague "that seems bad." It's a concrete risk spelled out in each platform's API terms of service. Repeated unintended late-night sends drag down the account's reputation score in short order.

So instead of "fix it later," I designed the solution as **"automatically revert within 10 minutes."**

```
┌───────────────────────────────────────────────────────────┐
│  macOS launchd                                            │
│                                                           │
│  ┌─────────────────────┐  StartCalendarInterval          │
│  │ outreach-ig.plist   │  8:20, 10:20, 12:20 ...  ──────►│ browser-slot.sh
│  │ outreach-th.plist   │  9:38, 11:38, 13:38 ...  ──────►│ run-lane.sh
│  │ followers-outreach  │  8:35, 10:35, 12:35 ...  ──────►│ （営業DM送信）
│  │ outreach-yt.plist   │  10:52, 12:52, 14:52 ... ──────►│
│  └─────────────────────┘                                  │
│           ▲ 書き換え検知 → 即時復元                        │
│           │                                               │
│  ┌─────────────────────────────────────────────┐          │
│  │ outreach-schedule-guard.plist               │          │
│  │   StartInterval: 600（10分ごと）            │          │
│  │        │                                   │          │
│  │        ▼                                   │          │
│  │ outreach-schedule-guard.sh                 │          │
│  │   1. plist を python3+plistlib で読む       │          │
│  │   2. SPECS の期待値と実値を比較             │          │
│  │   3. 差異あり → forensicログ + 復元 + reload│          │
│  └─────────────────────────────────────────────┘          │
└───────────────────────────────────────────────────────────┘
```

(In the diagram: "営業DM送信" = sends sales DMs; "書き換え検知 → 即時復元" = tamper detected → immediate restore; "10分ごと" = every 10 minutes; steps 1–3 = read the plist with python3+plistlib, compare expected values in SPECS against actual values, and on mismatch write a forensic log, restore, and reload.)

The watchdog (guard) is itself driven by its own plist (com.lily.outreach-schedule-guard.plist). By handing even the "watchdog that watches the watchdog" to launchd, I delegate the script's own stop/restart/crash resilience to the OS layer.

The core of `outreach-schedule-guard.sh`

is that the monitored targets are declared as a string array of **label:minute:hour-list**.

```
SPECS=(
  "com.lily.outreach-ig:20:8,10,12,14,16,18,20,22"
  "com.lily.outreach-th:38:9,11,13,15,17,19,21"
  "com.lily.followers-outreach:35:8,10,12,14,16,18,20,22"
  "com.lily.outreach-yt:52:10,12,14,16,18,20"
)
```

Read `com.lily.outreach-ig:20:8,10,12,14,16,18,20,22`

as "outreach-ig's StartCalendarInterval should be 8 entries at minute 20 with Hour=8,10,12,14,16,18,20,22." To add a new outreach lane, you add one line to the SPECS array and it's monitored.

To read `StartCalendarInterval`

from the XML plist, I chose Python's standard library `plistlib`

over `/usr/libexec/PlistBuddy`

or hand-rolled XML parsing, and for a reason.

``` python
actual="$(python3 - "$plist" <<'PY'
import plistlib, sys
try:
    with open(sys.argv[1],'rb') as f: d = plistlib.load(f)
    rows = d.get('StartCalendarInterval') or []
    if isinstance(rows, dict): rows = [rows]
    print(','.join(f"{r.get('Hour')}:{r.get('Minute')}" for r in rows))
except Exception as e:
    print('ERR')
PY
)"
```

`plistlib.load()`

handles both binary and XML plists. `StartCalendarInterval`

comes back as a `dict`

when there's a single entry and a `list`

when there are several, so `isinstance(rows, dict)`

absorbs that. The output is a comma-separated string like `8:20,10:20,12:20,...`

.

The expected value is generated in the same format on the bash side.

```
expected=""
IFS=',' read -r -a harr <<< "$hours"
for h in "${harr[@]}"; do expected="${expected}${expected:+,}${h}:${minute}"; done
```

`${expected:+,}`

is the idiom for "prepend a comma only if expected is non-empty." It produces `8:20,10:20,...`

, and a plain string comparison against actual is all the diff detection needs.

```
[ "$actual" = "$expected" ] && continue
```

If they match, `continue`

skips. If they differ, the next block runs.

**Step 1: forensic log**

```
log "$label: 書き換え検知 mtime=$(stat -f '%Sm' -t '%F %T' "$plist")"
log "$label:   現在   = $actual"
log "$label:   あるべき= $expected"
ps -Ao pid,lstart,comm | tail -n +2 | while read -r p rest2; do echo "$p $rest2"; done \
  | grep -iE "python|node|bash|launchctl|plutil" | tail -25 | while read -r l; do log "$label:   ps> $l"; done
```

`stat -f '%Sm'`

records the plist's last-modified time. The `ps`

command logs up to 25 processes matching python, node, bash, launchctl, or plutil. Even if it doesn't identify the culprit, a timestamp plus a process list is useful for investigation later.

**Step 2: backup**

```
cp "$plist" "$plist.bak-guard-$(date +%Y%m%d-%H%M%S)"
```

The tampered plist is saved under a name like `com.lily.outreach-ig.plist.bak-guard-20260823-120412`

. This preserves evidence so the pre-restore state can be reproduced afterward.

**Step 3: overwrite with correct values via plistlib**

``` python
python3 - "$plist" "$minute" "$hours" <<'PY'
import plistlib, sys
path, minute, hours = sys.argv[1], int(sys.argv[2]), sys.argv[3]
with open(path,'rb') as f: d = plistlib.load(f)
d['StartCalendarInterval'] = [{'Hour': int(h), 'Minute': minute} for h in hours.split(',')]
with open(path,'wb') as f: plistlib.dump(d, f)
PY
```

Other keys such as `Label`

, `ProgramArguments`

, and `EnvironmentVariables`

are left untouched. Only `StartCalendarInterval`

is replaced. Because it's written out with plistlib.dump, the generated XML conforms to Apple's official format.

**Step 4: validate and reload**

```
if plutil -lint "$plist" >/dev/null 2>&1; then
  launchctl bootout "gui/$(id -u)/$label" 2>/dev/null
  sleep 1
  if launchctl bootstrap "gui/$(id -u)" "$plist" 2>/dev/null; then
    log "$label: 復元して再読込した"
  else
    log "$label: 🔴 bootstrap に失敗した（手動確認が要る）"
  fi
else
  log "$label: 🔴 復元後のplistが壊れている（戻していない）"
fi
```

`plutil -lint`

confirms the file is valid before `launchctl bootout`

→ `bootstrap`

. `bootout`

is safe to call even against an agent that isn't loaded, so there's no need to pre-check whether the label is registered. If `bootstrap`

fails, the log entry gets a 🔴 and explicitly says "manual check required." The design doesn't hide situations it can't resolve on its own. (Log strings: "復元して再読込した" = restored and reloaded; "bootstrap に失敗した（手動確認が要る）" = bootstrap failed, manual check required; "復元後のplistが壊れている（戻していない）" = restored plist is broken, not reloaded.)

```
<key>StartInterval</key>
<integer>600</integer>
```

`StartInterval`

in `com.lily.outreach-schedule-guard.plist`

is 600 seconds, i.e. 10 minutes. launchd's StartInterval keeps counting time during system sleep, and on wake it detects "how many runs should have happened" and fires. So even if the tampering occurs during the three hours the Mac was asleep, the automatic restore runs within 10 minutes of waking.

Log output is split into two files, `~/.claude/logs/outreach-schedule-guard.out.log`

and `outreach-schedule-guard.err.log`

. Normal logs go to `stdout`

, unexpected bash errors to `stderr`

, so there's no mixed noise when monitoring in real time with `tail -f`

.

`-e`

is dropped from `set -uo pipefail`

Line one of the script contains a quiet but important decision.

```
set -uo pipefail
```

There's no `-e`

(exit immediately on error). That's deliberate. With `-euo pipefail`

, the whole script dies the moment `launchctl bootout`

returns non-zero.

`bootout`

returns an error when the specified label isn't registered with launchd. Since the four agents are checked in sequence inside a for loop, a failed `bootout`

on the first one would skip monitoring of the remaining three. That's not a watchdog. So I keep `-u`

(error on undefined variables) and `pipefail`

(catch failures mid-pipe) but drop `-e`

. The `bootout`

call also gets `2>/dev/null`

to silence the "not registered" message on stderr.

```
launchctl bootout "gui/$(id -u)/$label" 2>/dev/null
export PATH="/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin"
```

Scripts started by launchd inherit nothing from your login shell's `~/.zshrc`

or `~/.zprofile`

. However carefully you've grown `PATH`

in the terminal, none of it reaches the launchd execution environment. If `python3`

can't be found, every step that uses plistlib goes silent.

The same PATH is defined in guard.plist's `EnvironmentVariables`

block.

```
<key>EnvironmentVariables</key>
<dict>
  <key>PATH</key>
  <string>/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin</string>
</dict>
```

The monitored outreach-ig.plist, by contrast, has a somewhat longer PATH.

```
<key>BROWSER_SLOT_TIMEOUT_SEC</key>
<string>2700</string>
<key>PATH</key>
<string>~/.nvm/versions/node/v24.13.0/bin:/opt/homebrew/bin:...</string>
```

It has `BROWSER_SLOT_TIMEOUT_SEC`

(45-minute session cap) and the nvm Node.js path, because outreach-ig drives a browser via Playwright. The guard only uses `python3`

, `stat`

, `launchctl`

, and `plutil`

, so Homebrew plus the system PATH is enough. The "minimum required" PATH differs between watcher and watched.

```
log() { printf '%s %s\n' "$(date '+%F %T')" "$*" >> "$LOG"; }
```

`printf`

instead of `echo`

because `echo`

interprets flags like `-e`

depending on the shell implementation. `date '+%F %T'`

produces a timestamp in the form `2026-08-23 13:07:37`

.

`$LOG`

is a file path defined inside the script (`~/.claude/logs/outreach-schedule-guard.log`

) and appended to directly. It's separate from the StandardOutPath and StandardErrorPath defined in guard.plist.

```
<key>StandardOutPath</key>
<string>~/.claude/logs/outreach-schedule-guard.out.log</string>
<key>StandardErrorPath</key>
<string>~/.claude/logs/outreach-schedule-guard.err.log</string>
```

The result is three log files with distinct roles. ** .log** is the monitoring output the guard writes on purpose.

`.out.log`

`.err.log`

`tail -f ~/.claude/logs/outreach-schedule-guard.err.log`

is empty, you know instantly that there are zero bash-level problems. Mixing monitoring logs and shell errors in one file means parsing which noise is which every single time.Here is the log the guard wrote for outreach-th during the 2026-08-23 incident, verbatim.

```
2026-08-23 13:07:37 com.lily.outreach-th: 書き換え検知 mtime=2026-08-23 13:07:37
2026-08-23 13:07:37 com.lily.outreach-th:   現在   = 2:31,10:31,18:31
2026-08-23 13:07:37 com.lily.outreach-th:   あるべき= 9:38,11:38,13:38,15:38,17:38,19:38,21:38
2026-08-23 13:07:37 com.lily.outreach-th:   ps> 80406 日  8/23 12:50:01 2026  Python
2026-08-23 13:07:37 com.lily.outreach-th:   ps> 80842 金  8/21 11:06:32 2026  node
2026-08-23 13:07:37 com.lily.outreach-th:   ps> 81332 日  8/23 12:50:08 2026  bash
2026-08-23 13:07:37 com.lily.outreach-th:   ps> 82665 日  8/23 12:50:32 2026  playwright/driver/node
（以下、合計25件）
2026-08-23 13:07:38 com.lily.outreach-th: 復元して再読込した
```

(Line labels: "書き換え検知" = tamper detected; "現在" = current; "あるべき" = expected; "（以下、合計25件）" = 25 entries in total; "復元して再読込した" = restored and reloaded.)

A few facts can be read from this.

** mtime=2026-08-23 13:07:37**: the same second as the guard's own execution timestamp. Either the guard ran right after the plist was modified, or the modification happened just before the 10-minute poll. Either way, the numbers confirm detection within 10 minutes.

**The Playwright process in ps (PID 82665)**: a Playwright driver started at 12:50:32 was still around. That overlaps with the plist's modification window. Not conclusive evidence, but it supports the hypothesis that "the plist changed during some Playwright run."

**"Restored and reloaded" at 13:07:38**: less than one second from detection to restore. Everything, including re-registration with launchd, completes in real time.

`plutil -lint`

and `sleep 1`

are necessary

```
if plutil -lint "$plist" >/dev/null 2>&1; then
  launchctl bootout "gui/$(id -u)/$label" 2>/dev/null
  sleep 1
  if launchctl bootstrap "gui/$(id -u)" "$plist" 2>/dev/null; then
    log "$label: 復元して再読込した"
  else
    log "$label: 🔴 bootstrap に失敗した（手動確認が要る）"
  fi
else
  log "$label: 🔴 復元後のplistが壊れている（戻していない）"
fi
```

`plutil -lint`

is Apple's official plist validator. `plistlib.dump()`

generally produces valid XML, but an interruption mid-write or an unexpected filesystem error leaving a half-written byte sequence is not a zero-probability event. Bootstrapping a corrupt plist can put launchd in an unexpected state, so right after Python writes the file, it always goes through Apple's validation before re-registration.

`sleep 1`

is an explicit wait for launchd's processing. The `bootout`

command sends launchd a request to unload the label, but launchd completes that work asynchronously. Call `bootstrap`

immediately and launchd may decide it's "still registered" and fail.

Looking again at the guard's own plist, four keys are set explicitly.

```
<key>RunAtLoad</key>
<false/>
<key>LowPriorityIO</key>
<true/>
<key>Nice</key>
<integer>10</integer>
<key>ProcessType</key>
<string>Background</string>
```

** RunAtLoad: false**: don't run immediately at bootstrap time (e.g. login). Set to true, the guard would run on every startup. The fixed

`StartInterval: 600`

is sufficient, so the extra run is skipped.** LowPriorityIO: true and Nice: 10**: lower I/O priority and add +10 to CPU nice. The guard is a lightweight process that runs once every 10 minutes for a few dozen milliseconds. If it interfered with the I/O or browser rendering of outreach-ig, which is actually sending the sales DMs, that would defeat the purpose. The monitoring process never outranks the monitored one. These settings express that relationship correctly.

`-e`

flag: the watchdog was quietly dying on the first agent
The first version was written with `set -euo pipefail`

. Run manually, it checked all four SPECS entries. Run via launchd, no log entries ever appeared past the first one.

Looking at `~/.claude/logs/outreach-schedule-guard.log`

, nothing followed the outreach-ig processing log. `.err.log`

was empty. Nothing in launchd's journal either. It wasn't "the process didn't start." It was "started and stopped partway."

I found the cause when I manually checked the exit code of `launchctl bootout`

.

```
launchctl bootout "gui/$(id -u)/com.lily.outreach-th" 2>/dev/null
echo $?
```

Output: `36`

(error). outreach-th wasn't registered with launchd at that moment (already booted out in the previous cycle), so bootout returned non-zero and `-e`

terminated the script on the spot.

Two fixes. Change to `set -uo pipefail`

, and add `2>/dev/null`

to `bootout`

. That alone got all four processed in order. "Stop on error" looks safe, but for this use case it was the setting that killed the watchdog.

``` python
actual="$(python3 - "$plist" <<'PY'
import plistlib, sys
...
PY
)"
```

This worked perfectly in my local terminal, but run via launchd the script process never died and just stayed alive. Nothing in the logs. The guard process kept consuming a trickle of CPU.

I confirmed it was alive with `ps aux | grep outreach-schedule-guard`

, dug in with the strace equivalent, and found it blocking inside the plist step. The cause: a trailing space after the heredoc end marker `PY`

.

```
actual="$(python3 - "$plist" <<'PY '   # ← 末尾スペース
```

(The comment marks the trailing space.) zsh tolerates this, but the `/bin/bash`

that launchd invokes keeps waiting for `'PY '`

(space included) as the terminator. A line reading `PY`

never arrives on the input stream, so the Python process hangs waiting on stdin. With no timeout configured, when the guard tries to start on the next 10-minute cycle the previous process is still alive, and you get a double run.

The fix was just removing the trailing space, but the symptom combination of "no logs plus a process that won't die" made diagnosis slow. When you write automation scripts assuming zsh and run them under launchd (bash), you have to visually check heredoc end markers for stray whitespace.

`isinstance`

trap: an infinite restore loop on single-entry plists
When plistlib reads `StartCalendarInterval`

, multiple entries come back as `list[dict]`

. But **a single entry comes back as a bare dict**.

In the early version, written without knowing this, creating a test plist with only one `StartCalendarInterval`

entry threw a `TypeError`

.

```
TypeError: 'dict' object is not iterable
```

The Python exception is caught on the guard side and `actual`

becomes the string `ERR`

. `ERR`

never matches any expected value, so every cycle judges "tamper detected" → create backup → overwrite with plistlib.dump → plutil -lint → bootout → bootstrap → log "restored and reloaded," forever. The plist contents are actually correct, yet the guard keeps frantically writing "restored."

```
rows = d.get('StartCalendarInterval') or []
if isinstance(rows, dict): rows = [rows]   # この1行が防波堤
print(','.join(f"{r.get('Hour')}:{r.get('Minute')}" for r in rows))
```

(The comment reads: this one line is the breakwater.) All production plists have multiple entries, so it never reproduced in production. I only hit it when hand-building a simple test plist, and realized it was a landmine buried where "only the test environment steps on it." plistlib's behavior is documented, but writing "code that only anticipates the multi-entry case" is extremely natural, so you need the habit of checking types before using an API or absorbing them with `isinstance`

.

`stat`

syntax: completely different on macOS and Linux

```
mtime=$(stat -f '%Sm' -t '%F %T' "$plist")
```

This is macOS (BSD-style `stat`

) syntax. The Linux equivalent is `stat -c '%y' "$plist"`

. When I ran unit tests in a Docker container, this line failed with `illegal option -- f`

and I realized "this doesn't run in some container."

That said, this script is launchd-only, and launchd exists only on macOS, so Linux compatibility has no meaning here. Zero real harm, but it's a snag during development or testing on Linux.

To unpack `-f '%Sm' -t '%F %T'`

: `-f`

specifies a format string, `%S`

means "display the time using the `-t`

format," and `m`

is "last modification time (mtime)." The `%F %T`

passed to `-t`

is strftime format, so `%F`

= `2026-08-23`

and `%T`

= `13:07:37`

. If you're used to GNU's `stat -c`

, `-f`

means something entirely different and it's confusing. When writing macOS-specific `stat`

, it's faster to run `man stat`

first or try it in a macOS terminal before writing.

Beyond the four snags above (`-e`

flag, heredoc space, `isinstance`

, `stat`

syntax), unexpected walls keep appearing once you actually build this. Here's an exhaustive list so you don't burn the same hours.

**Mistaking the first argument of launchctl bootstrap for a domain**

`bootstrap "gui/$(id -u)" "$plist"`

, `gui/501`

means "GUI session 501." Confuse it with `user/$(id -u)`

(background session) or `system/`

(root only) and you can get a case where nothing errors and nothing silently starts either. For your own user agents, always `gui/$(id -u)`

.`id -u`

returns empty instead of a number

Rarely, when launchd starts the script from outside a session, environment variables get stripped and `id -u`

returns an empty string. You end up with a doubled slash like `bootout "gui//com.lily.outreach-ig"`

and `bootout`

fails silently. It's safer to write `: "${UID:=$(id -u)}"`

at the top of the script as a fallback.

**Backup files pile up without limit**

Every restore generates a `com.lily.outreach-ig.plist.bak-guard-20260823-120412`

. If incidents recur intermittently, `~/Library/LaunchAgents/`

fills with hundreds of backups. Add one line at the end of the guard script, `find "$LA" -name '*.bak-guard-*' -mtime +7 -delete`

, and backups older than 7 days are removed automatically.

**The plist errors when the log file doesn't exist**

If the **directory** of the path given to `StandardOutPath`

doesn't exist, launchd refuses to bootstrap the plist at all. In an environment where `~/.claude/logs/`

doesn't exist on first launch, the guard itself never starts. `mkdir -p "$(dirname "$LOG")"`

at the top of the script is mandatory, but the directories pointed to by the plist's StandardOutPath/StandardErrorPath must be created in advance as well.

`RunAtLoad: false`

delays the first check by 10 minutes

guard.plist's `RunAtLoad`

is `false`

. This avoids an unnecessary run right after login, but the flip side is that **after a Mac reboot, the first check runs 10 minutes later**. If you reboot right after an incident, that 10-minute window is blind. During incident response, get in the habit of manually starting it with `launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.lily.outreach-schedule-guard.plist`

before doing anything else.

**Cases where plutil -lint passes but launchd rejects**

`plutil -lint`

validates XML syntax, but launchd's key specification is a separate matter. For example, put only a `Hour`

key in a `StartCalendarInterval`

entry and omit `Minute`

, and `plutil -lint`

passes while launchd ignores the agent. When writing via plistlib, both keys must always be included, as in `{'Hour': int(h), 'Minute': minute}`

.**Double launch when the guard runs longer than 600 seconds**

`StartInterval: 600`

doesn't mean launchd starts the process every 600 seconds "regardless of whether the previous run finished." In practice, the next interval counts from when the previous run completes. However, if heavy forensic work or accumulated `sleep 1`

calls slow the whole script down, the "finish → immediate start" cycle can effectively jam. Four agents × restore × `sleep 1`

takes up to about 4 seconds, which is normally no problem.

**Nobody detects it when the guard's own plist is rewritten**

This is the design's "last guardian problem." If `StartInterval`

in `com.lily.outreach-schedule-guard.plist`

is rewritten, the guard can't detect that on its own. Countermeasures: include `"com.lily.outreach-schedule-guard:*:*"`

in the `SPECS`

array so the guard's own plist is compared too, or set up a separate cron that checks from outside with `launchctl print gui/$(id -u)/com.lily.outreach-schedule-guard | grep interval`

.

**The python3 path varies by Homebrew environment**

`export PATH="/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin"`

declared at the top of the script, `/opt/homebrew`

may not exist depending on Apple Silicon vs Intel Mac (Intel uses `/usr/local`

). I support both by adding `/usr/local/bin`

to guard.plist's `EnvironmentVariables`

.

```
  <string>/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin</string>
```

The slight difference between the plist PATH and the script PATH is intentional. The plist side is self-contained as the launch environment, and the script side serves as a runtime override.

`grep -iE "python|node|bash"`

matches the guard itself

When the forensic ps collection searches for bash and python, the guard script itself (`/bin/bash outreach-schedule-guard.sh`

) is a hit. No real harm, but you'll be surprised to find yourself in the "suspects" list when analyzing logs. You can add `grep -v "outreach-schedule-guard"`

to the filter to exclude yourself.

`StartInterval`

doesn't count time while the Mac is powered off

launchd's `StartInterval`

counts time while the system is "asleep," but not while it's fully **shut down**. If the machine was powered off for a long trip, the first check after power-on is 600 seconds later. This can't be changed, so before a long absence it's worth manually running the guard once and checking the log.

Based on the real code and the real incident, here are the principles to hold onto when writing this kind of "guardian script for automation."

**1. Declare expected values in a single array**

When "label:minute:hour-list" fits on one line like the `SPECS`

array, adding or changing monitored targets is just an edit to `SPECS`

. If monitoring logic and expected values are scattered, changing one creates inconsistency with the other.

**2. Know that the -e flag doesn't coexist with for loops**

`set -euo pipefail`

is a handy safety device, but in a script where commands returning non-zero are mixed into a loop, it kills the watchdog. Keep `-uo pipefail`

, drop `-e`

, and attach `2>/dev/null`

or `|| true`

individually to lines where errors are expected. That's more robust.**3. Split logs into three files**

Separate the monitoring log the script writes deliberately (`.log`

), launchd's captured stdout (`.out.log`

), and bash's unexpected errors (`.err.log`

). If `tail -f outreach-schedule-guard.err.log`

is empty, you know instantly there are zero shell-level problems. Mix them into one file and you pay that judgment cost every time.

**4. Always validate with plutil -lint before restoring**

`plistlib.dump()`

generates valid XML, but the chance of a byte sequence being corrupted by an interruption mid-write isn't zero. `launchctl bootstrap`

on a broken plist puts launchd into an undefined state. Always insert the step of reloading only what passes `plutil -lint`

.**5. Put sleep 1 between bootout and bootstrap**

`launchctl bootout`

completes asynchronously inside launchd. Call `bootstrap`

right away and it fails as "still registered." One second is enough, but skip it and you get hard-to-reproduce failures depending on the environment.**6. Preserve evidence with a backup before overwriting**

The order matters: `cp`

the tampered plist to a timestamped backup, then restore. If the file proving "what it was before" disappears after the restore, it's useless for root-cause investigation later. Pair this with a rule that auto-deletes backups after 7 days.

**7. Leave a process list in the forensics**

On tamper detection, log python, node, bash, launchctl, and plutil processes with `ps -Ao pid,lstart,comm`

. Even if it doesn't directly identify the culprit, a process list for the time window is grounds for forming hypotheses. In the 2026-08-23 incident, the forensic log showed that a `Playwright driver started at 12:50:32 (PID 82665)`

overlapped with the modification time.

**8. Absorb plistlib's type wobble with isinstance**

`StartCalendarInterval`

returns a `dict`

for one entry and `list[dict]`

for several. Fail to handle this Python standard library behavior and a simple test plist throws a `TypeError`

, causing the guard to false-positive into an infinite restore loop.

```
rows = d.get('StartCalendarInterval') or []
if isinstance(rows, dict): rows = [rows]
```

These two lines are the breakwater that keeps this from becoming a production outage.

**9. Manage the guard with its own launchd agent**

Make the script depend on cron or manual runs and monitoring stops the moment cron dies or you forget to run it. Registering the guard with launchd via `com.lily.outreach-schedule-guard.plist`

lets the OS layer auto-recover from Mac reboots, crashes, and user mistakes.

**10. Set the monitoring process's priority below the monitored one**

`LowPriorityIO: true`

, `Nice: 10`

, and `ProcessType: Background`

in guard.plist make explicit that the guard is a lightweight process running once every 10 minutes, while ensuring it doesn't interfere with the I/O of the real sales DM process. Setups where the monitoring script runs at higher priority than the production script, defeating the purpose, are surprisingly common.

**11. Declare PATH in both the script and the plist**

launchd doesn't read your login shell's `~/.zshrc`

. Write it not only in the script's `export PATH=...`

but also in the plist's `EnvironmentVariables > PATH`

, so `python3`

, `plutil`

, and `launchctl`

are reliably found through either launch path.

**12. Skip unnecessary launches with RunAtLoad: false, and understand its limitation**

`false`

. But this setting is inseparable from the fact that "the first check after login is delayed 10 minutes." If you want to verify the schedule manually right after a reboot, kick it manually with launchctl.**13. Watch for trailing spaces after bash heredoc end markers**

In zsh, a heredoc works fine even with `<<'PY '`

(space included), but the `/bin/bash`

that launchd launches keeps waiting for `PY`

as the terminator and the script blocks forever. The symptom shows up as "no logs plus a process that won't die," which takes time to diagnose. Always put the heredoc end marker on its own line and confirm there's no trailing space.

**14. Consolidate monitored plist labels in the SPECS array so changes are one line**

`SPECS`

array. A structure that requires adding configuration across multiple files inevitably leads to omissions and inconsistencies. Choosing a "change SPECS and everything follows" design from the start makes long-term operation easy.**15. Note explicitly that the stat options are macOS-specific**

`stat -f '%Sm' -t '%F %T'`

is BSD-style macOS-only syntax. Try to test just this line on Linux and it fails with `illegal option -- f`

. Since this script is launchd-only, there's no real harm, but it's a source of confusion when writing unit tests in Docker containers or CI environments. A one-line comment wherever a macOS-specific command is used prevents anyone picking up the code from getting stuck.The 2026-08-23 incident, where the launchd plists for my sales DMs were rewritten to a late-night schedule, drove home a reality: "keeping automation running is easier than building a mechanism that detects breakage within 10 minutes and reverts it."

The design philosophy of `outreach-schedule-guard.sh`

in one sentence: **"Assume things will break, and have the OS run a watchdog while the human sleeps."** A 73-line bash script packs into one file precise plist comparison via python3+plistlib, evidence preservation in forensic logs, atomic restore through `plutil -lint`

and `launchctl reload`

, and a "watchdog for the watchdog" structure where the guard itself is protected by launchd.

The foundation of ¥1.2M a month is an accumulation of unglamorous hardening like this. You end up spending more time making existing automation hard to break than writing flashy new automation scripts. I'm now convinced that's the essence of scaling solo development: separating "building the environment" from "doing the work."

The full picture of the system, the breakdown of the ¥1.2M/month, and the 30-day playbook are in a paid note (Japanese).

📕 [Claude Code自律環境で、実際どう稼ぐか ― 仕組み・実例・始め方・サポート](https://note.com/bokuwalily/n/n849b3a07784a)

*Written by **Lily** — I ship iOS apps and automate my content stack with Claude Code.

Follow along: [Portfolio](https://bokuwalily.com) · [X](https://x.com/bokuwalily) · [GitHub](https://github.com/bokuwalily)*
