p4ni.

Research

Your Scheduled Claude Code Agent Cannot Reach Your Own API

· 5 min read

On this page

I put my monthly monetization check on a Claude Code routine — a scheduled cloud agent that wakes up on the first of the month, audits the repository, reads the traffic numbers, and reports which phase of the plan I am actually in. The numbers were the whole point. I had already written a Cloudflare Worker that queries the GA4 Data API without Google’s SDK, plus a second one for Search Console, both sitting behind a bearer token. The routine would curl them and do the arithmetic.

The first run, on 1 August, came back with no numbers at all. It audited the repository fine. It just quietly reported the traffic section as unavailable and moved on.

What the proxy actually allows

On 19 August I went back and probed it properly from inside a routine. The execution environment sits behind an outbound proxy that allowlists by hostname, and the list is short. What went through:

  • api.anthropic.com and the rest of anthropic.com
  • the npm registry
  • PyPI
  • crates.io
  • proxy.golang.org

What came back 403:

  • hn.algolia.com
  • news.ycombinator.com
  • hacker-news.firebaseio.com
  • *.workers.dev — including p4ni-ga4-stats.reactpythonphp.workers.dev, which is mine

That last line is the one worth sitting with. The Worker is my code, on my Cloudflare account, protected by a token only I hold, written specifically to be called by this agent. None of that matters. The proxy does not know or care whose infrastructure is on the other end; the hostname is not on the list, so the connection dies before TLS. There is no self-serve way to add a domain, either — you cannot allowlist your way out from inside the agent, and the environment does not expose a config for it.

The allowlist has an obvious shape once you look at it: it is everything a coding agent needs to install dependencies and talk to the model, and nothing else. Package registries, plus Anthropic. It is a build sandbox, not a general-purpose runtime.

Why this is the right default

My first reaction was that this was a bug in my setup. It is not. A scheduled agent runs with nobody watching it, which is exactly the condition under which fetching arbitrary content is most dangerous. Anything the agent pulls over the network arrives as text in its context, and text in context is one bad paragraph away from being read as instructions — the failure mode I went through in detail in indirect prompt injection in the wild. At an interactive prompt you are sitting there to notice. On a cron schedule at 09:00 JST on the first of the month, you are not.

Locking egress to a registry allowlist removes the whole category. The agent can still install left-pad; it cannot be talked into POSTing your repository somewhere by a comment thread it fetched. If I were designing this I would make the same call, and I would rather find out the way I did — with a report that says “no numbers” — than not find out at all.

The cost is that a routine can only reason about what is already in front of it. So put it there.

Push, don’t pull

The fix is to move the network call to somewhere that has a network, run it on its own schedule, and commit the result to the repository the agent already checks out. The agent stops fetching and starts reading a file.

For this site that is a weekly GitHub Actions job. It collects Hacker News candidates for the idea backlog and, in the same run, hits both stats Workers and writes data/search-stats.json:

on:
  schedule:
    # 00:00 UTC Monday = 09:00 JST Monday
    - 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 "update candidates and search stats"
          git push

The runner has full outbound access, so the Workers answer normally. The routine now opens data/search-stats.json instead of calling anything, and the instructions it loads say so explicitly: read the file, do not re-fetch the API. Without that sentence the agent will try curl again on its own initiative, burn a minute on a connection that cannot succeed, and report a partial result — which is precisely what happened on 1 August.

Staleness is now your problem

Reading a file instead of an API moves the freshness question from the runtime to you, so the file has to carry its own timestamp:

{
  "collectedAt": "2026-08-19",
  "range": { "start": "2026-07-19", "end": "2026-08-16" },
  "ga4": { "host": "astro.p4ni.com", "pageViews": 32, "activeUsers": 12 }
}

The agent’s instructions tell it to check collectedAt and to say so in the report if the data is more than a week old. That is the honest version of a cached number: still usable, labelled. A weekly collector feeding a monthly routine means the numbers are at worst six days behind, which for a monetization checkpoint is noise.

Two smaller things fall out of this design. I do not accumulate history inside the JSON — the file is overwritten every run and git log -p data/search-stats.json is the history, which keeps one file small instead of letting it grow with every article. And the collector writes null for any section whose token is missing rather than failing the job, so Search Console could stay empty for the weeks before that Worker was deployed while GA4 kept flowing.

The real cost is that the tokens now live in two places: GitHub Actions secrets for the scheduled collection, and my local .env for the times I want a fresh number by hand. GitHub is the source of truth and the local copy is the mirror. Not elegant, but the alternative is a routine that cannot see anything.

The rule I ended up with

A scheduled agent is good at reading, writing, and deciding. It is not a place to put I/O. Anything that needs the network — an API call, a scrape, a webhook — belongs in CI, on its own schedule, with its output committed as a file. Then the agent’s job is what it should have been in the first place: look at what is there and say something useful about it.

Worth checking before you design around a routine: which hostnames answer, and which return 403. It took me eighteen days to ask that question, and the answer took about two minutes.