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.
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/builders —
defineWebhook (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.
| Name | Type | Description |
|---|---|---|
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.
| Name | Type | Description |
|---|---|---|
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;
| Name | Type | Description |
|---|---|---|
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}`);
}
| Name | Type | Description |
|---|---|---|
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;
| Name | Type | Description |
|---|---|---|
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.
| Name | Type | Description |
|---|---|---|
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.
| Name | Type | Description |
|---|---|---|
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.
| Name | Type | Description |
|---|---|---|
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.