---
title: "CLI Pack System: On-Demand Node Packs for the npm CLI"
description: "Local-first visual environment for building and running AI workflows. Build agents visually, deploy anywhere, privacy by design."
canonical: https://docs.nodetool.ai/cli-pack-system-design
markdown: https://docs.nodetool.ai/cli-pack-system-design.md
product: NodeTool
source: https://github.com/nodetool-ai/nodetool/blob/main/docs/cli-pack-system-design.md
---

# CLI Pack System: On-Demand Node Packs for the npm CLI

# CLI Pack System: On-Demand Node Packs for the npm CLI

Status: design, not implemented. Scope: the npm package `@nodetool-ai/cli`.
The desktop app and the Docker image keep their bundled backend.

This revision answers the adversarial review of the previous draft. Finding
codes F1 to F13 and question codes Q1 to Q5 refer to that review. Decisions
D1 to D7 are fixed. New items use D8 and later, R1 and later, and Q6 and later.

## 1. Summary

1. The core CLI stops depending on node packs and on heavy native libraries.
   Step 1 of the migration is this dependency cut plus a measured size gate.
   No installer work starts before it passes.
2. Packs are ordinary npm packages. They install into the npm project that
   already holds the CLI, so packs and core resolve from one tree.
3. A static generated catalog maps each node type to its pack. Every node type
   stays visible for search, validation, and the planner without its pack.
4. A run resolves missing packs before it starts. A missing pack produces a
   structured `missing_pack` error and exit code 3.
5. Only the CLI command layer can install. It needs its own authorization,
   separate from the permission gate. The server and MCP never install.
6. After an install, the CLI re-executes itself. One process sees one fixed
   set of packs.

## 2. Problem

`npm install @nodetool-ai/cli@0.8.3` installs about 1.2 GB on darwin-arm64.
The CLI depends on every node pack, and core packages depend on heavy native
libraries.

A read-only walk of the local workspace tree (method in section 8) gives
868 MB of apparent file size and 1,087 MB of disk usage for the current CLI
closure, in 884 packages. The difference from 1.2 GB comes from different
resolved versions in a clean install.

| Item | Apparent size | Pulled in by |
|---|---|---|
| `webgpu` | 67.9 MB | `gpu` `optionalDependencies` (`packages/gpu/package.json:22`) |
| `node-av` platform packages (`@seydx/*`) | 58.8 MB | `agents` → `@mediabunny/server` (`packages/agents/package.json:23`) |
| `@napi-rs/*` (canvas) | 48.6 MB | `agents`, `data-nodes`, `video-nodes`, `game-renderer` |
| `node-web-audio-api` | 40.8 MB | `audio-nodes` |
| `node-llama-cpp` and `@node-llama-cpp/*` | 48.0 MB | `cli` (`packages/cli/package.json:68`) |
| `pdfjs-dist`, `pdf-parse`, `@hyzyla/pdfium` | 65.9 MB | `document-nodes`, `websocket` |
| `@opentelemetry/*` | 23.6 MB | `runtime` (`packages/runtime/package.json:27-33`) |
| `typescript` (`@typescript/*`) | 23.2 MB | `execution` (`packages/execution/package.json:29`) |
| `js-tiktoken` | 21.4 MB | `runtime` (`packages/runtime/package.json:36`) |
| `three`, `playwright-core` | 34.0 MB | `game-renderer`, `video-nodes` |

All numbers in this document are local-tree estimates, not clean-install
measurements.

## 3. Goals and non-goals

Goals:

- A core install at or below the budget in section 8.4 that runs `--help`,
  `validate`, `run` and `debug` for core-node workflows (JSON and DSL),
  `node run` for core nodes, `serve`, and `mcp serve`.
- Every node type stays discoverable without its pack: `search_nodes`,
  `get_node_info`, `validate`, and the agent planner see the full catalog.
- A missing pack never fails silently. Every surface names the pack, its size,
  and the command that installs it.
- Packs install only with install authorization (D5).
- Installed packs always match the core version exactly.

Non-goals:

- The desktop app and the Docker image. They keep `@nodetool-ai/base-nodes`
  and the full bundle from `scripts/bundle-backend.mjs`.
- Python packs. They keep the Python bridge.
- Third-party packs. They keep the existing loader in
  `packages/node-sdk/src/pack-loader.ts`, which discovers packages that carry a
  `nodetool` field in `package.json`. First-party packs carry no such field
  today and must not gain one (D8).
- pnpm, Yarn, Bun, and `npx` installs. Section 11 defines how the CLI detects
  them and what it prints.

## 4. Tiers

| Tier | Contents | Installed |
|---|---|---|
| Core | `cli`, `protocol`, `config`, `security`, `auth`, `storage`, `models`, `node-sdk`, `kernel`, `runtime`, `execution`, `agents`, `chat`, `websocket`, `dsl`, `vectorstore`, `app-runtime`, `app-export`, `compute`, `model-pricing`, `timeline`, `gpu`, `storyboard`, `image-editor`, `model3d`, `browser`, `sandbox-compiler`, `huggingface`, `game-runtime`, `deploy`, `telegram`, `sdk`, `nodes-utils`, the core node packages `core-nodes`, `text-nodes`, `data-nodes`, `llm-nodes`, `code-nodes`, and the new `node-catalog` | with the CLI |
| Node pack | One npm package per pack id. Existing ids from `BUILTIN_NODE_PACKS` (`packages/protocol/src/builtin-packs.ts:61`): `whisper-cpp`, `elevenlabs`, `minimax`, `transformers-js`, `fal`, `kie`, `topaz`, `reve`, `atlascloud`, `higgsfield`, `together`, `replicate`, `huggingface`. New ids from the `base` split: `image`, `audio`, `video`, `document`, `game`, `blender`, `manimgx`, `automation`, `integration` | on demand |
| Runtime library | Third-party packages that a core code path loads lazily: `imaging` (`sharp`, `@napi-rs/canvas`), `llama` (`node-llama-cpp`), `typescript`, `tokenizer` (`js-tiktoken`), `tracing` (OpenTelemetry SDK packages), `game-renderer` (first-party, not a node pack) | on demand |

Rules:

- D16. Five libraries stay regular dependencies, because the features that
  use them have no fallback: `webgpu` (`gpu`), `mediabunny` with
  `@mediabunny/server` (`agents`), `@hyzyla/pdfium` (`websocket`,
  `document-nodes`), `chart.js` (`data-nodes`) and `node-web-audio-api`
  (`audio-nodes`, so it installs with the `audio` pack). The desktop app and
  the Docker image already require them.
- D8. A core package never declares a node pack in `dependencies`,
  `optionalDependencies`, or a non-optional peer.
- D9. A heavy library is a regular dependency of each pack that needs it. A
  core package that loads it lazily declares it as an optional peer with an
  exact version. This keeps the monorepo tree and the desktop bundle complete,
  because optional peers alone are never installed (R4).
- D10. A pack declares core packages as exact `peerDependencies`.
  `scripts/prepare-publish.mjs:53` already pins peers. A pack declares another
  pack it needs (for example `video` needs `audio`) as an exact regular
  dependency, and the catalog records it as `requires`.

