cd /news/developer-tools/retiring-a-claude-code-model-unatten… · home topics developer-tools article
[ARTICLE · art-65056] src=dev.to ↗ pub= topic=developer-tools verified=true sentiment=· neutral

Retiring a Claude Code Model Unattended with launchd: A Self-Destructing Job That Auto-Patches settings.json

A developer created a self-destructing launchd job that automatically patches Claude Code's settings.json to migrate from the deprecated Fable 5 model to Opus on the model's end-of-life date (2026-07-07). The job uses a date guard to prevent premature execution, performs an atomic JSON rewrite with jq, and then unregisters itself via launchctl unload to ensure it runs exactly once.

read6 min views46 publishedJul 19, 2026

This is another entry in my "Claude Code environment" series. Last time, in Multi-slot launchd retry design, 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.

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 themv

from.tmp

to production ifjq

'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.

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.

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"><dict>
  <key>Label</key><string>com.shun.model-transition-0707</string>
  <key>ProgramArguments</key><array>
    <string>/bin/bash</string>
    <string>~/.claude/scripts/model-transition-0707.sh</string>
  </array>
  <key>StartCalendarInterval</key><array>
    <dict><key>Hour</key><integer>6</integer><key>Minute</key><integer>50</integer></dict>
    <dict><key>Hour</key><integer>12</integer><key>Minute</key><integer>50</integer></dict>
    <dict><key>Hour</key><integer>20</integer><key>Minute</key><integer>50</integer></dict>
  </array>
  <key>EnvironmentVariables</key><dict>
    <key>PATH</key>
    <string>~/.nvm/versions/node/v24.13.0/bin:/opt/homebrew/bin:/opt/homebrew/sbin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin:~/.local/bin</string>
  </dict>
  <key>StandardErrorPath</key><string>~/.claude/logs/model-transition.err</string>
</dict></plist>

Three slots a day, at 6:50, 12:50, and 20:50. It's a direct application of the multi-slot design from my previous article. Once the first run completes and self-unloads, the second and later runs don't fire. Even if the Mac is asleep and misses 6:50, 12:50 and 20:50 can pick it up.

Because StandardErrorPath

is set in the plist, you don't need to write stderr redirection inside the script. When debugging, look at ~/.claude/logs/model-transition.err

.

jq

not on PATH, exit 127/opt/homebrew/bin

isn't in the plist's EnvironmentVariables.PATH

, the Homebrew-installed jq

isn't found. This is the classic pattern where manual runs from the terminal succeed but only launchd breaks. A staple Apple Silicon trap..model

keyjq -r '.model'

, the string null

is returned; grep -qi 'fable'

would fail to match and be fine, but considering future key-name changes or omissions, I made the empty-string fallback explicit with // empty

.launchctl unload

runs, that job's schedule is gone. It won't re-fire without another launchctl load

.StandardErrorPath

, launchd discards stderr to /dev/null or some unpredictable place. That makes debugging impossible, so always set it.This job looks specific to Fable 5's EOL, but it can be reused as-is whenever these three requirements line up.

Requirement Implementation in this job
Run exactly once on or after a specific day date guard via date +%Y%m%d comparison
Idempotent (same result no matter how many times it runs) check current value before rewriting
Automatically disappear once complete launchctl unload "$PLIST"

Application examples:

A "cron that should run only once" fires every time when persistently registered with cron or launchd, making idempotency tedious to guarantee. Designing it to "disappear once it runs" via self-unload is simpler than writing dedupe handling for a persistent job.

date +%Y%m%d

comparison) neutralizes catch-up firing — include it from the start.jq

patchinggrep -qi 'fable'

) makes it idempotent. It's safe to re-run even in an already-migrated environment.launchctl unload "$PLIST"

jq

, don't forget /opt/homebrew/bin

in the plist's PATHNext time, I plan to write about automation-health.sh

, which checks the liveness of the entire launchd setup — including disposable jobs like this one — with a single command.

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

Follow along: Portfolio · X · GitHub*

── more in #developer-tools 4 stories · sorted by recency
── more on @claude code 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain — perfect for shipping the agent you just read about.

$git push zahid main
Live at https://your-agent.zahid.host
Get free account → Pricing
from €0/mo · no card required
LIVE [news/retiring-a-claude-co…] indexed:0 read:6min 2026-07-19 ·