Workflow Triggers — Technical Design
Author: Matti Georgi Status: Draft — for review Last updated: 2026-07-26 PRD: triggers-prd.md Implementation plan: triggers-implementation-plan.md
1. Summary
A trigger is a row in trigger_registrations. One loop claims due rows and dispatches them into the existing job queue. Nothing about triggering lives in the graph.
The design rests on one reframe. “Wake up” is doing double duty for two problems:
- Start a run because something happened (a clock tick, an HTTP POST, a file change).
- Resume a run parked mid-flight (a human approval, an external callback).
The shipped code answers both by keeping a process alive inside the graph, then adds suspension, a durable inbox, and a watchdog to survive the fact that processes die. Problem 1 does not need any of that. It needs a start. Once starting moves out of the graph, problem 2 shrinks enough to defer entirely (PRD §11).
2. Design goals
- D1. State lives in the database, not in a process. Restart safety is then a property of the storage engine, not of a watchdog.
- D2. One dispatch path. The clock, the HTTP route, the UI, and the desktop adapter all insert rows; none of them call the runner.
- D3. The graph stays a pure function of its inputs. No node knows it was triggered.
- D4. Scheduling logic that is really business logic belongs in the workflow, not the scheduler.
- D5. Reuse: the job queue, the secret store, the cost ledger, the budget model, and share tokens all exist. Add no parallel versions.
3. Architecture
clock tick ─┐
HTTP POST ──┤
desktop ───┼──▶ trigger_registrations ──▶ claim ──▶ job queue ──▶ WorkflowRunner
UI / API ───┤ (one row per trigger) │ │
a run ──────┘ │ │
└──── settle ◀───────────┘
(state, next_fire_at, attempts, cost)
One loop drives the middle. Everything on the left inserts or updates a row. Everything on the right is unchanged NodeTool.
4. Data model
trigger_registrations exists (packages/models/src/schema/trigger-registrations.ts) with id, user_id, workflow_id, node_id, kind, config_json, enabled, cursor, last_fired_at, last_error. It is unused, so it can be reshaped freely.
Two changes to what is there:
- Drop
node_idand theunique(workflow_id, node_id)index. There are no trigger nodes, and a workflow needs more than one trigger. - Widen
cursor TEXTtostate JSON. Use case 20 needs a latch ({alerted_at, month}), not a cursor string.
Additions:
-- scheduling
next_fire_at TEXT, -- the loop's only index; NULL = event-driven only
every_s INTEGER, -- fixed interval
cron_expr TEXT, -- 5-field cron, mutually exclusive with every_s
cron_tz TEXT, -- IANA zone, e.g. "Europe/Berlin"
-- lifecycle
status TEXT NOT NULL DEFAULT 'pending', -- pending | claimed — dispatch state only.
-- "Disabled" is not a status: it is enabled=0
-- plus disabled_reason, so a registration can be
-- re-enabled without losing its dispatch state.
disabled_reason TEXT, -- failures | expired | max_runs | budget | user
attempts INTEGER NOT NULL DEFAULT 0,
consecutive_failures INTEGER NOT NULL DEFAULT 0,
expires_at TEXT,
max_runs INTEGER, -- counts successful runs (see §5)
run_count INTEGER NOT NULL DEFAULT 0,
-- concurrency
serialize_key TEXT, -- rows sharing a key run one at a time
-- safety
max_attempts INTEGER NOT NULL DEFAULT 3,
max_usd REAL,
budget_period TEXT DEFAULT 'month',
-- multi-tenancy and ops
secret_scope TEXT,
tags JSON,
scope TEXT NOT NULL DEFAULT 'cloud', -- cloud | desktop
-- event auth
auth_mode TEXT NOT NULL DEFAULT 'token', -- token | hmac_sha256 | none
auth_secret TEXT, -- reference into the secret store, not the
-- secret itself; the row stays safe to list
auth_header TEXT -- signature header name for hmac_sha256
Indexes: (status, next_fire_at) for the claim, (workflow_id), (kind, enabled), (serialize_key, status).
A second table holds inbound events, so a burst of webhooks does not contend on the registration row:
CREATE TABLE trigger_events (
id TEXT PRIMARY KEY,
registration_id TEXT NOT NULL,
payload_json JSON,
dedupe_key TEXT,
serialize_key TEXT, -- copied from the registration at insert; a config
-- payload path can override it per event, which is
-- how per-conversation ordering (use case 5) works
status TEXT NOT NULL DEFAULT 'pending',
attempts INTEGER NOT NULL DEFAULT 0,
next_fire_at TEXT NOT NULL,
run_id TEXT,
created_at TEXT NOT NULL
);
CREATE UNIQUE INDEX idx_trigger_events_dedupe ON trigger_events(registration_id, dedupe_key);
CREATE INDEX idx_trigger_events_due ON trigger_events(status, next_fire_at);
Events carry their own serialize_key (denormalized at insert) so the claim can
enforce ordering across both tables with one predicate: a key is busy if any row
in either table holds it in claimed.
The existing trigger_inputs table is close to this but is keyed by (run_id, node_id), which is the parked-run addressing scheme from the abandoned design. Drop it with the rest.
5. The dispatch loop
// one setInterval(1000) in packages/websocket
async function tick() {
const due = await claim(); // atomic; see below
for (const item of due) void dispatch(item);
}
async function claim() {
// UPDATE ... WHERE status='pending' AND next_fire_at <= now
// AND (serialize_key IS NULL OR serialize_key NOT IN (
// SELECT serialize_key FROM ... WHERE status='claimed'))
// SET status='claimed' LIMIT n RETURNING *
}
async function dispatch(item) {
const reg = item.registration;
if (overBudget(reg)) return disable(reg, 'budget'); // check BEFORE spending, not only at settle
if (reg.expires_at && nowIso() > reg.expires_at) return disable(reg, 'expired');
const params = {
...reg.config_json,
...item.payload, // event triggers only
state: reg.state,
last_fired_at: reg.last_fired_at,
now: nowIso(),
is_first_run: reg.run_count === 0,
};
const job = await jobQueue.enqueue(reg.workflow_id, params, {
userId: reg.user_id,
secretScope: reg.secret_scope,
registrationId: reg.id,
});
await settle(item, await job.completion);
}
settle is where the semantics live. For a scheduled item the retry and
next-fire bookkeeping lands on the registration row; for an event item it lands
on the event row (the registration only accumulates consecutive_failures,
last_error, run_count, and state). Both end by returning the claimed row to
pending — a settle that forgets to release the claim is a stuck trigger:
async function settle(item, result) {
// 1. State: write back whenever the run produced one, success or not.
if (result.outputs.state !== undefined) reg.state = result.outputs.state;
// 2. Next fire: the run wins, then the schedule.
const next = result.outputs.next_fire_at ?? nextScheduled(reg);
if (result.ok) {
reg.consecutive_failures = 0;
reg.run_count++; // counts successes; max_runs and is_first_run key off it
reg.last_fired_at = item.claimed_at; // so last_fired_at = last SUCCESSFUL fire; a failed
// occurrence re-covers its window on the next run,
// which is the at-least-once contract
reg.next_fire_at = next;
} else {
reg.consecutive_failures++;
reg.last_error = result.error;
reg.attempts++;
reg.next_fire_at = reg.attempts < reg.max_attempts
? nowPlus(backoff(reg.attempts)) // 2^attempts seconds, capped at 300s
: next; // give up on this occurrence
if (reg.attempts >= reg.max_attempts) reg.attempts = 0;
}
reg.status = 'pending'; // release the claim on BOTH paths
if (reg.consecutive_failures >= 5) disable(reg, 'failures');
if (reg.expires_at && next > reg.expires_at) disable(reg, 'expired');
if (reg.max_runs && reg.run_count >= reg.max_runs) disable(reg, 'max_runs');
if (overBudget(reg)) disable(reg, 'budget');
}
is_first_run is run_count === 0, so it stays true until the first success.
A first run that fails and retries sees is_first_run again, which is what a
backfill-seeding run wants.
5.1 Why a 1s poll and not long timers
setTimeout clamps any delay above 2³¹−1 ms (~24.85 days) to 1 ms. The kernel documents this at trigger.ts:98 and re-arms in chunks to work around it; WaitNode reintroduces it. A next_fire_at <= now comparison against a 1s tick cannot have the bug, at the cost of up to one second of scheduled latency. Event triggers do not pay that cost (§5.3).
5.2 Claiming is the only lock
UPDATE ... WHERE status='pending' is atomic in SQLite and in Postgres (schema-pg mirrors every table). Whoever’s update lands first owns the row. This is why run_leases is unnecessary: the claim is the lease, and it lasts exactly as long as the dispatch.
One portability note: UPDATE ... LIMIT ... RETURNING needs an optional SQLite build flag. The portable claim is SELECT the candidate ids, then UPDATE ... WHERE id IN (...) AND status='pending' RETURNING *, keeping only the rows the update actually flipped. Same atomicity, standard builds.
A crashed process leaves rows in claimed. A reaper in the same tick returns rows claimed longer than a threshold to pending, which is at-least-once and consistent with the retry semantics.
5.3 Eager dispatch
A chat reply cannot wait a second. Inserting a due event calls the same claim-then-dispatch path inline. The poller becomes the safety net for anything an insert-time crash missed. Two callers, one code path, so there is still one place where a run starts.
6. State
The state blob is passed in as a run param and read back from an output of the same name. No new node types: a workflow with an Input named state and an Output named state is a stateful trigger, and one without is stateless. It stays runnable by hand:
npm run dev:nodetool -- workflows run <id> --params '{"state":{"since":"2026-07-01"}}'
6.1 Write back on failure too
Run status controls retry scheduling. The state output controls position. Conflating them is a bug: an RSS run that posts items 1–6 and fails on 7 must not reprocess 1–6, and it will if state only persists on success.
The workflow is responsible for advancing state incrementally if it wants partial-progress semantics. A workflow that emits state only at the end gets all-or-nothing, which is a reasonable default.
6.2 What does not belong in it
State is what the trigger needs to decide what to fetch next. Forty content hashes qualify at about 3KB. A corpus of every URL ever seen does not, and belongs in a vectorstore collection or a SQLite node.
Enforced with a 64KB cap that fails the settle with a clear error, and {v: 1, ...} versioning so the shape can migrate.
6.3 Most pollers need no explicit state
last_fired_at and now are passed to every run. “Everything since the last run” is the common case and needs nothing on the row.
7. Scheduling
Three sources, in precedence order:
- The run. A
next_fire_atoutput overrides everything for the next fire. cron_expr+cron_tz. Calendar semantics, DST-correct.every_scannot express “8am local” (use case 1).every_s.next = anchor + ceil((now − anchor) / every_s) * every_s, which skips missed slots after downtime rather than firing a burst.
Self-scheduling (D4) is the load-bearing one. It collapses several features that would otherwise each need their own mechanism:
| Want | Return |
|---|---|
| Poll a render job with backoff | now + min(2^n, 300s) |
| Remind a client on Thursday | now + 2 days |
| Check the day before each renewal | the computed date |
| Nothing happened, back off | now + 1h |
| This client is hot, check often | now + 30s |
Cron and interval become defaults for the first fire. After that the workflow schedules itself, in the graph, where the user can see and edit the logic.
8. Concurrency
Rows sharing a serialize_key run one at a time, enforced in the claim. Poll registrations default serialize_key to their own id, which is the no-overlap rule (use case 11, use case 16) as a special case rather than a bespoke mechanism. Event triggers leave it null for concurrency, or set it per conversation for ordering.
Global limits come from the job queue: MAX_CONCURRENT_JOBS (default 4) and MAX_CONCURRENT_RUNS_PER_WORKFLOW (default 4), already in packages/websocket/src/settings-registry.ts. A 50-comment webhook burst inserts 50 rows and drains at the queue’s rate. No new limiter.
9. Events and auth
One route: POST /api/triggers/:token. The token identifies the registration. Auth is per registration:
token— the URL token is the credential. Fine for internal and desktop adapters.hmac_sha256— verifyauth_headeragainst HMAC-SHA256 of the raw body withauth_secret, compared in constant time. Required for GitHub, Stripe, and most providers. The raw body must be captured before JSON parsing.none— explicit opt-in, warned in the UI.
The route inserts a row and returns 202 {event_id, run_id?} — run_id is present when eager dispatch (§5.3) enqueued the run inline, absent when the queue was saturated and the poller will pick the event up. A duplicate dedupe_key returns 200 with the original event’s ids and inserts nothing. A disabled or expired registration returns 410 and inserts nothing, so a forgotten webhook endpoint cannot accumulate a backlog that fires on re-enable. Body cap 1MB.
dedupe_key comes from a configurable header (X-GitHub-Delivery) or a JSON body path (Stripe’s id — its Stripe-Signature header is a signature, not a delivery id) so provider retries do not duplicate runs. Regenerating a registration’s token invalidates the old URL; that is the revocation story for a leaked token-mode URL.
Client-facing approval links (use case 13) reuse nodetool_workflow_shares as the token source instead of minting a second capability system.
9.1 Trigger payloads are untrusted
Anyone who can email the agency, comment on a PR, or submit a form can put text in front of an agent that has tools and a wallet. This is the one requirement that is mostly docs and defaults rather than code:
- Templates wrap payloads in a delimited untrusted-content block.
- Triggered runs disable destructive tools unless explicitly enabled on the registration.
- The editor warns when a triggered workflow gives an agent write or shell tools.
10. Multi-tenancy
nodetool_secrets is uniquely keyed (user_id, key), so one human has exactly one GA_TOKEN. Eighteen clients need eighteen.
secret_scope on the registration makes the resolver try <scope>/<key> then fall back to <key>. One string on the row, one prefix in the lookup, no new store, and unscoped workflows keep working unchanged.
tags covers bulk lifecycle (a churned client’s eight triggers disabled in one action). Stamping registration_id on the cost ledger gives per-client spend from records that are already written.
Then use case 17 removes the need for the platform to do any of this itself: registrations are ordinary data a workflow can create, update, and delete. An onboarding workflow registers a weekly-report trigger per client with that client’s config, scope, and tags. Offboarding deletes by tag. Eighteen clients or two hundred, no fan-out feature.
11. Adapters
Filesystem watching needs a live process, and it belongs in the host rather than the graph. The Electron main process reads trigger_registrations WHERE kind='file' AND enabled=1, watches, debounces, waits for writes to settle, and POSTs to the trigger route. It restarts with the app and holds no state the database does not have.
scope = 'desktop' marks registrations the cloud cannot serve, so a user who builds a folder-watch workflow and then signs in on the web is told why it is idle instead of wondering.
IMAP polling is scope = 'cloud' and is an ordinary polling workflow, not an adapter.
12. What gets deleted
| Path | Lines |
|---|---|
packages/kernel/src/trigger.ts, trigger-manager.ts, trigger-wakeup.ts |
784 |
packages/kernel/tests/trigger*.test.ts |
1143 |
packages/automation-nodes/src/nodes/triggers.ts (all but Wait) |
~660 |
Workflow.hasTriggerNodes() and its tests |
~40 |
trigger_inputs, run_inbox_messages, run_leases, workers schemas and models |
~200 |
Wait stays, with the setTimeout clamp fixed and a documented ceiling. run_node_state stays for Phase 2.
The kernel symbols are exported from @nodetool-ai/kernel, so they are deprecated in one release and deleted in the next.
Net: roughly 2800 lines removed, roughly 900 added.
13. Alternatives considered
Wire up the existing durable-execution substrate. run_inbox_messages, run_leases, run_node_state, and workers describe a durable actor runtime with claim leases and per-node inbox persistence. It solves resume-from-mid-flight, which this design explicitly does not (PRD §N1). Rejected for now because it is a much larger surface, and because no shipped use case needs it: polling and webhooks retry from the top correctly, and approval flows split into two workflows. It stays in the schema for Phase 2.
Keep trigger nodes, fix them. Rejected. Every fix (cancellation, restart, port binding, persistence) is a step toward reimplementing a scheduler inside the runtime, and it leaves the graph impure and untestable by hand.
Cron in the process, jobs in memory. Rejected. The watchdog in trigger-manager.ts exists only because in-memory schedules die with the process. Moving the schedule to a row deletes the watchdog, the singleton, the start/stop race, and the abort bookkeeping.
A separate wakeups table for both starts and resumes. Attractive for Phase 2 symmetry, but it splits scheduling state across two tables in Phase 1 for no gain. Phase 2 can add kind='resume' rows to trigger_events addressed at (run_id, node_id).
14. Open questions
- Reaper threshold for stuck
claimedrows. A 25-minute video render (use case 16) must not be reaped as stuck. Proposal: derive from the job’s own timeout rather than a fixed constant. - Does
next_fire_atfrom a run need a floor? A workflow returningnowloops as fast as the queue allows. Proposal: clamp to 1s and log. - Budget accounting granularity.
application_budgetsreserves an estimate before the run and reconciles after. Reuse that ledger keyed onregistration_id, or add a simpler per-period sum over the cost records? - Should
statewrite-back require an explicit opt-in on the registration, so a workflow that happens to have an output namedstatedoes not silently become stateful? Proposal: keep it automatic — outputs are user-named, so an output calledstateis already deliberate — but display the detected state contract in the Triggers tab when the registration is created, so accidental statefulness is visible rather than silent.