The `base` pack in `BUILTIN_NODE_PACKS` is required today
(`packages/protocol/src/builtin-packs.ts:69-75`) and aggregates about 60 node
lists through `ALL_BASE_NODES` (`packages/base-nodes/src/index.ts:527`). The
split makes each npm package its own pack. Node type strings stay unchanged.
The generator in section 9 decides ownership, not this table.

## 5. Decisions and finding map

| Code | Status in this design |
|---|---|
| F1 | Fixed by D5 and section 13. The installer checks its own authorization. |
| F2 | Fixed by section 6. Step 1 of the migration. |
| F3 | Obsolete under D1. Packs resolve core from the same tree. Section 14.6 still verifies one copy. |
| F4 | Fixed by section 7. |
| F5 | Fixed by sections 14.6 and 17. Verification runs before scripts and imports. |
| F6 | Obsolete under D1. No generations, no hard links. |
| F7 | Obsolete under D1 and D2. One lock per install root, no GC, no live switching. |
| F8 | Fixed by D3 and section 9. Exact `node_type → pack` map. |
| F9 | Fixed by D6 and section 10. |
| F10 | Fixed by D4 and section 12. No mid-run retry. |
| F11 | Section 14 pins npm and flags. Section 19.3 is the matrix. |
| F12 | Section 8. Measured, with a budget. |
| F13 | Fixed by D7 and section 17. |

D1 needs one correction. The review of npm behavior in section 11.2 shows that
"the global prefix" cannot mean a separate `npm install -g <pack>`. It must mean
the CLI's own package directory inside the global prefix. Section 11 states the
rule.

## 6. Core dependency cut (F2)

### 6.1 Manifest edges from core to packs and heavy libraries

Read from the `package.json` files. "Cut" names the change.

| Edge | Location | Cut |
|---|---|---|
| `cli` → 14 node packs (`atlascloud`, `base`, `elevenlabs`, `fal`, `huggingface-nodes`, `minimax`, `replicate`, `reve`, `transformers-js-nodes`, `transformers-js-provider`, `video`, `whisper-cpp`) and `game-renderer` | `packages/cli/package.json:24-75` | Remove. Load through the pack loader (section 12). |
| `cli` → `reliability-harness` | `packages/cli/package.json:46` | Remove. It depends on `base-nodes` and `websocket` (`reliability/harness/package.json`). `nodetool reliability` already imports it dynamically (`packages/cli/src/commands/reliability.ts:98`) and reports "development checkout only" when absent. |
| `cli` → `node-llama-cpp` | `packages/cli/package.json:68` | Remove. `runtime` already declares it as an optional peer (`packages/runtime/package.json:53`). Runtime library `llama`. |
| `agents` → `blender-nodes`, `game-nodes`, `game-renderer`, `video-nodes` | `packages/agents/package.json:26,30,31,44` | Remove. Lazy pack import (6.2). |
| `agents` → `@mediabunny/server`, `mediabunny` | `packages/agents/package.json:23,61` | Keep as dependencies (D16). |
| `agents` → `@napi-rs/canvas` | `packages/agents/package.json:24` | Optional peer. Runtime library `imaging`. |
| `agents` → `office-text-extractor` | `packages/agents/package.json:98` (`optionalDependencies`) | Remove from `optionalDependencies`. It stays an optional peer (`:113`). |
| `websocket` → 18 node packs | `packages/websocket/package.json:35-81` | Remove. Lazy pack import. |
| `websocket` → `@hyzyla/pdfium`, `sharp` | `packages/websocket/package.json:29,78` | `@hyzyla/pdfium` stays a dependency (D16). `sharp` becomes an optional peer. Runtime library `imaging`. |
| `dsl` → `base-nodes`, `elevenlabs`, `huggingface-nodes`, `minimax`, `reve`, `transformers-js-nodes`, `whisper-cpp` | `packages/dsl/package.json:37-50` | Remove. Metadata comes from the catalog (6.2). |
| `execution` → `typescript` | `packages/execution/package.json:29` | Optional peer. Runtime library `typescript`. The only use is a dynamic import (`packages/execution/src/js-script-debug/type-check.ts:90,125`). |
| `execution` → `gpu` → `webgpu` | `packages/execution/package.json:20`, `packages/gpu/package.json:22` | Move `webgpu` from `optionalDependencies` to `dependencies` (D16). |
| `model-pricing` → `fal-nodes`, `kie-nodes` | `packages/model-pricing/package.json:18-19` | Move the generated unit-price JSON into `model-pricing`. Change the codegen output paths. The imports are `packages/model-pricing/src/index.ts:46-47`. |
| `runtime` → `js-tiktoken` | `packages/runtime/package.json:36` | Optional peer. Runtime library `tokenizer` (Q7). |
| `runtime` → `sharp` | `packages/runtime/package.json:40` | Optional peer. All four runtime uses are already dynamic. |
| `runtime` → OpenTelemetry SDK packages | `packages/runtime/package.json:28-33` | Keep `@opentelemetry/api`. Make the other six optional peers. Runtime library `tracing`. |
| `game-runtime` → `@dimforge/rapier3d-compat` | `packages/game-runtime/package.json:17` | Optional peer. Regular dependency of the `game` pack. The import is already dynamic (`packages/game-runtime/src/spatial3d/world.ts:11`). |
| `sandbox-compiler` → `esbuild` | `packages/sandbox-compiler/package.json:25` | Optional peer. Needed only by `nodetool packs compile`. |
| `data-nodes` → `@napi-rs/canvas`, `chart.js` | `packages/data-nodes/package.json:18,23` | `chart.js` stays a dependency (D16). `@napi-rs/canvas` becomes an optional peer. The chart node already imports it dynamically (`packages/data-nodes/src/nodes/lib-charts.ts:162-167`). |

D11. One new edge is required. `packages/cli/src/run-dsl.ts:11` imports
`tsx/esm/api`, but `tsx` is only a dev dependency (`package.json:232`,
`packages/dsl/package.json:54`). A clean install cannot run a DSL file through
that path. `tsx` becomes a regular dependency of `cli` (10.9 MB, 4 packages).
The two `npx tsx` calls (`packages/cli/src/nodetool-main.ts:586`,
`packages/cli/src/debug/target.ts:49`) are replaced by in-process `tsImport`.
`npx` fetches `tsx` from the registry when it is absent, which is an install
without consent.

### 6.2 Source import edges

A static scan of `src/` in every core package (tests excluded) found these
imports of node packs. A load-time trace confirmed which ones run on module
load: it imported each core package with a Node resolve hook and logged every
pack or heavy-library specifier.

