# Why Your AI Side-Hustle Script Won't Run: 5 JavaScript Config Failures That Break Claude/OpenAI Tools (and How to Isolate Node v

> Source: <https://dev.to/_7fb6011b57d383122b5a/why-your-ai-side-hustle-script-wont-run-5-javascript-config-failures-that-break-claudeopenai-dlo>
> Published: 2026-06-12 00:07:58+00:00

If you've ever copied a working AI automation script, run `node tool.js`

, and watched it die with `Cannot use import statement outside a module`

or `fetch is not defined`

— this article gets you unstuck. By the end you'll have a 30-second decision tree to tell whether the bug lives in your `package.json`

, your `tsconfig.json`

, or your runtime, plus two copy-paste diagnostic scripts that print exactly which environment you're in and why your imports resolved the way they did.

These five failures account for the majority of "the code is identical but it won't run on my machine" reports I see in AI-tooling repos. None of them are logic bugs. They're all configuration mismatches between how your code is *written* and how the JavaScript engine is *told to read it*.

`Cannot use import statement`

— the package.json `type`

field vs Claude SDK examples
The single most common breakage. You grab a snippet from the Anthropic SDK README:

``` python
import Anthropic from '@anthropic-ai/sdk';

const client = new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY });

const msg = await client.messages.create({
  model: 'claude-opus-4-8',
  max_tokens: 1024,
  messages: [{ role: 'user', content: 'Say hi' }],
});
console.log(msg.content[0].text);
```

You save it as `tool.js`

, run `node tool.js`

, and get `SyntaxError: Cannot use import statement outside a module`

. The code is correct. The problem is that Node defaults every `.js`

file to CommonJS unless told otherwise. The SDK docs assume ESM.

The fix is one line in `package.json`

:

```
{
  "name": "ai-side-tool",
  "type": "module",
  "dependencies": {
    "@anthropic-ai/sdk": "^0.30.0"
  }
}
```

The gotcha nobody mentions: once you set `"type": "module"`

, every `require()`

in your *other* helper files breaks with `require is not defined in ES module scope`

. So if your repo mixes a copied ESM SDK example with your own `require('./config')`

helpers, fixing failure 1 immediately triggers a second wave of errors. The clean rule: pick one module system per package. If you must keep a CommonJS file inside a `"type": "module"`

package, rename it to `.cjs`

. If you need ESM in a CommonJS package, rename it to `.mjs`

. The file extension *overrides* the `package.json`

default — that's the escape hatch.

`fetch is not defined`

— assuming the browser's global in Node
A huge amount of AI automation code is written assuming `fetch`

exists globally, because every example was tested in a browser console or a recent runtime. If you're on Node 16 or 17, or any environment that strips globals, a raw call to the OpenAI or Claude REST endpoint dies instantly:

``` js
// Breaks on Node < 18 with: ReferenceError: fetch is not defined
const res = await fetch('https://api.anthropic.com/v1/messages', {
  method: 'POST',
  headers: {
    'x-api-key': process.env.ANTHROPIC_API_KEY,
    'anthropic-version': '2023-06-01',
    'content-type': 'application/json',
  },
  body: JSON.stringify({
    model: 'claude-opus-4-8',
    max_tokens: 256,
    messages: [{ role: 'user', content: 'ping' }],
  }),
});
```

`fetch`

landed as a stable global in Node 18 and is unflagged from Node 21 onward. The fast diagnostic is a one-liner: `node -e "console.log(typeof fetch)"`

. If it prints `undefined`

, you're below the line. The robust fix is to stop depending on the global and import explicitly so the code runs identically on old and new runtimes:

``` js
import { fetch } from 'undici'; // explicit, version-independent
```

The deeper trap here is the *reverse* assumption. People writing browser-side AI widgets sometimes paste in Node-only modules like `import fs from 'node:fs'`

and ship it to a bundler. That doesn't throw at write-time; it throws at *bundle* time or as a blank white screen with `Module "fs" has been externalized for browser compatibility`

. Failure 2 is really one symptom of a single root cause: **you don't actually know which environment your code is executing in.** That's what the diagnostic at the end solves.

`tsconfig.json`

`module`

vs `moduleResolution`

— TypeScript compiles, Node refuses
This one is brutal because `tsc`

reports zero errors and the failure only appears at runtime. You write TypeScript for a Claude tool-use loop, compile it, and Node throws `ERR_MODULE_NOT_FOUND`

on a relative import that you can plainly see exists on disk.

The cause: with `"module": "NodeNext"`

, Node's ESM loader requires explicit file extensions in relative imports, but TypeScript lets you write extensionless paths in source. So this source:

``` js
import { buildTools } from './tools';
```

compiles to output that Node rejects, because at runtime the ESM resolver wants `./tools.js`

, not `./tools`

. The fix is to write the `.js`

extension in your *TypeScript source* — yes, `.js`

even though the file is `.ts`

— and set the config to match:

```
{
  "compilerOptions": {
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "target": "ES2022",
    "strict": true,
    "outDir": "dist"
  }
}
js
import { buildTools } from './tools.js'; // .js, resolved correctly by NodeNext
```

If instead your tool runs under a bundler (Vite, esbuild) or `tsx`

, you want `"moduleResolution": "Bundler"`

and you must *not* write extensions. The two configs are mutually exclusive, and copying a `tsconfig.json`

from a Vite project into a plain-Node project is how this bug travels. The tell: `tsc`

