Skip to main content
Alpha

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, 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; the generated manifest and contribution types live in the plugin reference.

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

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

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

{
  "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"]
  }
}
// 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

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:

// 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;
// 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
{
  "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 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
NameTypeDescription
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/:

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' });
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;
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' });

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

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:

netscript generate plugins

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

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

Step 7 — Verify it is wired up

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

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:

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 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)
NameTypeDescription
@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 — that page is the concrete walkthrough for the auth plugin specifically.

Production pitfalls

See also