# Author a plugin

**Scope.** This recipe shows how to author a *new* custom plugin from scratch — its canonical location, the manifest it exports through `definePlugin(...)`, the contribution shape the kernel reads, and the generated registry that makes those contributions discoverable at runtime. It is the advanced companion to [Add a first-party plugin](https://rickylabs.github.io/netscript/orchestration-runtime/how-to/add-a-plugin/), which only *installs* one of the four official plugins (`workers`, `sagas`, `triggers`, `streams`). If you just want a worker or a saga, install it there. Come here when you need a capability NetScript does not ship.

A NetScript plugin is **two packages**: a JSR-publishable **core engine** under `packages/plugin-<name>-core/` that owns the behavior (domain, ports, application, contract), and a thin **connector** under `plugins/<name>/` whose `mod.ts` exports a plugin **manifest** built with `definePlugin`. The framework discovers the connector because `netscript.config.ts` lists `./plugins/<name>/mod.ts`, and it discovers the plugin's *contributions* (jobs, services, stream topics, DB schemas, e2e gates) through a generated **registry**. Scaffold both tiers with `netscript plugin new <name>`, get location, manifest, and registry right, and the kernel wires the rest. The conceptual model behind that split lives in [The plugin system](https://rickylabs.github.io/netscript/explanation/plugin-system/); the generated manifest and contribution types live in the [plugin reference](https://rickylabs.github.io/netscript/reference/plugin/).

> Aspire is the control plane — start it first
>
> If your plugin contributes an API service or a background processor, those run as resources in the Aspire graph alongside Postgres and Redis. Bring orchestration up
>
> before
>
> you run any
>
> netscript db
>
> command or exercise your plugin's endpoints: from the project root,
>
> cd aspire && aspire start
>
> (dashboard at
>
> https://localhost:18888
>
> ). DB commands require Aspire running first. See
>
> the Aspire explanation
>
> for the resource graph.

## Before you start

You need an existing workspace and a clear idea of what your plugin *contributes*. The official plugins are the reference implementations — read one that resembles your goal before you write a line. The richest real-world exemplar is the **auth plugin**: a multi-package plugin (`plugins/auth/` plus `@netscript/plugin-auth-core` and three backend adapters) that composes a core seam, swappable adapters, and a single oRPC service. Study it when your plugin spans more than one package.

**Prerequisites**

| Name | Type | Description |
| --- | --- | --- |
| `Workspace` | `netscript init` | An existing project. If you have none, scaffold one first — see the tutorials. |
| `netscript CLI` | `on PATH` | Installed globally: deno install --global --allow-all --name netscript jsr:@netscript/cli. Confirm with netscript --help. |
| `A starting skeleton` | `netscript plugin new ` | Scaffold the two-tier core + connector pair, then adapt it. Read a first-party plugin's mod.ts + scaffold.plugin.json alongside it; the auth plugin is the best multi-package exemplar. |
| `Provider kind` | `worker \| saga \| trigger \| stream \| plugin` | Decide which archetype your plugin is. Utility/infra plugins use kind 'stream' or the generic 'plugin'. |

Throughout, run commands from your workspace root.

## Step 1 — Scaffold the two-tier skeleton

Every NetScript plugin is **two packages, not one**: a JSR-publishable **core engine package** under `packages/plugin-<name>-core/` that owns the behavior, and a **thin connector** under `plugins/<name>/` that wires that behavior into a host. The fastest — and doctrine-true — way to get both trees correct is the greenfield generator:

```sh
netscript plugin new notifier
```

`netscript plugin new <name>` emits the whole pair in one pass. It scaffolds a **proxy** connector by default; pass `--feature` to generate a route-backed feature connector instead. Its other options are `--force` (overwrite existing files instead of skipping them) and `--project-root <path>` (target a project root other than the current directory). The connector under `plugins/<name>/` is the workspace member `netscript.config.ts` references; the core package under `packages/plugin-<name>-core/` is where the domain, ports, application logic, and contract live and is independently publishable to JSR.

The **core engine package** owns the capability. Its `deno.json` is strict and exports `. ./contracts/v1 ./domain ./ports ./testing`:

```
packages/plugin-notifier-core/
├── deno.json                          # strict; exports . ./contracts/v1 ./domain ./ports ./testing
├── mod.ts                             # curated builders + domain type re-exports
├── README.md
├── src/domain/mod.ts                  # domain entities and ids
├── src/ports/mod.ts                   # the engine's ports (the seam adapters implement)
├── src/application/mod.ts             # application orchestration (define<Name>)
├── src/contracts/v1/notifier.contract.ts  # the v1 oRPC contract, extends the base plugin contract
├── src/contracts/v1/mod.ts            # contract barrel
├── src/testing/mod.ts                 # in-memory port doubles for tests
└── tests/contracts/notifier-contract-soundness_test.ts
```

The **thin connector** carries only integration. Its `deno.json` exports `. ./contracts ./services ./aspire ./cli ./scaffold`, and its `contracts/v1.ts` re-exports the core's `contracts/v1`:

```
plugins/notifier/
├── deno.json                 # exports . ./contracts ./services ./aspire ./cli ./scaffold
├── package.json
├── mod.ts                    # ← public manifest: definePlugin(...).build() + an inspect fn
├── README.md
├── contracts/v1.ts           # re-exports @netscript/plugin-notifier-core/contracts/v1
├── adapter.ts                # NetScriptPlugin adapter (install/doctor/info/update/remove)
├── aspire.ts                 # Aspire contribution
├── cli.ts                    # CLI adapter entrypoint
├── scaffold.ts               # scaffold adapter entrypoint
├── verify-plugin.ts          # manifest self-verification
├── scaffold.plugin.json      # manifest descriptor read by the CLI/kernel (kind, capabilities…)
├── scaffold.runtime.json
├── database/notifier.prisma  # (optional) plugin's Prisma models, aggregated at db generate
├── scaffolding/              # item scaffolder + stub the connector emits into userland
├── services/src/             # the plugin's service: context.ts, handlers.ts, main.ts
└── tests/public/manifest_test.ts
```

> Two tiers: behavior in core, integration in the connector
>
> The pair the generator emits mirrors the core/connector split every official plugin uses:
>
> packages/plugin-notifier-core/
>
> holds the domain, ports, application logic, and the versioned contract — the parts you would publish and reuse — while
>
> plugins/notifier/
>
> holds only the manifest, adapters, and service wiring, re-exporting the core's contract through
>
> contracts/v1.ts
>
> .
>
> netscript.config.ts
>
> references
>
> `./plugins/notifier/mod.ts`
>
> , so the connector is the workspace member you register; author capability changes in the core package and integration changes in the connector.

## Step 2 — Write the manifest descriptor (`scaffold.plugin.json`)

`scaffold.plugin.json` is the static descriptor the CLI and kernel read to understand your plugin's *archetype* and runtime needs. The single most important field is **`provider.kind`** — it tells the framework what category of contributions to expect. The official plugins set it as follows, and your plugin should pick the closest match:

**provider.kind by archetype (from the official scaffold.plugin.json files)**

| Name | Type | Description |
| --- | --- | --- |
| `worker` | `background-processor` | Job handlers run by a worker processor. defaultEntrypoint bin/combined.ts, concurrencyEnvVar WORKER_CONCURRENCY (default 2) in current Aspire metadata; runtime entrypoints read WORKERS_CONCURRENCY, servicePort 8091. |
| `saga` | `background-processor` | Durable message-driven sagas. defaultPermissions ['--unstable-kv','--allow-all'], concurrencyEnvVar SAGA_CONCURRENCY, servicePort 8092. |
| `trigger` | `ingress` | Webhooks / schedules / file-watchers. defaultEntrypoint src/runtime/trigger-processor.ts, concurrencyEnvVar TRIGGER_CONCURRENCY (default 10), servicePort 8093. |
| `stream` | `utility / plugin` | Infra/utility plugin. requiresDb=false, requiresKv=false, portRangeKey PLUGIN_API, servicePort 4437. |

A worker-archetype descriptor looks like this — copy the shape and change the names. The `officialSource` block is what the official plugins carry; for a custom plugin you set `canonicalName`, `servicePort`, and any `dependencies` your plugin needs wired ahead of it:

```json
{
  "provider": {
    "kind": "worker",
    "category": "background-processor",
    "defaultEntrypoint": "bin/combined.ts",
    "defaultServiceEntrypoint": "services/src/main.ts",
    "defaultRequiresDb": true,
    "defaultRequiresKv": true,
    "concurrencyEnvVar": "WORKER_CONCURRENCY"
  },
  "officialSource": {
    "canonicalName": "notifier",
    "servicePort": 8095,
    "dependencies": ["streams"]
  }
}
```

```ts
// provider.kind          -> which contribution category to scan for
// defaultEntrypoint       -> the background processor aspire starts
// defaultServiceEntrypoint-> the API service aspire starts (if any)
// defaultRequiresDb / Kv  -> whether to provision Postgres / Redis for it
// concurrencyEnvVar       -> env var that caps processor concurrency
// officialSource.servicePort -> the HTTP port the service binds
// officialSource.dependencies -> plugins wired BEFORE this one in the graph
```

> Pick a free port
>
> The official plugins claim
>
> :8091
>
> (workers),
>
> :8092
>
> (sagas),
>
> :8093
>
> (triggers),
>
> :8094
>
> (
>
> auth
>
> ), and
>
> :4437
>
> (streams). Choose a
>
> different
>
> port for your custom plugin's service (for example
>
> :8095
>
> ) so it does not collide in the Aspire graph.

