I went from ¥100k a month as a student to ¥600k juggling side gigs, then to ¥0 after a layoff—and six months of building an autonomous Claude Code environment brought me to ¥1.2M a month in revenue. The difference wasn't working harder. It was betting on "grow the environment" instead of "do the work."
Why This Setup Works
Look up how to make money on the side and you'll always land on the same three options: sell your skills, make content, or build a service. They're all correct. But they share one ceiling: your time is finite.
Back when I was juggling gigs at ¥600k a month, I genuinely hit the wall on working hours. Every article, every DM, every proposal needed my own hands. The more clients I had, the more quality slipped. I couldn't add more, and I couldn't let quality drop—and that's exactly when the layoff came. I decided I wasn't going to do it the same way again.
For the restart, I chose a different approach: build the environment that runs without me first. Today, launchd jobs fire at 8:00 and 10:35 every morning, generate a stock of articles, and post a notification to Discord. Whether my Mac is open or I'm asleep, the article stock keeps growing.
There are three reasons this setup is strong.
Time accumulates. Human work consumes time. Environment work produces it. Every job run stacks up as inventory that doesn't disappear. Even when I'm sick and can't do anything for three days, the jobs quietly keep growing the stock. "As long as they're running correctly," that is—and that "if" is the whole subject of this post.
The scale changes by an order of magnitude. A human can write a few articles a day at most. Jobs run in parallel, as many as you like. Right now, four lanes—article generation, daily PDCA, structuring external data, and triaging incoming DMs—all run during the same night. I passed the throughput of a single person because agents were lined up side by side.
How deeply you understand quota structure changes. Someone who uses Claude Code as a "tool to ask the AI" and someone who wires it in as "part of the infrastructure" have fundamentally different monthly throughput. To use it as infrastructure, you have to know exactly how quota is shared and how it's consumed, or you will get stuck. This outage happened precisely in that gap of knowledge.
The Overall Flow
Here's the skeleton of the content-generation pipeline up front. I'll dissect the outage in the second half, so having the big picture first will make it easier to follow.
launchd
~/Library/LaunchAgents/com.shun.article-daily.plist
起動タイミング: 08:00 / 10:35(毎日)
│
▼
~/.claude/scripts/claude-quota-guard.py
--job com.shun.article-daily
│ ← クォータ残量のガード層
▼
~/.discord/run-and-notify.sh
"zenn" "Zenn記事ストック生成"
│ ← Discord通知ラッパー
▼
~/.claude/scripts/article-daily-stock.sh apply
│ ← 実際の生成ロジック
▼
claude -p "プロンプト..." [--model ???]
│
├─ --model を明示した場合
│ 指定モデルの独立した枠で実行
│ → 記事テキストが正常に返る ✅
│
└─ --model を省略した場合
対話セッションの「既定モデル枠」を共有消費
→ 枠が切れると以下の1行だけ stdout に吐いて終了:
"You've reached your Fable limit."
→ exit code は非0
→ ただしログには「1行返ってきた」としか残らない
→ [ -s output ] 等の素朴なチェックを通り抜ける ❌
→ 何週間でも誰も気づかない ← 実際に3週間気づかなかった
The bottom of the diagram is the heart of the outage. claude -p does not error when you omit --model. It quietly goes off and consumes the interactive session's quota. When that quota runs out, it returns a single-line message and exits. That one line gets mistaken for "there was output," and detection slips through.
The plist Implementation
Here's an excerpt from the actual com.shun.article-daily.plist (real paths replaced with ~).
<key>ProgramArguments</key>
<array>
<string>~/.claude/scripts/claude-quota-guard.py</string>
<string>--job</string>
<string>com.shun.article-daily</string>
<string>--</string>
<string>/bin/bash</string>
<string>~/.discord/run-and-notify.sh</string>
<string>zenn</string>
<string>Zenn記事ストック生成</string>
<string>/bin/bash</string>
<string>~/.claude/scripts/article-daily-stock.sh</string>
<string>apply</string>
</array>
<key>StartCalendarInterval</key>
<array>
<dict>
<key>Hour</key><integer>8</integer>
<key>Minute</key><integer>0</integer>
</dict>
<dict>
<key>Hour</key><integer>10</integer>
<key>Minute</key><integer>35</integer>
</dict>
</array>
<key>StandardOutPath</key>
<string>~/.claude/logs/article-daily.out.log</string>
<key>StandardErrorPath</key>
<string>~/.claude/logs/article-daily.err.log</string>
<key>ProcessType</key><string>Background</string>
<key>LowPriorityIO</key><true/>
<key>Nice</key><integer>10</integer>
ProcessType: Background, LowPriorityIO: true, and Nice: 10 go in as a set. By explicitly telling the OS I/O scheduler "this is a low-priority background task," they keep the job from interfering with foreground work (opening Figma, looking something up in Safari).
The call chain is four levels deep: launchd → quota-guard → run-and-notify → article-daily-stock. claude -p is invoked inside article-daily-stock.sh, and whether --model is present there is the whole question.
Four Lanes Surfaced at Once on September 3, 2026
In my case, the same omission surfaced in four jobs simultaneously. Quoting the record left in my knowledge base:
| Lane | Symptom | Damage window |
|---|---|---|
gen-column.mjs (LINE column generation) |
exit 1 every morning with the one-line "Fable limit". Stock dwindled to 2 remaining | 2026/8/13–9/3 (3 weeks) |
triage (incoming DM classification) |
Every quota-exhausted run fell through to "unclassifiable"; not a single incoming DM had been auto-replied since 9/2 | 9/2– |
brand-404 (IG daily PDCA) |
Zero output due to Fable limit. That day's action items and daily report never generated |
9/2 |
hosei-grad-planner (PDF structuring) |
All 3 runs at 00:21 / 04:30 / 10:30 produced zero structured data | 9/3 |
For three weeks, gen-column.mjs burned through its stock every morning while "looking like it was working fine." It was only discovered when the stock hit 2 remaining. The job's log recorded "1 line returned." Nobody was reading what that one line said.
"Four lanes at once" is an important fact too. If just one job breaks, it's easy to notice. When several go silent at the same time, the unease of "everything's too quiet" fades. If anything, it pulls you toward the misreading that "automation is working and my hands are free."
The root cause is a code defect: not writing --model. Combined with lax log monitoring that treats "one line = success," it went undetected for three weeks. Each hole on its own would have been easy to spot, yet stacked together they created a blind spot.
I'll dissect that structure in the second half: why omitting --model is so hard to notice, how to build one script that makes detection reliable, and how to implement a grep gate that mechanically rejects any existing plist with the flag missing.
Implementation Details
Where --model Goes and How to Write It
Looking at article-daily-stock.sh after the post-outage fix, there are two places where claude -p is called: topic planning (auto-generating the next topic when the queue is empty) and article body generation. Both have --model.
# キューが空のとき: 実作業からネタを自動立案(article-daily-stock.sh 327-330行目)
TOPIC_JSON=$(run_to 600 "$CLAUDE" -p "$REPLENISH_PROMPT" \
--strict-mcp-config --mcp-config '{"mcpServers":{}}' \
--model "${ARTICLE_MODEL:-sonnet}" --effort high \
--output-format text --allowedTools "Read,Grep,Glob,Bash" --max-turns 20 2>>"$LOG")
# 記事本文の生成(article-daily-stock.sh 397-401行目)
run_to 1500 "$CLAUDE" -p "$PROMPT" \
--strict-mcp-config --mcp-config '{"mcpServers":{}}' \
--model "${ARTICLE_MODEL:-sonnet}" --effort high \
--output-format text --allowedTools "Read,Grep,Glob,Write,Bash" --max-turns 40 >> "$LOG" 2>&1 \
|| log "WARN: claude -p exit非0(検証で弾く)"
The key is the "${ARTICLE_MODEL:-sonnet}" form. Even if the ARTICLE_MODEL environment variable is undefined, it falls back to sonnet, so the "forgot it and fell through to the default quota" accident can't happen. It also satisfies a separate operational rule: "don't hardcode model IDs in code." If you embed a model ID as a string, you end up rewriting every script when the vendor retires an alias. Make it switchable through a single variable and you can flip every lane with one line: launchctl setenv ARTICLE_MODEL haiku.
One more thing: I always include the combination --strict-mcp-config --mcp-config '{"mcpServers":{}}'. Launching from launchd doesn't go through a login shell, so the .mcp.json in the home directory gets read. If it contains external MCP server configuration, the batch may hit external networks unintentionally. Explicitly passing an empty server config completely blocks external MCP calls inside the batch.
Designing strict_quota_message—The 400-Character Wall
Inside claude-quota-guard.py there's a function called strict_quota_message(). This is the single most important piece of detection logic for this incident.
# claude-quota-guard.py 41-47行目
STRICT_QUOTA_PATTERNS = (
r"(?:you(?:'ve| have) )?hit your (?:weekly |5-?hour |usage |session )?limit",
r"claude (?:ai )?usage limit reached",
r"approaching your (?:weekly |usage )?limit",
)
STRICT_QUOTA_MAX_CHARS = 400
# claude-quota-guard.py 159-170行目
def strict_quota_message(text: str) -> bool:
"""成功終了(exit 0)の本文が「上限メッセージそのもの」かを判定する。
上限本文は短く、その一文だけが返る。記事本文にたまたま同じ語が出ても発火させない。
"""
trimmed = text.strip()
if not trimmed or len(trimmed) > STRICT_QUOTA_MAX_CHARS:
return False
return any(
re.search(pattern, trimmed, flags=re.IGNORECASE)
for pattern in STRICT_QUOTA_PATTERNS
)
The STRICT_QUOTA_MAX_CHARS = 400 cap is the linchpin of the design. When Claude hits a limit, the string it returns is a single sentence like "You've reached your Fable limit." or "You've hit your weekly limit."—a few dozen characters at most. A properly generated article body, on the other hand, is almost never under 400 characters. This length asymmetry is what mechanically separates "this is the limit message itself" from "this is an article body that mentions limits."
The regex patterns are designed with the same thinking. The loose QUOTA_PATTERNS (used when exit is non-zero) and the strict STRICT_QUOTA_PATTERNS (used when exit is 0) are kept separate to prevent a false positive where an article whose subject happens to be "quota" trips my own circuit breaker. In fact, during the period when this check was still loose, I had an incident where "an article written about quota tripped the circuit breaker," which is how I settled on the current implementation that switches detection logic based on exit 0 vs. non-zero.
The Postcondition Gate—"There Was Output" Is Not Success
article-daily-stock.sh includes a function called article_ok() (lines 133–181). Even if claude -p returns a healthy exit code, no article is accepted unless it passes this function.
# article-daily-stock.sh 133行目
MIN_ARTICLE_BYTES=1200
# article-daily-stock.sh 173-181行目
article_ok() {
local f="$1"
[ -s "$f" ] || return 1
[ "$(stat -f%z "$f" 2>/dev/null || echo 0)" -ge "$MIN_ARTICLE_BYTES" ] || return 1
grep -qE '^title:' "$f" || return 1
frontmatter_title_ok "$f" || return 1
grep -qiE 'request timed out|不明な商品|TODO: *本文|\(生成失敗\)' "$f" && return 1
return 0
}
At least 1200 bytes, a title: frontmatter key present, a title of 70 characters or fewer, and no timeout phrasing mixed in—only when all four conditions are met is the run recorded as "generation succeeded." This validation block sits immediately after claude -p, and if the file doesn't meet the bar, it's deleted and the script exits (lines 411–415). So as not to waste the day's slot, the marker file (~/.claude/logs/.article-daily-done-${TODAY}) is designed to be created "only after validation passes" (line 509).
A postcondition is evidence that "generation completed correctly." Not whether a file exists, but whether the file's contents have the expected quality, verified mechanically. Since switching to this mindset, the misreading of "the job ran = output was stocked" has gone away.
A grep Gate That Mechanically Catches the Omission
It's hard for a human to find a missing --model. The more files you have, the more visual inspection breaks down. The following one-liner sweeps across ~/.claude/scripts/ and ~/Library/LaunchAgents/ and extracts "lines that have claude -p but no --model."
# plist配下のジョブスクリプトで --model を書き忘れている箇所を抽出する
grep -rn "claude -p" ~/.claude/scripts/ \
| grep -v -- '--model' \
| grep -v '^Binary'
Ideally this would go into CI or a pre-commit hook, but launchd jobs aren't connected to normal CI. Instead I use the following approach. During the weekly launchctl list | grep com.shun check that confirms all jobs exist, I run the grep above alongside it. Zero lines back means everything's safe. Even one line back means something's missing somewhere.
In cases of multi-level calls—a script called from a plist calls a script which calls another—you need to follow the chain recursively down to the leaf script.
# 多段呼び出しも含めて再帰的に検索
grep -rn "claude -p\|claude --print" \
~/.claude/scripts/ \
~/dev/ \
--include="*.sh" --include="*.mjs" --include="*.js" --include="*.py" \
| grep -v -- '--model' \
| grep -v '#.*claude -p' # コメント行は除外
Without narrowing target extensions via --include, the results balloon. The four types .sh .mjs .js .py covered 90% of my environment.
Where I Got Stuck
Three Weeks Without Noticing, Until Stock Hit 2
gen-column.mjs is a job that generates one LINE column every morning. It was supposed to raise an alert once stock dropped below 5, but for some reason it never fired. When stock hit 2, I was manually looking at Discord notifications and finally thought, "something's wrong."
Digging through the logs, every morning looked like this.
[2026-08-14 08:03:12] claude -p "..." 実行
[2026-08-14 08:03:19] 出力: "You've reached your Fable limit."
[2026-08-14 08:03:19] 出力文字数: 32
[2026-08-14 08:03:19] ジョブ完了
The job "completed" and returned exit 0, so quota-guard's tally recorded it as "success." The stock-count logic was adding "normal completion = 1 article generated," so the stock count appeared to be increasing. In reality, not a single one was added.
What exposed it was counting the actual number of files in stock directly with ls. It was 2. The gap between the job's success count and the real file count was the evidence.
After that, I made two changes: stock monitoring uses the real file count instead of a counter, and a postcondition check runs after generation. A counter is a declaration of intent to "generate"; it is not evidence that generation "succeeded."
The Wording Changed and the Circuit Breaker Let It Straight Through
On September 2, 2026, it came to light that QUOTA_PATTERNS in claude-quota-guard.py did not include session limit.
That day's error message looked like this.
You've hit your session limit · resets 6:50pm (Asia/Tokyo)
This is the wording for the "5-hour session limit." It's phrased differently from the earlier "weekly limit" or "Fable limit." Because QUOTA_PATTERNS at the time didn't have this pattern, quota detection slipped through. As a result, every job that day—including 3 note lanes—kept firing blanks against the limit, and 306 lines of wasted Claude calls piled up over 4 hours.
The fix was adding r"resets?\s+(?:at\s+)?\d{1,2}(?::\d{2})?\s*[ap]m" to QUOTA_PATTERNS (claude-quota-guard.py line 37). It's a pattern that catches the combination of the verb "resets" and a time in am/pm format. That line is still in the code today, with a comment recording the live account: "2026-09-02: the 5-hour limit comes back in the form of ~."
The lesson here is the principle that quota detection must not depend on the vendor's wording. Vendors change wording. They don't announce it. Anthropic has already used multiple phrasings: "You've reached your limit," "You've hit your Fable limit," "You've hit your session limit." Pattern matching needs to be written on the "structural features of the wording," not "exact match of the wording."
The Circuit Breaker Stopped the Posting Jobs Too
On August 21, 2026, when the circuit breaker opened (= detected quota exhaustion), a problem arose where not only generation jobs but posting jobs stopped as well.
There's a record in the comments of claude-quota-guard.py.
# 🔴 2026-08-21: circuit が開くと全ジョブが一律で止まるため、消費の大半を占める
# 返信/エンゲージ系がクォータを使い切った巻き添えで「投稿」まで停止していた。
# 実測(launchd.log 累計): xpilot.autopost は 231実行/308スキップ=実行率43%で、
# threadspilot.engage(64%) より優先度が低い扱いになっていた。投稿はその時間帯を逃すと
# 二度と埋まらないので...
Timing is everything for posting. A post that misses its 8 a.m. slot won't have the same effect if it goes up at night. Its priority is fundamentally different from a generation job's.
The cause was a design in which "the circuit breaker stops all jobs without distinction." The generation jobs were the ones that exhausted the quota, yet the posting jobs got taken down as collateral.
The remedy was adding a --priority flag (it remains in the current code as the priority: bool argument of run_job()). Priority jobs are allowed, via claim_priority_probe(), to attempt one run per PRIORITY_PROBE_INTERVAL (default 30 minutes) even while the circuit is open (claude-quota-guard.py lines 454–468). If the attempt succeeds, it's judged that "quota has recovered" and the circuit closes. With this design, one mechanism achieves two goals: "posting doesn't stop, and recovery is detected early."
Exit 0, but the Output Was a Quota Message
The hardest pattern to find was "exit code 0 with a limit message on stdout." This happens not only when --model is omitted, but also under certain conditions where claude returns the quota message as a "success."
The original quota-guard recorded exit code 0 as "success" and leaned toward closing the circuit. So when a limit message came back with exit 0, the circuit didn't open, the job was recorded as "success," and the actual output was just the one limit line—a state that could persist for cycle after cycle.
Introducing strict_quota_message() (the 400-character check described earlier) was the direct fix for this. Even with exit 0, if stdout meets that function's conditions, it's treated as quota detection and the circuit opens (claude-quota-guard.py lines 187–190).
def is_quota_response(returncode: int, text: str) -> bool:
if returncode != 0:
return quota_message(text) # ゆるいパターン
return strict_quota_message(text) # 厳しいパターン(400字以下+構造マッチ)
Judge by both the exit code and the output content. Since this design went in, there have been no more misses from "exit 0 false successes."
Looking back now, all four failures grew from the same single root: the property that "claude -p doesn't error even with incomplete arguments." Incomplete arguments, unexpected behavior—Claude returns something regardless. Because that "something" gets recorded as output, everything looks normal if monitoring is lax.
Rather than designing jobs on the assumption of perfection, assume imperfect execution and layer detection and recovery on top—since moving to that philosophy, accidents like a four-lane simultaneous stall haven't happened.
Pitfalls
Listed in the order I actually hit them. For every one, I thought "I'll notice eventually"—and didn't.
Omitting --model doesn't make the command error
claude -p runs silently even with incomplete arguments. When omitted, it shares the interactive session's quota, so the problem surfaces "when the interactive quota runs out," not the moment you write the batch. It doesn't break the day you forget it; it dies quietly weeks later at some unrelated moment. The farther apart cause and effect are, the harder it is to spot the cause.
A limit message comes back with exit 0
When --model is omitted and the interactive quota runs out, claude -p writes "You've reached your Fable limit." to stdout and exits. There are cases where the exit code is 0 here. A script that judges success by $? can't distinguish the limit message from normal output. [ -s output.txt ] passes too, as long as the file isn't empty. Even 32 characters counts as "there was output."
The stock counter and the real file count drift apart
In the gen-column.mjs case, every time the job "completed" with exit 0 the success counter went up, and stock "appeared to be increasing." In reality, not one was added. A counter is a declaration that "I tried to generate," not evidence that "I generated." The reason it took three weeks to notice, until stock was actually at 2, was that the monitoring metric was the counter.
Four lanes stopping at once gets misread as "automation is working"
If one job stops, it's easy to notice "something's off." When four go silent at once, the unease of "everything's too quiet" fades. If anything, it pulls you toward "my hands are free = automation is working." The four-lane stall that surfaced on 9/3 was missed for three weeks by exactly this mechanism.
Vendors change quota wording without notice
The 9/2 error came in the form "You've hit your session limit · resets 6:50pm (Asia/Tokyo)," phrased differently from the earlier "weekly limit" and "Fable limit." QUOTA_PATTERNS at the time didn't include this pattern, and it slipped past quota detection. The result was 306 lines of wasted Claude calls over 4 hours. Write detection as "exact match of the wording" and it's neutralized the instant the vendor changes the phrasing.
The circuit breaker stops posting jobs uniformly
This was the 8/21 incident. When generation jobs exhausted the quota, posting jobs stopped as collateral. Measured, xpilot.autopost's execution rate had dropped to 43%. Generation jobs can retry if they fail. A post that misses its window can never be filled in. Stopping jobs with fundamentally different priorities under the same circuit breaker was the design flaw.
launchd doesn't go through a login shell
A script started from a plist runs in a different environment than when you run it manually from the terminal. The PATH in ~/.zshrc isn't read. node and claude commands managed by nvm can't be found. That's why com.shun.article-daily.plist explicitly sets PATH under EnvironmentVariables. This is the cause of the vast majority of "works locally, dies under launchd."
.mcp.json gets read and the batch hits external networks
Under launchd, the .mcp.json in the home directory is read automatically. If there's MCP server configuration for local development, the batch goes out to connect externally without meaning to. Without --strict-mcp-config --mcp-config '{"mcpServers":{}}', a batch can do things you can't reproduce locally.
Hardcoding model IDs in scripts causes mass breakage
When the vendor retires or renames a model alias, you end up rewriting every script with the string embedded. Write it with the version baked in, like --model claude-sonnet-4-5, and every model update means a tour through all your scripts.
The "alert when stock drops below 5" never fired
Because the alert logic pulled the stock count from the counter, the alert didn't fire while the counter was "increasing" (it actually wasn't). The monitoring metric has to be the real file count on the filesystem, not the generation job's success count.
Best Practices
Distilled from the four-lane simultaneous stall and three weeks of unnoticed loss, these are the rules I still apply to every job.
1. Always specify --model on claude -p
This is the starting point for everything. Omission is forbidden. Before writing a job, make it a rule that "a claude -p without --model does not exist."
2. Write it with an environment-variable fallback
--model "${ARTICLE_MODEL:-sonnet}"
Don't write model IDs directly into scripts. The :-sonnet fallback for the undefined case prevents "forgot it and fell to the default quota," and one line—launchctl setenv ARTICLE_MODEL haiku—switches every job.
3. Put a postcondition gate immediately after claude -p
MIN_ARTICLE_BYTES=1200
article_ok() {
[ -s "$f" ] || return 1
[ "$(stat -f%z "$f")" -ge "$MIN_ARTICLE_BYTES" ] || return 1
grep -qE '^title:' "$f" || return 1
grep -qiE 'request timed out|TODO: *本文' "$f" && return 1
return 0
}
"There was output" is not success. Minimum byte count, presence of frontmatter, no timeout phrasing mixed in—only when all three conditions are met is it recorded as "generation succeeded."
4. Monitor stock by real file count
A counter is intent; the real file count is evidence. Base the alert metric on the actual number from ls ~/.claude/stock/ | wc -l. If the counter goes up but the files don't, it's a failure.
5. Write quota detection on the "structure of the wording"
Instead of an exact match like "Fable limit", use structural patterns like these.
r"(?:you(?:'ve| have) )?hit your (?:weekly |5-?hour |usage |session )?limit"
r"resets?\s+(?:at\s+)?\d{1,2}(?::\d{2})?\s*[ap]m"
Even if the vendor changes the wording, any phrasing with the structure of "you've hit a limit" gets caught.
6. Judge by both exit code and output content
def is_quota_response(returncode: int, text: str) -> bool:
if returncode != 0:
return quota_message(text) # ゆるいパターン
return strict_quota_message(text) # 厳しいパターン(400字以下+構造マッチ)
To prevent exit 0 false successes, check the exit code and stdout independently.
7. Use a 400-character cap to separate quota messages from article bodies
The message Claude returns at a limit is a few dozen characters. A normal article body is almost never 400 characters or less. If len(trimmed) > 400, don't treat it as a limit message—this one line prevents the false positive where "an article about quota trips my own circuit breaker."
8. Put --strict-mcp-config --mcp-config '{"mcpServers":{}}' into batches as a pair
Under launchd, .mcp.json gets read. Explicitly passing an empty server config shuts down the accident of a batch unintentionally calling external MCP. These two flags are one setting together. One alone doesn't work.
9. Change circuit-breaker behavior by job priority
Don't stop posting jobs and generation jobs with the same circuit breaker. Mark posts with priority: true and allow one attempt per PRIORITY_PROBE_INTERVAL (every 30 minutes) even while the circuit is open. A successful attempt detects quota recovery and closes the circuit—one mechanism achieves both keeping posts alive and detecting recovery early.
10. Run the grep gate the moment you write a plist
grep -rn "claude -p\|claude --print" \
~/.claude/scripts/ ~/dev/ \
--include="*.sh" --include="*.mjs" --include="*.js" --include="*.py" \
| grep -v -- '--model' \
| grep -v '#.*claude -p'
Zero lines means every job is safe. Even one line back means something's missing. Always run it when adding a plist. Pairing it with the weekly launchctl list check turns it into a habit.
11. The three-piece set: ProcessType: Background / LowPriorityIO: true / Nice: 10
Put this combination into every batch job. By explicitly telling the OS "low-priority background task," it won't interfere with foreground work like having Figma open or researching in a browser. The plist default sets nothing (Standard priority), so if you don't specify, your foreground gets squeezed.
12. Set the launchd PATH explicitly via EnvironmentVariables
node and claude managed by nvm exist only in the login shell's PATH. Since launchd launches don't go through a login shell, specify a PATH containing nvm's binary path under the EnvironmentVariables key. In the com.shun.article-daily.plist implementation, ~/.nvm/versions/node/v24.13.0/bin goes at the front of PATH.
13. For multi-level calls, grep recursively down to the leaf
In the chain plist → quota-guard.py → run-and-notify.sh → article-daily-stock.sh → claude -p, applying the grep gate only to the plist never inspects the leaf script. Specify .sh .mjs .js .py via --include and search recursively to the end of the call chain.
14. Leave dated incident comments in the script
Line 37 of claude-quota-guard.py still has the comment # 2026-09-02: 5時間上限は〜の形式で返る. Writing down why a pattern was added and when means that six months later, you (or Claude Code) don't have to guess "why is this pattern here" when reading the code. Incident history gets read more when it sits next to the code than in a commit message.
Summary
Omitting --model is a one-line oversight. It produced an outage that lasted three weeks, hit four lanes simultaneously, and drained stock down to 2. The command doesn't error, it returns exit 0, and the log says "there was 1 line"—when those three overlap, a human eye won't catch it for weeks.
To use automation as infrastructure, you can't design jobs on the premise that they "work perfectly." The right philosophy is to assume imperfect execution and layer detection and recovery on top. Explicit --model is the starting point; the postcondition gate, real-file-count monitoring, structural pattern detection, and the priority-aware circuit breaker are its extensions.
Today, accidents where four lanes go silent at once no longer happen. The same principles apply as jobs grow, so even with 171 jobs running, the added mental cost is close to zero. "The environment works in my place" only holds because there's a mechanism in place for it to notice when it breaks and recover on its own.
Have you ever had a scheduled job that "succeeded" every single day while producing nothing—and what was the check that finally caught it?
The full picture of the system, the breakdown of the ¥1.2M/month, and the 30-day procedure are compiled in a paid note.
📕 How to actually make money with an autonomous Claude Code environment — system, real examples, getting started, and support
Written by **Lily* — I ship iOS apps and automate my content stack with Claude Code.
Follow along: Portfolio · X · GitHub*
Top comments (0)