Triggers
Triggers are what turn LatchAI from a tool you drive into a tool that runs. Three trigger node types are armed by a trigger service inside the daemon; a workflow with one of them runs on its own as long as the engine is up.

A trigger node’s schedule lives in the workflow document like any other config.
Adding one is the same motion as any other node: drop it on the canvas, fill in its one
field in the inspector, ⌘S. The save rewrites <home>/workflows/<id>.json, the trigger
service notices the file changed, and the schedule is live — no restart, no separate
scheduler config.
cron_trigger
Section titled “cron_trigger”config.schedule is a cron expression, evaluated by croner.
Five fields are the familiar minute hour day month weekday; a sixth leading field means
seconds.
{ "id": "nightly", "type": "cron_trigger", "position": { "x": 60, "y": 160 }, "config": { "schedule": "0 2 * * *" }}| Expression | Fires |
|---|---|
0 2 * * * |
02:00 every day |
*/10 * * * * |
every ten minutes |
0 */5 * * * * |
every five minutes, on the zero second |
0 9 * * 1-5 |
09:00 on weekdays |
The node’s output is the run payload when there is one, otherwise the literal string
cron: <schedule> — so a downstream template can say which schedule woke it.
Schedules run in the machine’s local time: the daemon constructs each job without a timezone option, so a laptop that moves timezones moves its 2am job with it. A schedule croner can’t parse is reported on the daemon’s console and that one trigger is skipped; the rest of the workflow’s triggers still arm.
file_trigger
Section titled “file_trigger”config.path is a workspace-relative directory, watched recursively. The changed
file’s path — the watched directory plus the filename, as a relative path — becomes the
node’s output, so downstream nodes know what fired them.
{ "id": "inbox", "type": "file_trigger", "position": { "x": 60, "y": 160 }, "config": { "path": "input/inbox" }}The specifics matter here more than usual:
- The directory is created if it doesn’t exist, so arming a watch on a fresh path is not an error.
- A path that escapes the workspace is refused and logged. Mounted folders are not watchable this way — the watch root is the workspace, deliberately.
- Dotfiles are ignored, and only real files fire the trigger. Directory events don’t, which is what stops macOS FSEvents replaying the watched directory’s own creation at arm time as a phantom run.
- Events are debounced 400ms per workflow, so one editor save that writes a temp file and renames it produces one run rather than three.
webhook_trigger
Section titled “webhook_trigger”The daemon exposes POST /hooks/<workflow-id>; the request body becomes the node’s
output, capped at 8000 characters. A workflow with no webhook node returns 404 on that
path, and a disabled workflow is refused rather than silently ignored.
A webhook node may set config.secret, which callers present via the x-latchai-secret
header or a ?secret= query parameter. The comparison is constant-time over hashes of
both values, so neither the secret nor its length leaks through timing.
{ "id": "hook", "type": "webhook_trigger", "position": { "x": 60, "y": 160 }, "config": { "secret": "s3cr3t" }}curl -X POST http://127.0.0.1:7777/hooks/hook-echo \ -H 'x-latchai-secret: s3cr3t' \ -H 'content-type: application/json' \ -d '{"story":"AP-512","state":"merged"}'The inspector prints the node’s own URL, so you don’t have to remember the shape.
Because the engine binds loopback and rejects non-local Host/Origin headers by
default, webhooks are local-only until you deliberately change the bind address
(LATCHAI_BIND). If you do expose it, set config.secret first: the hook has no other
authentication.
Arming and re-arming
Section titled “Arming and re-arming”The trigger service watches the workflows/ directory and hot re-arms on any change —
whether that change came from a save in the UI, a CLI write, or a git checkout — after a
500ms settle. You do not restart anything to change a schedule.
Re-arming rebuilds every cron job and file watcher from the files on disk, so a schedule you deleted stops firing, and one you renamed doesn’t double up.
One run at a time
Section titled “One run at a time”Only one run per workflow is in flight at a time. A trigger that fires while its workflow
is still running is skipped and logged — the guard that keeps a slow ten-minute job from
stacking on itself. The drop is visible, not silent: it emits a trigger.skipped event
carrying the workflow id and the source that fired.
That event has no run id, which means it reaches the live stream and the events panel but never lands in any run’s log — it is operational chatter about a run that didn’t happen, not run history.
Runs that do start carry their source on the run.started event (cron 0 2 * * *,
file input/inbox, webhook, manual, subworkflow:<nodeId>), so the run log and the
Run Monitor distinguish a cron fire from a manual click.
Dashboards have their own schedule
Section titled “Dashboards have their own schedule”A dashboard definition’s refresh: cron is armed by the same
service, from the dashboards/ directory, and re-armed the same way when that directory
changes. It shares the discipline but not the queue: a refresh that comes due while the
previous build is still running is skipped, and the guard belongs to the dashboard service
rather than the workflow one.
Turning triggers off
Section titled “Turning triggers off”Setting disabled: true on a workflow leaves its triggers unarmed while manual runs keep
working — the safe way to park an automation without deleting it. The sidebar has a
per-workflow toggle for it.
Keeping the daemon alive
Section titled “Keeping the daemon alive”Triggers only fire while the engine is running. To survive logout and reboot, install it as a launchd service: see Run LatchAI as a service.