Summary
Land the event-trigger half of SURFACE.md §1 (the harness example) as a single
webhook-backed slice, end-to-end. Today flow() returns a plain handle; the
.on(...) surface described in §1 does not exist, no trigger executor exists,
and there is no path from an external event to a spawned journal run. This
issue closes that gap for one canonical trigger source (HTTP webhook), setting
the pattern for provider-specific triggers like slack.mention() (which land
with slice B, helper namespaces).
The spec text
From docs/SURFACE.md §1 (lines 33-53):
export default flow("chief", {
identity: "chief", // gate 8 — a principal
memory: { script: true, agent: true }, // gate 5 — relayhistory-backed
budget: "$20/day",
})
.on(slack.mention("#exec"), async (f, event) => { // gate 2 — trigger = entry condition
...
});
No process runs between events: the handler wakes, executes to its next await,
parks. That is gate 4's system-of-ephemeral-agents, and the author never meets
a lease, journal, or offset.
Scope
Three deliverables in one slice:
-
Surface primitive. flow(...).on(trigger, body) and one built-in trigger
source constructor webhook(name: string, filter?: WebhookFilter):
import { flow, webhook } from '@relayflows/surface';
export default flow('release-note').on(webhook('release-note'), async (f, event) => {
// event: parsed JSON payload
await f.run(`echo ${JSON.stringify(event.tag)}`);
f.done('success');
});
.on() is chainable on the existing FlowHandle and returns a
TriggeredFlowHandle that keeps the same runtime contract; multiple .on()
calls on one flow are allowed. Each call registers a (triggerSource, flowName, body) triple with the surface's authored-flow definition so
getFlowDefinition can expose it.
-
Trigger executor CLI. flows serve-webhook --data-dir <dir> --port <p>
binds an HTTP receiver. Each POST /webhooks/<trigger-name> writes the JSON
body to <data-dir>/inbox/<trigger-name>/<uuid>.json (atomic rename) and
returns 202 Accepted. Failure modes: non-JSON body → 400 invalid_json;
trigger name that is not registered in the loaded flow set → 404 unknown_trigger; write failure → 500 inbox_write_failed. The receiver
itself is stateless — one process, one port, no queue in RAM.
-
Watcher + spawn. Inside relayflowd, a new
kernel/relayflowd/src/trigger_watcher.rs polls <data-dir>/inbox/*/ at 1
Hz (bounded, cheap; a file-system watcher upgrade is a follow-up). Each new
<trigger>/<uuid>.json triggers a run of every flow that registered on that
trigger: journal run.spawned with event: <parsed-json> as the input, then
move the file to <data-dir>/inbox-processed/<trigger>/<uuid>.json before
the journal write completes. On restart, files still in inbox/ are
re-picked up (idempotency: filename is the run's idempotency key, matching
gate 6's writeback election).
Preflight
webhook('X') on a flow is legal only if X is present in the nearest
flows.json's executors array (SURFACE.md §1 line 195 wording preserved
verbatim: "a trigger executor is considered registered only when its name is
present in this author-written executors array"). Refusal:
no_executor: webhook trigger "X" is not registered in flows.json. This is
checked in packages/sdk/src/preflight.ts before any journal write.
Direct-input runs (flows run flow.ts --input …) remain unchanged. Preflight
only enforces the executor allowlist when the flow declares a trigger.
Acceptance evidence
curl -X POST http://localhost:8087/webhooks/release-note -d '{"tag":"v1.0"}'
returns 202 and writes <data-dir>/inbox/release-note/<uuid>.json with
exactly that payload.
- The running
relayflowd picks the file up within ≤2s (poll interval + one
spawn) and journals run.spawned for the registered flow with input: {"tag":"v1.0"}; the body's second arg is {"tag":"v1.0"}; run.completed
follows.
flows check refuses a flow declaring webhook('unregistered') before any
journal contact, exit code 2, message: REFUSED [no_executor] webhook trigger "unregistered" is not registered in flows.json.
- Killing
flows serve-webhook mid-run does not lose the event: on restart of
the daemon, files remaining in inbox/ are re-picked up (idempotency: the
filename is the run's idempotency key, so the second attempt is a no-op if
the journal already recorded the spawn).
Files
packages/surface/src/triggers.ts — new. Exports webhook(name, filter?)
and the TriggerSource type. No side effects; a trigger source is a
plain-data object {kind: 'webhook', name, filter}.
packages/surface/src/flow.ts — extend to record .on() triples on the
authored flow definition; export TriggeredFlowHandle.
packages/surface/src/index.ts — re-export webhook, TriggerSource.
packages/sdk/src/cli/serve-webhook.ts — new. Node http server + inbox
writer. No dependencies beyond stdlib.
packages/sdk/src/cli.ts — wire flows serve-webhook subcommand.
packages/sdk/src/preflight.ts — trigger-in-executors check.
kernel/relayflowd/src/trigger_watcher.rs — new. tokio::spawn inside main
poll loop; reuses the existing journal client.
kernel/relayflowd/src/lib.rs — register trigger_watcher.
testdata/webhook-hello.flow.ts — one authored trigger flow.
packages/sdk/tests/serve-webhook.test.ts — HTTP end-to-end.
kernel/relayflowd/tests/trigger_watcher_test.rs — file-in-inbox → journal
spawn.
docs/SURFACE.md — add the "Triggers" subsection under §1 documenting
webhook() and the serve-webhook command.
Not-in-scope (follow-ups)
- Webhook signing/auth. Add a
NOTE(security): in serve-webhook.ts
linking a follow-up issue. This slice accepts unsigned POSTs — usable in a
local dev demo only.
- Rate limiting. No limit today; follow-up.
- Provider-specific triggers like
slack.mention(), github.pr_opened().
These are helper-namespace primitives and land with slice B.
- Replay of missed webhooks under daemon downtime beyond the inbox retry
guarantee. If the receiver was down when the event was emitted, this slice
never sees it. Documented as a limitation; follow-up: reconciliation from
provider read state.
- File-system-notify upgrade of the 1 Hz poller. Follow-up if latency
becomes an issue.
- Multi-tenant port sharing. One receiver process, one data-dir. Follow-up.
- Trigger removal / stop-serving. SIGTERM only; no admin API.
PR feedback loop rule
Before task-exit: run the full CI pipeline locally where possible; push; watch
gh pr checks; fix any bot findings of severity ≥ Medium before declaring
done. False-positive bot findings get a comment reply explaining why.
Summary
Land the event-trigger half of SURFACE.md §1 (the harness example) as a single
webhook-backed slice, end-to-end. Today
flow()returns a plain handle; the.on(...)surface described in §1 does not exist, no trigger executor exists,and there is no path from an external event to a spawned journal run. This
issue closes that gap for one canonical trigger source (HTTP webhook), setting
the pattern for provider-specific triggers like
slack.mention()(which landwith slice B, helper namespaces).
The spec text
From
docs/SURFACE.md§1 (lines 33-53):Scope
Three deliverables in one slice:
Surface primitive.
flow(...).on(trigger, body)and one built-in triggersource constructor
webhook(name: string, filter?: WebhookFilter):.on()is chainable on the existingFlowHandleand returns aTriggeredFlowHandlethat keeps the same runtime contract; multiple.on()calls on one flow are allowed. Each call registers a
(triggerSource, flowName, body)triple with the surface's authored-flow definition sogetFlowDefinitioncan expose it.Trigger executor CLI.
flows serve-webhook --data-dir <dir> --port <p>binds an HTTP receiver. Each
POST /webhooks/<trigger-name>writes the JSONbody to
<data-dir>/inbox/<trigger-name>/<uuid>.json(atomic rename) andreturns
202 Accepted. Failure modes: non-JSON body →400 invalid_json;trigger name that is not registered in the loaded flow set →
404 unknown_trigger; write failure →500 inbox_write_failed. The receiveritself is stateless — one process, one port, no queue in RAM.
Watcher + spawn. Inside
relayflowd, a newkernel/relayflowd/src/trigger_watcher.rspolls<data-dir>/inbox/*/at 1Hz (bounded, cheap; a file-system watcher upgrade is a follow-up). Each new
<trigger>/<uuid>.jsontriggers a run of every flow that registered on thattrigger: journal
run.spawnedwithevent: <parsed-json>as the input, thenmove the file to
<data-dir>/inbox-processed/<trigger>/<uuid>.jsonbeforethe journal write completes. On restart, files still in
inbox/arere-picked up (idempotency: filename is the run's idempotency key, matching
gate 6's writeback election).
Preflight
webhook('X')on a flow is legal only ifXis present in the nearestflows.json'sexecutorsarray (SURFACE.md §1 line 195 wording preservedverbatim: "a trigger executor is considered registered only when its name is
present in this author-written
executorsarray"). Refusal:no_executor: webhook trigger "X" is not registered in flows.json. This ischecked in
packages/sdk/src/preflight.tsbefore any journal write.Direct-input runs (
flows run flow.ts --input …) remain unchanged. Preflightonly enforces the executor allowlist when the flow declares a trigger.
Acceptance evidence
curl -X POST http://localhost:8087/webhooks/release-note -d '{"tag":"v1.0"}'returns
202and writes<data-dir>/inbox/release-note/<uuid>.jsonwithexactly that payload.
relayflowdpicks the file up within ≤2s (poll interval + onespawn) and journals
run.spawnedfor the registered flow withinput: {"tag":"v1.0"}; the body's second arg is{"tag":"v1.0"};run.completedfollows.
flows checkrefuses a flow declaringwebhook('unregistered')before anyjournal contact, exit code 2, message:
REFUSED [no_executor] webhook trigger "unregistered" is not registered in flows.json.flows serve-webhookmid-run does not lose the event: on restart ofthe daemon, files remaining in
inbox/are re-picked up (idempotency: thefilename is the run's idempotency key, so the second attempt is a no-op if
the journal already recorded the spawn).
Files
packages/surface/src/triggers.ts— new. Exportswebhook(name, filter?)and the
TriggerSourcetype. No side effects; a trigger source is aplain-data object
{kind: 'webhook', name, filter}.packages/surface/src/flow.ts— extend to record.on()triples on theauthored flow definition; export
TriggeredFlowHandle.packages/surface/src/index.ts— re-exportwebhook,TriggerSource.packages/sdk/src/cli/serve-webhook.ts— new. Nodehttpserver + inboxwriter. No dependencies beyond stdlib.
packages/sdk/src/cli.ts— wireflows serve-webhooksubcommand.packages/sdk/src/preflight.ts— trigger-in-executors check.kernel/relayflowd/src/trigger_watcher.rs— new.tokio::spawninside mainpoll loop; reuses the existing journal client.
kernel/relayflowd/src/lib.rs— registertrigger_watcher.testdata/webhook-hello.flow.ts— one authored trigger flow.packages/sdk/tests/serve-webhook.test.ts— HTTP end-to-end.kernel/relayflowd/tests/trigger_watcher_test.rs— file-in-inbox → journalspawn.
docs/SURFACE.md— add the "Triggers" subsection under §1 documentingwebhook()and theserve-webhookcommand.Not-in-scope (follow-ups)
NOTE(security):inserve-webhook.tslinking a follow-up issue. This slice accepts unsigned POSTs — usable in a
local dev demo only.
slack.mention(),github.pr_opened().These are helper-namespace primitives and land with slice B.
guarantee. If the receiver was down when the event was emitted, this slice
never sees it. Documented as a limitation; follow-up: reconciliation from
provider read state.
becomes an issue.
PR feedback loop rule
Before task-exit: run the full CI pipeline locally where possible; push; watch
gh pr checks; fix any bot findings of severity ≥ Medium before declaringdone. False-positive bot findings get a comment reply explaining why.