packages/docs/plugin-resolution-and-node-path.md
This doc explains why dynamic plugin imports fail without NODE_PATH and how we fix it across CLI, dev server, and Electrobun.
Note: The source files referenced in this document live in the elizaOS submodule (
eliza/). Runbun run setup:upstreamsto populate the submodule so you can inspect them locally. All paths likesrc/runtime/eliza.tsrefer toeliza/packages/app-core/src/runtime/eliza.tsunless otherwise noted.
The runtime (src/runtime/eliza.ts) loads plugins via dynamic import:
import("@elizaos/plugin-sql")
Node resolves this by walking up from the importing file's directory. When eliza runs from different locations, resolution can fail:
| Entry point | Importing file location | Walks up from | Reaches root node_modules? |
|---|---|---|---|
bun run dev | src/runtime/eliza.ts | src/runtime/ | Usually yes (2 levels) |
eliza start (CLI) | dist/runtime/eliza.js | dist/runtime/ | Usually yes (2 levels) |
| Electrobun dev | eliza-dist/eliza.js | apps/app/electrobun/eliza-dist/ | No — walks into apps/ |
| Electrobun packaged | app.asar.unpacked/eliza-dist/eliza.js | Inside the .app bundle | No — different filesystem |
In the Electrobun cases (and sometimes the built dist case depending on bundler behavior), the walk never reaches the repo root where @elizaos/plugin-* packages are installed. The import fails with "Cannot find module".
NODE_PATH is a Node.js environment variable that adds extra directories to module resolution. We set it in three places so every entry path resolves plugins:
src/runtime/eliza.ts (module-level)const _repoRoot = path.resolve(_elizaDir, "..", "..");
const _rootModules = path.join(_repoRoot, "node_modules");
if (existsSync(_rootModules)) {
process.env.NODE_PATH = ...;
Module._initPaths();
}
Why here: Covers bun run dev (dev-server.ts imports eliza directly) and any other in-process import of eliza. The existsSync guard means this is a no-op in packaged apps where the repo root doesn't exist.
Note on Module._initPaths(): It is a private Node.js API but widely used for exactly this purpose (runtime NODE_PATH mutation). Node caches resolution paths at startup; after we set process.env.NODE_PATH we must call it so the next import() sees the new paths.
eliza/packages/app-core/scripts/run-node.mjs (child process env)const rootModules = path.join(cwd, "node_modules");
env.NODE_PATH = ...;
Why here: The CLI runner spawns a child process that runs eliza.mjs → dist/entry.js → dist/eliza.js. Setting NODE_PATH in the child's env ensures the child resolves from root even though dist/ doesn't have its own node_modules.
eliza/packages/app-core/platforms/electrobun/src/native/agent.ts (Electrobun native runtime)// Dev: walk up from __dirname to find node_modules
// Packaged: use ASAR node_modules
Why here: The Electrobun native runtime loads eliza-dist/eliza.js via dynamicImport(). In dev mode, __dirname is deep inside apps/app/electrobun/build/src/native/ — we walk up to find the first node_modules directory (the monorepo root). In packaged mode, we use the ASAR's node_modules instead.
tsdown with noExternal: [/.*/] inlines most dependencies, but @elizaos/plugin-* packages are loaded via runtime dynamic import (the plugin name comes from config, not a static import). The bundler can't inline them because it doesn't know which plugins will be loaded. They must be resolvable at runtime.
In the packaged .app, eliza.js lives at app.asar.unpacked/eliza-dist/eliza.js. Two levels up is Contents/Resources/ — no node_modules there. The existsSync check in eliza.ts returns false, so the NODE_PATH code is skipped entirely. The packaged app instead copies runtime packages into eliza-dist/node_modules during the desktop build (copy-runtime-node-modules.ts for Electrobun) and agent.ts sets that packaged node_modules directory on NODE_PATH.
Some @elizaos packages (e.g. @elizaos/plugin-sql) publish a package.json with exports["."].bun = "./src/index.ts". Why they do that: In the upstream monorepo, Bun can run TypeScript directly, so pointing to src/ avoids a build step. The published npm tarball, however, only includes dist/ — src/ is not shipped. When we install from npm, the "bun" condition points to a path that does not exist.
What happens: Bun's resolver prefers the "bun" export condition. It tries to load ./src/index.ts, the file is missing, and we get "Cannot find module … from …/src/runtime/eliza.ts" even though the package is in node_modules. Bun does not fall back to the "import" condition when the "bun" target is missing.
Our fix: packages/app-core/scripts/patch-deps.mjs runs as part of
packages/app-core/scripts/run-repo-setup.mjs. It finds affected @elizaos
packages and, if exports["."].bun points to ./src/index.ts and that file
does not exist, removes the "bun" and "default" conditions that reference
src/. After the patch, only "import" and similar built-output conditions
remain, so Bun resolves to ./dist/index.js. In a development workspace where
the plugin is checked out with src/ present, the package is left unchanged.
@elizaos/plugin-openrouterThis repo currently resolves @elizaos/plugin-openrouter via a local
workspace link (workspace:*) during development. When not using the local
checkout, the root package.json pins 2.0.0-alpha.13 (the current known-good
npm tarball). 2.0.0-alpha.12 shipped broken dist entrypoints and must be avoided.
2.0.0-alpha.12The published npm tarball for 2.0.0-alpha.12 contains truncated JavaScript outputs for the Node ESM and browser entrypoints (dist/node/index.node.js, dist/browser/index.browser.js). Those files only include the bundled utils/config helpers (~80 lines). The main plugin implementation (the object that should be exported as openrouterPlugin and as default) is not present in the file, but the final export { … } list still names openrouterPlugin and openrouterPlugin2 as default.
Why Bun errors: When the runtime loads the plugin, Bun builds/transpiles that entry file and fails with errors like openrouterPlugin is not declared in this file — the symbols are exported but never defined. The CommonJS build (dist/cjs/index.node.cjs) is incomplete in the same way (export getters reference a missing import_plugin chunk).
Why we do not postinstall-patch the dist: The broken release is missing the
entire plugin body, not a single wrong identifier (contrast
@elizaos/plugin-pdf, where a small string replace fixes a bad export alias).
Reconstructing the plugin from source inside Eliza would fork upstream and be
fragile. When you are not using the local workspace checkout, prefer the known
good published 2.0.0-alpha.13 artifact.
dist/node/index.node.js and confirm it defines the default export / openrouterPlugin, or run bun build node_modules/@elizaos/plugin-openrouter/dist/node/index.node.js --target=bun after install.^2.0.0-alpha.10 allowed Bun to resolve alpha.12, which broke installs that upgraded the lockfile.User-facing context and configuration for OpenRouter itself live in OpenRouter plugin (Mintlify: /plugin-registry/llm/openrouter).
Optional plugins (and some core-adjacent packages) can end up in the load set because of plugins.allow, plugins.entries, connector configuration, features.*, environment variables (e.g. provider API keys or wallet keys that trigger auto-enable), or plugins.installs. When resolution fails with missing npm module or missing browser stagehand, the log used to look like a generic runtime error.
Why we record provenance: collectPluginNames() optionally fills a PluginLoadReasons map (first source wins per package). resolvePlugins() passes it through; benign optional failures are summarized as Optional plugins not installed: … (added by: …). That answers “what should I change?” — edit config, unset env, install the package, or add a plugin checkout — instead of chasing a false “eliza is broken” hypothesis.
Browser / stagehand: @elizaos/plugin-browser expects a stagehand-server tree that is not in the npm tarball. Eliza discovers plugins/plugin-browser/stagehand-server by walking parents from the runtime so both flat Eliza checkouts and eliza/ submodule layouts resolve. See Developer diagnostics and workspace.
As part of the Plugin Workspace architecture, we load dependencies via workspace:* out of the vendored source tree (eliza/packages/* and eliza/plugins/*). Sometimes, you need to verify that what works in a workspace:* context will successfully pack into tarballs and install strictly downstream as if published.
We provide two scripts to validate and prevent drift:
packages/app-core/scripts/pack-upstreams.mjsTo simulate a real publish release locally, run node packages/app-core/scripts/pack-upstreams.mjs. It iterates over the target packages, builds them when needed, runs npm pack, and places the resulting .tgz artifacts in the root artifacts/ directory.
packages/app-core/scripts/check-upstream-drift.mjsTo ensure that root-level explicitly pinned dependencies (e.g.,
"@elizaos/plugin-openrouter": "2.0.0-alpha.13") do not drift from source, run
node packages/app-core/scripts/check-upstream-drift.mjs. The command inspects
root pins against the package.json inside local vendor trees and fails if their
explicitly pinned specifications diverge from source.
Because all packages resolve via workspace:*, local modifications are live the
moment you restart bun run dev. No npm link, custom NODE_PATH patches, or
cache-busting are required for workspace packages.