Automating a Daily Morning Health Check for Your Claude Code Setup with launchd A developer automated their daily morning health check for a Claude Code setup using launchd, consolidating production HTTP probes, hook latency, launchd exit codes, API costs, and git status into a single Markdown file appended to Obsidian. The script runs three times a day and includes retry logic to eliminate false alarms from Vercel cold starts. In my previous post, Monitoring Claude Code hook watchdogs with launchd https://zenn.dev/bokuwalily/articles/inject-bytes-watchdog , I set up liveness monitoring for hooks — and immediately ran into the next question: a healthy hook means nothing if the product behind it is down. What I really wanted was a single page I could skim in five minutes every morning and know that everything is fine. That page is daily-brief.sh . launchd runs it three times a day 8:00, 10:30, and at login , and it compiles production HTTP probes, hook latency p95, launchd exit codes, 7-day API costs broken down by model, and per-project git status into one Markdown file appended to Obsidian. The more you automate, the higher the risk that something breaks silently. I used to open all of these manually every morning: git status for each projectJust opening them took 3–5 minutes. Two incidents slipped through unnoticed 2026-06-11: GitHub Scout silently going blank, and a server configuration error in the autolike license API . Consolidating everything into one automatically delivered page makes missing things physically impossible. launchd ├─ StartCalendarInterval: 8:00 ├─ StartCalendarInterval: 10:30 └─ RunAtLoad: true(ログイン時) ↓ ~/.claude/scripts/daily-brief.sh ↓ ~/.claude/logs/daily-brief-YYYYMMDD.md ← 正本ログ ~/.claude/logs/daily-brief-latest.md ← 最新コピー ~/Desktop/Daily Brief/today-brief-YYYYMMDD.md ~/Documents/claude-obsidian/wiki/briefs/daily/today-brief-YYYYMMDD.md Even when the second run fires at 10:30, the marker < -- daily-brief YYYYMMDD -- prevents duplicate appends details below . The script opens like this: bash /usr/bin/env bash launchd で毎日 8:00 / 10:30 / ログイン時 実行(再実行してもマーカーで二重追記しない)。 注意: Desktop / ~/Documents vault は TCC 保護領域 → plist は /bin/bash 直起動(FDA付与済み)。 /bin/zsh 経由だと FDA 未付与で書き込みに失敗する。 set -uo pipefail DATE=$ date +%Y%m%d TS=$ date '+%Y-%m-%d %H:%M' OUT="$HOME/.claude/logs/daily-brief-${DATE}.md" Vercel cold starts can delay the first request, so a single one-shot curl produces false alarms as 000 timeout . Here's the implementation of probe url : probe url { local name=$1 url=$2 code=000 attempt Vercel等のコールドスタートで初回が遅い→単発だと000/タイムアウトの誤報。 最大3回リトライ+timeout延長で潰す。 for attempt in 1 2 3; do code=$ curl -s -o /dev/null -w "%{http code}" --max-time 15 "$url" 2 /dev/null || echo 000 if "$code" = "200" || "${code:0:1}" = "3" ; then break; fi sleep 2 done if "$code" = "200" || "${code:0:1}" = "3" ; then echo "- ✅ ${name} : ${code} ${url} " else echo "- ❌ ${name} : ${code:-無応答} ${url} " flag red "${name} 本番が ${code:-無応答}" fi } Only the combination of --max-time 15 , sleep 2 , and 3 retries finally eliminated the false alarms. In today's run 2026-07-24 08:10 , all four production apps — the job-hunting tracker, the job-hunting navigator, the graduation planner, and AETHERIA RPG — came back 200 OK. probe autolike goes all the way through the Gumroad billing flow. It hits the API with a dummy key: if the response is "license not found," things are healthy the API is alive ; if it's "server configuration error," the env vars are missing, which means Pro billing is dead. probe autolike { local product=$1 resp resp=$ curl -s --max-time 8 -X POST https://autolike-license-server.vercel.app/api/verify \ -H "Content-Type: application/json" \ -d "{\"key\":\"HEALTHCHECK-DUMMY\",\"product\":\"${product}\"}" 2 /dev/null if echo "$resp" | grep -q "サーバー設定エラー"; then echo "- ❌ autolike/${product} : サーバー設定エラー(env未設定→Pro課金死)" flag red "autolike/${product} がサーバー設定エラー" elif echo "$resp" | grep -qE "ライセンスが見つかりません|valid"; then echo "- ✅ autolike/${product} : 課金フロー稼働(Gumroad到達OK)" fi } Anything flagged RED gets collected into a "🚨 Needs attention" section at the top of the brief. Today it caught two items — a GitHub Scout scoring failure and a missing audit log in the affiliate auto-poster — and I noticed both first thing in the morning. Every hook execution appends one line — {"ts":..., "hook":..., "elapsed ms":..., "exit code":...} — to hook-latency.jsonl , and hook-latency-report.sh aggregates it. python3 - "$JSONL" "$DAYS" <<'PY' stats = collections.defaultdict list ... for hook, vals in stats.items : vals sorted = sorted vals n = len vals sorted p95 = vals sorted min n-1, int n 0.95 rows.append hook, n, sum vals sorted //n, p95, vals sorted -1 , fail.get hook, 0 rows.sort key=lambda r: -r 3 p95 降順(遅いものを上に) flag = " ⚠" if p95 1500 else "" print f"{hook:<32} {n: 5} {mean: 6}ms {p95: 6}ms {mx: 6}ms {fl: 5}{flag}" PY It flags anything with p95 1500ms with a ⚠ and sorts slowest-first, so you can tell at a glance what's heavy. Here are today's actual numbers last 24h, 356 records : === hook latency last 1d, 356 records === hook n mean p95 max fail ---------------------------------------------------------------------- skills-auto-update.sh 8 2030404ms 3215081ms 3215081ms 0 ⚠ obsidian context.sh 71 61835ms 15679ms 3194281ms 0 ⚠ message display filter.sh 69 23740ms 9106ms 1469891ms 0 ⚠ cost guard.sh 68 16719ms 6789ms 1019530ms 0 ⚠ user prompt submit.sh 70 5516ms 6426ms 338132ms 0 ⚠ model routing reminder.sh 70 901ms 5703ms 6386ms 0 ⚠ obsidian context.sh shows a p95 of 15679ms ~16 seconds and a mean of 61835ms ~1 minute , but the mean is dragged up by max outliers, so p95 is what I judge by. Since the columns are sorted by p95 descending, "higher up = heavier" reads at a glance. launchctl list | grep com.shun | awk '{printf "- %s last exit=%s \n", $3, $2}' | head -15 Today's output excerpt : - com.shun.daily-brief last exit=0 - com.shun.vault-auto-ingest last exit=0 - com.shun.autopilot last exit=1 - com.shun.self-repair last exit=0 You can immediately see that only autopilot is at exit=1. The other 13 jobs were all exit=0. For details I go to ~/.claude/logs/com.shun.