The workspace layout, the dependency direction the core depends on, and how a new adapter or agent is registered.
Use a pnpm workspace.
Recommended structure:
llm-runtime-dock/
├── src/
│ └── index.ts composition root
├── bin/
│ └── lrd.mjs the `lrd` binary
├── apps/
│ └── gateway/
├── packages/
│ ├── core/
│ ├── cli/
│ ├── adapter-mtplx/
│ ├── adapter-lm-studio/
│ ├── adapter-omlx/
│ ├── adapter-ollama/
│ ├── adapter-custom/
│ ├── agent-opencode/
│ ├── agent-claude/
│ └── agent-codex/
├── examples/
│ └── config.yaml
├── scripts/
├── tests/
├── docs/
├── package.json the published manifest, and the workspace root
├── pnpm-workspace.yaml
├── tsconfig.json
├── eslint.config.js
├── prettier.config.js
├── LICENSE
└── README.md
The repository root is the published package. The composition root lives at
src/index.ts rather than in a package of its own: it is forty lines that only
wire concrete adapters and agents together, and putting it at the root means the
manual in README.md and LICENSE reach the npm page, which a nested package
directory would not.
If a separate package adds no real public boundary, it may remain an internal module instead. Do not create packages merely for theoretical modularity.
Target package names:
@llm-runtime-dock/core
@llm-runtime-dock/cli
@llm-runtime-dock/adapter-mtplx
@llm-runtime-dock/adapter-lm-studio
@llm-runtime-dock/adapter-omlx
@llm-runtime-dock/adapter-ollama
@llm-runtime-dock/adapter-custom
@llm-runtime-dock/agent-opencode
@llm-runtime-dock/agent-claude
@llm-runtime-dock/agent-codex
Those names organise the sources. Every one of them is private: true and none
is published — they exist for the development flow, not for npm.
The one published package is llm-runtime-dock and is meant to be installed
globally:
npm i -g llm-runtime-dockIt exposes one binary, deliberately shorter than the package name:
lrd
A global install shapes a few things: the published package must carry built output rather than sources, the bin entry needs a proper ESM shim with a shebang, and Node >= 24 must be enforced through engines so the failure is a clear message instead of a syntax error.
The published tarball holds dist/index.js, bin/lrd.mjs, the manifest,
README.md and LICENSE — nothing else, and no dependencies at all. That is
not what tsc produces: it emits file-per-file and never inlines
node_modules, so a tsc-only build would have to declare all nine workspace
packages plus Commander, YAML, Zod, @inquirer/prompts, smol-toml and
jsonc-parser as real dependencies and publish the whole graph to npm.
So the build has two stages, and the order matters:
pnpm -r run build # tsc per package — what tests and typecheck consume
node scripts/bundle.mjs # esbuild — collapses the graph into one filepnpm build runs both. esbuild resolves each workspace package through its
exports field, i.e. against that package's dist/, so the bundle reflects the
last tsc run rather than the current sources. That is deliberate — the bundle
exercises the real published entry points — but it means the stages cannot be
reordered.
esbuild is the right size for this: it is what Vite uses underneath, without a dev server or a plugin layer, and it stays within "avoid unnecessary dependencies" better than a wrapper such as tsup would.
pnpm dev runs the bundle rather than the tsc output, which is how the shipped
artifact gets exercised daily instead of first at publish time.
- Node.js >= 24
- TypeScript
- ESM
- pnpm
- strict TypeScript
- native
fetch - AbortController for request/process cancellation
- a small HTTP framework is acceptable, but do not introduce a heavy framework without a reason
- Commander for CLI parsing (subcommands, options, help)
@inquirer/promptsfor the two places the CLI asks a question: which model a role should use (§23), and whether a save may delete a model the probe did not find (§22)- YAML configuration
- JSON Schema or equivalent runtime validation for config
- Vitest or equivalent test runner
Avoid unnecessary dependencies.
The core must not import any concrete runtime adapter.
Dependency direction:
CLI ───────────────┐
Gateway ───────────┼──> Core <── Adapter implementations
│
Config ────────────┘
Adapters depend on core interfaces.
Core must contain:
- configuration/domain types
- model resolution
- runtime registry
- the resident slot
- lifecycle orchestration
- runtime state machine
- scheduler/queue
- health/readiness handling
- model identity verification
- request routing
- errors
- logging interfaces
Core must NOT contain:
- MTPLX-specific logic
- LM Studio-specific logic
- oMLX-specific logic
- Ollama-specific logic
- coding-agent configuration formats
- mapping of configuration options onto a specific runtime's CLI flags
- inference implementation
- GUI automation
- model downloading
- quantization
- GPU memory management
- database
- web UI
Registration is explicit. There is no npm package discovery, and none is needed: the composition root imports the adapters it ships with and hands them to a registry.
Conceptually:
registerRuntimeAdapter(adapter);The core must work without importing concrete adapters.
Future versions may support npm package discovery. Dynamic discovery is deliberately absent here.
A plugin should provide:
- adapter ID
- config schema, including its curated launch-option schema and its reserved-argument list
- model release mechanism (
unload_modelorstop_server) - default probe target (§22)
- runtime adapter, including its
probe()implementation - optional CLI integration metadata
- capability declaration
Adapters must be independent of each other.
pnpm install
pnpm build # pnpm -r run build, in dependency order, then the bundle
pnpm bundle # the esbuild stage alone, over an existing package build
pnpm test # builds, then every package's tests, then the e2e suite
pnpm test:unit # package tests only
pnpm test:e2e # root end-to-end suite only
pnpm lint
pnpm typecheck # every package, plus every tests/ directory
pnpm cleanEvery script runs on Windows as well as macOS and Linux: clean uses rimraf
rather than rm -rf, pnpm dev is a Node launcher rather than a shell redirect,
and nothing relies on shell globbing or a POSIX-only utility.
Each package owns its own build/typecheck/clean scripts, and the root
delegates with pnpm -r run. Build order comes from the workspace:*
dependencies, so there is no separate graph to keep in sync — and independent
packages build in parallel. Work on one package with a filter:
pnpm --filter @llm-runtime-dock/core run build # just core
pnpm --filter '...@llm-runtime-dock/core' run build # core and everything downstreampnpm -r covers the workspace packages and never the root, so the root's own
build — which calls pnpm -r run build — cannot recurse into itself. The
publishing stage is not part of that graph: node scripts/bundle.mjs reads the
packages' dist/ output and is therefore always last
(Packaging).
The rule that core must not import an adapter (§6) is
enforced by pnpm's isolated node_modules, not by tsconfig:
packages/core/node_modules/@llm-runtime-dock does not exist. Adding a package
therefore means creating it, adding workspace:* where it is used, and running
pnpm install. Nothing else to update.
pnpm dev runs lrd from the current sources, rebuilding incrementally first so
a stale binary is never run — including the esbuild stage, so day-to-day work
exercises the artifact that ships rather than one only publishing would produce.
Build output goes to stderr, so stdout stays pipeable:
pnpm dev -- probe mtplx --save --dry-run
pnpm dev -- doctor --config examples/config.yaml
pnpm dev -- probe mtplx > snippet.yamlpackages/core/tests/ config, scheduler, resolution, discovery, apply
packages/runtimes/*/tests/ that runtime's options, argv and reserved flags
packages/agents/*/tests/ that agent's config format
packages/cli/tests/ command-line parsing
apps/gateway/tests/ error mapping, loopback rules
tests/e2e/ everything that spans packages or spawns a runtime
Run one package's tests with pnpm --filter @llm-runtime-dock/core run test.
Package tests import their own code from ../src and need no built output; the
end-to-end suites import workspace packages by name, so they exercise the real
published entry points against dist. See §31 for what
those suites are required to cover.
Strict TypeScript; avoid any; prefer discriminated unions and explicit domain
types. Keep adapters thin, keep orchestration in core, and keep business logic
out of HTTP route handlers.
Every function is an arrow function and there are no classes — stateful objects
are closure factories returning an object literal, paired with an exported
interface of the same name. Both rules are enforced by ESLint, so a violation
fails pnpm lint rather than review.