cd /news/ai-tools/why-your-ai-side-hustle-script-won-t… Β· home β€Ί topics β€Ί ai-tools β€Ί article
[ARTICLE Β· art-24640] src=dev.to pub= topic=ai-tools verified=true sentiment=Β· neutral

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

A developer has identified five JavaScript configuration failures that cause AI automation scripts written for Claude and OpenAI tools to break when run locally, despite the code being logically correct. The most common issue is the `Cannot use import statement outside a module` error, which occurs when Node.js defaults `.js` files to CommonJS while the SDK examples assume ESM, requiring a `"type": "module"` field in `package.json`. Another frequent failure is `fetch is not defined` on Node versions below 18, which can be resolved by explicitly importing the fetch function from the `undici` package instead of relying on the global.

read9 min publishedJun 12, 2026

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:

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:

// 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:

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 requires explicit file extensions in relative imports, but TypeScript lets you write extensionless paths in source. So this source:

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:

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:

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:

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:

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.

── more in #ai-tools 4 stories Β· sorted by recency
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain β€” perfect for shipping the agent you just read about.

$git push zahid main
β†’ Live at https://your-agent.zahid.host βœ“
Get free account β†’ Pricing
from €0/mo Β· no card required
LIVE [news/why-your-ai-side-hus…] indexed:0 read:9min 2026-06-12 Β· β€”