## Step 3 — Export the manifest from `mod.ts`

`mod.ts` is the plugin's public surface. It exports the **manifest** — built with the `definePlugin(name, version)` fluent builder from `@netscript/plugin` — plus an `inspect*` helper, mirroring the official plugins. The builder is the doctrine-true way to assemble a manifest: each `withX(...)` method adds one contribution axis, and `.build()` validates the result against the manifest schema and freezes it. Follow the same convention the scaffold emits:

```ts
// Public manifest for the notifier plugin.
// Mirrors the official plugins: build the manifest with definePlugin(...),
// then export it alongside an inspect helper.
import { definePlugin } from '@netscript/plugin';

export const NOTIFIER_PLUGIN_ID = 'notifier' as const;
export const NOTIFIER_API_DEFAULT_PORT = 8095 as const;

export const notifierPlugin = definePlugin('@notifier/plugin', '0.1.0')
  .withDescription('Delivers user notifications via worker jobs.')
  .withLicense('MIT')
  .withAuthor('Acme Platform Team')
  .withDependencies({ streams: true })
  .withE2e([{ name: 'notifier.smoke', command: 'deno test --allow-all tests/' }])
  .build();

export const inspectNotifier = () => ({
  id: NOTIFIER_PLUGIN_ID,
  version: notifierPlugin.version,
  port: NOTIFIER_API_DEFAULT_PORT,
});

export default notifierPlugin;
```

