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>.

TriggerFires onNeeds
schedulecron expressions (minute-level — this is your machine, not CI)
file-watchfiles created, modified, or deleted under watched globs
gitlocal repository events: commits, branch creationa git repo
run-completedanother workflow finishing (done or failed)
manualyou, via lk run or the desktop app
githubissues, comments, pull requests, reviews, pushes, CI runsConsole
slackmentions, DMs, keywords in a channel, emoji reactionsConsole
webhooka POST to a personal URL you issueConsole

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:

PredicateAsksExample
from / from_notwhich person did itfrom_not: [octocat]
from_botwhich bots may fire it at allfrom_bot: ["renovate[bot]"]
mentionswho the text namesmentions: me
onwhose thing it happened toon: 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_botfrom_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:

TokenObject formPoints 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.

FilterEventsMatches against
actionsevery event that carries an action (push and other ref events don't)the event's actionopened, labeled, review_requested, completed, …
labelsissues, 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
assigneeissues and pull requeststhe assignees of the issue or pull request
reviewerpull requests, on review_requestedthe reviewer a review_requested names
workflowsworkflow_runa workflow_run's workflow name or file path
conclusionsworkflow_runa workflow_run's conclusion, and only once it is completedrequested and in_progress never match
branchespush, create, delete, workflow_run, pull requestsa 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.

FilterMatches against
actionsthe item's actioncreated, edited, deleted, reordered, converted, archived, restored
content_typewhat 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

EventFires on
mentionsomeone @-mentions the loopkeep app
dma direct message to the app
messageany message the app can see, including DMs — needs at least one filter
reactionan 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 write from_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_bot changes 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 whose on: names an unknown token or key can't be interpreted at all. Neither is registered, and both are reported as warnings — lk trigger explain prints 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:

WrittenWhy 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 eventsA 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:

TriggerDefaultWhy
schedulelatestmissed cron ticks collapse into one
file-watchlatesta burst of saves is one change
gitalleach commit is individually meaningful
run-completedallskipping 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.