Skip to main content
Alpha

Triggers & ingress

The triggers plugin is NetScript's front door for the outside world. It turns external events — inbound webhooks, files dropped on disk, and cron schedules — into durable background work. A trigger handler does one job: receive an event and return a list of actions. The canonical action is enqueueJob(...), which hands the payload to the workers plugin so the heavy lifting happens off the request path. The HTTP call returns immediately; the job runs durably behind it.

Trigger ingress flow: a webhook POST is verified by an HMAC check, persisted as an event, and dispatched to the handler which returns an enqueueJob action; a file-watch event is debounced and stabilised before reaching the same handler path.
Two ingress edges, one processor: webhook POST → HMAC verify → event store → handler → enqueueJob; file-watch → debounce + stability → handler. Both converge on the trigger processor, which retries and DLQs.

This closes the continuous-app loop. A webhook lands on the triggers service, it enqueueJobs a workers job, that job publishes a saga message, and the saga advances a workflow — all from one inbound POST.

What it is

A trigger is a declarative event source plus a handler. NetScript ships three authorable trigger builders from @netscript/plugin-triggers-core/buildersdefineWebhook (inbound HTTP), defineFileWatch (filesystem events), and defineScheduledTrigger (cron). All three return a frozen definition the runtime walker discovers; all three share one handler contract — (event, context) => Promise<TriggerActionResult[]> — and one runtime processor that verifies, persists, retries, and dead-letters. Three more kinds (queue, stream, manual) are reserved in the type surface but not yet executable. The processing engine itself lives in @netscript/watchers (file watching) and the trigger processor/ingress runtime (@netscript/plugin-triggers-core/runtime).

Learn / Do

Minimal example — a verified payment webhook

The 80% case: a webhook that verifies an HMAC signature, then fans the inbound payment event out to a single durable job. The handler returns immediately; the real work runs on the workers plugin.

// plugins/triggers/payment-status-webhook.ts
import { defineWebhook, enqueueJob } from '@netscript/plugin-triggers-core/builders';
import type { JobDefinition } from '@netscript/plugin-workers-core';

// The workers job this webhook will enqueue (authored in plugins/workers/jobs/).
const reconcilePaymentJob = {
  id: 'reconcile-payment' as JobDefinition<'reconcile-payment'>['id'],
  name: 'Reconcile Payment',
  topic: 'default',
} satisfies JobDefinition<'reconcile-payment'>;

// Inbound POST -> enqueue a workers job. Ingress verifies the HMAC signature
// (verifier: 'hmac-sha256' + secretEnv) BEFORE this handler ever runs, so by the
// time you see the event the sender is already proven.
export const paymentStatusWebhook = defineWebhook(
  (event) => Promise.resolve([
    enqueueJob(reconcilePaymentJob, {
      payload: { raw: event.payload.body },
      idempotencyKey: event.idempotencyKey, // de-dupe sender retries
      priority: 80,
    }),
  ]),
  {
    id: 'payment-status-webhook',
    path: 'payments/status',
    verifier: 'hmac-sha256',
    secretEnv: 'PAYMENT_WEBHOOK_SECRET',
    description: 'Verified payment-status webhook that enqueues reconciliation.',
    tags: ['webhook', 'payments', 'verified'],
  },
);

export default paymentStatusWebhook;

Key types first — the webhook definition

defineWebhook(handler, spec) takes your handler and a static WebhookSpec. These are the actual fields the builder accepts (confirmed via deno doc), shown before any prose so you can read the contract at a glance.