| Import | Kind | Cut |
|---|---|---|
| `packages/cli/src/nodetool-main.ts:46`, `packages/cli/src/node-registry.ts:15` → `base-nodes/builtin-packs` | static | Replace `registerBuiltinPacks` with the catalog pack loader (section 12). |
| `packages/cli/src/providers.ts:38-39` → `transformers-js-provider`, `whisper-cpp` | static | Register each provider only when its pack is installed. |
| `packages/cli/src/commands/timeline-render.ts:267-430` → `video-nodes/nodes/timeline/*` | dynamic | `importPack("video", subpath)`. The type import at `:12` is erased. |
| `packages/cli/src/commands/game.ts:419-637`, `game-smoke.ts:11` → `game-renderer` | dynamic | `importRuntimeLibrary("game-renderer", subpath)`. |
| `packages/agents/src/capabilities/model3d.ts:28`, `timeline-bake.ts:22` → `blender-nodes` | static, inside a lazily loaded capability module | `importPack("blender")`. The capability returns `missing_pack`. |
| `packages/agents/src/timeline-preview/model3d.ts:32,161` → `video-nodes/nodes/model3d/*` | static and dynamic | `importPack("video", subpath)`. |
| `packages/agents/src/capabilities/game*.ts` → `game-nodes`, `game-renderer` | dynamic | `importPack("game")`, `importRuntimeLibrary("game-renderer")`. |
| `packages/agents/src/analysis/media-decode.ts:16`, `mediabunny-runtime.ts:12`, `sandbox-av-media.ts:22` → `mediabunny` | static | Keep. `mediabunny` is core (D16). |
| `@napi-rs/canvas` static imports in `agents` (`capabilities/agentic-storyboard-finish.ts:2`, `derive-shot.ts:13`, `storyboard-layout-check.ts:1`, `timeline-clip-frames.ts:1`, `evals/surfaces/sketch.ts:33`, `timeline-preview/compare.ts:16`, `frames.ts:23`, `model3d.ts:23`, `rasterize.ts:17`, `sheet.ts:14`) and `timeline` (`fonts/google-fonts-node.ts:15`, `fonts/register-node.ts:18`) | static | One `loadCanvas()` helper that throws `missingRuntimePackageError`. The trace shows `evals/surfaces/sketch.js` and the timeline font modules load on `import "@nodetool-ai/agents"`, so these block startup today. |
| `packages/websocket/src/node-registry-setup.ts:33` → `base-nodes/builtin-packs` | static | The server takes a pack source: `bundled` (desktop, Docker: `base-nodes` registrars) or `installed` (CLI: catalog loader). |
| `packages/websocket/src/server.ts:28-30` → `transformers-js-provider`, `whisper-cpp`, `base-nodes` (`setCodeNodeAgentsModule`) | static | Providers register only when installed. Import `setCodeNodeAgentsModule` from `code-nodes`, which stays core. |
| `packages/websocket/src/routes/nodes.ts:10` → `base-nodes` (`resolveComfyWorkflow`, `resolveKieDynamicSchema`) | static | `importPack("integration")`. The route returns `missing_pack`. |
| `packages/websocket/src/routes/documents.ts:17` → `document-nodes/lib/pdf-text` | static | `importPack("document", "lib/pdf-text")`. The route already handles `missingRuntimePackageOf` (`:16`). |
| `packages/websocket/src/triggers/file-watch.ts:35` → `automation-nodes` | static | Extract the file-watch predicates into core (`protocol` or `websocket`). They are pure functions. |
| `packages/websocket/src/trpc/routers/games.ts:9` → `game-renderer/preparation3d` | static | `importRuntimeLibrary("game-renderer")`. |
| `packages/websocket/src/routes/kie-pricing.ts:7` → `kie-nodes/kie-pricing-api` | static | Move `kie-pricing-api.ts` into `model-pricing`. It has no imports. |
| `packages/websocket/src/trpc/routers/models.ts:7,44` → `whisper-cpp`, `transformers-js-nodes` | static | Lazy. Return empty model lists with a pack hint when absent. |
| `packages/websocket/src/model-download-runtime.ts:16`, `routes/games.ts:110` | dynamic | Pack import with `missing_pack`. |
| `packages/dsl/src/export.ts:1`, `script-export.ts:22` → `ALL_BASE_NODES` | static | Read metadata from `@nodetool-ai/node-catalog`. `buildFactoryRefs` (`export.ts:116`) and `builtinMetadataFor` (`script-export.ts:228`) need metadata only, not classes. |
| `packages/dsl/src/registry.ts:30-69` → `base-nodes` and six packs | dynamic | Use the catalog pack loader. Catalog-only types are never Python candidates (section 10). |
| `packages/model-pricing/src/index.ts:46-47` | static | See 6.1. |
| `packages/websocket/src/lib/thumbnail.ts:29`, `mcp-agent-tools.ts:87`, `screenshot-docs-seed.ts:26`, `trpc/routers/games.ts:4` → `sharp` | static | Route through `runtime/src/image-codec.ts`, which already imports `sharp` dynamically. |
| `packages/runtime/src/token-counter.ts:13` → `js-tiktoken` | static | Lazy, with a character-count estimate when absent (Q7). |
| `packages/runtime/src/run-trace-processor.ts:3` → `@opentelemetry/resources` | static | Lazy. Load only when a trace sink is configured. |
| `packages/sandbox-compiler/src/bundle.ts:17` → `esbuild` | static | Lazy. |

### 6.3 Loader helpers

Two helpers live in core. They are the only way core code reaches a pack or a
runtime library.

```ts
// @nodetool-ai/node-catalog
export function importPack<T>(packId: string, subpath?: string): Promise<T>;
export function importRuntimeLibrary<T>(libraryId: string, subpath?: string): Promise<T>;
```

Each resolves the npm name from the catalog, calls `importOptionalModule`
(`packages/config/src/optional-modules.ts:12`), and maps a module-not-found
error for that package to a `MissingPackError` carrying the `missing_pack`
payload (section 12.2). Other errors propagate. A lint rule forbids direct
imports of node packs from core packages, and the clean-core tests in 6.4 prove
it at runtime.

### 6.4 Clean-core tests

The monorepo tree contains every pack, so in-repo Jest cannot prove the cut.
`scripts/smoke-packed-cli.mjs` already installs packed tarballs into an empty
project and runs the CLI against an empty HOME. It gains a core-only variant
that installs the CLI tarball with no pack tarballs available:

1. `nodetool --help` lists every command, including those that need packs.
2. `node -e 'import("@nodetool-ai/dsl")'` from the install root succeeds.
3. `nodetool run examples/workflows/concat_text.ts` succeeds (DSL through `tsx`).
4. `nodetool workflows run` on a core-node JSON workflow succeeds.
5. `nodetool serve` answers `GET /health`.
6. `nodetool mcp serve` answers `initialize`, lists tools, and runs a core-node
   workflow through `execute_code`.
7. `nodetool validate` on a `fal.*` workflow reports `pack_not_installed` and
   runs property validation on the `fal.*` node.
8. `nodetool run` on that workflow exits 3 and prints the `missing_pack` JSON
   with `--json`.
9. A guard asserts that no `@nodetool-ai/*` node pack and none of the runtime
   libraries in section 4 exist under the install's `node_modules`. The check
   fails on a deliberately added pack before it is enforced.

## 7. Shared third-party identity (F4)

`ensureCodecs` (`packages/agents/src/analysis/mediabunny-runtime.ts:21-32`)
registers the `@mediabunny/server` codecs into whatever `mediabunny` instance
the server package resolves. `inputFrom` (`:35`) creates inputs with the
instance that `agents` resolves. The installed `@mediabunny/server` declares
`mediabunny` as a peer. Two instances mean codecs register on one and inputs
open on the other.

