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.
The failure modes ingress must absorb are mundane and expensive: a payment provider retries the
same webhook five times because your handler took too long, or a half-written CSV fires an import
mid-copy on a network share. Each has a declared answer here — idempotencyKey collapses sender
retries and stabilityThreshold waits out the half-written file. Because every trigger is a frozen
definition rather than routing code, the registry surface can list what fires what — the same
property that lets a coding agent enumerate an app's ingress edges before touching one.
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 0 (disabled). |
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 when processExisting is true. |
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, DebounceFilter collapses rapid burst events per path, 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 while the trigger runtime is active.
export const nightlyReconcile = defineScheduledTrigger(
() => Promise.resolve([enqueueJob(nightlyReconcileJob, { payload: {} })]),
{
id: 'nightly-reconcile',
description: 'Nightly reconciliation sweep.',
cron: '0 2 * * *',
timezone: 'UTC',
},
);
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. |
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. Both action variants are wired end-to-end: jobs enqueue immediately, while deferred events are persisted and replayed once their due time arrives.
| 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({ until }) |
✅ live |
Persists the processed event for one-shot replay at or after the ISO timestamp. Pending records survive runtime restarts; successful replay removes the record. |
Deferred replay is durable and at-least-once. The runtime persists the event before reporting
deferred, resolves its current trigger definition after restart, and deletes the record
only after successful replay. Keep handlers idempotent because a crash after dispatch but before
record deletion can replay the event again.
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 (assigned port).
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 its assigned port. POST to the webhook's resolved path.
# Replace <triggers-port> with your assigned triggers API port.
curl -X POST http://localhost:<triggers-port>/api/v1/webhooks/inbound/generic \
-H 'content-type: application/json' \
-d '{"verbose": false}'
# Watch the resulting ingress events:
curl 'http://localhost:<triggers-port>/api/v1/events?limit=10'
# The enqueued job lands on the workers plugin (on its assigned port):
ns-workers executions --limit=10 --json
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
Where ingress lives, compared
Inngest — the closest neighbor for this event-to-work shape — models ingress as events sent to its
platform: your app or a webhook transform sends events to Inngest's event API, and Inngest invokes
the matching functions over HTTP, owning the scheduling and retry loop. A NetScript trigger keeps
the same shape inside your workspace: the definition is a TypeScript file the runtime walker
discovers, ingress is your own :8093 service verifying HMAC over the raw request bytes, and the
retry policy, event store, and dead-letter queue run in your trigger processor under Aspire. The
difference to weigh is operational locus, not capability count — with triggers, the pieces that
decide whether an event becomes work are resources in your own Aspire graph, inspectable in your
own telemetry, and yours to run.