DEV Community

スシロー
スシロー

Posted on

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

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.

Failure 1: 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:

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);
Enter fullscreen mode Exit fullscreen mode

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"
  }
}
Enter fullscreen mode Exit fullscreen mode

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.

Failure 2: 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:

// 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' }],
  }),
});
Enter fullscreen mode Exit fullscreen mode

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:

import { fetch } from 'undici'; // explicit, version-independent
Enter fullscreen mode Exit fullscreen mode

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.

Failure 3: 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:

import { buildTools } from './tools';
Enter fullscreen mode Exit fullscreen mode

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"
  }
}
Enter fullscreen mode Exit fullscreen mode
import { buildTools } from './tools.js'; // .js, resolved correctly by NodeNext
Enter fullscreen mode Exit fullscreen mode

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.

Failure 4: An infinite retry loop that fires Claude/OpenAI calls back-to-back

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));
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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.

Failure 5: 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:

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
Enter fullscreen mode Exit fullscreen mode

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:

node --import dotenv/config tool.js
Enter fullscreen mode Exit fullscreen mode

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

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;
}
Enter fullscreen mode Exit fullscreen mode

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.

The Node-vs-browser isolation script that ends the guessing

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:

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();
Enter fullscreen mode Exit fullscreen mode

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'
Enter fullscreen mode Exit fullscreen mode

The mental model to keep

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.

Top comments (0)