Skip to content

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 cron trigger node and its inspector

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.

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.

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.

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" }
}
Terminal window
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.

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.

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.

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.

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.

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.