研究紹介
Claude Code の定期エージェントは自分で建てた API にも届かない
· 読了まで約7分
目次
月次の収益化チェックを Claude Code の routine に載せた。毎月 1 日の朝に起きて、リポジトリを監査し、アクセス数を読み、計画のどのフェーズにいるかを報告する定期エージェントだ。狙いは数値のほうにあった。Google の SDK を使わずに GA4 Data API を叩く Worker はすでに書いてあり、Search Console 用にもう 1 本、どちらもベアラートークンで守ってある。routine がそれを curl して集計すればいい、という設計だった。
8 月 1 日の初回実行は、数値のところが空のまま終わった。リポジトリの監査は問題なく済んでいる。アクセス数だけ「取得できず」と書いて先へ進んでいた。
何が通り、何が 403 で落ちるか
8 月 19 日に routine の中から改めて叩いて確かめた。実行環境はホスト名の許可リストを持つ送信プロキシの内側にあり、そのリストはかなり短い。
通ったもの。
api.anthropic.comをはじめとするanthropic.com- npm のレジストリ
- PyPI
- crates.io
proxy.golang.org
403 が返ったもの。
hn.algolia.comnews.ycombinator.comhacker-news.firebaseio.com*.workers.dev—— 自分のp4ni-ga4-stats.reactpythonphp.workers.devを含む
最後の 1 行が本題だ。この Worker は自分のコードで、自分の Cloudflare アカウントにあり、自分しか持っていないトークンで守ってあり、そもそもこのエージェントから呼ばれるために書いた。それでも通らない。プロキシは接続先が誰の持ち物かを見ていないし、見る必要もない。リストに無いホスト名なら TLS を張る前に落ちる。しかも許可ドメインを自分で足す手段が無い。エージェントの側から許可リストは広げられないし、環境側にもその設定は出ていない。
許可されている顔ぶれを並べると、この環境が何のために作られたかがはっきりする。依存パッケージを取ってくる先と、モデルを叩く先だけだ。汎用の実行環境ではなく、ビルド用のサンドボックスとして設計されている。
塞いであるのは正しい
最初は自分の設定ミスだと思った。そうではなかった。定期エージェントは誰も見ていないところで動く。任意のコンテンツを取りに行かせるのがいちばん危ないのは、まさにその条件下だ。取ってきたものはテキストとしてコンテキストに入り、コンテキストに入ったテキストは、悪意ある一段落を挟むだけで指示として読まれうる。実際に観測された間接プロンプトインジェクション で追いかけたのがこの経路だった。対話中なら人が見ているので気づける。毎月 1 日の朝 9 時に無人で走っている最中は、誰も見ていない。
送信先をレジストリだけに絞れば、この危険はまるごと消える。エージェントは left-pad を入れられるが、拾ってきたコメント欄に唆されてリポジトリをどこかへ送らされることはない。自分が設計する側でも同じ判断をすると思う。しかも気づき方としては、「数値なし」の報告が上がってくるのは悪くないほうだった。
代わりに払う対価は、routine が目の前にある物しか扱えないことだ。だったら目の前に置いておけばいい。
取りに行かせるのをやめて、置いてある物を読ませる
やることは単純で、ネットワーク呼び出しをネットワークのある場所へ移し、別のスケジュールで走らせ、結果をエージェントがどうせチェックアウトするリポジトリにコミットする。エージェントは fetch をやめてファイルを読む。
このサイトでは週次の GitHub Actions がその役をしている。ネタ候補を Hacker News から拾い、同じ実行の中で 2 本の Worker を叩いて data/search-stats.json を書く。
on:
schedule:
# UTC 月曜 00:00 = JST 月曜 09:00
- cron: '0 0 * * 1'
workflow_dispatch:
permissions:
contents: write
jobs:
scan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with:
node-version: 22
- run: node scripts/collect-hn.mjs
- run: node scripts/fetch-stats.mjs
env:
GA4_STATS_TOKEN: ${{ secrets.GA4_STATS_TOKEN }}
GSC_STATS_TOKEN: ${{ secrets.GSC_STATS_TOKEN }}
- name: Commit if anything changed
run: |
if git diff --quiet -- docs/IDEAS.md data/; then exit 0; fi
git config user.name 'github-actions[bot]'
git config user.email '41898282+github-actions[bot]@users.noreply.github.com'
git add docs/IDEAS.md data/
git commit -m "ネタ候補と検索実績を更新"
git push
runner は外に出られるので、Worker は普通に応答する。routine 側は何も叩かず data/search-stats.json を開くだけになった。あわせて、エージェントに読ませる手順書に「このファイルを読む。API を叩き直さない」と明記してある。この 1 文が無いと、エージェントは自分の判断でまた curl を試し、成立しない接続に時間を使って、途中までの結果を報告する。8 月 1 日に起きたのがそれだった。
鮮度の管理は自分の仕事になる
API ではなくファイルを読ませると、データが新しいかどうかを保証する役目が実行環境から自分に移る。だからファイル自身に日付を持たせる。
{
"collectedAt": "2026-08-19",
"range": { "start": "2026-07-19", "end": "2026-08-16" },
"ga4": { "host": "astro.p4ni.com", "pageViews": 32, "activeUsers": 12 }
}
エージェントの手順書には、collectedAt を見て 1 週間以上古ければ報告にその旨を添えろ、と書いてある。キャッシュされた数値の誠実な扱い方はこれだと思う。使ってよいが、古さは明示する。週次で集めた数値を月次の routine が読むので、ずれても 6 日。収益化のチェックポイントには十分な精度だ。
この形にすると細かいところが 2 つ決まる。まず履歴を JSON の中に溜めない。毎回上書きして、推移は git log -p data/search-stats.json に持たせる。配列で積むと記事が増えるぶんだけファイルが膨らむわりに、欲しいのはたいてい直近の数字だけだ。もう 1 つ、トークンが無い項目は null で通してジョブ自体は成功させる。おかげで Search Console 側の Worker をデプロイするまでの数週間、そこだけ空のまま GA4 の数値は流れ続けた。
本当の対価はトークンの置き場が 2 か所になることだ。定期収集用に GitHub Actions の secrets、手元で最新値が欲しいとき用に .env。GitHub 側を正としてローカルは写しにしている。きれいではないが、置き場を 1 か所に寄せようとすると routine の側が何も見えなくなる。
routine に I/O を置かない
定期エージェントが得意なのは読むこと・書くこと・判断することで、I/O を置く場所ではない。ネットワークが要る仕事 —— API 呼び出し、スクレイピング、webhook —— は CI に降ろし、それぞれのスケジュールで走らせて、出力をファイルとしてコミットする。そうすればエージェントの仕事は本来あるべき形に戻る。置いてあるものを見て、そこから言えることを言う。
routine を前提に設計を始める前に、どのホストが応答してどこが 403 を返すかだけは確かめておくといい。この質問にたどり着くまで 18 日かかったが、答えは 2 分で出た。