Magic Tools
Pitfall NotesBy CooconAugust 24, 2026385 views4 min read

You Set ANTHROPIC_BASE_URL. Claude Code Ignored It.

The Symptom

Point Claude Code at a third-party API gateway — the one-api / new-api family, anything that speaks the Anthropic wire format at your own URL. The setup looks textbook. Three lines in ~/.zshrc:

export ANTHROPIC_BASE_URL="https://xxxx.example.com"
export ANTHROPIC_AUTH_TOKEN="sk-..."
export CLAUDE_CODE_USE_VERTEX=0

New shell, run claude, and the header says:

Fable 5 · Google Vertex AI

Vertex. The gateway's base URL might as well not exist.

Same day, a second problem. CloudCLI — the renamed claudecodeui, a web UI that drives the local claude binary remotely — runs under macOS launchd (the Mac's equivalent of systemd). Log in, and it keeps asking me to pick a provider, claiming it isn't authenticated. Meanwhile the CLI in my terminal is happily hitting the gateway. Same user. Same machine.

Two symptoms, two unrelated root causes, one thing in common: you think the env var is set; the process disagrees.

Pitfall 1: settings.json env Outranks Your Shell Export

First, verify the shell itself. echo $ANTHROPIC_BASE_URL — correct. echo $CLAUDE_CODE_USE_VERTEX — 0. Nothing wrong at that layer.

So go read Claude Code's own config. Sitting in the env block of ~/.claude/settings.json, three lines of leftover test config:

{
  "env": {
    "CLAUDE_CODE_USE_VERTEX": "1",
    "ANTHROPIC_VERTEX_PROJECT_ID": "hello",
    "CLOUD_ML_REGION": "global"
  }
}

ANTHROPIC_VERTEX_PROJECT_ID is literally hello. Obvious placeholder from a Vertex experiment months ago, never cleaned up.

That's the root cause: the env field in settings.json is a forced injection, and it outranks anything you export in your shell. The CLAUDE_CODE_USE_VERTEX=0 in .zshrc gets overwritten with "1" at process start. Claude Code sees a complete Vertex config, takes the Vertex path, and never so much as looks at ANTHROPIC_BASE_URL — that variable isn't on the code path it chose.

The fix is deleting three lines.

claude -p "1+1"
# answers normally, over the gateway

One distinction worth nailing down. The env precedence is not the same rule as the model precedence chain (--model > ANTHROPIC_MODEL > settings.json > default, which I tested separately). In the model chain, settings.json sits below environment variables: it applies only if you didn't set anything else. The env sub-field inverts that: if it's set, it wins. And it hides inside a JSON file you never open, unlike a shell config you scroll past daily — which is exactly why it bites harder than the model chain does.

Pitfall 2: launchd Services Never Read Your Shell Config

CLI fixed. CloudCLI still unauthenticated.

Open its plist:

<!-- ~/Library/LaunchAgents/com.xxxx.claudecodeui.plist -->
<key>EnvironmentVariables</key>
<dict>
    <key>PATH</key>
    <string>/usr/local/bin:/usr/bin:/bin</string>
    <key>HOME</key>
    <string>/Users/xxxx</string>
</dict>

PATH and HOME. That's the whole environment. No ANTHROPIC_BASE_URL, no ANTHROPIC_AUTH_TOKEN.

Root cause: launchd does not read ~/.zshrc. Exactly like systemd on Linux — a service process is not forked from your interactive shell, so every export in .zshrc / .bashrc is invisible to it. CloudCLI's own environment is empty, so the environment it hands down to the claude child process is empty too.

The fix is not stuffing the variables into the plist. That means writing a token into an XML file and maintaining a second copy of your config. Put the gateway settings in the env block of ~/.claude/settings.json instead:

{
  "env": {
    "ANTHROPIC_BASE_URL": "https://xxxx.example.com",
    "ANTHROPIC_AUTH_TOKEN": "sk-..."
  }
}

From there, both paths pick it up:

  • the Claude Code CLI reads settings.json's env on every start, independent of whatever the parent process handed it;
  • CloudCLI's auth logic reads that same file on purpose — the comment on its loadSettingsEnv says so outright: it works even when the service process env is empty.

Same mechanism, opposite roles. In pitfall 1 the env block was the culprit; in pitfall 2 it's the cure.

Verification: Simulate the Service Environment With env -i

Don't verify by restarting launchd and logging into the web UI again — that feedback loop is far too slow. Wipe the environment right in your terminal and reproduce what the service actually gets:

env -i HOME="$HOME" PATH="/usr/local/bin:/usr/bin:/bin" claude -p "1+1"

env -i drops every inherited variable and keeps only what you pass explicitly — which is precisely as much as launchd provides. If that command returns an answer, your config genuinely lives somewhere the tool reads for itself, rather than being spoon-fed by the shell.

The trick generalizes to every "works in my terminal, breaks in the service" report: cron, systemd, Docker entrypoints. Same environment discontinuity, same one-line probe.

Lessons to Take Away

  1. When an env var "is set" but does nothing, check env in ~/.claude/settings.json first. A matching key there wins, no matter how many times you export it in your shell. And note the direction is the opposite of the model precedence chain — don't reuse one mental model for both.
  2. Service managers don't read shell config. Processes started by launchd, systemd, or cron cannot see your .zshrc. The moment an env-var-driven tool is launched by one of them, the config has to move somewhere the tool reads itself: settings.json, the service's own EnvironmentVariables, or a dedicated env file.
  3. env -i is the fastest way to fake a clean service environment. No restarts, no re-login — one command tells you whether the config landed in the right place.
  4. Clean up test leftovers. ANTHROPIC_VERTEX_PROJECT_ID: "hello" was obviously throwaway; it should have died with the experiment. It never throws an error. It just quietly fights your next config, months later, from the last file you'd think to open.

Get field notes like this every Saturday

Subscribe to Dev Breakfast: daily AI coding picks at 8:00, plus a Saturday roundup of this week's hands-on tests with Claude Code / Codex / local models. Written in Chinese.

Related Articles

Claude Code "Prompt is too long" vs "maximum context length": Why Auto-Compact Works for One and Not the Other (Tested)

Claude Code decides a request was too long by matching the error text. If the backend says prompt is too long or input is too long for requested model, it auto-compacts the conversation and retries — invisible to you. DeepSeek and OpenAI-style gateways say This model's maximum context length is …, which it doesn't recognize: you get API Error: 400 on every turn. Manual /compact works; the better fix is to declare the real window with CLAUDE_CODE_MAX_CONTEXT_TOKENS (non-claude- model IDs) or CLAUDE_CODE_AUTO_COMPACT_WINDOW (claude- IDs) so it compacts before hitting the limit. Tested on Claude Code 2.1.285 against a local stub.

claude-codedeepseek+6
pitfallsOct 5, 20267 min
109

Claude Code Stuck on a Spinner With No Response: It Waits 6 Minutes per Attempt, and 10 Retries Can Hang It for Over an Hour (Tested)

When Claude Code spins without output or sits at Retrying in 0s, the backend is usually not sending anything. Tested on 2.1.285 (API key + ANTHROPIC_BASE_URL): with no response headers it waits 6 minutes (360 s) per attempt before timing out — setting API_TIMEOUT_MS to 600000 or 900000 doesn't change that; only lower values work. With the default 10 retries it can hang for over an hour. A gateway that buffers the reply until generation finishes will never deliver a reply that takes over 6 minutes. Press Esc to interrupt. All tested against a local stub.

claude-codetroubleshooting+5
pitfallsOct 5, 20269 min
90

Claude Code 429 "Request rejected (429)": How Long It Retries, and Why retry-after Over 60 Seconds Fails Instantly (Tested)

On a 429, Claude Code retries up to 10 times over about 3 minutes. If retry-after is 60 seconds or less it waits the full time (10 s and 60 s both recovered in testing); at 61 seconds or more it does not retry at all and fails immediately with API Error: Request rejected (429). Gateway messages such as new-api's are shown verbatim; an empty body shows status code (no body). In interactive mode every prompt sends 2 requests, so rate-limit usage doubles. All tested against a local stub on Claude Code 2.1.285.

claude-codetroubleshooting+4
pitfallsOct 4, 20268 min
86
Claude Code MCP Shows Connected but 0 Tools: "Invalid result for tools/list" (ttlMs / cacheScope), Reproduced and Fixed

Claude Code MCP Shows Connected but 0 Tools: "Invalid result for tools/list" (ttlMs / cacheScope), Reproduced and Fixed

An MCP server shows as connected but exposes 0 tools, and the log says Invalid result for tools/list with ttlMs and cacheScope failing validation. Reproduced with a stub server on Claude Code 2.1.280, 2.1.285 and 2.1.288: the cause is not extra fields being rejected. The server negotiated MCP 2026-07-28 and then left out fields that revision requires (resultType, ttlMs, cacheScope); adding unknown fields works fine. Whether stdio uses the new protocol is decided by a remote flag that is off by default, so the same version breaks for some users and not others, and 2.1.280 fails the same way once negotiation is on. MCP_PROTOCOL_NEGOTIATION=legacy (also works in settings.json env) restores the tools; servers fix it by adding the three fields.

mcpclaude-code+4
pitfallsOct 3, 20268 min
80

Published by Magic Tools