WebhookSpec — the second argument to defineWebhook(handler, spec)
NameTypeDescription
id string (required) Stable identifier for the webhook definition, e.g. 'payment-status-webhook'. Branded internally as a WebhookId.
path string (required) Ingress sub-path the webhook answers on, e.g. 'payments/status'. Resolved as the :triggerId route segment under /api/v1/webhooks/.
verifier 'hmac-sha256' | 'memory' | string (required) Verification strategy selector (WebhookVerifierKind). 'hmac-sha256' performs a real signature check; 'memory' is open (dev only); a custom string selects a verifier you wire via selectVerifier.
secretEnv string? Name of the env var holding the signing secret. The runtime reads it (resolveSecret) and hands it to the verifier — the secret is never inlined in the definition.
description string? Human-readable summary surfaced by inspect/registry tooling.
tags readonly string[]? Free-form labels for discovery/filtering, e.g. ['webhook','payments','verified'].
metadata Readonly>? Arbitrary structured metadata attached to the definition.

The handler receives a TriggerEvent<'webhook', WebhookTriggerPayload>. The payload carries the captured HTTP request: body (parsed/raw), headers, method (always "POST"), path, and an optional remoteAddr. The event envelope also surfaces idempotencyKey, traceparent/tracestate, and requestHeaders — so de-duplicating sender retries is a one-liner, as in the example above.

HMAC verification — proving the sender

verifier: 'hmac-sha256' activates the HmacSha256WebhookVerifier adapter from @netscript/plugin-triggers-core/adapters. Ingress hashes the raw request body with the secret (resolved from secretEnv) and compares it against the signature header before persisting the event or running your handler — a bad signature is rejected at the edge. The 'memory' verifier (MemoryWebhookVerifier) accepts everything and is for local iteration only.

HmacSha256WebhookVerifier — constructor options (from @netscript/plugin-triggers-core/adapters)
NameTypeDescription
secret string? Signing secret. Usually supplied by the runtime from the definition's secretEnv rather than hard-coded here.
signatureHeader string? Header carrying the sender's HMAC signature to compare against (e.g. 'x-signature').
idempotencyHeader string? Header whose value is surfaced as the event idempotencyKey when present, so sender retries collapse.

File-watch triggers — react to dropped files

defineFileWatch(handler, spec) turns filesystem events into the same durable-work pipeline. The watching primitive lives in @netscript/watchers; the trigger builder wraps it so a dropped file becomes a handler invocation that returns enqueueJob actions. Below: a CSV drop-folder that enqueues one import job per stable file.

// plugins/triggers/product-import.ts
import { defineFileWatch, enqueueJob } from '@netscript/plugin-triggers-core/builders';
import type { JobDefinition } from '@netscript/plugin-workers-core';

const importProductsJob = {
  id: 'import-products' as JobDefinition<'import-products'>['id'],
  name: 'Import Products',
  topic: 'default',
} satisfies JobDefinition<'import-products'>;

// Fires once a *.csv lands in ./incoming/products and stops growing. The
// stabilityThreshold guards against half-written files on a network share:
// the watcher waits for 3 unchanged checks 1s apart before yielding the event.
export const productImportWatch = defineFileWatch(
  (event) => Promise.resolve([
    enqueueJob(importProductsJob, {
      payload: { file: event.payload.path },
      idempotencyKey: event.payload.path, // one import per file path
      priority: 50,
    }),
  ]),
  {
    id: 'product-import-watch',
    paths: ['./incoming/products'],
    patterns: ['*.csv'],
    on: ['create', 'modify'],
    stabilityThreshold: { checkIntervalMs: 1000, stableChecks: 3 },
    description: 'Imports a product CSV the moment it lands and stabilises.',
    tags: ['file-watch', 'erp', 'import'],
  },
);

