docs/plans/2026-05-02-multi-package-system-proposal.md
Promptfoo should move from "one published package that happens to contain many systems" to "one familiar full package backed by a small set of explicit package layers."
The recommendation is:
promptfoo as the default full install and compatibility facade.This gives lightweight consumers a smaller dependency graph without making the
normal npm install promptfoo experience worse. It also gives the team a safer
path to provider packs and future products without forcing a flag day.
Today, the published root package owns several quite different responsibilities:
That makes the root package convenient, but also broad:
83 direct runtime dependencies and 36
optional dependencies.src/index.ts
imported migrations, models, sharing, provider loading, and redteam APIs.
The prototype starts separating that shape by moving the public Node API
behind src/node/evaluate.ts, while the internal orchestration stays in the
Node layer at src/evaluate.ts and promptfoo remains the facade.src/main.ts and src/commands/view.ts are already outer-shell concerns,
not core evaluation concerns.The system has grown past the point where one package boundary expresses the architecture well.
One obvious default.
promptfoo remains the package most users install.
Narrow leaves, convenient facade. Lightweight consumers should not pay for servers, databases, CLIs, or provider SDKs they do not use.
No runtime dependency magic. Dependency installation happens at install/build/publish time, not when an eval starts or a server boots.
Private boundaries before public promises. We should first make internal ownership real inside the monorepo, then publish only the packages that survive real use.
Dual-format correctness over format ideology. Promptfoo already supports both ESM and CommonJS. Public packages should keep doing that until we intentionally decide otherwise.
Package artifacts are the contract. A source-tree green build is not enough; every published package must be packed, installed, imported, required, and exercised as a user would consume it.
flowchart TD
schema["@promptfoo/schema"]
core["@promptfoo/core"]
node["@promptfoo/node"]
redteam["@promptfoo/redteam"]
providers["@promptfoo/provider-*"]
view["@promptfoo/view-server"]
cli["@promptfoo/cli"]
facade["promptfoo"]
schema --> core
core --> node
core --> redteam
core --> providers
node --> view
node --> cli
redteam --> cli
providers --> facade
node --> facade
redteam --> facade
view --> facade
cli --> facade
@promptfoo/schemaPurpose
May depend on
zodMust not depend on
fs, better-sqlite3, Express, provider SDKs, CLI libraries, server codeWhy it exists
@promptfoo/corePurpose
May depend on
@promptfoo/schemaMust not depend on
Why it exists
@promptfoo/nodePurpose
evaluate()-style Node APIMay depend on
@promptfoo/core@promptfoo/schemabetter-sqlite3, glob, chokidar, dotenvWhy it exists
@promptfoo/redteamPurpose
May depend on
@promptfoo/core@promptfoo/node only where it truly needs Node adaptersWhy it exists
@promptfoo/view-serverPurpose
May depend on
@promptfoo/nodeWhy it exists
@promptfoo/cliPurpose
node, redteam, and view-serverMay depend on
@promptfoo/node@promptfoo/redteam@promptfoo/view-serverWhy it exists
@promptfoo/provider-*Purpose
@promptfoo/provider-openai@promptfoo/provider-anthropic@promptfoo/provider-aws@promptfoo/provider-google@promptfoo/providers-core for zero-extra-dependency or
very common providersMay depend on
@promptfoo/coreWhy it exists
Important
promptfooPurpose
Behavior
@promptfoo/node.Every runtime dependency must have one declared owner:
If a dependency is used by more than one package, we should prefer:
Do not keep dependencies at the root merely because multiple packages happen to use them during the transition.
src/**.core knows only provider interfaces and registration metadata.node may ship a default provider registry, but should not be the owner of every
provider SDK forever.optionalDependencies only for genuinely optional platform/native
capability, not as a substitute for package ownership.The repository already uses npm workspaces and has established CI around them. The split does not require an immediate package-manager migration.
Recommended first-stage workspace layout:
packages/
schema/
core/
node/
redteam/
view-server/
cli/
provider-openai/
provider-anthropic/
src/app/
site/
src/ can migrate inward over time. During the first phase, thin package entry
files may point at existing source while we move code by ownership.
Each publishable package should produce:
dist/
esm/
cjs/
types/
and expose only supported entrypoints through exports.
Recommended package export shape:
{
"type": "module",
"exports": {
".": {
"types": "./dist/types/index.d.ts",
"import": "./dist/esm/index.js",
"require": "./dist/cjs/index.cjs"
}
}
}
For Node-targeted packages, TypeScript should be checked in nodenext mode so
the compiler models Node's dual-format resolver. Browser/bundler packages can
keep bundler-oriented settings where appropriate.
Add CI checks for:
The strongest useful addition from the OpenClaw study is a generated dependency ownership report:
dependency owner direct users transitive size
better-sqlite3 @promptfoo/node node ...
express @promptfoo/view-server view-server ...
@anthropic-ai/sdk provider-anthropic provider package ...
That report should fail CI when:
The useful lesson from OpenClaw is not "copy its exact repo layout." It is that dependency management gets simpler when ownership is explicit and package artifacts are tested like user-facing products.
Adopt:
Do not adopt blindly:
import and requireUse a fixed-version monorepo release for the first public split:
@promptfoo/* packages share the same versionpromptfoo depends on exact matching versions of internal public packagesThis is easier for users, support, and rollback while boundaries are still forming. Independent versions become worth revisiting only after package consumption patterns are stable.
Keep release automation centralized and extend the current flow rather than inventing a second release system immediately:
promptfoo facade lastPromptfoo already publishes with provenance in the current release workflow, so this should be an extension of the existing release path rather than a parallel system.
Before publish, every public package should prove:
npm pack contents are completeimport worksrequire workspromptfoo facade still exposes current behaviorThe current smoke-test philosophy already says "test the built package, not source code." This proposal extends that idea from the root package to every public package.
Promptfoo should remain dual-mode during the split.
import and require conditions until we make a
separate deprecation decision.import and require smoke tests.Modern Node can load more ESM from CommonJS than older Node versions could, but
Promptfoo already promises a require entrypoint today. Keeping that promise
while the package graph changes avoids combining two migrations into one.
The system should feel no worse locally than the repo does today.
promptfoo unless a smaller package is the point of
the examplenpm run build
npm test
npm run test:package -- --package @promptfoo/node
npm run deps:ownership
npm run pack:check
Most contributors should continue to:
Package boundaries should make reasoning easier, not force every engineer to become a release engineer.
docs/architecture/packages.md
docs/agents/package-development.md
site/docs/usage/packages.md
Per-package README template
For users, the story should be simple:
promptfoo when you want the normal product.@promptfoo/node when you want the Node library without the CLI/server.Exit criterion
schema, core, and nodepromptfoo behavior unchangedExit criterion
cli and view-serverExit criterion
@promptfoo/node can build and test without CLI/server dependencies.Exit criterion
Recommended first public packages:
@promptfoo/schema@promptfoo/node@promptfoo/view-serverKeep promptfoo as the full facade.
Exit criterion
Only publish provider packages where there is a clear benefit:
Exit criterion
Pros
Cons
Pros
Cons
Pros
Cons
| Risk | Mitigation |
|---|---|
| Internal package churn leaks into public API | Publish only after private boundary use stabilizes |
| Dual ESM/CJS builds become inconsistent | Artifact smokes for both import modes on every public package |
| Developers slow down in a new monorepo layout | Keep root install/build/test commands as the golden path |
| Provider package count becomes confusing | Publish provider packs selectively; keep promptfoo full install |
| Version skew across packages | Start with fixed versions and exact internal deps |
| Release workflow becomes fragile | Reuse current release pipeline, add package-graph and tarball acceptance checks |
promptfoo as the long-lived full facade.The first milestone should be deliberately modest:
packages/schema, packages/core, and packages/node.@promptfoo/node while keeping
promptfoo exports unchanged.@promptfoo/node can build and test without CLI/server imports.If that milestone is not useful, we learn cheaply. If it is useful, the rest of the package system has a clear path.
src/index.ts mixed the Node API with migrations,
sharing, provider loading, and redteam exports. The prototype moves the Node
orchestration into src/node/evaluate.ts as the first concrete seam.src/main.ts is already a CLI shell around lower-level functionality.src/commands/view.ts and src/server/** are natural view-server owners.src/providers/** is already a natural future provider-package boundary.src/types/index.ts is already being deconstructed, which points toward
schema as a first extraction.exports maps for public packages.nodenext mode so TypeScript models
the same import/require behavior Node uses.npm pack plus clean-install smoke tests as the release contract, not an
optional extra.