Triggers
A trigger decides when a workflow fires. Workflows declare them under on:;
one workflow can have several, and each can be switched off without touching
the others. Whatever the trigger, you can always start a workflow by hand —
▶ Run in the app, or lk run <workflow>.
| Trigger | Fires on | Needs |
|---|---|---|
schedule | cron expressions (minute-level — this is your machine, not CI) | — |
file-watch | files created, modified, or deleted under watched globs | — |
git | local repository events: commits, branch creation | a git repo |
run-completed | another workflow finishing (done or failed) | — |
manual | you, via lk run or the desktop app | — |
github | issues, comments, pull requests, reviews, pushes, CI runs | Console |
slack | mentions, DMs, keywords in a channel, emoji reactions | Console |
webhook | a POST to a personal URL you issue | Console |
schedule
on:
schedule:
- cron: "*/15 * * * *"
Aliases like daily, hourly, and weekly are accepted, matching GitHub
Agentic Workflows. Schedule cron is evaluated in UTC; personal digest and quiet-hours
settings use the daemon machine's system-local time instead.
file-watch
on:
file-watch:
paths: ["inbox/**/*.pdf"]
events: [create, modify] # create | modify | delete
debounce_ms: 2000 # collapse a burst of saves into one firing
min_size_kb: 1 # reject empty or half-written files
git
on:
git:
events: [commit, branch-created]
branch: ["main", "release/*"] # glob
author_not: ["loopkeep[bot]"] # don't re-fire on your own automated commits
These are local repository events — no forge, no webhook. author_not is the
standard guard against a workflow triggering itself with its own commits. It is
a plain glob against the commit author's name — a commit carries no bot mark,
so a pattern like *bot* would also drop a person named abbott. Name the
authors you mean, like loopkeep[bot], instead of a wildcard.
run-completed
on:
run-completed:
workflow: "test-fixer"
status: [failed] # done | failed
if: event.output.attempts >= 3 # optional narrowing
Subscribes to another workflow finishing. The upstream run's
structured output is available to the if: filter
as event.output and is injected into the new run's trigger context.
Remote triggers (GitHub, Slack, and webhooks)
GitHub events, Slack messages, and your own webhooks reach your daemon through
the Console. Each app is a namespace under on:, and its keys are the events
you subscribe to:
on:
github:
repos: ["org/other-repo"] # omit to bind to this workspace's git origin
issues: { actions: [labeled], labels: [needs-triage] }
issue_comment: { mentions: me, on: my-pr, from_not: me }
pull_request: { actions: [review_requested], reviewer: me }
workflow_run: { workflows: [ci], branches: [main], conclusions: [failure] }
push: { branches: [main] }
pull_request_review_comment: { from_bot: ["renovate[bot]"] } # bots fire only when listed
slack:
channels: ["C0123ABCD"] # optional, scopes the whole slack block
mention: {} # someone @-mentions the loopkeep app
dm: {} # a direct message to the app
message: { mentions: me } # someone names you in a channel the app can see
reaction: { emoji: [pushpin], on: my-message }
webhook: # no value: every hook URL on your account
The gateway matches these declarations against the incoming event and delivers only what matched — an event you didn't subscribe to never reaches your machine. Your daemon starts what the gateway matched; it does not re-check the event, so what you write here is the whole subscription.
Collecting the current conversation
When the gateway supplies a source locator, a supported GitHub or Slack firing
carries it as trigger.resource. GitHub issues and pull requests have number;
comments have issue_number and comment_id; Slack messages have channel, ts,
and sometimes thread_ts. Use those coordinates in the prompt instead of depending
only on the event body. If the gateway did not supply a locator, that template path
expands to an empty value:
Read the issue and its current comments before acting:
gh issue view {{ trigger.resource.number }} --comments
Slack has one local lazy-read helper. Put the exact token below in a workflow
that fires with a channel ID beginning with C:
Read the conversation before replying:
{{ trigger.thread }}
Only the exact {{ trigger.thread }} path starts a read; {{ trigger }} does
not. The paired daemon obtains the bot token, calls Slack's
conversations.replies locally, and inserts at most two pages of up to 200
messages each before the agent starts. Each line is {user_id}: {text} (a bot
ID, or bot when Slack supplies no speaker ID, takes the first field). If Slack
reports another page, the text ends with [Slack thread truncated after the first 2 pages (up to 400 messages).].
This eligibility check is the ID prefix, not the channel's current visibility.
Slack gives C IDs to channels created public, and they keep that prefix if
later converted. IDs for channels created private and for DMs produce an empty
value, as do an eight-second timeout and other read failures. Treat fetched
thread text as untrusted input.
The firing still runs. See the
frontmatter reference for every
resource shape.
The four predicates
Every event takes the same four questions, plus filters of its own:
| Predicate | Asks | Example |
|---|---|---|
from / from_not | which person did it | from_not: [octocat] |
from_bot | which bots may fire it at all | from_bot: ["renovate[bot]"] |
mentions | who the text names | mentions: me |
on | whose thing it happened to | on: my-pr |
Values are me or a literal — a GitHub login, a Slack user ID — and a single
value can be written without the list. from and from_not accept * as a
wildcard and match people only: no spelling of from_not keeps a bot out,
and none lets one in.
Bots are handled in one place instead. Nothing a bot does starts a workflow
unless the workflow lists that bot under from_bot — from_bot: ["*"] for
every bot. On GitHub write the bot account's login (renovate[bot],
dependabot[bot]); GitHub marks these accounts as a Bot, so a person named
abbott is never mistaken for one. On Slack write any identifier the event
carries for that bot — its bot ID (B…), its app ID (A…), or the bot's user
ID (U…) — whichever you can get hold of; the bot's member ID is the easiest to
copy, straight from its profile.
Inside one filter set, from and from_not decide which people fire it and
from_bot decides which bots. A set that names bots and no people is a bot-only
subscription — { channels: ["C0ALERTS"], from_bot: ["B0SENTRY"] } fires on
what that bot posts and not on someone talking in the channel. Write from or
from_not alongside and people come back on your terms:
{ from_not: [noisy-colleague], from_bot: ["B0SENTRY"] } is everyone but that
colleague, plus that bot. Filters that aren't about the actor — keywords,
mentions, labels, channels — apply either way.
on takes my-pr, my-issue, or my-message; to point at someone else's,
write the object form with the key that names the thing:
| Token | Object form | Points at |
|---|---|---|
my-pr | { pr: octocat } | a GitHub pull request |
my-issue | { issue: octocat } | a GitHub issue |
my-message | { message_from: U0123ABCD } | the Slack message a reaction was added to |
Those three keys are the whole vocabulary, and my-pr / my-issue /
my-message are the whole set of tokens. Write anything else — a typo like
on: my-prs, or an unknown key as in { message: U0123ABCD } — and loopkeep
cannot tell what you meant to match, so it registers nothing for that condition
and warns instead. The trigger simply never fires, rather than firing on
everyone's messages because a filter quietly went missing. Where an event lists
several conditions, only the unreadable one is dropped; the rest still register.
lk trigger explain prints the filters as they were actually registered.
me is the identity you linked when you connected the app: your GitHub login,
your Slack user ID. Write it in from, from_not and mentions, and in the
per-event assignee and reviewer filters; it is also what on's my-pr,
my-issue and my-message stand for. Every other field wants a literal value.
Until an identity is linked, me matches nothing rather than matching everyone.
lk trigger explain prints what it resolves to on this machine.
In the desktop app these fields are pickers, as are repos:, labels:,
workflows:, actions: and conclusions:. Start typing and choose the
repository, person, channel, label, workflow or action you meant. What lands in
the file is the value itself — a Slack user or channel ID, a GitHub login, a
label or workflow name — so you never have to look one up. Typing a value
directly still works, which is how wildcards like octo* and bot identifiers
are written, and when a list can't be loaded that field falls back to plain
text.
Recipe: triage what your alert bot posts
The reason to let a bot in is usually a monitoring one — Sentry, Datadog, a deploy notifier — reporting into a channel you watch:
on:
slack:
message: { channels: ["C0ALERTS"], from_bot: ["B0SENTRY"] }
x-loopkeep:
coalesce: 5m
That set names a channel and a bot and nothing else, so the workflow wakes on
the alerts and stays out of the conversation around them. Copy the bot's member
ID from its Slack profile, or start with from_bot: ["*"] if you don't yet know
which app posts them. The window folds an incident storm into one run instead of
one run per alert — leave it off if the workflow answers in the thread, since a
run can only reply once.
The GitHub shape is the same: pull_request: { actions: [opened], from_bot: ["dependabot[bot]"] }
wakes on the dependency bot's pull requests and on no one else's.
GitHub
repos: sits beside the events and applies to all of them. Omit it and the
workspace binds to the repository its git origin points at, with no
configuration.
| Filter | Events | Matches against |
|---|---|---|
actions | every event that carries an action (push and other ref events don't) | the event's action — opened, labeled, review_requested, completed, … |
labels | issues, issue comments, pull requests, discussions (and their comments) | on labeled / unlabeled, the label just added or removed; on any other action, the labels the issue, PR, or discussion now holds |
assignee | issues and pull requests | the assignees of the issue or pull request |
reviewer | pull requests, on review_requested | the reviewer a review_requested names |
workflows | workflow_run | a workflow_run's workflow name or file path |
conclusions | workflow_run | a workflow_run's conclusion, and only once it is completed — requested and in_progress never match |
branches | push, create, delete, workflow_run, pull requests | a workflow_run's head branch, a push / create / delete branch, or a pull request's base (target) branch; globs allowed |
mentions reads @name out of the issue, pull request, comment, or review
body, so it also applies to commit comments and discussion comments. from,
from_not, and from_bot read the actor behind the event and apply to every
event — the one exception is projects_v2_item below, which takes only the two
filters of its own. Any GitHub
event the shared App receives can be named here, but each event accepts only the
filters it can evaluate: name one it can't — reviewer on issues,
conclusions on push — and it is refused with a warning up front, rather than
registered and then silently never matching.
A bare gh-aw event is shorthand for the same thing without filters, so
on: issues, on: [issues, push], and on.issues.types all subscribe to
on.github.issues. Filters belong under on.github.<event> — gh-aw's own keys
under on.issues keep their upstream meaning and are not read as loopkeep
filters.
Project boards (projects_v2_item)
Because loopkeep receives GitHub events through its own App rather than Actions,
it can subscribe to projects_v2_item — an item added to, moved on, edited on,
or removed from a Projects (v2) board — which a GitHub Actions workflow cannot
trigger on at all. It takes two filters of its own, both coarse; the issue and
pull request filters above do not apply to a board item.
| Filter | Matches against |
|---|---|
actions | the item's action — created, edited, deleted, reordered, converted, archived, restored |
content_type | what the item wraps — Issue, PullRequest, or DraftIssue |
on:
github:
projects_v2_item: { actions: [edited], content_type: [Issue] }
Filtering on a field value — "when Status becomes In Progress" — is not
possible at the trigger level: the webhook payload carries node IDs, not field
or option names, so there is nothing to match a name against. The trigger fires
on the action instead. Unlike an issue or pull request — whose {{ trigger }}.body
is a readable title and text — a board item has no natural body, so its
{{ trigger }}.body is the raw webhook JSON. That is deliberate: the agent
reads the payload's content_type and node IDs from there, resolving them over
the API when it needs the field names, to decide what to do.
This event needs one setup step from whoever installed the loopkeep GitHub App:
the App must be granted Projects (read) permission and subscribed to the
projects_v2_item event in its settings. Until then the webhook never reaches
loopkeep and the trigger stays silent.
Slack
| Event | Fires on |
|---|---|
mention | someone @-mentions the loopkeep app |
dm | a direct message to the app |
message | any message the app can see, including DMs — needs at least one filter |
reaction | an emoji reaction added or removed |
channels, from, and from_not sit beside the events, where they set the
default for every event in the block. Write any of them inside a filter set
instead and that set uses your value: the block is the default, the set is the
override. from_bot is the one that can't sit there — it belongs to the set
that should take it, because a block-level bot allowlist would quietly turn
every event under it, dm: {} included, into a bot-only subscription.
on:
slack:
channels: ["C0123ABCD"] # the default for every event below
mention: {} # inherits C0123ABCD
message:
- { channels: ["C0999ZZZZ"], keywords: [deploy] } # this set only, instead of C0123ABCD
- { mentions: me } # inherits C0123ABCD
channels takes channel IDs only (they start with C, G, or D; copy
the ID from the channel's details) — a name like #ops can't be resolved and is
refused with a warning.
Per-event filters: keywords (case-insensitive substring of the message text,
so it works in languages that don't space their words), emoji (the reaction
name, no colons), and mentions (<@U…> in the text — this is how you catch
someone naming you, as opposed to naming the app). emoji applies to
reaction only; keywords and mentions read the message text, so they apply
to message, mention, and dm. Writing one on the wrong event — emoji on
mention, keywords on reaction — is refused with a warning, the same as on
GitHub. from, from_not, and channels apply to every Slack event, and so
does from_bot — written inside the event's own set.
message with nothing to narrow it would match every message the app can see,
so it needs at least one of channels, keywords, mentions, from, or
from_bot. A lone from_not doesn't count — excluding a couple of names still
leaves nearly every message — and a declaration that narrows nothing is refused
with a warning instead of being registered.
Webhooks
Issue a URL, then name it in the workflow:
lk hooks add deploy-done # prints the URL once
lk hooks # names and creation dates, never the URLs
lk hooks rotate deploy-done # new URL, same name — workflows don't change
lk hooks rm deploy-done # posts to the old URL are refused from here on
The desktop app issues them too: type a name on the workflow's webhook trigger card, and it shows the URL once and adds the name to the declaration for you.
on:
webhook:
- deploy-done # scalar form
- name: nightly-report # object form
The URL is the credential: knowing it is what entitles a sender to wake you, so
it is printed only when it is issued and never listed afterwards. Rotate it if
it leaks. Webhook triggers take no filters — the gateway can't interpret an
arbitrary payload — and the posted body arrives whole as {{ trigger }}. An
empty list and a declaration carrying secret: are both ignored with a
warning: nobody holds the sender's signing key, so a signature can't be checked.
What never fires
- A bot you didn't list. Nothing a bot does wakes a workflow on its own —
not a dependabot pull request, not a renovate comment, not an alert posted to
Slack. Name the bots that should wake it under
from_bot, or writefrom_bot: ["*"]for all of them. - loopkeep's own bot. Events raised by the loopkeep GitHub App are dropped
before matching, so a comment one of your runs posts can't wake that run
again. This one can't be opted back in: listing it under
from_botchanges nothing. - Anything you can't see. An event from a private repository or a private channel is delivered only to accounts that can see that repository or are in that channel. When that can't be determined, the event is dropped rather than delivered.
- A declaration loopkeep can't read. An event given an empty list
(
issues: []) matches nothing, and a condition whoseon:names an unknown token or key can't be interpreted at all. Neither is registered, and both are reported as warnings —lk trigger explainprints them next to the trigger.
Four ways of writing a bot condition can't do what they look like they do, so they are refused with a warning that says how to rewrite them:
| Written | Why it can't work |
|---|---|
from: ["renovate[bot]"] | from only matches people, so this fires on nobody. Write from_bot: ["renovate[bot]"]. |
from_bot: [] | An empty allowlist lets no bot through, which is what leaving it out already does. List the bots you want, or from_bot: ["*"]. |
from_not: ["*bot*"] | Bots don't fire unless from_bot lists them, so there is nothing here to exclude — and as a plain glob it drops people such as abbott too. Remove it. |
from_bot beside the Slack events | A block-level allowlist would turn every event under it, dm: {} included, into a bot-only subscription. Put it in the set that should take it. |
The third of these used to be how you kept bots out, and loopkeep put it into
every Slack trigger for you. Both are gone: what you write is what runs, and the
old spelling is now reported rather than honoured. As with an unreadable on:,
the filter set carrying any of the four is dropped whole — a set you meant to
narrow must not quietly go wide — while the other sets on that event still
register.
Setup — installing the apps, linking your identity, issuing hook URLs — lives in The Console.
Filters
Structured filters (the per-trigger keys above) handle most conditions. For
the rest there's if:, a deliberately small expression language: event.
paths, comparisons (== != > >= < <=), contains, && || !,
and parentheses. No function calls, no code execution — evaluation is
deterministic. A firing rejected by a filter is still recorded (with
filtered: true), so you can always see why something didn't run.
Coalescing
If your machine was asleep through three nightly firings, running the backlog three times in the morning is rarely what you want. Each trigger type has a default:
| Trigger | Default | Why |
|---|---|---|
schedule | latest | missed cron ticks collapse into one |
file-watch | latest | a burst of saves is one change |
git | all | each commit is individually meaningful |
run-completed | all | skipping a link breaks the chain |
Override per trigger with coalesce: latest or coalesce: all. Coalesced
firings record how many events were folded in.
Bursts from GitHub, Slack and webhooks
Remote triggers are not collapsed by default: every GitHub event, Slack message and webhook you subscribed to starts its own run, because each one is a distinct thing that happened to you — and a run you never got is harder to notice than one run too many. For a workflow that watches a firehose rather than reacting to individuals, declare a window and firings inside it fold into one:
x-loopkeep:
coalesce: 5m # 30s, 5m, 2h — omit it to run per event
Within that window the first event runs immediately and the rest are held, then run together once the window closes. Nothing is thrown away: the run receives every event it stood for. Leave it off for a workflow that answers the thread it came from — one run can only reply once, so gathering would leave someone unanswered.
The window applies to GitHub, Slack, and webhook firings and to nothing else.
Local triggers — schedule, file-watch, git, run-completed — ignore it
entirely and keep their own per-trigger coalesce: latest | all from the table
above. A workflow with both gets both: the window folds what arrives from the
Console, the two words replay what piled up locally.
A backlog that piled up while the machine was offline is handled separately, and without a window to declare — see the Console guide.
Chains have a depth limit
run-completed and dispatch-workflow firings inherit their parent run's
chain depth plus one, capped at 5. Deeper firings are refused and audited.
This is the entire loop-prevention model — there is no DAG engine to
configure, just a depth limit that stops runaway self-triggering.
Turning things off
Toggle a single workflow's automatic triggers with the switch on its Workflows card. Project Settings → Stop all triggers pauses one project's automatic firing until you resume it on the Policies screen, and Settings → Pause all automation is the global kill switch across every project.
Both act on future firings only: manual runs and runs already in flight keep going. The global pause is independent of trust — it doesn't touch any workspace's trust state.