D12. A shared-identity library is a package whose module instance two
installed packages must share. The catalog marks it (`shared: true`). The rules:

1. Core and packs resolve shared-identity libraries from one tree (D1).
2. The catalog pins each shared-identity library to one exact version and
   integrity. Core declares it as an exact optional peer.
3. The installer refuses a plan that adds a second copy (section 14.5).
4. After install, the installer resolves the library from each dependent
   package directory and requires one realpath (section 14.6).
5. `mediabunny` and `@mediabunny/server` are regular dependencies of
   `agents` (D16), so one tree holds both.

Test: in the packed-tarball smoke, call `ensureCodecs()` and decode the first audio frame and the first video frame of
a checked-in fixture through `inputFrom`. The test asserts decoded sample
counts and frame dimensions, not only a successful import.

## 8. Size measurement and budget (F12)

### 8.1 Method

A read-only walker starts at the core workspace packages and follows
`dependencies`, `optionalDependencies`, and non-optional `peerDependencies`. It
resolves each name with Node's `node_modules` lookup from the dependent's real
directory. Workspace edges outside the core tier are not followed. Each
external package directory counts once, excluding its nested `node_modules`.
Workspace packages count `dist/`, `package.json`, and `README.md`, which match
their `files` fields. Apparent size sums `st_size`. Disk usage sums
`st_blocks × 512`. The tree is the local darwin-arm64 checkout with Node 24.

Step 1 commits this walker as a script so that CI can rerun it.

### 8.2 Results (local-tree estimates)

| Scenario | Packages | Apparent | Disk |
|---|---|---|---|
| S0: current CLI closure | 884 | 868 MB | 1,087 MB |
| S1: draft core (packs and the draft's four libraries removed) | 628 | 395 MB (346 MB external) | 526 MB |
| S3: proposed core (S1 plus every cut in 6.1) | 550 | 233 MB | 345 MB |

S1 external size (346 MB) agrees with the review's 336 MB.

D16 cancels three cuts in the table below (`@hyzyla/pdfium`, `mediabunny`,
`chart.js`) and keeps `webgpu` and `node-av` in core. On the local tree the
five core additions use about 170 MB of disk: `webgpu` 68 MB, `node-av` with
its platform package 64 MB, `werift` 11 MB, `mediabunny` 11 MB,
`@hyzyla/pdfium` 11 MB, `chart.js` 6 MB. `node-web-audio-api` (41 MB) moves
with the `audio` pack. The projections in 8.3 and the gate in 8.4 predate D16.

Savings of each cut, measured on top of S1:

| Cut | Apparent | Disk |
|---|---|---|
| OpenTelemetry SDK packages | 30.2 MB | 47.3 MB |
| `@napi-rs/canvas` | 25.2 MB | 25.3 MB |
| `typescript` | 23.3 MB | 23.6 MB |
| `js-tiktoken` | 21.4 MB | 21.5 MB |
| `sharp` and `@img/*` | 18.3 MB | 18.4 MB |
| `@hyzyla/pdfium` | 10.8 MB | 10.8 MB |
| `esbuild` | 10.3 MB | 10.3 MB |
| `mediabunny` | 9.8 MB | 10.4 MB |
| `@dimforge/rapier3d-compat` | 7.9 MB | 8.0 MB |
| `chart.js` | 6.0 MB | 6.3 MB |

### 8.3 Adjustments for a clean install

| Adjustment | Apparent | Disk |
|---|---|---|
| `better-sqlite3`: the local tree holds a source build (17 MB `build/`), a prebuilt install holds one 1.9 MB `.node` file | −15 MB | −15 MB |
| Source maps in core `dist/` (do not publish `*.map`) | −16 MB | −27 MB |
| `tsx` added (D11) | +11 MB | +11 MB |
| Projected clean core | about 213 MB | about 314 MB |

The largest remaining items in S3: `better-sqlite3` (26.5 MB locally),
`@openclaw/fs-safe` (16.7 MB, of which 15 MB is native binaries for seven
platforms in `dist/native/`), `drizzle-orm` (9.9 MB), `openai` (9.3 MB),
`@mixmark-io/domino` via `turndown` (7.4 MB), `@anthropic-ai/sdk` (6.1 MB), and
NodeTool's own `dist/` (50 MB apparent: 16 MB JavaScript, 15 MB maps, 14 MB
declarations).

### 8.4 Budget

D13. The gate measures `du -sk node_modules` after a clean
`npm install <cli tarball>` in an empty project on linux-x64 in CI.

- Hard gate at the end of step 1: the walker's measurement of the D16 core.
  The earlier 320 MB figure excludes the D16 libraries.
- Ratchet target for a later release: 250 MB disk. It needs the upstream
  `@openclaw/fs-safe` per-platform split (about −13 MB), no declaration files
  in runtime-only packages (about −14 MB), and lazy provider SDKs.

The previous 200 MB goal is not reachable with SQLite, the LLM provider SDKs,
the MCP SDK, and Fastify in core. The check must fail on a deliberately
oversized fixture before it is enforced.

## 9. The node catalog (D3, F8)

`@nodetool-ai/node-catalog` is a new core package. It holds:

- `index.json`: pack entries, runtime library entries, and the exact owner map.
- `nodes.json.gz`: full metadata for every node type.

The 0.8.3 tree registers 3,310 node types with all packs loaded. Their merged
metadata is 8.16 MB of JSON and 828 KB gzipped. `fal` owns 1,645 types and
`replicate` 662.

### 9.1 Format

```ts
interface NodeCatalogIndex {
  schemaVersion: 1;
  coreVersion: string;                       // equals the CLI version
  packs: CatalogPack[];
  runtimeLibraries: CatalogRuntimeLibrary[];
  owners: Record<string, string>;            // node_type -> pack id, total
}

interface PinnedPackage {
  name: string;
  version: string;                           // exact
  integrity: string;                         // sha512 of the published tarball
}

interface CatalogPack {
  id: string;                                // BUILTIN_NODE_PACKS id, never renamed
  kind: "core" | "node-pack";
  npm: PinnedPackage;                        // version === coreVersion
  registrar: string;                         // named export, called with the registry
  requires: readonly string[];               // other pack ids
  runtimeLibraries: readonly string[];
  secrets: readonly string[];                // e.g. ["FAL_API_KEY"]
  scriptAllowlist: readonly PinnedPackage[]; // closure packages allowed to run install scripts
  installBytes: number;                      // disk, linux-x64, measured by the release job
  nodeCount: number;
}

interface CatalogRuntimeLibrary {
  id: string;
  packages: readonly PinnedPackage[];        // direct packages to install
  shared: readonly string[];                 // shared-identity names (D12)
  scriptAllowlist: readonly PinnedPackage[];
  installBytes: number;
}

interface CatalogNode {
  node_type: string;
  pack: string;
  metadata: NodeMetadata;                    // final registrar output
  declared: DeclaredPropertyMetadata[];      // input to validateNodeProperties
}
```

### 9.2 Generation

`scripts/generate-node-catalog.mjs` runs in a child process with a fixed
environment (empty HOME, no secrets, `NODE_ENV=production`):

