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 a workspace member under plugins/<name>/ whose mod.ts exports a plugin manifest built with definePlugin. The framework discovers it 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. Get those three things right — location, manifest, registry — and the kernel wires the rest. The conceptual model behind that wiring 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 reference plugin plugins// Install a first-party plugin with --samples and read its mod.ts + scaffold.plugin.json as your template. 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 — Create the plugin directory

The canonical, config-referenced home for every plugin is plugins/<name>/ — that is where netscript.config.ts points and where you author. A scaffold may also leave a slimmer top-level <name>/ workspace member that stages a subset of files for a background processor; plugins/<name>/ is the real source of truth. Edit there.

The fastest way to get a correct skeleton is to install a first-party plugin whose archetype matches yours, then read and adapt it:

deno run -A packages/cli/bin/netscript-dev.ts plugin add worker --name workers --samples

A minimal hand-authored plugin needs only a mod.ts manifest (built with definePlugin) and a scaffold.plugin.json manifest descriptor. A capability-bearing plugin adds contribution modules, a deno.json, and (optionally) a services/, bin/, database/, and contracts/ tree:

plugins/notifier/
├── mod.ts                 # ← public manifest: exports definePlugin(...).build() + an inspect fn
├── scaffold.plugin.json   # ← manifest descriptor read by the CLI/kernel (provider.kind, ports…)
├── deno.json              # workspace member: name, exports, import map
├── contracts/v1/          # (optional) oRPC contract re-exports, frontend-safe
├── database/notifier.prisma  # (optional) plugin's Prisma models, aggregated at db generate
├── services/src/          # (optional) the plugin's API service (main.ts, router.ts)
├── jobs/                  # (optional) worker job-handler contributions
└── bin/combined.ts        # (optional) background-processor entrypoint

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 })[]. Raw Hono routes, NOT oRPC. 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 mounts raw Hono
// routes — there is no oRPC contract layer here. 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. Add your plugin's mod.ts to the plugins array — note the entries are ./plugins/<name>/mod.ts, confirming plugins/<name>/ as the canonical location:

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>/jobs.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 startning, 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 http://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