Background jobs
One typed handler file is the whole job. Registration, dispatch, retry, execution
tracking, scheduling, tracing, and the HTTP trigger API all come from the worker runtime —
so shipping a working background job (whether you write it or an AI agent does) means
authoring a single defineJobHandler module, not first assembling a queue, a worker pool,
and an inspection UI around it.
A NetScript background job is a durable, KV-backed TypeScript handler that runs in
the background worker process, separate from your request-serving services. You author a
job as one defineJobHandler(...) callable, give it an id, and the runtime takes care of
registration, dispatch, retry, execution tracking, scheduling, and an HTTP API to enqueue
and inspect runs. It is the unit you reach for whenever work should happen after a request
returns — charging a payment, sending a welcome email, processing an upload — without
blocking the caller.
The story: a screenshot becomes a diagnosis
The clearest picture of what this contract buys comes from a production incident-diagnosis assistant built on NetScript for a legacy ERP estate. A support engineer pastes an alert-email screenshot into a chat channel; a background job runs a vision model over the image and extracts the structured fields (program, error number, table, timestamp) that downstream tooling needs to trace the failure.
Two properties of the job contract shaped that worker into its correct form:
- The payload is queue-borne, so it stays small. The first version enqueued the raw image bytes and hit Deno KV's 64 KB enqueue limit. The fix — carry only a small reference through the queue and fetch the bytes back over a typed service client inside the handler — is exactly the shape the contract steers you toward: the queue moves references to work, not blobs.
- Workers stay compute-only. The app's channel database holds an exclusive OS file
lock, so a second process opening it crashes outright. Because a job handler receives a
ctxand returns aJobResult— rather than a database connection — writing results back through the owning service's typed client was the natural default, not a discipline the team had to invent. The single writer keeps owning its database; the worker never touches it.
The same run is traced end to end: dispatch, execution, progress events, and the subprocess hop (if any) land in the Aspire dashboard without handler-side wiring. The rest of this page is the mechanism behind that story.
What it is
A job is plain TypeScript that runs in the worker process, not the request process.
The headline surface — defineJobHandler, createSuccessResult, and createFailureResult
from @netscript/plugin-workers-core — lets you write a typed handler over a ctx, do the
work, and return a structured JobResult. Job dispatch and execution are instrumented with
real OpenTelemetry spans that show up in the Aspire dashboard
automatically, so a queued run is observable end to end without wiring. Jobs currently run
in-process inside the background worker; the same queue and scheduler also drive polyglot tasks when
the work is owned by another runtime. The why-behind-the-choreography lives in
Durability model.
Learn → / Do →
Minimal example
Add the workers plugin to a published workspace with the public package install flow:
netscript plugin install @netscript/plugin-workers
For local-source contributor work inside this monorepo, use the maintainer binary when you need first-party samples:
deno run -A packages/cli/bin/netscript-dev.ts plugin install worker --name workers --samples
That local path lands real, compiling modules you can read and trigger immediately — including
plugins/workers/jobs/health-check.ts (a job handler) and
plugins/workers/tasks/validate-payload.ts (a polyglot task). The plugin's API service comes up on the port assigned to it at scaffold time (stable high-range, >= 49152; discover yours in the console output, appsettings.json, or Aspire dashboard).
A job handler is an async callable over a ctx object that returns a JobResult. Parse the
payload with a Zod schema, do the work, and return createSuccessResult(...) or
createFailureResult(...). The job's identity is attached with Object.assign(handler, { id }).
// plugins/workers/jobs/process-payment.ts
import {
createFailureResult,
createSuccessResult,
defineJobHandler,
} from '@netscript/plugin-workers-core';
import { z } from 'zod';
// Payload contract — parse ctx.payload before doing any work.
const ProcessPaymentPayloadSchema = z.object({
orderId: z.string().min(1),
amountCents: z.number().int().positive(),
});
const handler = defineJobHandler(async (ctx) => {
const { orderId, amountCents } = ProcessPaymentPayloadSchema.parse(ctx.payload ?? {});
// Optional progress callback — the runtime wires reportProgress on real runs.
ctx.reportProgress?.(10, 'charging customer');
const charge = await chargeCustomer(orderId, amountCents);
if (!charge.ok) {
// A failure result is recorded and feeds the retry policy.
return createFailureResult(`charge declined: ${charge.reason}`);
}
ctx.reportProgress?.(100, 'charged');
// The success payload (data) is persisted on the execution record.
return createSuccessResult({ orderId, chargeId: charge.id, amountCents });
});
// The id is how the runtime registers, lists, and triggers the job.
export default Object.assign(handler, { id: 'process-payment' });
Once the workers API is up (Aspire first — see Production notes), enqueue a run by id:
# Enqueue through the workers API (port 8091), without hand-writing HTTP.
ns-workers trigger process-payment \
--payload='{"orderId":"o_42","amountCents":4999}'
# Watch it land in the KV-backed execution history.
ns-workers executions --limit=10 --json
Key types first — JobHandlerContext & JobResult
A handler is (ctx: JobHandlerContext<TPayload>) => JobResult<TResult> | Promise<…>. These
two shapes are the contract you write against; read them before the option tables.
| Name | Type | Description |
|---|---|---|
id |
string |
The execution id for this run. Stable per dispatched run. |
job |
{ id: string } | undefined |
The job definition reference (its id), when available. |
payload |
TPayload |
The enqueued payload. Parse it with your Zod schema before use. |
correlationId |
string | undefined |
Correlation id propagated across the dispatch for tracing. |
traceparent |
string | undefined |
W3C traceparent of the dispatching span; child spans nest under it. |
tracestate |
string | undefined |
W3C tracestate accompanying the traceparent. |
reportProgress |
(percent, message?) => void | Promise |
Optional progress callback wired by the runtime on real runs; emits job.progress events. |
| Name | Type | Description |
|---|---|---|
success |
true | false |
Discriminant. createSuccessResult sets true; createFailureResult sets false. Branch on this. |
data |
TResult | undefined |
The result payload, persisted on the execution record (present on success, optional on failure). |
error |
string |
Failure message — required when success is false (the first arg to createFailureResult). |
Worker runtime modes (WORKER_RUNTIMES)
Only in-process job execution is implemented today. The background worker service is
separate from the API service, but its job handlers share one isolate and event loop.
WORKER_RUNTIMES / WorkerRuntime retain vocabulary for compatibility; they are not a
runner selector. web-worker and subprocess job-runner modes are reserved, not implemented.
Subprocess execution is implemented separately for polyglot tasks.
| Name | Type | Description |
|---|---|---|
in-process |
WorkerRuntime |
Implemented: handlers share the background worker process and event loop; asynchronous I/O can overlap, synchronous CPU work cannot run in parallel. |
web-worker |
WorkerRuntime |
Reserved job-runner vocabulary; no Web Worker isolate pool is implemented. |
subprocess |
WorkerRuntime |
Reserved job-runner vocabulary. Subprocesses are implemented for task executors, not job handlers; Deno task permissions apply only to Deno tasks. |
| Name | Type | Description |
|---|---|---|
WORKERS_CONCURRENCY |
env (number) |
Queue concurrency in the background worker process. Aspire metadata injects it with the declared default (2); the entrypoint defaults to 1 when it is unset. |
concurrency |
number |
Per-topic max concurrent workers (WorkersConfigData.concurrency / per-group scaling). |
mode |
'combined' | 'distributed' |
Per-topic deployment mode: one combined runner vs. distributed runners. Defaults to 'combined'. |
queueProvider |
'auto' | 'deno-kv' | 'redis' | 'postgres' | 'amqp' |
Queue backend. 'auto' resolves a provider; see Choose a queue provider. |
jobsDir / tasksDir |
string |
Directories scanned for default-exported job and task modules. |
Enqueue from a trigger
The HTTP …/trigger endpoint is one way in; the other is declaratively, from a trigger
handler, which returns an enqueueJob(...) action. enqueueJob(job, options) comes from
@netscript/plugin-triggers-core and binds an imported job definition to a payload, so an
inbound webhook or a scheduled trigger drops work onto this same runtime.
// plugins/triggers/triggers/payment-webhook.ts
import { defineWebhook, enqueueJob } from '@netscript/plugin-triggers-core';
// Import the job definition you want to dispatch (its default export carries the id).
import processPayment from '../../workers/jobs/process-payment.ts';
const handler = defineWebhook(
// The handler returns trigger actions; enqueueJob is the most common one.
(event) => [
enqueueJob(processPayment, {
payload: { orderId: event.payload.orderId, amountCents: event.payload.amountCents },
idempotencyKey: event.payload.orderId, // optional: collapse duplicate deliveries
}),
],
{ id: 'payment-webhook', path: '/webhooks/payment', verifier: 'hmac-sha256', secretEnv: 'WEBHOOK_SECRET' },
);
export default handler;
| Name | Type | Description |
|---|---|---|
payload |
TPayload |
The payload handed to the job's ctx.payload. Parse it with the job's Zod schema. |
idempotencyKey |
string |
Collapses duplicate deliveries — at-most-once effect per key. |
concurrencyKey |
string |
Serializes runs that share a key (e.g. per-order) so they do not overlap. |
priority |
number |
Dispatch priority for this enqueue action. |
Trigger a job from a typed client
The ns-workers trigger command above calls the same OpenAPI route. Because the workers API is a plugin
service, the same trigger is reachable with a generated typed client over the RPC route
/api/rpc/* — no OpenAPI-only fallback and no transport 404. A first-party plugin API mounts
its router under a named segment, so the client takes a routerName alongside serviceName;
Aspire injects the URL under services__workers-api__http__0.
// Reach the workers plugin API with a generated typed client.
import { createServiceClient } from '@netscript/sdk/client';
import { workersContract } from '@netscript/plugin-workers/contracts';
const workers = createServiceClient<typeof workersContract>({
contract: workersContract,
serviceName: 'workers-api',
routerName: 'workers',
});
// triggerJob is served over /api/rpc/* and returns { jobId, triggered }.
// Over REST the {id} path segment carries the target job; over RPC (no path
// segment) it travels in this input object. Either way it resolves to
// input.id — with no id at all the call fails with a 422 VALIDATION_ERROR.
const { jobId, triggered } = await workers.triggerJob({
id: 'process-payment',
payload: { orderId: 'o_42', amountCents: 4999 },
});
// triggerTask is the polyglot-task twin, returning { taskId, triggered }.
const task = await workers.triggerTask({ id: 'validate-payload', payload: {} });
| Name | Type | Description |
|---|---|---|
triggerJob({ id, payload?, priority?, delay?, correlationId? }) |
{ jobId, triggered } |
POST /jobs/{id}/trigger — enqueue a run of the job named by the {id} path segment; resolves the jobId and a triggered flag. |
triggerTask({ id, payload?, priority?, delay?, correlationId? }) |
{ taskId, triggered } |
POST /tasks/{id}/trigger — enqueue a run of the polyglot task named by the {id} path segment; resolves the taskId and a triggered flag. |
Observe cancellation and deadlines
The handler's ctx.signal is an executor-owned AbortSignal. With
InProcessJobRunner, pass it to abort-aware I/O such as fetch, and call
ctx.signal.throwIfAborted() at checkpoints in long loops. Yield between chunks so
cancellation can be delivered. The worker owns this lifetime; it does not require
an app or an owner session to remain open.
ctx.deadlineAt is an optional epoch-millisecond deadline. The in-process runner
uses its clock at dispatch plus job.timeout, or the earlier caller-supplied
deadlineAt when both exist. With neither configured, the deadline is absent.
Use the remaining time to bound downstream work rather than starting a fresh full
job timeout for every operation.
Inspect ctx.signal.reason.name after abort: TimeoutError means the deadline
elapsed, ShutdownError means the runner began stopping, and AbortError means
caller cancellation. The first abort cause is preserved. An abort-aware handler
should release resources in finally and propagate the abort rather than report
success. See the cancellation reference
for the dispatch contract and runtime tuning
for the cleanup budget.
Operator cancellation limit
The shipped Workers API runs router exposes inspection operations only; it has no
operator cancel operation wired to the handler signal. A stored cancelled
execution status is not evidence that a handler was aborted. For a custom host
using InProcessJobRunner, supply a caller signal to dispatch and abort its
controller to request cancellation. Do not assume that closing a UI, disconnecting
an HTTP request, or deleting a job definition cancels a running job. This contract
is verified for the in-process runner; it does not establish equivalent propagation
for every isolated or external task executor.
Graceful shutdown
Background runners must drain in flight work before they exit, or a redeploy loses jobs
mid-run. The @netscript/plugin-workers-core/shutdown subpath provides a ShutdownManager
that registers stoppable resources and stops them in priority order, with a timeout, when a
shutdown is requested.
// plugins/workers/bin/with-shutdown.ts
import { ShutdownManager } from '@netscript/plugin-workers-core/shutdown';
import { startWorkers } from '@netscript/plugin-workers-core';
const runtime = await startWorkers({ autoStart: true });
const shutdown = new ShutdownManager({ timeoutMs: 10_000 });
// Register the runtime so a drain stops it gracefully (lower priority stops first).
shutdown.register({ id: 'workers-runtime', priority: 100, stop: (reason) => runtime.stop(reason) });
// Tie OS signals to the drain, then report what stopped / failed / timed out.
Deno.addSignalListener('SIGTERM', async () => {
const report = await shutdown.shutdown('SIGTERM');
console.info('shutdown', report.state, { stopped: report.stopped, timedOut: report.timedOut });
Deno.exit(0);
});
| Name | Type | Description |
|---|---|---|
register(resource) |
void |
Register a ShutdownResource ({ id, stop(reason?), priority? }) to be stopped on drain. |
unregister(id) |
void |
Remove a resource from the drain set. |
shutdown(reason?, { timeoutMs }) |
Promise |
Stop registered resources in priority order; returns { state, stopped, failed, timedOut }. |
waitForShutdown() |
Promise |
Resolves once shutdown has started — await it in long-running loops. |
createAbortController() |
AbortController |
An AbortController that aborts when shutdown begins; pass its signal into in-flight async work. |
state |
'running' | 'shutting-down' | 'stopped' |
Current lifecycle state of the manager. |
Workers API & where jobs come from
Once Aspire is up and the schema is wired, the workers API service (on its assigned port) registers your job
by id and exposes HTTP endpoints to seed, trigger, and inspect runs. These are the
endpoints the CLI E2E suite validates live.
| Name | Type | Description |
|---|---|---|
GET /health |
liveness |
Health probe for the workers API service. |
GET /api/v1/workers/jobs |
list |
All registered job definitions (id, name, topic) discovered from the jobs directories. |
POST /api/v1/workers/jobs/{id}/trigger |
enqueue |
Enqueue a run of the job with this id, passing a JSON payload body. |
GET /api/v1/workers/executions?limit=10 |
history |
Recent executions and outcomes (KV-backed execution state). |
GET /api/v1/workers/tasks |
list |
Task registry view (polyglot defineTask entries). |
POST /api/v1/workers/seed |
seed |
Seed the workers store with the registered jobs. |
GET /api/v1/workers/subscribe |
SSE |
Server-sent-events stream of execution updates (KV-watch). |
Observability: real job traces out of the box
Job-level observability is not a stub. The workers runtime instruments the scheduler → queue → worker → subprocess path with real OpenTelemetry spans, and those traces appear in the Aspire dashboard automatically once Aspire is up.
| Name | Type | Description |
|---|---|---|
Job dispatch + execution |
span |
Each enqueued run gets a span with attributes, duration, status, and job.started / job.completed / job.failed / job.exception events. |
Step + progress events |
event |
job.step.* and job.progress (current / total / percentage) events are emitted on real job runs. |
Subprocess trace continuation |
context |
W3C traceparent / tracestate is propagated into the subprocess runner, so the child trace links back to the dispatching span. |
Scheduler + cron spans |
span |
Scheduler-start, schedule-job, dispatch, and cron-run spans cover the timer/cron path that fires scheduled jobs. |
For spans you author inside a handler, import directly from @netscript/telemetry
(e.g. @netscript/telemetry/instrumentation for withChildSpan). These nest correctly under
the automatic dispatch span. See Observability for the model and
Add OpenTelemetry for the recipe.
How it compares
Trigger.dev is a managed platform for background jobs and agent workflows; Temporal is a durable-execution system you self-host or rent as Temporal Cloud; NetScript's worker runtime is a plugin that lives inside your own repository and process tree. Each model carries a real tradeoff — a managed platform removes operations, a replay-based cluster gives the strongest durability guarantees, a local-first plugin keeps everything in code you own. The rows below are structural facts drawn from each project's public documentation (as of mid-2026), not rankings.
| NetScript workers | Trigger.dev | Temporal | |
|---|---|---|---|
| Handler code | Plain async TypeScript (defineJobHandler); failed runs are retried, never replayed |
TypeScript tasks in your repo, executed by the platform | Workflow code replayed from event history; side effects live in activities |
| Determinism constraint on orchestration code | None — handlers may call Date.now(), Math.random(), and do direct I/O |
Not required | Required in workflow code: no Date.now(), Math.random(), or direct I/O |
| Where handlers execute | Your background worker process: in-process jobs; subprocess task executors | Managed cloud; self-hosting via Docker Compose | Self-hosted cluster or Temporal Cloud |
| Backing stores | Pluggable queue (deno-kv, redis, postgres, amqp); Deno KV execution state; Postgres job definitions |
Platform-managed | Database, Elasticsearch, server, and workers (self-hosted) |
| Control plane | None separate — the workers API is one of your services (running on its scaffold-assigned port) | The Trigger.dev platform | The Temporal cluster |
The practical consequence of the first two rows: a NetScript job handler is ordinary
TypeScript with no replay-safety rules to learn, so code an agent writes for a service
moves into a worker unchanged — the isolation question is deferred to the runner mode
(the WORKER_RUNTIMES table above), a deployment setting rather than a handler concern.
Production notes
The scaffold's createJobTools(ctx) helper is backed by the published
@netscript/plugin-workers-core implementation. Its trace helpers add events and child
spans beneath the active job span; progress emits telemetry and forwards to the runtime channel.
The log.* helpers remain console-backed.
Reference →
This hub is intentionally thin — the full generated API for @netscript/plugin-workers
(defineJobHandler, the job/task builders, the runtime, the shutdown manager, and every
exported type and subpath) lives in the reference.