# 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](https://rickylabs.github.io/netscript/netscript/orchestration-runtime/how-to/add-a-plugin/) ; to *author* a new one, see [author-a-plugin](https://rickylabs.github.io/netscript/netscript/orchestration-runtime/how-to/author-a-plugin/) . For the exact symbols a plugin author calls, use the [@netscript/plugin reference](https://rickylabs.github.io/netscript/netscript/reference/plugin/) . The [auth model](https://rickylabs.github.io/netscript/netscript/explanation/auth-model/) is the richest worked example of every idea here.

![A single plugin splits into an HTTP API service resource and one or more isolated background-processor resources, each started by the AppHost as a separate Aspire resource with its own permissions.](https://rickylabs.github.io/netscript/netscript/assets/diagrams/plugin-thread-isolation.svg)

*One plugin, many resources: the API service and each background processor run as separate AppHost resources, so a crash or a load spike in one cannot take the others down.*

> Three nouns carry the whole model
>
> A plugin declares a
>
> manifest
>
> ; the manifest names a set of
>
> contributions
>
> against a fixed set of extension axes; a generated
>
> registry
>
> turns those declarations into static modules the host imports. Hold those three nouns and the rest of this page is detail.

## 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 packages and their sibling core packages**

| 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.ts` is 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.

> One import path, two responsibilities
>
> The saga DSL (
>
> defineSaga
>
> ,
>
> defineQuery
>
> ,
>
> defineSignal
>
> ) is exported from
>
> @netscript/plugin-sagas-core
>
> , where the capability is authored.
>
> @netscript/plugin-sagas/runtime
>
> exports the runtime infrastructure instead — the publisher, the durable runtime, the saga stores, the runner, and the supervisor. The plugin package itself stays thin: its
>
> mod.ts
>
> re-exports only the manifest surface (
>
> sagasPlugin
>
> ,
>
> inspectSagas
>
> ) so the behavior/integration boundary stays intact in source.

This two-tier architecture is also why certain plugin surfaces enforce strict behavior boundaries. In `@netscript/plugin-streams`, the manifest-layer helper functions (`defineStreamProducer` and `defineStreamConsumer`) act as stub declarations that throw a `StreamUnsupportedOperationError` when invoked. This design boundary ensures that declarative manifest definitions remain decoupled from the active execution runtime. So dynamic stream routing must be driven using the core engine in `@netscript/plugin-streams-core` (via `createDurableStream`) directly. For more details on this layout, see [durable streams](https://rickylabs.github.io/netscript/netscript/durable-workflows/streams/) for the producer-versus-manifest framing.

This two-tier shape is not reserved for the official plugins. When you scaffold a custom plugin with `netscript plugin new <name>`, the generator emits the same pair: a JSR-publishable core engine package under `packages/plugin-<name>-core/` (its domain, ports, application orchestration, `contracts/v1`, and testing doubles) and a thin connector under `plugins/<name>/` (its manifest, adapter, Aspire contribution, CLI/scaffold entrypoints, and service) whose `contracts` subpath simply re-exports the core's `contracts/v1`. So custom plugins inherit the same behavior-versus-integration split — thin, composable, and independently publishable — from the moment they are generated. See

[author-a-plugin](https://rickylabs.github.io/netscript/netscript/orchestration-runtime/how-to/author-a-plugin/) for the walkthrough.

## What a plugin contributes: the manifest

A plugin describes itself with a typed **manifest**, assembled through the `definePlugin()` builder in [@netscript/plugin](https://rickylabs.github.io/netscript/netscript/reference/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**.

```ts
// 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.6")
  .withType("background-processor")
  .withService({ name: "workers-api", entrypoint: "./services/src/main.ts", port: 8091 /* note: your scaffold's port will differ */ })
  .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 methods, each declaring one kind of contribution**

| 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.

> Contributions are a closed set; plugins are an open set
>
> The host understands a
>
> fixed
>
> list of contribution shapes, but any number of plugins may contribute against them. That asymmetry is what lets capabilities compose without the host growing new code for each plugin.

## 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.

> Compose, don't edit
>
> A host
>
> assembles
>
> plugins; it never edits another boundary's code to accept one. Adding workers, sagas, triggers, streams, or auth means shipping a manifest and regenerating the registry — the host's own source is untouched. This is the discipline that keeps capabilities independently versioned and independently removable.

The practical sequence — `netscript plugin install …` followed by the registry-generation step — is covered in the [add-a-plugin how-to](https://rickylabs.github.io/netscript/netscript/orchestration-runtime/how-to/add-a-plugin/) and the

[CLI reference](https://rickylabs.github.io/netscript/netscript/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:

```ts
// 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), // note: your scaffold's port will differ
  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:

**Where each plugin's background work runs**

| 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:

1. **Crash containment.** A processor that hits an unrecoverable error takes down only its own resource. The HTTP API keeps serving; other processors keep running.
2. **Independent scaling.** The API and the processors have different load profiles — request latency versus queue depth — so they are tuned and scaled independently (`WORKER_CONCURRENCY` on the background resource never touches the API).
3. **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.

> The key insight
>
> "One plugin" is a packaging unit, not a runtime unit. At runtime a plugin fans out into one API service and N background resources — that fan-out is exactly what the AppHost reads from the manifest and the plugin's Aspire contribution. See
>
> orchestration with Aspire
>
> for how the AppHost assembles them.

## 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-core` defines 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-auth` is the **composing plugin**: it carries the manifest, runs the `auth-api` oRPC service on `:8094`, and selects exactly **one** active backend via `NETSCRIPT_AUTH_BACKEND` (default `kv-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](https://rickylabs.github.io/netscript/netscript/explanation/auth-model/) for the full port-and-adapter walkthrough.

> Package pins & the audit surface
>
> Scaffolded JSR imports use exact
>
> @0.0.6
>
> pins for the aligned release train. Auth also ships a structured, redacted audit surface —
>
> createAuthTelemetry
>
> from
>
> @netscript/plugin-auth-core
>
> — which records audit spans only when a
>
> subjectHashSalt
>
> is configured (
>
> NETSCRIPT_AUTH_AUDIT_SALT
>
> ); without a salt it runs as a no-op. See
>
> Observability
>
> for the full audit-trail model.

## Why the model looks like this

The shape exists to keep three concerns from leaking into each other:

1. **Capability authors** work in a core package and think only about behavior, ports, and contracts — never about how a host discovers them.
2. **Plugin packages** stay small: they translate a capability into a manifest of contributions, which is the only thing a host has to understand.
3. **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

[Add a first-party plugin  The task-oriented recipe: scaffold, register, regenerate, verify.](https://rickylabs.github.io/netscript/netscript/orchestration-runtime/how-to/add-a-plugin/)

[The auth model  The five-unit core/adapter/plugin split — the model at its richest.](https://rickylabs.github.io/netscript/netscript/explanation/auth-model/)

[Orchestration with Aspire  How the AppHost turns manifests into running API and background resources.](https://rickylabs.github.io/netscript/netscript/explanation/aspire/)

[Plugin authoring contract  definePlugin(), the contribution axes, and the registry emitter.](https://rickylabs.github.io/netscript/netscript/reference/plugin/)

Each plugin's surface is documented on its own reference page:

[workers](https://rickylabs.github.io/netscript/netscript/reference/workers/) ,

[sagas](https://rickylabs.github.io/netscript/netscript/reference/sagas/) ,

[triggers](https://rickylabs.github.io/netscript/netscript/reference/triggers/) , and

[streams](https://rickylabs.github.io/netscript/netscript/reference/streams/) . For the wider picture, see

[architecture](https://rickylabs.github.io/netscript/netscript/explanation/architecture/) and

[the durability model](https://rickylabs.github.io/netscript/netscript/explanation/durability-model/) .

[Contracts & type flow](https://rickylabs.github.io/netscript/netscript/explanation/contracts/) [Auth model](https://rickylabs.github.io/netscript/netscript/explanation/auth-model/)