1. For each pack, create a fresh `NodeRegistry`, call the pack's registrar, and
   record the registered types as that pack's ownership. Registrars are the
   source, not class metadata. `registerBaseNodes` overrides metadata for some
   types (`packages/base-nodes/src/index.ts:605-640`).
2. Fail when two packs register the same type (duplicate ownership).
3. Read `getMetadata()` and the class's declared properties for each type.
   Fail when a declared property holds a value that JSON cannot represent.
4. Sort packs by id, nodes by `node_type`, and object keys. Omit timestamps.
5. Write `index.json` and `nodes.json.gz` with a fixed gzip level and mtime.

Pins (`integrity`, `installBytes`) come from the release job (section 17). The
in-repo file holds placeholder pins that the release job replaces.

`npm run check:node-catalog` regenerates the catalog and diffs it. It fails on
any difference and asserts that it compared more than zero node types. Add it
to `test:affected` path checks and to the harness registry
(`packages/cli/src/harness/registry.ts`) for changes under node packages.

Namespace prefixes stay for display and search only. `findBuiltinPackForNodeType`
(`packages/protocol/src/builtin-packs.ts:39-46`) picks the first matching
namespace. `nodetool.control.If` (`core-nodes`), `nodetool.text.Concat`
(`text-nodes`), and `nodetool.game.SpriteSheet` (`image-nodes`) all start with
`nodetool`, and `kie` appears in both the `base` and the `kie` pack. The CLI
uses `owners` instead.

## 10. Catalog-only nodes (D6, F9)

### 10.1 Origin and availability

`NodeMetadataSource` (`packages/node-sdk/src/registry.ts:28-32`) gains the
value `"catalog"`. `NodeRegistry` gains:

```ts
type NodeAvailability = "executable" | "catalog" | "python" | "unknown";

loadCatalog(entries: Iterable<CatalogNode>): void;   // source "catalog", packageId = pack id
availability(nodeType: string): NodeAvailability;
catalogEntry(nodeType: string): CatalogNode | undefined;
```

- `executable`: a class is registered. `has()` keeps its meaning (`:328`).
- `catalog`: catalog metadata, no class.
- `python`: metadata from `"python-package"` or `"python-bridge"`, no class.
- `unknown`: neither.

A registered class always wins over catalog metadata for the same type.

### 10.2 Call sites that must change

| Site | Today | Change |
|---|---|---|
| `packages/node-sdk/src/registry.ts:197` `validateNode` | Returns `[]` without a class | For `catalog`, call `validateNodeProperties` (`packages/node-sdk/src/validation.ts:167`) with `catalogEntry().declared` |
| `packages/node-sdk/src/graph-validation.ts:1398` | `unknown_node` when `!has()` | For `catalog`, emit `pack_not_installed` with pack id, size, and install command |
| `packages/node-sdk/src/graph-validation.ts:1444`, `:940`, `:1539` | Skip checks when `!has()` | Run property and edge checks for `catalog` from metadata |
| `packages/execution/src/service/workflow-run.ts:805-812` | `getMetadata && !has` means Python | Use `availability() === "python"` |
| `packages/websocket/src/plugins/websocket.ts:128`, `:163`, `packages/websocket/src/http-api.ts:381` | Same Python test | Use `availability() === "python"` |
| `packages/dsl/src/registry.ts:84-89`, `:107-113` | No TS class means Python candidate | Exclude `catalog` |
| `packages/execution/src/executor-resolver.ts:21-27` | Falls back to the Python executor | Throw `MissingPackError` for `catalog` |
| `packages/execution/src/session.ts:287` `hasTsExecutor` | `has()` | Unchanged. Catalog types never reach execution (section 12) |

No pack overrides `validateProperties` today (no `static validateProperties`
outside `node-sdk`). A parity test runs fixture property bags through the
class validator and the catalog validator for every node type in the catalog
and requires equal issue lists. Another test builds a graph with an unloaded
TypeScript node with invalid properties. It asserts the property issues and
asserts that `ensurePythonBridge` is never called.

## 11. Install layout (D1)

### 11.1 Install modes

The CLI finds its package root (`cliRoot`, the directory whose `package.json`
names `@nodetool-ai/cli`) from the realpath of its entry file.

| Mode | Detection | Install root | npm save mode |
|---|---|---|---|
| `global` | `dirname(cliRoot)` is `<npm root -g>/@nodetool-ai` | `cliRoot` | `--no-save`, full desired set every time |
| `local` | `cliRoot` is `<X>/node_modules/@nodetool-ai/cli` and `<X>/package.json` lists `@nodetool-ai/cli` | `<X>` | `--save --save-exact` |
| `npx` | `cliRoot` contains a `_npx` path segment | none | unsupported |
| `dev` | `cliRoot` is not under `node_modules` (workspace checkout) | none | packs are workspaces, nothing to install |
| `unsupported` | `.pnpm` in the path, a Yarn PnP file, a Bun global directory, or a local install that the project does not list | none | unsupported |

`npm root -g` runs only in `packs` commands and in the installer, never at
startup.

For `npx`, `unsupported`, and `dev`, the `missing_pack` payload sets
`install.supported: false` and a reason. For `npx` the text is:
`Packs need a global or local install. Run "npm install -g @nodetool-ai/cli" or "npm install @nodetool-ai/cli" in a project.`
This answers Q5. The CLI never writes into the npx cache.

### 11.2 Why the CLI package directory, not the global prefix

These experiments ran in a scratch directory against a local test registry
with three tiny packages: `cli` depends on `core`, and `pack-a` and `pack-b`
declare `core` as a peer. Each ran on npm 10.9.8 (Node 22.22.3) and npm 11.16.0
(Node 24.18.0) on macOS. The results matched.

- E1. `npm install -g pack-a` next to a global `cli` installs a second copy of
  `core` at `<prefix>/lib/node_modules/pack-a/node_modules/core`. Each global
  package is its own npm project. A separate global pack install breaks module
  identity.
- E2. `npm install --no-save pack-a` with the CLI package directory as the
  working directory places `pack-a` beside `core` and resolves its peer to the
  existing copy. No `package.json` or `package-lock.json` changes.