export default productImportWatch;
FileWatchSpec — the second argument to defineFileWatch(handler, spec)
NameTypeDescription
id string (required) Stable identifier for the file-watch definition.
paths readonly string[] (required) Directories to watch (at least one). Relative paths resolve against the process cwd.
patterns readonly string[] (required) Glob patterns selecting which filenames fire, e.g. ['*.csv','sales_*.xlsx'].
on readonly ('create'|'modify'|'remove')[] (required) Which filesystem lifecycle events yield to the handler (FileWatchLifecycle).
ignored readonly string[]? Glob patterns to exclude from the watch.
debounceMs number? Per-file debounce window collapsing rapid successive events for the same path.
stabilityThreshold { checkIntervalMs, stableChecks }? Network-FS tolerant gate: waits for stableChecks unchanged polls (checkIntervalMs apart) so half-written files don't fire early.
description string? Human-readable summary for tooling.
tags readonly string[]? Free-form discovery labels.
metadata Readonly>? Arbitrary structured metadata.

The handler receives a TriggerEvent<'file-watch', FileWatchTriggerPayload>; the payload carries path, kind (the lifecycle event), and — when the watcher could read them — size, modifiedAt, and stableChecks.

The underlying watcher

For lower-level or standalone file watching (outside the trigger pipeline), call createWatcher(options) from @netscript/watchers directly. It returns a FileWatcher whose watch() is an async generator of WatchEvents.

// scripts/watch-incoming.ts
import { createWatcher } from "@netscript/watchers";

const watcher = createWatcher({
  paths: ["./incoming"],
  patterns: ["*.csv"],
  events: ["create", "modify"],
  stabilityThreshold: { checkIntervalMs: 1000, stableChecks: 3 },
});

for await (const event of watcher.watch()) {
  console.log(`${event.kind}: ${event.path}`);
}
createWatcher(options) — WatcherOptions (from @netscript/watchers)
NameTypeDescription
paths readonly string[] (required) Directories to watch (at least one required).
patterns readonly string[]? Glob patterns for filtering files. Default ['*'].
events readonly EventKind[]? Which FS events to yield ('create'|'modify'|'remove'). Default ['create'].
debounceMs number? Per-file debounce in milliseconds. Default 2000.
contentHash boolean? Compute a SHA-256 content hash for de-duplication. Default true.
processExisting boolean? Scan existing files on startup and emit them as create events. Default false.
forcePolling boolean? Force the polling strategy instead of native FS notifications. Default false (use for network paths).
pollIntervalMs number? Polling interval for the polling strategy. Minimum 500. Default 5000.
minFileSize number? Skip files smaller than this many bytes. Default 0.
maxFileAge number? Skip files older than this (ms); only applies during the startup scan.
stabilityThreshold StabilityOptions? When set, waits for files to stop growing before yielding (network-FS tolerant).
signal AbortSignal? Abort signal for graceful shutdown of the watch loop.

The watcher selects the strategy for you: native OS notifications for local paths, polling for network paths or when forcePolling: true, and a HybridStrategy that blends both. Events pass a filter pipeline before you see them — GlobFilter limits filenames, StabilityFilter waits for files to stop growing, and DedupFilter skips repeated content hashes within its window. The concrete NativeStrategy, PollingStrategy, and HybridStrategy classes are internal; construct watchers with createWatcher or new FileWatcher(...).

Scheduled triggers — cron without a daemon

defineScheduledTrigger(handler, spec) fires a handler on a cron schedule. The spec combines discovery metadata (id, description, tags, metadata) with the cron ScheduledTriggerSpec. The scheduler runs inside the trigger processor entrypoint, not the Hono API service.

// plugins/triggers/nightly-reconcile.ts
import { defineScheduledTrigger, enqueueJob } from '@netscript/plugin-triggers-core/builders';
import type { JobDefinition } from '@netscript/plugin-workers-core';

const nightlyReconcileJob = {
  id: 'nightly-reconcile' as JobDefinition<'nightly-reconcile'>['id'],
  name: 'Nightly Reconcile',
  topic: 'default',
} satisfies JobDefinition<'nightly-reconcile'>;

