DEV Community

Bobby Chugani
Bobby Chugani

Posted on

Running the Claude CLI as a production backend when the API won't take your token

If you pay for a Claude Max subscription and try to reuse that OAuth token against
api.anthropic.com, you get a 404. Not a 401, which would at least tell you what
happened. The subscription and the API are separate products with separate billing,
and the token from one is not currency in the other.

That leaves you with a real cost decision. Either you pay twice, once for the
subscription your team already uses and again per-token for anything you build, or
you find a way to use the subscription you have. This post is about the second
option: running claude --print as a subprocess behind a small HTTP service, and
the four things that broke when I put it in production.

I have three of these running on one VPS now. They have been up for months. None of
this is subtle, but every one of these took a while to work out from a symptom that
did not obviously point at the cause.

The shape

A systemd unit runs a small Node server. It spawns the Claude CLI per request with
--print, captures stdout, and returns it. The public app calls that service over a
shared secret.

const args = [
  '--print',
  '--model', 'claude-sonnet-4-6',
  '--output-format', 'json',
  '--no-session-persistence',
  '--system-prompt', system,
];
const child = spawn(CLAUDE_BIN, args, { cwd: SANDBOX, env, stdio: ['pipe','pipe','pipe'] });
Enter fullscreen mode Exit fullscreen mode

--output-format json is worth taking. You get a wrapper object with the result plus
total_cost_usd, so you can log what each request would have cost and keep an honest
picture of what the subscription is actually saving you.

1. The CLI's own auto-updater deletes the binary you are executing

This one produced the best bug of the year. Intermittent EACCES on spawn, maybe
one request in a few hundred, never reproducible on demand.

The Claude CLI updates itself. It replaces /usr/local/bin/claude in place. If a
request happens to spawn during that swap, the file you are executing is gone
mid-execution. Under normal interactive use you would never notice. Under a
per-request service, you notice.

The fix is to stop executing the file that updates itself:

src="$(readlink -f /usr/local/bin/claude)"
dst="/opt/myservice/bin/claude"
install -m 0755 "$src" "$dst.new"
mv "$dst.new" "$dst"          # atomic replace, no window where dst is missing
Enter fullscreen mode Exit fullscreen mode

Point the service at the private copy and refresh it deliberately when you want a
newer CLI. DISABLE_AUTOUPDATER=1 in the unit environment is worth setting too, but
the private copy is the part that actually closes the race, because the updater you
are disabling is not always the one that runs.

2. CLAUDECODE leaks into child processes

If anything in your chain was itself launched from a Claude Code session, the
environment carries CLAUDECODE=1. A CLI spawned underneath sees it, believes it is
nested inside another session, and misbehaves.

const env = { ...process.env };
delete env.CLAUDECODE;
env.IS_SANDBOX = '1';     // required if the service runs as root
Enter fullscreen mode Exit fullscreen mode

This one is nasty because it works perfectly when you test it by hand from your own
shell and fails only when triggered through the path that matters.

3. Bulk loops hit a burst limit that looks exactly like a code bug

Fire claude --print back to back and the first ten to twenty-five calls succeed,
then everything fails until it cools off. I found this the expensive way: a
ninety-page batch job produced eleven pages and seventy-nine consecutive failures,
and a single call by hand minutes later worked fine. So not auth, not quota. Pure
throttling.

If you are doing bulk work: pace it, back off and retry two or three times, and make
failures re-queueable so a transient throttle does not consume the work item. And log
e.stderr, not e.message. execFileSync puts the entire command line in the
message, and with a long prompt that turns your log into unreadable noise. That
detail hid the real cause from me for an embarrassingly long time.

4. Turn the tools off if the input is untrusted

One of my services takes a URL from the public internet, fetches the page, and asks
the model to analyse it. The page content is a stranger's text arriving inside my
prompt.

'--tools', '',      // empty allowlist, every built-in tool disabled
Enter fullscreen mode Exit fullscreen mode

The model gets pre-fetched evidence and nothing else. It cannot fetch, cannot read
files, cannot be talked into either by a page that includes instructions addressed to
it. Everything the model needs is gathered by the Node process first and passed in as
data. If you take one thing from this post, take this one: prompt-level instructions
to ignore injected text are a request, not a control. An empty tool allowlist is a
control.

The one that surprised me: the CLI as a search provider

My server's IP is flagged. Every search engine I could reach from it either serves a
CAPTCHA or, worse, serves a page that looks like results and is not. I needed to know
whether a business ranks for its own name, and had no working way to ask.

The CLI has WebSearch built in, and it runs on Anthropic's infrastructure, so my IP
never enters the picture:

[bin, '--print', '--model', 'claude-haiku-4-5-20251001',
 '--no-session-persistence',
 '--allowedTools', 'WebSearch',
 '--permission-mode', 'acceptEdits',
 prompt]
Enter fullscreen mode Exit fullscreen mode

Both flags are needed. Without --allowedTools WebSearch a non-interactive session
refuses the tool and tells you to grant permission in settings, which is not
actionable from a daemon.

It takes thirty to ninety seconds, so it is a fallback rather than a primary. But it
costs nothing on a subscription I already pay for, and it works where four scraping
approaches did not.

A warning attached to that, since it nearly shipped. Before I found this, I tried
scraping Bing with a headless browser. It returned HTTP 200, the correct page title,
my query echoed back in the HTML, and ten well-formed result rows whose hrefs
base64-decoded cleanly. Every structural check passed. The decoded URLs pointed at a
children's clothing retailer on one run and French anime sites on the next: a
convincing SERP shell full of unrelated ads, served because the site did not trust
me. Assert that results contain your query's own terms. A row count proves
nothing at all.

Is this a good idea

For internal tooling, batch work and anything where a slow path is acceptable: yes.
It is the difference between a line item and no line item.

For latency-critical user-facing requests: no. Process spawn plus model time is
seconds, not milliseconds.

And read your plan terms rather than taking my word for what yours permits. I am
describing what worked, not making a claim about what you are allowed to do.

The site checker I built on top of this is open source and MIT, if you want to see
the shape in full: github.com/guestarAI/findable.
The scoring engine there has no LLM in it at all, which is rather the point. The
model writes the explanation; the deterministic part does the looking.

Top comments (1)

Collapse
 
vinhnguyenthanhdn profile image
Vinh Nguyen

Deleting CLAUDECODE only holds if your Node server is the real parent of the CLI — a supervisor or wrapper in between can reintroduce it without the service ever seeing it. I hit the mirror image of this in VS Code: a variable exported in the integrated terminal never reached the Claude Code panel, because the extension host is a sibling process tree started by the app rather than a child of that shell. The cheap check in both directions is to make the spawned process print its own environment once on a debug path, which separates "the value is wrong" from "this process never inherited it".