- E3. A following `npm install --no-save pack-b` removes `pack-a` ("added 1
  package, and removed 1 package"). The installer must pass every desired pack
  in every call.
- E4. A reinstall with `npm install -g cli` removes every pack from the CLI
  directory.
- E5. In a local project, `npm install --save-exact pack-a` records the pack in
  the project's `package.json` and resolves its peer to the hoisted `core`.

E4 means a global CLI upgrade removes packs. Section 13.3 handles restoring
them.

### 11.3 State

D14. The installed set is what exists in the tree. On start the CLI reads
`<root>/node_modules/@nodetool-ai/` once and keeps the entries that the catalog
lists with `version === coreVersion`. A pack with another version is ignored
and reported by `packs doctor`.

The desired set lives in `<data dir>/cli-packs.json`, keyed by the realpath of
the install root: `{ root, coreVersion, packs: string[], runtimeLibraries: string[] }`.
The data directory is the one the CLI resolves for its database. Writes are
atomic (temporary file and rename) under the install lock. The file records
what the user asked for. It never authorizes an install.

## 12. Missing-pack resolution (D4)

### 12.1 Before a run

At start the CLI registers core node classes, then the registrars of installed
packs, then catalog entries for every other type (section 10). Commands that
need no registry skip this.

```ts
function findMissingPacks(graph: GraphInput, registry: NodeRegistry): MissingPackSet;
```

It walks every node type in the graph, including nodes inside embedded
subgraphs and the graphs of referenced sub-workflows loaded through the same
lookup the run uses. Each `catalog` type maps to its pack through `owners`,
plus the transitive `requires` and runtime libraries. `findMissingPacks` runs
inside the execution preflight that already returns structured errors
(`packages/execution/src/service/workflow-run.ts:795-804`), so every entry
point gets it: `run`, `debug`, `node run`, `workflows run`, the HTTP and
WebSocket run handlers, and `run_workflow` inside MCP.

The CLI then applies the install policy (section 13). If it installs, it
re-executes (section 16). Otherwise it fails with `missing_pack`. A DSL file
builds its graph first and goes through the same check. A DSL file that imports
a pack package directly fails inside `tsImport` with a module-not-found error
for a catalog npm name. The CLI maps it to `missing_pack`.

Calls that pick a node type at run time cannot be scanned: CodeAct
`invoke_node` and `open_node_stream`, `run_node`, and agent control tools. They
return `missing_pack` as a structured tool error to the agent. The run
continues. Nothing retries (F10). A lazily loaded runtime library behaves the
same way through `importRuntimeLibrary`.

### 12.2 The `missing_pack` error (Q1)

```json
{
  "error": "missing_pack",
  "packs": [
    { "id": "fal", "npm": "@nodetool-ai/fal-nodes", "version": "0.8.3", "installBytes": 12582912, "requires": [] }
  ],
  "runtimeLibraries": [],
  "nodeTypes": ["fal.image.FluxDev"],
  "install": { "supported": true, "reason": null, "command": "nodetool packs add fal" },
  "restartRequired": false
}
```

| Surface | Form |
|---|---|
| CLI run commands | stderr text, the JSON above with `--json`, exit code 3 |
| `nodetool validate` | `pack_not_installed` issues. Exit 3 when every error is `pack_not_installed`, else exit 1 |
| HTTP run endpoints | status 424 with a preflight detail of kind `missing_pack` |
| WebSocket run | a job error message with the same payload |
| MCP | a tool result with `isError: true` and the payload as structured content. `restartRequired: true`, because a running MCP server cannot load new packs |

No production code in `packages/cli/src` assigns exit code 3. It uses 0, 1,
130, and 143. `packages/cli/src/child-exit.ts:15` forwards any child status, so
`serve` and `chat` can pass a 3 from a child. Callers must check the
`missing_pack` discriminator, not the exit code alone.

## 13. Install authorization (D5, F1, Q3)

### 13.1 Sources

An install proceeds only with one of these:

1. An explicit `nodetool packs add <id...>` or `packs add --restore`.
2. `--install-missing` on `run`, `debug`, `node run`, or `workflows run`.
3. `NODETOOL_AUTO_INSTALL=1` in the environment of those commands.
4. An interactive prompt, when stdin and stderr are TTYs and `CI` is unset. The
   default answer is No.

The prompt names each pack and runtime library with version and size, the
packages that will run install scripts, and the files that change in local
mode (`package.json`, `package-lock.json`).

`serve`, `mcp serve`, the headless job runner, chat, and every agent tool never
install. They ignore `NODETOOL_AUTO_INSTALL` and return `missing_pack`.

### 13.2 Enforcement inside the installer

The permission gate cannot carry this. `decidePermission` allows every
category in `auto` mode (`packages/runtime/src/permission-gate.ts:57-59`), and
`headlessGate()` returns mode `auto` (`:197`). MCP uses it
(`packages/websocket/src/mcp-agent-tools.ts:367`).

- The installer lives in `packages/cli`. No package that `websocket`, `agents`,
  or `runtime` depends on exports it.
- `installPacks(request, authorization)` requires an `InstallAuthorization`
  value. Only the CLI command layer constructs it, with the source, the exact
  pack and runtime library ids, and the script allowlist the user saw.
- The installer rejects a request for any id outside the authorization.
- The `serve` and `mcp serve` entry points set a process flag. The installer
  refuses to run in a process with that flag.
- There is no `packs.install` capability. The agent-facing `packs` capability
  module stays limited to sandbox packages.

### 13.3 Upgrades and restore

After a global CLI upgrade, the new CLI finds the previous desired set in
`cli-packs.json` and packs missing from the tree (E4). It prints one stderr
line: `2 packs from 0.8.3 are not installed for 0.8.4: fal, video. Run "nodetool packs add --restore".`

The CLI never reinstalls without authorization from 13.1. In a run with
`--install-missing` or `NODETOOL_AUTO_INSTALL=1`, it installs only what that
graph needs. The desired set records intent. It never authorizes new install
scripts or new runtime libraries. When a restore would run scripts from
packages not in the previous version's allowlist, the prompt lists them.

## 14. The installer

### 14.1 npm

- Use the npm that ships with the running Node: `npm-cli.js` beside
  `process.execPath` (`../lib/node_modules/npm/bin/npm-cli.js` on Unix,
  `node_modules/npm/bin/npm-cli.js` on Windows), run with `process.execPath`.
  Fall back to `npm` on `PATH`. On Windows, never spawn `npm.cmd` through a
  shell.
- D15. Minimum npm 10.9.0. Supported and tested: 10.9.x and 11.x (section
  19.3). The installer refuses older versions with an upgrade command. It warns
  on npm 12 or later.
- The installer rejects `--preserve-symlinks` and `--preserve-symlinks-main` in
  `process.execArgv` and `NODE_OPTIONS`, because they change module identity.

### 14.2 Flags

Forced on every call as command-line flags, which take precedence over
`.npmrc` files and `npm_config_*` environment variables:

```
--ignore-scripts --omit=dev --include=prod --include=optional
--strict-peer-deps --legacy-peer-deps=false --install-strategy=hoisted
--prefer-dedupe --package-lock=true --global=false --workspaces=false
--force=false --dry-run=false --no-audit --no-fund --update-notifier=false
```

Plus, by mode:

- `global`: `--no-save`, working directory `cliRoot`, and every desired pack
  and runtime library spec in each call (E3).
- `local`: `--save --save-exact --save-prod`, working directory the project
  root.

Honored user configuration: `registry`, scoped registries, auth tokens, `proxy`,
`https-proxy`, `noproxy`, `ca`, `cafile`, `cert`, `key`, `strict-ssl`, `cache`,
`offline`, `prefer-offline`, the `fetch-*` retry and timeout settings,
`maxsockets`, `userconfig`, and `globalconfig`. A corporate mirror works
because verification uses content integrity, not the registry host.

### 14.3 Lock

One lock per install root: `<root>/node_modules/.nodetool-packs.lock`, created
with exclusive create (`wx`). It holds the pid, host name, a random token, and
the start time. The owner rewrites its mtime every 5 seconds. Another installer
waits up to 10 minutes and prints a line every 10 seconds. A lock with an mtime
older than 60 seconds is stale and is removed. A user's own concurrent `npm`
command is not covered. `packs doctor` detects its effects.

### 14.4 Flow

1. Detect the mode (11.1). Stop for `npx`, `dev`, and `unsupported`.
2. Check the authorization (13.2) and the npm version (14.1).
3. Expand the request with `requires` and runtime libraries. Compute the new
   desired set.
4. Take the lock.
5. Snapshot the tree: every installed package's path, name, version, and
   integrity from `node_modules/.package-lock.json`.
6. Plan with `npm install --dry-run` and the same flags and specs (14.5).
7. Run `npm install` with `--ignore-scripts`.
8. Verify the tree (14.6). No install script and no pack import has run yet.
9. Run install scripts only for allowlisted packages present in the new set:
   `npm rebuild <name> --ignore-scripts=false --foreground-scripts`. Each must
   match its pinned version and integrity.
10. Load-check in a child process:
    `nodetool packs doctor --installed --json --pack <ids>`. It registers each
    new pack in a fresh registry, compares the owned types with the catalog,
    and checks that the pack resolves `@nodetool-ai/node-sdk` to the core's
    realpath.
11. Write `cli-packs.json`. Release the lock.
12. Re-execute or print the restart notice (section 16).

### 14.5 Plan check

npm 11 prints `add`, `change`, and `remove` arrays with `--dry-run --json`.
npm 10.9.8 prints only counts in JSON. Both print `add <name> <version>`,
`remove <name> <version>`, and `change` lines in text form. The installer
parses the text lines on npm 10 and the arrays on npm 11. A unit test pins both
formats.

The plan is refused when:

- It changes or removes any package that exists before the install, except the
  packages a `packs remove` request owns exclusively.
- It adds a core package or a second copy of a shared-identity library.
- It adds an `@nodetool-ai` package whose version differs from the core
  version.

Installs are therefore additive. A running `serve` process keeps working,
because no file it loaded changes.

### 14.6 Verification (F5)

Before any install script or import:

1. Every `@nodetool-ai` package in the tree has the core version, and each core
   package exists exactly once.
2. The integrity of each installed pack, each allowlisted script package, and
   each shared-identity library equals the catalog pin. The installer reads
   integrity from `node_modules/.package-lock.json` and fails when it is
   missing.
3. The tree diff against the snapshot contains only additions (14.5).
4. Each shared-identity library resolves to one realpath from every dependent
   (D12).

Other third-party packages float within their declared ranges, as they do for
the core CLI install today (R1).

### 14.7 Rollback

A failure in steps 7 to 10 triggers rollback:

- `global`: rerun `npm install --no-save` with the previous desired set, which
  removes the added packages (E3).
- `local`: `npm uninstall --save` for the added specs.

Then the installer compares the tree with the snapshot. When it matches, the
installer releases the lock and exits 1 with the original error. When it does
not match, `cli-packs.json` records `dirty: true`, and every later start prints
a one-line pointer to `nodetool packs doctor --repair`.

## 15. Command surface

`nodetool packs` exists today with `compile` for sandbox packs
(`packages/cli/src/commands/packs.ts:21`). The new verbs join it (Q6).

```
nodetool packs list [--json]              id, kind, installed, version, size, node count, required by
nodetool packs add <id...> [--yes]        install packs or runtime libraries with their requirements
nodetool packs add --restore              install the desired set recorded for this root
nodetool packs add --for <workflow|file>  install every pack one graph needs
nodetool packs remove <id...>             remove, keeping packs that another installed pack requires
nodetool packs doctor [--json] [--repair] mode, npm version, versions, integrity, single copies, registration, dirty state
nodetool packs why <node_type|id>         owner pack, requirements, runtime libraries, installed state
nodetool packs compile                    unchanged (sandbox packs)

nodetool run|debug|node run|workflows run ... --install-missing
```

`--yes` on `packs add` skips the prompt. The explicit command is the
authorization. `packs remove` warns that running `serve` and `mcp serve`
processes may fail on a later lazy import from the removed pack.

## 16. Process model and caches (D2, Q4)

After an install from `run`, `debug`, `node run`, or `workflows run`, the CLI
re-executes: `spawnSync(process.execPath, [...process.execArgv, ...process.argv.slice(1)])`
with inherited stdio and `NODETOOL_PACKS_REEXEC=1`. It exits with
`childExitCode` (`packages/cli/src/child-exit.ts:12`). A re-executed process
never installs. If packs are still missing, it exits 3.

`packs add` from a terminal prints:
`Restart running "nodetool serve" and "nodetool mcp serve" processes to load fal.`

Because of re-exec, no cache needs a refresh path. These are the snapshots
that a long-running `serve` or `mcp serve` keeps, which is why they need a
restart:

| Cache | Location |
|---|---|
| DSL builtin registry promise | `packages/dsl/src/registry.ts:13,23-26` |
| DSL factory references and script-export metadata | `packages/dsl/src/export.ts:107-114`, `packages/dsl/src/script-export.ts:226-235`. Catalog-backed after the cut, so they stop depending on installed packs. |
| CLI validation registry | `packages/cli/src/validate/index.ts:45-51` |
| Sandbox module catalog | `packages/websocket/src/sandbox-catalog.ts:34-40` (`refreshSandboxCatalog`), `packages/cli/src/sandbox-catalog.ts` |
| Provider registry (packs register local providers) | `packages/runtime/src/providers/provider-registry.ts:39` |
| Mediabunny codec registration, which also memoizes a rejected promise | `packages/agents/src/analysis/mediabunny-runtime.ts:18-31` |
| Optional-module fallback directory | `packages/config/src/optional-modules.ts:39`, read per call |

Registry metadata and search caches invalidate on mutation
(`packages/node-sdk/src/registry.ts` revision counter). REST and tRPC metadata
endpoints read the registry per request. MCP builds a fixed capability list,
not one tool per node.

## 17. Publishing (D7, F13)

1. Remove `NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}`
   (`.github/workflows/publish.yaml:96`). Configure npm trusted publishing
   (OIDC) for every package. The workflow already grants `id-token: write`
   (`:19`). Delete the `NPM_TOKEN` secret.
2. Pack each package once. Publish the tarball files, not directories, so the
   bytes that CI pinned are the bytes on the registry.
3. Order: build, pack the node packs, compute pins, generate `node-catalog`
   with the pins, pack the core packages, run the packed smoke tests (core-only
   and with packs), publish everything under the dist-tag `staging`.
4. Verify for every catalog pack and runtime library pin: `npm view
   <name>@<version> dist.integrity` equals the pin, and a provenance
   attestation exists whose source repository is `nodetool-ai/nodetool` and
   whose workflow is `publish.yaml`. Then install the CLI from the registry
   with `staging` and run one pack install.
5. Promote with `npm dist-tag add <name>@<version> latest` for every package,
   the CLI last. A failure in step 4 leaves `latest` unchanged.

`unmetDeps` in `scripts/publish-packages.mjs:180` blocks a package only on its
declared dependencies. Once packs leave the CLI's dependencies, step 4 is the
gate that keeps the catalog from advertising a missing pack.

## 18. Security

| Threat | Control |
|---|---|
| An agent or a headless run installs code (F1) | Install authorization separate from the permission gate (13.2). No install capability. Server and MCP processes refuse. |
| A tampered or substituted pack tarball | Integrity pin from the catalog, which ships inside the provenance-attested core tarball (14.6, 17). |
| A malicious install script in a pack's closure | `--ignore-scripts` for every install. Allowlisted packages run scripts only after integrity verification, pinned to version and integrity (14.4 step 9). |
| A stolen long-lived publish token | OIDC-only publishing (17). |
| A partial release | Staging tag, verification, then promotion (17). |
| Implicit downloads | Remove the `npx tsx` calls (D11). The CLI never invokes `npx`. |
| Privilege escalation | The installer never runs `sudo`. EACCES on a root-owned prefix fails with instructions. |
| Third-party pack loader confusion | First-party packs carry no `nodetool` manifest field (D8). The third-party loader keeps its allowlist and reserved namespaces (`packages/node-sdk/src/pack-loader.ts:19-37`). |
| An install changes files under a running process | Additive plan check (14.5). |
| Drifting transitive dependencies | Residual risk R1. |

Every install prints one stderr line per installed package with name, version,
and whether it ran a script.

## 19. Test plan

### 19.1 Unit

- Catalog generator: ownership per registrar, duplicate ownership error,
  deterministic output, non-JSON property error, non-zero comparison.
- `availability()` for each origin. Validation parity (section 10.2). Python
  routing never sees `catalog` types.
- `findMissingPacks` over subgraphs, sub-workflows, `requires`, and runtime
  libraries.
- `missing_pack` payload and exit code per surface.
- Authorization matrix: each source, each refusing process, ids outside the
  authorization.
- Mode detection on Unix and Windows paths, including `_npx`, `.pnpm`, and a
  workspace checkout.
- npm flag builder, version check, and plan parser for npm 10 text and npm 11
  JSON output.
- Lock: exclusive create, heartbeat, stale removal.

### 19.2 Integration

- A local test registry fixture serves packuments for packed tarballs, as in
  the section 11.2 experiments. It runs the real installer against packed
  NodeTool tarballs without the public registry.
- Clean-core tests (6.4).
- Real decode with `media` (section 7).
- Re-exec: a TTY-simulated `run` with a missing pack installs, re-executes,
  and finishes with the child's exit code.
- Additive plan refusal: a fixture pack that requires a different version of
  an installed package is refused, and the tree is unchanged.
- Rollback: a fixture pack whose load check fails is removed, and the tree
  equals the snapshot.
- Each new gate (size budget, catalog drift, clean-core guard) fails on
  deliberately invalid input before it is enforced.

### 19.3 npm matrix (F11)

Run in CI on macOS arm64, Linux x64 (glibc), and Windows x64, each with
Node 22 (npm 10.9.x) and Node 24 (npm 11.x):

| Case | Expected |
|---|---|
| Global default prefix, add one pack | Pack beside core, one copy of each core package |
| Global, add a second pack | First pack stays (E3 avoided by full desired set) |
| Global custom prefix (`--prefix`, nvm) | Same as default |
| Global CLI reinstall | Packs gone, restore line printed (E4) |
| Local project, add and remove | `package.json` updated, user `npm ci` keeps the pack |
| `npx` | `missing_pack` with `install.supported: false` |
| pnpm and Yarn installs | `unsupported` with a message |
| `.npmrc` with `ignore-scripts=true`, `legacy-peer-deps=true`, `global=true`, `install-strategy=nested`, `omit=optional`, `package-lock=false` | Forced flags win. Allowlisted scripts still run. |
| Registry mirror with custom CA | Install succeeds, integrity matches |
| Offline with a warm cache | Install succeeds |
| Offline with a cold cache | Clear network error, tree unchanged |
| Two concurrent `packs add` | Second waits, both succeed |
| Install killed during `npm install` | `packs doctor` reports, `--repair` restores |
| Root-owned global prefix | EACCES message, no `sudo` |
| Windows long paths and the global `%APPDATA%\npm` layout | Mode detection and installs succeed |
| `NODE_OPTIONS=--preserve-symlinks` | Refused |

## 20. Migration plan

1. **Dependency cut and measurement.** No installer work.
   - Commit the walker from 8.1 and add a report-only size check to the packed
     smoke.
   - Generate `@nodetool-ai/node-catalog` with the drift check (section 9).
     DSL export and validation need it without packs.
   - Add catalog availability and the call-site changes (section 10).
   - Apply every cut in 6.1 and 6.2, add the loader helpers (6.3), and fix
     D11.
   - Add the core-only smoke variant (6.4). It installs a CI-built CLI tarball
     whose manifest has the node packs removed. The published CLI keeps its
     packs during this step, so users see no change.
   - Exit criteria: the core-only smoke passes, the size check reports at or
     below 320 MB disk (D13), and `npm run backend:smoke` passes for the
     desktop bundle.
2. **Publishing hardening** (can run beside step 1). OIDC-only, tarball
   publishing, staging tag, verification, and promotion (section 17).
3. **Pack loader.** Installed-pack discovery, catalog registration,
   `findMissingPacks`, `missing_pack`, and exit code 3. Users can install packs
   with plain npm in this step.
4. **Installer and `packs` commands** behind `NODETOOL_PACKS_INSTALLER=1`, with
   the npm matrix (19.3) in CI.
5. **Switch.** Remove the node packs from the CLI manifest. Enforce the size
   gate. Remove the installer flag. Update `docs/cli.md`.

## 21. Risks

- R1. Third-party transitive dependencies of packs float within their ranges.
  A new upstream release can change a pack's closure after the CLI ships. Only
  NodeTool packages, allowlisted script packages, and shared-identity libraries
  are pinned.
- R2. A global CLI upgrade removes packs (E4). The restore line and
  `packs add --restore` cover it, but an unattended upgrade leaves runs failing
  with `missing_pack` until someone authorizes a restore.
- R3. `packs remove` can break a running server on a later lazy import.
- R4. Moving a library to an optional peer removes it from the monorepo tree
  and the desktop bundle unless a pack depends on it (D9). `npm run
  backend:smoke` and `bundle-backend.mjs` must cover each moved library.
- R5. Making `sharp` optional removes thumbnails and image resizing from a
  core-only `serve` until `imaging` is installed.

## 22. Open questions

- Q6. `nodetool packs` already means sandbox pack tooling, and the agent
  capability `packs` lists sandbox packages. Keep one command for both kinds,
  or rename the new verbs to `nodetool nodes add` and similar?
- Q7. Is a character-count estimate acceptable for chat compaction when
  `tokenizer` is absent, or must `js-tiktoken` stay in core at 21 MB?
- Q8. Should `llm-nodes` and `code-nodes` stay core? They have no heavy
  dependencies, and the Agent and Code nodes are common in core workflows.
- Q9. Local mode writes the user's `package.json` and lockfile. Is that
  acceptable, or should local mode require an explicit `npm install` and only
  print the command?
- Q10. Who owns the upstream request to split `@openclaw/fs-safe` native
  binaries per platform?

Q2 from the review becomes the matrix in 19.3. Q1, Q3, Q4, and Q5 are answered
in sections 12.2, 13.3, 16, and 11.1.