// Runs at 02:00 UTC. backfill replays fires the scheduler missed while down.
export const nightlyReconcile = defineScheduledTrigger(
  () => Promise.resolve([enqueueJob(nightlyReconcileJob, { payload: {} })]),
  {
    id: 'nightly-reconcile',
    description: 'Nightly reconciliation sweep.',
    cron: '0 2 * * *',
    timezone: 'UTC',
    persistent: true,
    backfill: { enabled: true, windowMs: 86_400_000, policy: 'fire-once' },
  },
);

export default nightlyReconcile;
Scheduled trigger spec — DefineScheduledTriggerSpec + ScheduledTriggerSpec
NameTypeDescription
id string (required) Stable identifier for the scheduled trigger definition.
cron CronExpression (required) Cron expression governing when the handler fires, e.g. '0 2 * * *'.
timezone string? IANA timezone the cron expression is evaluated in. Defaults to the runtime's zone.
persistent boolean? Whether the schedule state survives restarts so missed fires can be reasoned about.
backfill TriggerBackfillSpec? Quartz-style misfire handling: { enabled, windowMs, policy: 'fire-now'|'fire-once'|'do-nothing', maxMissedFires? } — replays fires the scheduler missed while down.
description string? Human-readable summary for tooling.
tags readonly string[]? Free-form discovery labels.
metadata Readonly>? Arbitrary structured metadata.

Supported trigger actions

Every trigger handler returns actions — declarative descriptions of what should happen after the event is accepted. The runtime processor reads each action and dispatches it. Exactly one action is wired end-to-end today; a second is defined in the type surface but not executable, and it now fails loud rather than silently dropping.

Trigger actions — what the runtime processor actually does
NameTypeDescription
enqueueJob(job, opts) ✅ live Hands the payload to the workers plugin. The supported, end-to-end path — it closes the continuous-app loop (event → job → saga). opts: { payload?, idempotencyKey?, concurrencyKey?, priority? }.
defer(...) ⛔ unsupported Defined in the action union (DeferAction) but NOT executable. The processor throws an unsupportedOperation error and routes the message to the dead-letter queue (DLQ). There is no deferred replay — do not author a trigger that relies on defer.

Runtime — ingress, processor, retry

The trigger runtime (@netscript/plugin-triggers-core/runtime) assembles the pieces the API service and background processor use. You configure these when wiring a custom host; the scaffold wires them for you.

Trigger runtime entry points (from @netscript/plugin-triggers-core/runtime)
NameTypeDescription
createTriggerIngress(options) → TriggerIngressPort Builds the webhook ingress edge. Options: { definitions, eventStore, processor, verifier, selectVerifier?, resolveSecret?, logger?, now?, createEventId? }. Verifies + persists, then hands off to the processor; responds 202 Accepted.
createTriggerProcessor(options) → TriggerProcessor Builds the processor that runs handlers and dispatches actions. Options: { idempotency, dlq, dispatchAction?, logger?, now?, random? }. Applies the retry policy and routes exhausted/unsupported events to the DLQ.
defaultRetryPolicy() → TriggerRetryPolicy The default { maxAttempts, initialDelayMs, maxDelayMs, backoffMultiplier, jitter } policy applied before DLQ handoff. Override per definition via the trigger's retry field.

Endpoints & port

The triggers API service runs on :8093. The webhook router resolves the inbound :triggerId segment against your registered defineWebhook definitions — so a definition with path: 'payments/status' is reachable at POST :8093/api/v1/webhooks/payments/status.

Triggers API — runtime surface (port 8093, Hono)
NameTypeDescription
POST /api/v1/webhooks/:triggerId Hono route Generic dispatch — :triggerId matches the path of any registered defineWebhook definition. Verifies, persists, responds 202 Accepted.
POST /api/v1/webhooks/inbound/generic Hono route The scaffold's generic inbound webhook. Posting JSON here resolves trigger id 'inbound/generic' and runs its actions.
GET /api/v1/events?limit=10 Hono route Recent ingress events recorded by the event store.
GET /health Hono route Liveness check for the triggers API service.