is green but `node dist/index.js`

is red. Compilation success means nothing about runtime resolution.

This is a config failure with a *cost*, which is why it's the one to fear in an unattended side-hustle cron job. SDKs retry automatically on `429`

and `5xx`

. If you *also* wrap the call in your own retry, and your own retry has no backoff and no max-attempt ceiling, a single transient error turns into a tight loop that re-fires the model call as fast as the network allows. On a billed API that's real money; on a rate-limited key it just gets you throttled harder.

Here is a retry wrapper that is actually safe — bounded attempts, exponential backoff with jitter, and it respects the `Retry-After`

header the API sends back instead of guessing:

```
async function callWithBackoff(fn, { maxAttempts = 5, baseMs = 500 } = {}) {
  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
    try {
      return await fn();
    } catch (err) {
      const status = err?.status ?? err?.response?.status;
      const retriable = status === 429 || (status >= 500 && status < 600);
      if (!retriable || attempt === maxAttempts) throw err;

      // Honor server's Retry-After (seconds) if present, else exponential.
      const retryAfter = Number(err?.headers?.['retry-after']);
      const wait = Number.isFinite(retryAfter)
        ? retryAfter * 1000
        : baseMs * 2 ** (attempt - 1) + Math.floor(Math.random() * 250);

      console.warn(`attempt ${attempt} failed (status ${status}); waiting ${wait}ms`);
      await new Promise((r) => setTimeout(r, wait));
    }
  }
}
```

The config insight that makes this category disappear: if you're using an official SDK, it *already* retries (the Anthropic and OpenAI SDKs default to a small number of automatic retries with backoff). Adding your own naive loop on top is double-retrying. Either disable the SDK's retries via its constructor option and own the logic yourself, or keep the SDK's and don't add a loop. Pick one layer. The two-attempts-per-layer multiplication is what turns a 30-second blip into a runaway.

`dotenv`

loaded too late, so the API key is silently undefined
The final one is an ordering bug that masquerades as an auth bug. With ESM, all `import`

statements are *hoisted* and evaluated before any top-level code runs. So this looks right and is wrong:

``` js
import 'dotenv/config';            // looks like it runs first...
import { client } from './client.js'; // ...but this module's top-level code
                                       // already read process.env at import time
```

If `./client.js`

constructs the Anthropic client at module top level (`new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY })`

), that read happens during the import graph evaluation, and depending on resolution order the key can still be `undefined`

. The symptom is a `401`

even though `.env`

is sitting right there with a valid key — which sends people on a wild goose chase regenerating keys that were never the problem.

Two reliable fixes. Load env *before* the import graph using Node's `--import`

flag, so it's guaranteed first:

``` python
node --import dotenv/config tool.js
```

or make the client construction lazy so it reads the env only when first called, long after dotenv has run:

``` js
let _client;
export function getClient() {
  if (!_client) {
    if (!process.env.ANTHROPIC_API_KEY) {
      throw new Error('ANTHROPIC_API_KEY missing — check .env load order');
    }
    _client = new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY });
  }
  return _client;
}
```

That explicit `throw`

is the unsung hero. A clear "key missing" error at the right layer saves you from debugging the API when the bug is in your file ordering.

Every failure above traces back to one question: *which environment is this code running in, and how is it reading modules?* Run this once in any context — Node, a browser console, a bundler output, a GitHub Actions step — and it tells you the ground truth instead of you assuming:

``` js
function describeEnv() {
  const report = {
    runtime: 'unknown',
    moduleSystem: typeof require === 'function' ? 'CommonJS-ish' : 'ESM-ish',
    hasFetch: typeof fetch !== 'undefined',
    hasWindow: typeof window !== 'undefined',
    hasProcess: typeof process !== 'undefined',
  };

  if (report.hasWindow) report.runtime = 'browser';
  else if (report.hasProcess && process.versions?.node) {
    report.runtime = `node ${process.versions.node}`;
    report.fetchExpected = Number(process.versions.node.split('.')[0]) >= 18;
  }

  console.table(report);
  return report;
}

describeEnv();
```

Reading the output is a decision tree:

`runtime`

says `browser`

but you're importing `fs`

/`node:*`

→ that's Failure 2's reverse; remove the Node-only module or move it server-side.`runtime`

is `node 16.x`

and `hasFetch: false`

→ Failure 2; upgrade Node or import `undici`

.`moduleSystem`

is `CommonJS-ish`

but you wrote `import`

statements → Failure 1; set `"type": "module"`

or rename to `.mjs`

.`hasProcess: true`

but your `process.env`

key is empty → Failure 5; check dotenv load order with `--import`

.For GitHub Actions specifically, the runtime line is invaluable, because the runner's Node version often differs from your laptop's. A one-line workflow step pinning the version prevents the entire class of "works locally, fails in CI" reports:

```
- uses: actions/setup-node@v4
  with:
    node-version: '22'
```

None of these are AI-specific bugs — they bite every JavaScript project — but AI side-hustle tooling concentrates them because you're constantly pasting SDK examples from different eras and runtimes into one repo. The config files are the contract: `package.json`

's `type`

decides module system, `tsconfig.json`

's `module`

/`moduleResolution`

decides how imports resolve, and your Node version decides which globals exist. When a copied script won't run, don't reread the logic — run `describeEnv()`

and check those three contracts first. Ninety percent of the time the code was fine and the environment was lying to you.
