The plugin system
This essay explains the mental model behind a NetScript plugin: how a plugin relates to its sibling core package, what it contributes to a host through a typed manifest, how the host discovers it through generated registries rather than hand-imports, and why a single plugin's HTTP API and its background work run as separate processes. Read it to understand the shape before you extend it.
If you only want to add a plugin to a workspace, follow the
add-a-plugin how-to ; to author a new one, see author-a-plugin . For the exact symbols a plugin author calls, use the @netscript/plugin reference . The auth model is the richest worked example of every idea here.
A plugin is a thin layer over a core package
The single most important thing to understand is that a NetScript plugin is usually two packages, not one. The real logic lives in a core package; the plugin is a thin integration layer that contributes that logic to a host.
| Public plugin | Sibling core package | What lives in core |
|---|---|---|
@netscript/plugin-workers |
@netscript/plugin-workers-core |
Job/scheduler runtime, ports, contracts. |
@netscript/plugin-sagas |
@netscript/plugin-sagas-core |
Saga DSL, durable runtime, durability tiers. |
@netscript/plugin-triggers |
@netscript/plugin-triggers-core |
Trigger definition builders, ingress runtime. |
@netscript/plugin-streams |
@netscript/plugin-streams-core |
Durable stream producer runtime. |
@netscript/plugin-auth |
@netscript/plugin-auth-core (+ backends) |
AuthBackendPort seam, domain, contracts. |
The split follows the architecture doctrine's separation of behavior from integration:
- The core package implements the capability itself — the authoring DSL (for example the saga and trigger definition builders), the runtime, the ports it depends on, the adapters, telemetry helpers, and the versioned contract types. This is where the long-running behavior and state live.
- The plugin package is a thin integration layer (the doctrine's Plugin Package
archetype). Its
mod.tsis small. It re-exports the userland surface from its core sibling rather than redefining it, and it adds the one thing the core cannot supply on its own: a manifest that tells a host what this plugin contributes and how to wire it up.
Because of this split, core packages appear as an Internals subsection on each plugin's reference page rather than as separate top-level entries. Application authors normally reach core symbols through the public plugin, so the public plugin is the surface users consume.
This is also why some plugin surfaces are deliberately thin or even fail loud. In
@netscript/plugin-streams, the manifest-side helpers redirect you to the real producer runtime
in @netscript/plugin-streams-core (createDurableStream) by surfacing a
StreamUnsupportedOperationError rather than quietly doing nothing. The behavior lives in core;
the plugin only carries the manifest. The boundary is a feature, not an oversight — see
durable streams for the producer-versus-manifest framing.
What a plugin contributes: the manifest
A plugin describes itself with a typed manifest, assembled through the definePlugin()
builder in
@netscript/plugin
. The manifest is a
declaration, not executable wiring: definePlugin(name, version) returns a builder, and each
chained .with*() call adds a contribution along a well-defined extension axis.
// plugins/workers/src/public/mod.ts (abridged)
import { definePlugin } from "@netscript/plugin";
// definePlugin(name, version) returns a builder. No behavior runs here —
// each .with*() call only NAMES a contribution against a fixed axis.
export const workersManifest = definePlugin("@netscript/plugin-workers", "0.0.1-alpha.12")
.withType("background-processor")
.withService({ name: "workers-api", entrypoint: "./services/src/main.ts", port: 8091 })
.withBackgroundProcessor({ name: "workers-combined", entrypoint: "./bin/combined.ts", concurrency: 2 })
.withBackgroundProcessor({ name: "workers-worker", entrypoint: "./bin/worker.ts", concurrency: 2 })
.withBackgroundProcessor({ name: "workers-scheduler", entrypoint: "./bin/scheduler.ts" })
.withDbSchemas([{ path: "./database/workers.prisma", engine: "postgres" }])
.withContractVersions([{ version: "v1", loader: "./contracts/v1/mod.ts" }])
.withRuntimeConfigTopics([{ name: "workers", schemaPath: "./runtime/workers.schema.json" }])
.withAspire("./src/aspire/mod.ts")
.build();
The builder exposes a fixed set of contribution axes — the vocabulary every host understands. The most load-bearing ones:
| Builder method | Contributes | Used for |
|---|---|---|
.withService(…) |
An HTTP service: name, entrypoint, port. | The plugin's oRPC API resource. |
.withBackgroundProcessor(…) |
A long-running processor: name, entrypoint, concurrency. | Workers, runners, processors — runs apart from the API. |
.withDbSchemas(…) |
A Prisma schema fragment + engine. | Owned tables merged into the workspace schema. |
.withStreamTopics(…) |
Typed stream topic definitions. | Durable change-data channels. |
.withRuntimeConfigTopics(…) |
A named, schema-validated config topic. | Per-plugin runtime configuration. |
.withContractVersions(…) |
A versioned contract loader. | Forward/backward contract compatibility. |
.withTelemetry(…) |
Telemetry registration. | Spans, metrics, structured logs. |
.withAspire(…) |
An Aspire contribution module path. | How the AppHost materializes the plugin's resources. |
Contributions are the vocabulary the host understands. A workers plugin contributes worker job definitions and processors; a sagas plugin contributes saga definitions; a streams plugin contributes stream topics and a service; an auth plugin contributes its oRPC service, contract versions, and a runtime-config topic. The host never needs to know the internals of any plugin — it only needs to understand these contribution shapes.
This is the doctrine's principle of registration over inheritance for cross-package extension: a plugin registers named contributions against open extension axes instead of subclassing host internals. Registration scales because the host can validate a registration at composition time, log it, and reject conflicts — for example, duplicate plugin names are rejected with a structured error that references both contributors.
How the host discovers plugins: generated registries
NetScript does not auto-discover plugins from node_modules, and the host does not
hand-import each plugin. Plugins live under plugins/ and are discovered through the file system,
but the link between declaring a plugin and the runtime using it is a generated registry.
Rather than scanning the file system at runtime, the scaffold/codegen step emits static registry modules — TypeScript that the runtime imports directly — so that adding a plugin is a regeneration step, not a code edit. Two properties of this loader matter for the model:
- Load order is deterministic. Plugins must not depend on the order in which other plugins load; the doctrine forbids order-dependence and asserts against it.
- Each load contributes named registrations against one or more extension axes, and duplicate names across plugins are rejected with a structured error.
The practical sequence — netscript plugin add … followed by the registry-generation step — is
covered in the
add-a-plugin how-to
and the
CLI reference . Regenerating the registry is what makes a freshly added plugin visible to the runtime.
Why background work runs as separate processes
A single plugin almost never runs as one process. Its HTTP API and its background work are materialized by the AppHost as distinct Aspire resources, each with its own entrypoint and its own permission set. The workers plugin is the clearest example: its Aspire contribution registers the API as a Deno service and each processor as a Deno background resource:
// plugins/workers/src/aspire/workers-contribution.ts (abridged)
// The API is a service resource; each processor is a SEPARATE background resource.
const api = builder.addDenoService("workers-api", {
entrypoint: "plugins/workers/services/src/main.ts",
port: ctx.port("workers-api", 8091),
permissions: WORKERS_SERVICE_PERMISSIONS, // narrow: serve HTTP
});
const combined = builder.addDenoBackground("workers-combined", {
entrypoint: "plugins/workers/bin/combined.ts",
permissions: WORKERS_BACKGROUND_PERMISSIONS, // wider: queues, db
});
// …plus workers-scheduler and workers-worker, each its own resource
return [api, combined, scheduler, worker];
The background entrypoints are real files in every scaffolded plugin, and each capability picks the shape it needs:
| Plugin | Background entrypoint | AppHost resource |
|---|---|---|
| workers | bin/combined.ts (also worker.ts, scheduler.ts) |
Deno background, separate from workers-api. |
| sagas | src/runtime/saga-runner.ts |
Deno background saga-runner. |
| triggers | src/runtime/trigger-processor.ts |
Deno background trigger-processor. |
The reason this is structural rather than incidental is three-fold:
- Crash containment. A processor that hits an unrecoverable error takes down only its own resource. The HTTP API keeps serving; other processors keep running.
- Independent scaling. The API and the processors have different load profiles — request
latency versus queue depth — so they are tuned and scaled independently
(
WORKER_CONCURRENCYon the background resource never touches the API). - Least privilege. The API gets a narrow permission set (serve HTTP); background resources get the wider set they need (queues, database, file watching). Splitting the processes lets each one run with only the permissions its job requires.
Auth: the model at its richest
The auth plugin is the clearest single exemplar of every idea on this page, because it stretches the core/plugin split across five units instead of two:
@netscript/plugin-auth-coredefines the port —AuthBackendPort— and nothing provider-specific. It is the seam.- Three pure backend adapters implement that port for different identity providers:
@netscript/auth-kv-oauth(the only interactive backend — full OAuth/OIDC redirect flow),@netscript/auth-workos, and@netscript/auth-better-auth(both non-interactive). @netscript/plugin-authis the composing plugin: it carries the manifest, runs theauth-apioRPC service on:8094, and selects exactly one active backend viaNETSCRIPT_AUTH_BACKEND(defaultkv-oauth).
That is the whole model written large: behavior lives in core and adapters, the plugin only
composes and declares, and the host sees one service contribution regardless of which backend is
active. Where a backend cannot satisfy a capability — for instance, asking a non-interactive
backend to run an interactive sign-in — the seam fails loud with a typed
AuthBackendOperationUnsupportedError rather than silently misbehaving. Read the
auth model for the full port-and-adapter walkthrough.
Why the model looks like this
The shape exists to keep three concerns from leaking into each other:
- Capability authors work in a core package and think only about behavior, ports, and contracts — never about how a host discovers them.
- Plugin packages stay small: they translate a capability into a manifest of contributions, which is the only thing a host has to understand.
- Hosts depend on a stable, finite set of contribution shapes and a deterministic registry, so adding a plugin never requires editing host code — it requires a manifest and a regenerated registry.
The result is that capabilities compose. A host can add workers, sagas, triggers, streams, and auth in any combination, and each plugin contributes its services, background processors, schemas, and topics through the same vocabulary — each running as its own isolated resource.
Where to go next
Each plugin's surface is documented on its own reference page:
workers ,
sagas ,
triggers , and
streams . For the wider picture, see
architecture and