nodejs skill
Write, review, and debug Node.js runtime code, including modules, package manifests, streams, the event loop, and native TypeScript execution.
- GitHub stars
- 23
- Files
- 17
- Last commit
- Nov 11, 2026
- License
- MIT
- Allowed tools
- —
What does the nodejs skill do?
nodejs is an agent skill. Write, review, and debug Node.js runtime code, including modules, package manifests, streams, the event loop, and native TypeScript execution.
Summary generated from the skill's SKILL.md.
About the nodejs skill
Developers working on Node.js runtimes can use this skill to write, review, and debug server-side code, CLI tools, and scripts. It provides specialized guidance for managing the module system, package manifests, and the event loop. When an agent encounters runtime errors like ERR_MODULE_NOT_FOUND or ERR_REQUIRE_ESM, it can load this skill to diagnose issues involving ESM/CommonJS interoperability, stream buffering, or process lifecycle hangs.
The skill focuses on the specific constraints of the Node.js environment, such as the engines.node floor in package.json and the stability index of various APIs. It assists with native TypeScript execution, helping to avoid syntax errors like unsupported enums or decorators. It also covers the node:test runner and the permission model. By referencing specific LTS major versions and stability levels, it ensures that code remains compatible with the intended runtime version rather than just the local machine's binary.
What an agent can do with it
- 1Debug module resolution errorsIdentify why an import works in a bundler but fails in the Node.js runtime.
- 2Configure package exports correctlyDesign a package manifest that properly seals internals and handles conditional exports for different module systems.
- 3Validate TypeScript compatibilityEnsure TypeScript files can run natively without encountering unsupported syntax during type stripping.
- 4Optimize event loop performancePrevent I/O starvation by identifying recursive calls that block the process from serving traffic.
Written from the skill's SKILL.md. Every use case is checked against a quote from it.
How to install the nodejs skill
Each command copies the skill folder into the client's skills directory. Restart the client or start a new session to load it.Install the nodejs skill in Plugin
/plugin marketplace add xobotyi/cc-foundry /plugin install javascript@foundry
Install the nodejs skill in Claude Code
~/.claude/skills/nodejs/SKILL.md
$ npx degit xobotyi/cc-foundry/plugins/javascript/skills/nodejs ~/.claude/skills/nodejsInstall the nodejs skill in Claude Code (project)
.claude/skills/nodejs/SKILL.md
$ npx degit xobotyi/cc-foundry/plugins/javascript/skills/nodejs .claude/skills/nodejsInstall the nodejs skill in Codex
~/.agents/skills/nodejs/SKILL.md
$ npx degit xobotyi/cc-foundry/plugins/javascript/skills/nodejs ~/.agents/skills/nodejsInstall the nodejs skill in Gemini CLI
~/.gemini/skills/nodejs/SKILL.md
$ npx degit xobotyi/cc-foundry/plugins/javascript/skills/nodejs ~/.gemini/skills/nodejsInstall the nodejs skill in Claude.ai
npx degit xobotyi/cc-foundry/plugins/javascript/skills/nodejs nodejs && zip -r nodejs.zip nodejs # claude.ai → Customize → Skills → + → Create skill → Upload a skill → nodejs.zip
SKILL.md
View on GitHubOne thread runs every callback in a Node process, so anything that holds it holds the whole program. Three biases decide most calls:
- The
engines.nodefloor decides what may be written, not thenodebinary on the machine. - Read an API's stability index before depending on it. Node ships an API for years before committing to it, and an experimental one can be deleted without a deprecation cycle.
- Check the standard library before adding a dependency. It absorbed
fetch, a test runner, a file watcher,glob, SQLite,.envparsing, a script runner, and TypeScript execution.
Node Version
engines.node in package.json is the floor a project claims. Read it before writing code and never reach for an API
above it. Nothing enforces it at run time — Node never reads the field, and npm checks it only under
engine-strict=true — so the floor holds by discipline and by CI running the oldest major it names.
The installed binary is not the floor. node --version reports what one machine has, not what the project supports.
Support windows, which decide whether a floor is defensible:
- 20 — end of life 2026-04-30. A floor of
>=20claims a runtime that receives no fixes at all. - 22 — maintenance from 2025-10-21, end of life 2027-04-30. Security and critical fixes only; no new capability.
- 24 — Active LTS from 2025-10-28, maintenance from 2026-10-20, end of life 2028-04-30.
- 26 — released 2026-05-05, Active LTS from 2026-10-28, end of life 2029-04-30.
Floor version per feature — the APIs and behavior changes that decide what may be written.
- 20.19.0 —
require(esm)unflagged on the 20 line; syntax detection on by default - 22.0.0 —
node --run;fs.glob;WebSocketglobal; watch mode Stable;assert { type: 'json' }removed - 22.12.0 —
require(esm)unflagged on the 22 line; JSON modules Stable - 22.13.0 — Permission Model Stable and
--permissionaccepted;node:sqliteunflagged - 22.15.0 —
module.registerHooks() - 22.16.0 —
import.meta.dirnameandimport.meta.filenameStable;--experimental-config-file - 22.18.0 — type stripping on by default;
import.meta.main - 22.21.0 —
--env-fileStable;NODE_USE_ENV_PROXY - 24.0.0 —
--experimental-permissionrenamed--permission;AsyncLocalStorageonAsyncContextFrame;URLPatternglobal; test runner awaits subtests;url.parse()runtime-deprecated (DEP0169);argswith{ shell: true }runtime-deprecated (DEP0190) - 24.12.0 — type stripping Stable
- 24.20.0 —
--permission-audit - 26.0.0 —
Temporalglobal;module.register()runtime-deprecated (DEP0205);--experimental-transform-typesremoved;node:_stream_*removed. It also inherits what 25 added and neither 22 nor 24 received:--allow-net, and with it network confinement under--permission - 26.3.0 —
permission.drop()
No odd major is a defensible floor. Node 21, 23, and 25 are end of life — 25 since 2026-06-01 — so a feature that arrived on one is reachable only from the LTS major above it.
Read [${CLAUDE_SKILL_DIR}/references/versions/node-NN.md] — one file per LTS major: node-20.md, node-22.md,
node-24.md, node-26.md — when writing against a feature near the floor, and whenever raising the floor. Each carries
what its major added, the stability each addition holds, the behavior it changed, and the traps it introduced.
Stability Index
Every documented API carries one, and it decides how the API may be used, not merely how finished it is.
- 2 - Stable — protected by semver. Safe in a published library's surface.
- 1 - Experimental — outside semver, and the level most unfinished APIs print. Three subdivisions refine it: 1.2 - Release candidate, where no further breaking change is anticipated but none is ruled out; 1.1 - Active development, nearing minimum viability; 1.0 - Early development, unfinished and expecting substantial change.
- 3 - Legacy — semver-protected and unmaintained. Bugs in it are not fixed. Migrate rather than report.
- 0 - Deprecated — carries a DEP number and a migration.
What follows from the index:
- An experimental API can be removed with no deprecation cycle.
--experimental-transform-typesshipped in 22.7.0 and was deleted in 26.0.0, taking the runtime path for TypeScript enums with it. - Never put an experimental API in a published library's public surface. A consumer cannot see that the risk was taken on their behalf.
- The index moves per major.
TracingChannelis Stable from 26.8.0 and experimental on 24;module.register()is supported on 24 and runtime-deprecated on 26. State the index for the floor, not for the machine. - Check the index rather than the flag. Type stripping needs no flag from 22.18.0 and was not Stable until 24.12.0; an unflagged feature is not a committed one.
Module System
- Declare
"type"in everypackage.json. Without it, a.jsor extensionless file is parsed as CommonJS and re-parsed as an ES module when that fails. The retry costs startup time on every ES module and turns a real syntax error into a confusing module-system error. .mjsand.cjsoverride"type"; nothing overrides them. Use them to place one file of the other kind inside a package, never as the default.- Node's resolver performs no extension search and no directory-index lookup. It loads the specifier as written, so
./startupresolves in a bundler and throwsERR_MODULE_NOT_FOUNDhere. - Import a builtin through
node:—import fs from 'node:fs/promises'. The prefix cannot be shadowed by a package of the same name, andnode:testandnode:sqliteresolve under no other specifier. import.meta.dirnameandimport.meta.filenamereplace__dirnameand__filename, and exist only onfile:modules.- The
assertimport-attribute form was removed in 22.0.0 and throws. Onlywithparses. require()of an ES module works from 20.19.0 and 22.12.0, and throwsERR_REQUIRE_ASYNC_MODULEwhen anything in the graph uses top-levelawait. That is a property of the whole graph, so a transitive dependency can break a caller that did not change.- Reach for
module.registerHooks(), notmodule.register(). The latter is runtime-deprecated in 26.0.0 (DEP0205) and needs--allow-workerunder the Permission Model, because its hooks run on a worker thread.
Read [${CLAUDE_SKILL_DIR}/references/conventions/modules.md] when a specifier resolves in a bundler and not in Node,
when writing a loader hook, or when repeated start-up cost is worth caching — it carries what decides a file's module
system, the specifier rules, the two hook APIs, and the compile cache.
Package Manifest
"exports"seals the package. Once present, nothing outside the map is importable, deep relative paths included. Add it deliberately: it is the only way to keep internals private, and adding it to a published package is a breaking change.- Condition order in the map is the match order. Earlier keys win, so write the most specific first.
"default"goes last and"types"first."default"matches every caller, and"types"is a convention of the type systems rather than a condition Node reads."import"and"require"describe the caller's syntax, not the target's format. A.mjsfile behind"require"is legal and loads throughrequire(esm).- Use
"module-sync"instead of a dual build where one ES module must serve both callers. Two builds of one package give a process two class identities and two copies of every module-level singleton. "imports"keys start with#and are the only path-alias mechanism that survivesnode file.ts, because nothing in the runtime readstsconfig.json.- Every
"exports"target starts with./. Absolute paths,file:URLs,../, and any.,.., ornode_modulessegment are rejected withERR_INVALID_PACKAGE_TARGET.
Read [${CLAUDE_SKILL_DIR}/references/conventions/modules.md] when designing an "exports" map or publishing a package
that must serve both require and import — it carries the condition-matching rules, the target-path restrictions, and
the dual-package hazard.
TypeScript Execution
Node erases type syntax and runs the result. It does not compile, does not type-check, and does not read
tsconfig.json.
- Keep the project's type-check command in the verification path. Running a
.tsfile proves it parses, nothing more. import typeis mandatory for type-only imports. Erasure is syntactic: without the keyword the import survives as a value import and fails at run time.verbatimModuleSyntax: truemakestscenforce it.- Write the real extension in the specifier —
import './x.ts', andrequire('./x.ts')in CommonJS. Node loads the file that exists; it does not rewrite.jsto.ts. - Enums,
namespacewith runtime code, parameter properties, and import aliases raiseERR_UNSUPPORTED_TYPESCRIPT_SYNTAX.--experimental-transform-typeshandled some of them and was removed in 26.0.0. SeterasableSyntaxOnly: trueso the compiler rejects them first. - A decorator fails as a bare
SyntaxErrorinstead, with nocodeto match on — measured on 26.2.0. The parser rejects the@before type stripping runs. - A dependency shipping
.tsfiles cannot be loaded. Node refuses TypeScript under anynode_modulespath, deliberately and at every version.
Read [${CLAUDE_SKILL_DIR}/references/conventions/typescript-execution.md] when a .ts file fails to run, when
choosing between type stripping and a compiler, or when writing the tsconfig.json that matches the runtime — it
carries the flag history, the full refused-syntax list, and the matching compiler options.
Blocking
- No synchronous file, process, or crypto call on a request path.
readFileSync,execSync,pbkdf2Sync,inflateSyncbelong in CLIs, build scripts, and startup code that runs before the server listens. - Bound every input before parsing it.
JSON.parseis synchronous and scales with input size, so an unbounded body is a denial of service that needs no bug. - Never match a backtracking pattern against user input. Nested quantifiers, overlapping alternations, and
backreferences with repetition give one request the whole thread. Use
indexOfor a linear engine. - Recursive
process.nextTickstarves I/O. The tick queue drains to exhaustion before the loop advances, so a self-schedulingnextTickstops the process serving traffic while looking idle.queueMicrotaskhas the same property one level down. setImmediatebeatssetTimeout(fn, 0)inside an I/O callback and races it at the top level. Never encode either ordering as a dependency.- Partition long synchronous loops with
setImmediatebetween chunks, or move them to a worker.
Read [${CLAUDE_SKILL_DIR}/references/conventions/concurrency.md] when a process stops serving traffic while looking
idle, or when one slow operation surfaces as an unrelated failure — it carries the loop phase order and the list of work
that shares the libuv thread pool.
Streams
- Compose with
pipelinefromnode:stream/promises. A.pipe()chain propagates neither errors nor destruction, so one failure leaves the rest of the chain open with its descriptors held. - Respect the return value of
write().falsemeans the buffer is overhighWaterMark; keep writing and memory grows without any error being raised.pipelineandfor awaithandle this. highWaterMarkis a threshold, not a cap. A single write larger than it still buffers in full, and in object mode the count is objects rather than bytes.- Destroy a stream on the error path.
stream.destroy(err)releases the descriptor or socket; an undestroyed stream leaks it. - An unhandled
'error'event crashes the process. Every stream outside apipelineneeds a listener. readable.map,filter,take,reduceand the rest are Experimental, on every major through 26. They read like committed API and are not. Keep them out of a library's surface.- Do not stream data already in memory. Streams earn their cost on unbounded or arriving-over-time input.
Read [${CLAUDE_SKILL_DIR}/references/conventions/streams.md] when crossing between Node streams and web streams, when
writing a custom Readable, Writable, or Transform, or when tuning highWaterMark — it carries the interop matrix,
the platform-dependent defaults, and the whole-stream consumers.
Errors and Exit
- Set
process.exitCodeand let the loop drain.process.exit()terminates immediately, and a write to a pipe or a socket may still be in flight — measured on 26.2.0/macOS, 512 KiB written to a pipe arrives as 64 KiB. A write to a file is synchronous and survives, which is why the loss reads as intermittent. - Match on
error.code, never on the message. Every built-in error carries one; messages change between minors. util.inspectrenders acausechain, so an error chained with{ cause: err }reaches a Node logger intact without the caller unwrapping it.- Never resume after
'uncaughtException'. Installing the listener disables the crash and leaves the process running with unknown state. Log, flush, exit. - An unhandled rejection is fatal by default. Do not add
--unhandled-rejections=warnto make a test suite pass. 'exit'listeners run synchronously only. Anything asynchronous scheduled there never runs.- Cancel with
AbortSignal.fetch,fs/promises,timers/promises,pipeline,stream.addAbortSignal, andevents.onall take{ signal }. - A cancelled call does not report itself the same way twice. Node's own APIs reject with an
AbortErrorcarryingcode: 'ABORT_ERR';fetchrejects with aDOMExceptionwhosecodeis the number20; and afetchdeadline built fromAbortSignal.timeout(ms)rejects withname === 'TimeoutError', not'AbortError'— measured on 26.2.0. Testsignal.abortedwhere the signal is in scope, anderr.name === 'AbortError' || err.name === 'TimeoutError'where it is not. - Set the HTTP server timeouts.
requestTimeoutdefaults to 300000 ms andkeepAliveTimeoutto 5000 ms; the latter must be shorter than the idle timeout of any proxy in front, or the proxy reuses a socket Node has closed.
Read [${CLAUDE_SKILL_DIR}/references/conventions/lifecycle.md] when writing a graceful shutdown, when a process
refuses to exit or exits without flushing, or when auditing server timeouts — it carries the shutdown sequence, the
deprecation flags, and the timeout defaults.
Async Context and Diagnostics
run(store, fn)is the only Stable way to enter a store.enterWithandwithScopeare Experimental, andenterWithapplies for the rest of the current synchronous execution rather than for a scope — called inside one event handler it leaks into every later handler on the same emit.- Instrument with
diagnostics_channel, not with a logger call. Publishing to a channel with no subscriber costs nothing measurable, so the instrumentation can ship enabled. - Create a channel once at module top level and guard expensive payload construction with
channel.hasSubscribers. - Subscribers receive the message object by reference. A subscriber that mutates it changes what the publisher and later subscribers see.
- Measure loop lag with
perf_hooks.monitorEventLoopDelay(), not with asetTimeoutdrift estimate. - Get a stack out of a native crash with
--report-on-fatalerror. No JavaScript handler runs at that point.
Read [${CLAUDE_SKILL_DIR}/references/conventions/observability.md] when context goes missing across an async boundary,
when adding tracing to a library, or when picking a profiling flag — it carries the context-loss diagnosis, the
TracingChannel contract, and the list of channels Node publishes itself.
HTTP Client
- Consume or cancel every response body, on the error path too. undici does not release the connection until the body is read or cancelled, and the garbage collector is not prompt enough to save it — a pool exhausted this way stalls rather than errors.
fetchdoes not reject on a non-2xx status. Checkres.ok.fetchhas no total timeout. Passsignal: AbortSignal.timeout(ms), combined with the caller's signal throughAbortSignal.any.http.Agentoptions do not reachfetch. It uses undici's global dispatcher; change it with adispatcheroption per request orsetGlobalDispatcherprocess-wide.fetchignoresHTTP_PROXYunless told otherwise —NODE_USE_ENV_PROXY=1or--use-env-proxy, from 22.21.0 and 24.5.0.- Drop to
node:httpfor upgrades,CONNECT, trailers, or per-request agent control, and tonode:http2for HTTP/2, whichfetchdoes not speak.
Read [${CLAUDE_SKILL_DIR}/references/conventions/http-client.md] when tuning connection pooling, or when deciding
between fetch and node:http — it carries the dispatcher model and the undici version each Node major embeds.
Workers and Processes
- Workers are for CPU work. Node's asynchronous I/O beats a worker at I/O, and each worker carries a full V8 isolate.
- Size a pool from
os.availableParallelism().os.cpus().lengthignores CPU affinity and container limits. --max-old-space-sizeis per isolate. Every worker adds its own heap, so the container limit must cover the sum.- A worker receives no signals. The main thread must tell its workers to stop and wait for them, and
process.exit()inside a worker ends only that thread. - A worker does not inherit the Permission Model.
process.envin a worker is a copy unless theWorkerwas constructed withenv: SHARE_ENV.- Never pass an argument array together with
{ shell: true }.spawnandexecFilejoin it with spaces without escaping, which is command injection — runtime-deprecated as DEP0190 in 24.0.0. - Raise
UV_THREADPOOL_SIZEin the environment, not in the program. The pool is built during initialization, so assigningprocess.env.UV_THREADPOOL_SIZEat run time does nothing.
Read [${CLAUDE_SKILL_DIR}/references/conventions/concurrency.md] when building a worker pool, or when choosing between
worker_threads, child_process, and cluster — it carries how data crosses a thread boundary and the full list of
behavior differences inside a worker.
Permission Model
--permissionis a seat belt, not a sandbox. Node's own documentation states it does not protect against malicious code. Never present it to a reviewer as containment for untrusted input.- It does not restrict the network below 25.0.0.
--allow-netdoes not exist on the 22 and 24 lines, so a process confined there still opens any socket it likes. - Symlinks are followed out of the granted set. A relative link inside a granted directory reaches any path.
- An already-open file descriptor is never checked, including one inherited from the parent process.
- A granted directory that exists gets an implicit
/*; one that does not, does not. Write the wildcard explicitly when the directory is created at run time. - Derive the grant list with
--permission-audit(24.20.0) before enforcing. It publishes every violation to anode:permission-model:*channel and denies nothing.
Read [${CLAUDE_SKILL_DIR}/references/conventions/permissions.md] when confining a process, when a run fails with
ERR_ACCESS_DENIED, or when writing the grant list — it carries the full scope-to-flag map, the path-matching rules,
and everything the model does not cover.
Supply Chain
npm ciin CI and in image builds. It requires a lockfile, removesnode_modulesfirst, and fails rather than rewriting the lockfile when it disagrees withpackage.json.- Set
ignore-scripts=truein.npmrc. Lifecycle scripts run for the whole dependency tree with the runner's privileges, and this is where almost all registry compromise lands. Re-enable it per package where a build genuinely needs it. - Set
min-release-ageto a window in days (npm 11.10.0). A compromised-account attack publishes a version that is minutes old, and the window is what blocks it. When no version of a dependency clears the window the command errors, so treat that exit as a signal rather than a reason to remove the setting. Themin-release-age-excludeescape hatch needs npm 12.0.0, which no Node line bundles. - Publish from CI over OIDC trusted publishing, not with a token. npm classic tokens were revoked on 2025-12-09, and
a granular token with
Bypass 2FAset overrides both account-level and package-level 2FA for publishing. - Node 22 ships npm 10, so neither
min-release-agenor trusted publishing works on that line without upgrading npm. - Corepack stopped shipping with Node 25, so
"packageManager"is inert unlesscorepackis installed separately.
Read [${CLAUDE_SKILL_DIR}/references/conventions/supply-chain.md] when hardening an install, configuring publishing,
or auditing what a lockfile actually guarantees — it carries the trusted-publishing constraints, the provenance rules,
and the npm configuration keys.
CLI and Flags
node --run <script>is notnpm run. It skipspreandpostscripts, sets nonpm_package_*variables, and does not pass--env-filevariables to the command.--env-fileis read before the Permission Model initializes, so--permissioncannot restrict which file it reads, and the file can setNODE_OPTIONS.- The real environment overrides an
--env-filevalue. Later--env-fileflags override earlier ones. NODE_OPTIONSrefuses-eand-p. Node exits with-e is not allowed in NODE_OPTIONSrather than ignoring the flag.- A bare script path in
NODE_OPTIONSis dropped silently — the file never runs and nothing warns, measured on 26.2.0. Load code from the variable with--requireor--import. --testdefaults to process isolation, one child per file, and reports asynchronous work that outlives a test as a failure rather than dropping it.--testpicks up.tsfiles under the same name patterns unless--no-strip-typesis passed.
Read [${CLAUDE_SKILL_DIR}/references/conventions/cli-and-flags.md] when wiring a container entrypoint, building a
single executable, or choosing hardening flags — it carries the environment-file rules, the test-runner CLI surface, the
SEA build paths per major, and --disable-proto, --frozen-intrinsics, and --secure-heap.
Files in this skill
17nodejs skill questions
How do I install the nodejs skill in Claude Code?
Run npx degit xobotyi/cc-foundry/plugins/javascript/skills/nodejs ~/.claude/skills/nodejs. Claude Code loads skills from ~/.claude/skills (all projects) or .claude/skills (one project) and uses a skill when its description matches the task.
Which clients can use the nodejs skill?
Any client that reads the Agent Skills format: Claude Code, Codex, Gemini CLI and claude.ai (upload as a ZIP). Scripts in the skill run only where the client allows code execution.
Can I use experimental Node.js APIs in a published library?
No, you should never include experimental APIs in a library's public surface because they can be removed without a deprecation cycle.
Does Node.js automatically compile TypeScript files?
No, Node.js erases type syntax and runs the result; it does not compile, type-check, or read tsconfig.json.
What is the risk of using `JSON.parse` on large inputs?
Since JSON.parse is synchronous, an unbounded input can lead to a denial of service by blocking the event loop.