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.
| 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:
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:
| 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:
{
"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:
| 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 |
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--registerflag 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:
| 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 — that page
is the concrete walkthrough for the auth plugin specifically.
Production pitfalls
See also
Install one of the four official plugins instead of writing your own.
Add authenticationBuild the multi-package auth plugin — the production exemplar for a core seam plus swappable backends.
The plugin systemWhy plugins are thread-isolated background processors, and how the kernel loads them.
plugin referenceThe generated @netscript/plugin API — definePlugin builder, manifest type, contribution shapes.
Aspire orchestrationThe AppHost resource graph that runs your plugin's service and background processor.