```ts
// definePlugin(name, version) returns a PluginBuilder. Each axis is one
// withX(...) call; .build() validates + freezes the manifest.
//
//   .withService(...)             -> an oRPC/API service contribution
//   .withBackgroundProcessor(...) -> a worker/saga processor entrypoint
//   .withStreamTopics([...])      -> durable stream topic schemas
//   .withDbSchemas([...])         -> Prisma models aggregated at db generate
//   .withContractVersions([...])  -> versioned oRPC contracts
//   .withRuntimeConfigTopics([...]) -> runtime-config schemas
//   .withMigrations([...])        -> DB migration contributions
//   .withE2e([...])               -> merge-readiness gates
//   .withDependencies({...})      -> plugins wired ahead of this one
//   .withAspire(modulePath)       -> the Aspire contribution module
//   .build()                      -> immutable, schema-validated manifest
```

```json
{
  "name": "@notifier/plugin",
  "version": "0.1.0",
  "exports": {
    ".": "./mod.ts",
    "./contracts": "./contracts/v1/mod.ts"
  },
  "imports": {
    "@netscript/plugin": "jsr:@netscript/plugin",
    "@netscript/plugin-workers-core": "jsr:@netscript/plugin-workers-core",
    "zod": "jsr:@zod/zod@4.4.3"
  }
}
```