Author + call a trigger

The simple case is one webhook that fans an inbound request out to a single job. The advanced case validates the payload first, then enqueues. The simple case is adapted from the scaffold's plugins/triggers/generic-webhook.ts sample and compiles as-is; the advanced tab is an illustrative pattern, not scaffold code — the real plugins/triggers/webhook-validate-data.ts is an accept-and-drop sample (() => Promise.resolve([])) that imports no zod and enqueues no job. Every handler returns an array of enqueueJob actions — the only supported trigger action.

// plugins/triggers/generic-webhook.ts
import { defineWebhook, enqueueJob } from '@netscript/plugin-triggers-core/builders';
import type { JobDefinition } from '@netscript/plugin-workers-core';

// A reference to the workers job this webhook will enqueue.
const healthCheckJob = {
  id: 'workers-plugin-health-check' as JobDefinition<'workers-plugin-health-check'>['id'],
  name: 'Workers Health Check',
  topic: 'default',
} satisfies JobDefinition<'workers-plugin-health-check'>;

// Inbound POST -> enqueue a workers job. The HTTP call returns immediately;
// the job runs durably on the workers plugin (:8091).
export const genericInboundWebhook = defineWebhook(
  () => Promise.resolve([
    enqueueJob(healthCheckJob, { payload: { verbose: false }, priority: 50 }),
  ]),
  {
    id: 'generic-inbound-webhook',
    path: 'inbound/generic',
    verifier: 'memory',
    description: 'Open webhook that enqueues the workers plugin health-check job.',
    tags: ['webhook', 'runtime-task', 'health-check'],
  },
);

export default genericInboundWebhook;
// plugins/triggers/webhook-validate-data.ts (pattern)
import { defineWebhook, enqueueJob } from '@netscript/plugin-triggers-core/builders';
import type { JobDefinition } from '@netscript/plugin-workers-core';
import { z } from 'zod';

// Validate the inbound body before doing any work.
const InboundSchema = z.object({
  userId: z.string().min(1),
  source: z.string().default('webhook'),
});

const createUserSettingsJob = {
  id: 'create-user-settings' as JobDefinition<'create-user-settings'>['id'],
  name: 'Create User Settings',
  topic: 'default',
} satisfies JobDefinition<'create-user-settings'>;

export const validatedInboundWebhook = defineWebhook(
  (event) => {
    // Parse + validate. On a bad shape, accept-and-drop (return []),
    // or throw to reject the request.
    const parsed = InboundSchema.safeParse(event.payload.body ?? {});
    if (!parsed.success) return Promise.resolve([]);

    // Hand the validated payload to a workers job.
    return Promise.resolve([
      enqueueJob(createUserSettingsJob, {
        payload: { userId: parsed.data.userId },
        priority: 80,
      }),
    ]);
  },
  {
    id: 'validated-inbound-webhook',
    path: 'validate/data',
    verifier: 'memory',
    description: 'Validates the body with zod, then enqueues create-user-settings.',
    tags: ['webhook', 'validated'],
  },
);

export default validatedInboundWebhook;
# Triggers API runs on :8093. POST to the webhook's resolved path.
curl -X POST http://localhost:8093/api/v1/webhooks/inbound/generic \
  -H 'content-type: application/json' \
  -d '{"verbose": false}'

# Watch the resulting ingress events:
curl 'http://localhost:8093/api/v1/events?limit=10'

# The enqueued job lands on the workers plugin (:8091):
curl http://localhost:8091/api/v1/workers/executions?limit=10

How it wires to the rest of the app

This is the last rung of the continuous-app thread. The generic-inbound-webhook enqueues the workers health-check job; the create-user-settings job (authored in the background jobs tutorial) publishes a UserSettingsCreated saga message; the saga from the durable workflow tutorial handles it. One inbound POST drives the whole chain — and every link is real scaffold code that compiles.

Production notes

Reference

triggers