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.
| 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 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:
| 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 })[]. 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:
| 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.