Retiring a Claude Code Model Unattended with launchd: A Self-Destructing Job That Auto-Patches settings.json A developer automated the migration of Claude Code's model setting ahead of Fable 5's end of life using a self-destructing launchd job. The script rewrites the model field in settings.json, fires a desktop notification, and unregisters itself via launchctl unload. A date guard prevents premature execution due to launchd's catch-up behavior. This is another entry in my "Claude Code environment" series. Last time, in Multi-slot launchd retry design https://zenn.dev/bokuwalily/articles/multi-slot-launchd-retry , I wrote about the "idempotent morning/noon/night triple-fire" pattern. This time I'm applying it to something concrete: the "disposable migration job" I set up ahead of Fable 5's end of life 2026-07-07 , and I'm publishing the full implementation. It rewrites the model field in settings.json with jq , fires a desktop notification, and finally removes its own registration with launchctl unload and disappears. The design wraps launch, execution, and self-destruction into a single script. This isn't specific to model migration — it's a general-purpose pattern for "a job that changes a setting exactly once on a specific day and then vanishes," so I'll show all of the actual code. If your environment has "model": "claude-fable-5" written in Claude Code's ~/.claude/settings.json , and that setting lingers past the EOL date, you quietly end up with unintended behavior. No error is raised — it might be running on a fallback or a different model — but you lose track of which model you're actually using . Opening settings.json by hand and editing it is the simplest approach, but getting a human to line up "on the exact day," "without forgetting," and "exactly once" is surprisingly hard. I delegated it to launchd and automated it. 1. Date guard → if before 7/7, exit 0 immediately catch-up firing defense 2. Patch settings.json → backup → jq rewrite → JSON validation → mv 3. Notify + self-unload → notify via osascript → remove self with launchctl unload The core of "disposable" is that step 3 unregisters the job itself . This ensures there's no firing from that point forward. However, the plist file is not deleted — it stays in ~/Library/LaunchAgents/ . I keep it so that running launchctl load again can bring it back a comment in the script even notes "keep the plist = re-registerable" . launchd has a catch-up behavior where, when a Mac boots up, it retroactively runs past slots that "should have fired." If you register the job before 7/7, there's a chance it fires early at the moment of a Mac reboot. The very first line stops this. Do nothing if before 7/7 catch-up firing defense if "$ date +%Y%m%d " -lt 20260707 ; then log "skip: before 2026-07-07"; exit 0 fi date +%Y%m%d returns an integer in 20260707 format, so you can decide with a numeric comparison. The actual processing only runs for the first time on or after 7/7. Without this date guard, the script runs every time you register it before 7/7 and reboot the Mac. Catch-up is behavior specific to StartCalendarInterval jobs, so always keep it in mind when building scheduled jobs. The model rewrite is done as an atomic four-step operation. current=$ jq -r '.model // empty' "$SETTINGS" if echo "$current" | grep -qi 'fable'; then cp "$SETTINGS" "$SETTINGS.bak-model-transition" jq '.model = "opus"' "$SETTINGS" "$SETTINGS.tmp" \ && jq . "$SETTINGS.tmp" /dev/null \ && mv "$SETTINGS.tmp" "$SETTINGS" log "switched model: $current - opus" else log "no-op: model is already '$current'" fi The intent of each step: jq -r '.model // empty' — returns an empty string if the .model key doesn't exist. This keeps the string null from flowing downstream. grep -qi 'fable' — rewrites only when the value contains fable , case-insensitively. If it has already been migrated to another model, it falls into the else branch, writes only a no-op log, and exits idempotent . cp "$SETTINGS" "$SETTINGS.bak-model-transition" — a backup taken before the rewrite, for recovery on failure. jq . "$SETTINGS.tmp" /dev/null — validates that the rewritten .tmp is valid JSON before the mv . This keeps broken JSON from being placed on the production path.Since the script declares set -uo pipefail at the top, if any part of the && chain fails, the mv doesn't run. The reason for inserting jq . file /dev/null validation is to stop the mv from .tmp to production if jq 's output somehow becomes broken JSON. With this structure — which embeds a string literal instead of using --argjson — it's actually unlikely to happen, but keeping it as a habit makes it safe when reusing the pattern in other rewrite scripts. Once the rewrite is complete, it sends a desktop notification via osascript . /usr/bin/osascript -e 'display notification "Fable 5終了に伴いデフォルトモデルをOpusへ切替えました" with title "Claude model transition"' \ /dev/null 2 &1 || true The || true is there because I don't want a failed notification to mark the whole job as an error. The notification is purely a report to a human, not the main body of the work. Once its job is done, it unloads itself. Once the job is done, remove it keep the plist = re-registerable launchctl unload "$PLIST" 2 /dev/null || true log "done job unloaded " The PLIST variable is defined at the top of the script as PLIST="$HOME/Library/LaunchAgents/com.shun.model-transition-0707.plist" . launchctl unload does not delete the plist file, so the file remains in ~/Library/LaunchAgents/ . Running launchctl load "$PLIST" next time re-registers it instantly.