The manifest is data, not behavior: it declares *what* the plugin is (name, version, dependencies) and *which contribution axes* it owns. The actual capability lives in the contribution modules you write next, which the registry binds to the kernel. See the [plugin reference](https://rickylabs.github.io/netscript/reference/plugin/) for the full builder surface and the manifest type it produces.

## Step 4 — Author a contribution

A contribution is a typed module the registry scans and exposes to the runtime. The shape depends on your `provider.kind`:

**Contribution authoring API by kind**

| Name | Type | Description |
| --- | --- | --- |
| `worker` | `defineJobHandler` | A job: defineJobHandler(async (ctx) => …) + createSuccessResult/createFailureResult; id via Object.assign(handler, { id }). Lives in jobs/. |
| `saga` | `defineSaga(id)…build()` | Fluent builder: .durability('t1').state({…}).on(type, fn).build(); effects via sagaComplete({…}). Durable store is kv \| prisma. |
| `trigger` | `defineWebhook` | defineWebhook(handler, { id, path, verifier, tags }); handler returns enqueueJob(jobRef, { payload, priority })[]. The service speaks a typed v1 oRPC contract for trigger/event introspection and management; the webhook ingress endpoint stays a raw signature-verifying route by design. enqueueJob is live; defer throws + routes to DLQ. |
| `stream` | `createDurableStream / defineStreamSchema` | Real producer runtime via @netscript/plugin-streams-core. The @netscript/plugin-streams manifest helpers (defineStreamProducer/Consumer) fail loud — they throw StreamUnsupportedOperationError. |

For a worker-archetype plugin, a contribution is an ordinary job handler. This is the exact authoring API the official workers plugin uses — drop the file in `plugins/notifier/jobs/`:

```ts
import {
  createSuccessResult,
  defineJobHandler,
} from '@netscript/plugin-workers-core';
import { z } from 'zod';

const PayloadSchema = z.object({ userId: z.string().min(1) });

const handler = defineJobHandler(async (ctx) => {
  const { userId } = PayloadSchema.parse(ctx.payload ?? {});
  // …deliver the notification here…
  return createSuccessResult({ userId, delivered: true });
});

export default Object.assign(handler, { id: 'send-notification' });
```

```ts
import { defineWebhook, enqueueJob } from '@netscript/plugin-triggers-core/builders';

// A webhook contribution: returns an array of enqueueJob effects that
// bind inbound HTTP to worker jobs. The triggers service serves a typed
// v1 oRPC contract for trigger/event introspection and management; the
// webhook ingress route (POST /api/v1/webhooks/:triggerId) stays a raw
// signature-verifying endpoint by design. enqueueJob is the only live
// action; a defer action throws and routes to the DLQ.
export const inbound = defineWebhook(
  () => Promise.resolve([
    enqueueJob(jobRef, { payload: { verbose: false }, priority: 50 }),
  ]),
  { id: 'notifier-inbound', path: 'inbound/notify', verifier: 'memory', tags: ['webhook'] },
);
export default inbound;
```

```ts
import { createDurableStream, defineStreamSchema } from '@netscript/plugin-streams-core';

// The producer runtime is REAL. createDurableStream serves a durable
// stream (an Aspire service on :4437) you can upsert/delete/flush against.
const schema = defineStreamSchema({ /* topic entity schema */ });
const producer = createDurableStream({
  streamPath: '/notifier/events',
  schema,
  producerId: 'notifier-service',
});
producer.upsert('event', { id: 'evt-1', status: 'sent' });
```

> Edges: trigger ingress is a raw route, stream helpers fail loud
>
> Two reality checks the official plugins make explicit.
>
> Triggers
>
> serve a
>
> typed v1 oRPC contract
>
> for trigger/event introspection and management, but the
>
> webhook ingress endpoint
>
> (
>
> POST /api/v1/webhooks/:triggerId
>
> ) stays a
>
> raw signature-verifying route
>
> by design — it verifies an HMAC signature over the raw request bytes, which oRPC's schema parsing cannot do, so third-party senders (with no NetScript client) POST to that plain URL; the supported action is
>
> enqueueJob
>
> (live), while
>
> defer
>
> is defined-but-unsupported (it
>
> throws
>
> and routes to the DLQ — no deferred replay).
>
> Streams
>
> are split: the
>
> producer runtime is real
>
> via
>
> @netscript/plugin-streams-core
>
> 's
>
> createDurableStream
>
> (served as an Aspire service on
>
> :4437
>
> and used by the workers, auth, and sagas plugins). Only the
>
> @netscript/plugin-streams
>
> manifest helpers (
>
> defineStreamProducer
>
> /
>
> defineStreamConsumer
>
> ) are unsupported — they
>
> fail loud
>
> , throwing
>
> StreamUnsupportedOperationError
>
> , and redirect you to the core package. There is no in-process consumer
>
> subscribe()
>
> ; consume over HTTP/SSE. See
>
> Streams
>
> for the producer-vs-helper split.

## Step 5 — Register the plugin in `netscript.config.ts`

The kernel only loads plugins listed in the config, so the generated connector's `mod.ts` has to appear in the `plugins` array. **If you scaffolded through the CLI, this already happened.** Both wiring commands run a workspace mutator that adds the connector to `netscript.config.ts` for you:

- `netscript plugin new <name>` — the greenfield generator from [Step 1](#step-1-scaffold-the-two-tier-skeleton) — registers the connector it emits; the `--register` flag defaults to on.
- `netscript plugin install <kind> --name <name> --local-path <path>` — installs a plugin workspace from a local package directory and registers it as part of the same install.

After either command, open `netscript.config.ts` and confirm the entry is present — the entries are **`./plugins/<name>/mod.ts`**, confirming `plugins/<name>/` as the canonical location.

Only reach for the manual edit below if you are hand-authoring the connector without the CLI: add the `mod.ts` path to the `plugins` array yourself.

```ts
import { defineConfig } from '@netscript/config';

export default defineConfig({
  name: 'my-app',
  version: '1.0.0',
  paths: { services: 'services', apps: 'apps', contracts: 'contracts', plugins: 'plugins' },
  plugins: [
    './plugins/streams/mod.ts',
    './plugins/workers/mod.ts',
    './plugins/sagas/mod.ts',
    './plugins/triggers/mod.ts',
    './plugins/notifier/mod.ts', // ← your custom plugin
  ],
});
```

## Step 6 — Generate the registry

Listing the plugin is not enough — the runtime addresses contributions through a **generated registry**. For a worker-archetype plugin, the generator scans `plugins/<name>/jobs/` and writes a jobs registry (e.g. `.netscript/generated/plugin-<name>/job-registry.ts`) keyed by each handler's `id`. Generate it:

```sh
netscript generate plugins
```

If your plugin contributes runtime configuration schemas, also generate those:

```sh
netscript generate runtime-schemas
```

After generation, your contribution is discoverable by its `id` (a job at `POST /api/v1/<name>/jobs/{id}/trigger`, a webhook at `POST /api/v1/webhooks/<path>`, and so on).

> Regenerate after every contribution change
>
> The registry is a build artifact, not a live scan. Any time you add, rename, or remove a contribution module, re-run
>
> netscript generate
>
> and restart
>
> aspire start
>
> (or let it hot-reload) so the service and its background processor pick up the new registry.

## Step 7 — Verify it is wired up

List the registry and run the health check to confirm the kernel sees your plugin:

```sh
netscript plugin list      # your plugin should appear in the registry
netscript plugin doctor    # checks plugin health and reports wiring problems
```

With Aspire running, your plugin's service is live on the port you chose. Confirm it and exercise a contribution — for the `notifier` worker example on `:8095`:

```sh
curl http://localhost:8095/health
curl http://localhost:8095/api/v1/notifier/jobs

curl -X POST http://localhost:8095/api/v1/notifier/jobs/send-notification/trigger \
  -H 'content-type: application/json' \
  -d '{ "payload": { "userId": "user-42" } }'
```

You should see the contribution registered in the jobs list and an execution recorded after you trigger it. Watch it live in the Aspire dashboard at [https://localhost:18888](https://localhost:18888) under your plugin's resource.

## A real multi-package exemplar: the auth plugin

When your plugin grows beyond a single package — a core seam plus swappable adapters plus one service — the **auth plugin** is the production reference to copy. It is built from five units:

**Auth plugin topology (a multi-package plugin)**

| Name | Type | Description |
| --- | --- | --- |
| `@netscript/plugin-auth-core` | `core seam` | Defines AuthBackendPort, domain types, contracts/v1, and stream events. Adapters depend on it; it depends on no adapter. |
| `@netscript/auth-kv-oauth` | `backend adapter` | Interactive OAuth/OIDC backend (the only backend with an interactive sign-in flow). Default backend. |
| `@netscript/auth-workos` | `backend adapter` | Non-interactive WorkOS AuthKit backend; signin/callback return unsupported on this backend. |
| `@netscript/auth-better-auth` | `backend adapter` | Non-interactive better-auth backend; signin/callback return unsupported on this backend. |
| `@netscript/plugin-auth (plugins/auth/)` | `unifying plugin` | Composes ONE active backend (NETSCRIPT_AUTH_BACKEND, default kv-oauth) into one oRPC service (auth-api on :8094). |

The pattern to copy: a **core package owns the port** (`AuthBackendPort`), each **adapter is pure** (implements the port, declares no service), and the **plugin under `plugins/<name>/`** composes one active adapter into a single service and registry. That keeps adapters swappable and the kernel-facing manifest small. Build the full thing in [Add authentication](https://rickylabs.github.io/netscript/identity-access/how-to/add-authentication/) — that page is the concrete walkthrough for the auth plugin specifically.

> Alpha package pins
>
> CLI scaffolds pin
>
> jsr:@netscript/plugin-auth-core@0.0.1-beta.11
>
> and siblings at the exact aligned alpha version. Keep generated plugin workspaces on one NetScript release train.

## Production pitfalls

> Read before you ship a custom plugin
>
> - **Aspire not running** — a plugin's API service and background processor are Aspire resources. If endpoints 404 or jobs never execute, `aspire start` from `aspire/` is almost always the cause. DB-backed plugins also need it up before `netscript db`.
> - **Stale registry** — the runtime reads a generated registry, not your source tree. Forgetting `netscript generate` after adding a contribution is the most common "my job isn't registered" bug. Regenerate, then restart Aspire.
> - **Port collision** — do not reuse `:8091`/`:8092`/`:8093`/`:8094`/`:4437`. Set a free `servicePort` in both `scaffold.plugin.json` and `mod.ts`.
> - **Wrong canonical directory** — author in `plugins/<name>/`, the directory `netscript.config.ts` references. Edits to a top-level staging copy are not the source of truth.
> - **Mismatched archetype expectations** — a `trigger` plugin serves a typed v1 oRPC contract for introspection/management while keeping the webhook ingress endpoint a raw signature-verifying route, and only the `enqueueJob` action is live; a `stream` plugin's real runtime is the *producer* in `@netscript/plugin-streams-core`, while the `@netscript/plugin-streams` manifest helpers throw `StreamUnsupportedOperationError`. Match your plugin's behavior to its declared `provider.kind`.
> - **Unstated dependencies** — if your plugin needs another plugin wired ahead of it, declare it with `.withDependencies({...})` on the manifest and in `officialSource.dependencies` so the Aspire graph orders them correctly.

## See also

[Add a first-party plugin  Install one of the four official plugins instead of writing your own.](https://rickylabs.github.io/netscript/netscript/orchestration-runtime/how-to/add-a-plugin/) [Add authentication  Build the multi-package auth plugin — the production exemplar for a core seam plus swappable backends.](https://rickylabs.github.io/netscript/netscript/identity-access/how-to/add-authentication/) [The plugin system  Why plugins are thread-isolated background processors, and how the kernel loads them.](https://rickylabs.github.io/netscript/netscript/explanation/plugin-system/) [plugin reference  The generated @netscript/plugin API — definePlugin builder, manifest type, contribution shapes.](https://rickylabs.github.io/netscript/netscript/reference/plugin/) [Aspire orchestration  The AppHost resource graph that runs your plugin's service and background processor.](https://rickylabs.github.io/netscript/netscript/explanation/aspire/)
