Workflow frontmatter
Every key a workflow's YAML frontmatter supports. Keys shared with GitHub
Agentic Workflows keep their upstream meaning; loopkeep's own settings live
nested under x-loopkeep. Unknown keys are preserved with a warning.
on — triggers
on:
schedule:
- cron: "0 3 * * *" # aliases like daily/hourly/weekly also accepted
file-watch:
paths: ["inbox/**/*.pdf"]
events: [create, modify] # create | modify | delete
debounce_ms: 2000
min_size_kb: 1
coalesce: latest # latest | all
git:
events: [commit, branch-created]
branch: ["main", "release/*"]
author_not: ["loopkeep[bot]"]
run-completed:
workflow: "test-fixer"
status: [failed] # done | failed
if: event.output.attempts >= 3
manual: {}
github: # GitHub events, delivered via the Console
issues: { assignee: me, labels: [bug], actions: [opened] }
issue_comment: { mentions: me } # mentions / from / from_not take me or a login
pull_request_review_comment: { from_bot: ["renovate[bot]"] } # bots fire only when listed
pull_request: { actions: [review_requested], reviewer: me } # reviewer = who was asked
pull_request_review: { on: my-pr } # on: my-pr | my-issue, or { pr: <login> } for someone else
workflow_run: { workflows: [ci], branches: [main], conclusions: [failure] }
push: { branches: [main] } # matches payload.ref
projects_v2_item: { actions: [edited], content_type: [Issue] } # board item; needs the App's Projects permission
repos: ["org/other-repo"] # omit to bind to this workspace's origin remote
slack: # delivered via the Console
mention: {} # mention | dm | message | reaction
message: { keywords: [deploy], mentions: me } # at least one filter is required
reaction: { emoji: [pushpin], on: my-message } # someone else: { message_from: <user id> }
channels: ["C0123ABCD"] # channel IDs only (start with C, G, or D) — names like #ops are not matched
webhook: # no value: every hook URL on your account
- deploy-done # scalar, or the object form { name: deploy-done }
on.schedule cron is evaluated in UTC. Personal digest and quiet-hours settings
use the daemon machine's system-local time instead.
An event's value can also be a list of filter sets, which is an OR: the trigger fires if any one set matches, and filters within a set still have to match together.
on:
slack:
message:
- { channels: [C0123ABCD], keywords: [deploy] }
- { mentions: me }
- { channels: [C0ALERTS], from_bot: ["B0123ALERT"] } # this set fires on that bot alone
External events (github, slack, webhook) share one shape: each app is a
namespace whose keys are the events you subscribe to, and each event takes
filters. Most filters are lists (assignee, labels, actions, mentions,
from/from_not/from_bot, reviewer, keywords, emoji, and for workflow_run the
workflows/branches/conclusions). Each event accepts only the filters it can
evaluate against its payload — labels on issues, pull requests, and
discussions (and their comments); branches on push, create, delete,
workflow_run, and a pull request's base (target) branch; reviewer on a pull
request's review_requested; emoji on a Slack reaction; keywords and
mentions on Slack message / mention / dm — and a filter an event can't
evaluate is refused with a warning rather than accepted and then silently never
matched. on.slack.message needs at least one of
keywords, mentions, channels, from, or from_bot — without one it would match every
message the app can see, so it is ignored with a warning (a lone from_not doesn't count).
No bot fires a trigger unless the filter set lists it under from_bot
(from_bot: ["*"] for every bot): from and from_not match people only, and a set that
names bots and no people fires on those bots alone. from_bot belongs to a filter set and
cannot sit beside the Slack events, where it would turn every event in the block into a
bot-only subscription. Three more spellings are refused with a warning because they can
never fire: a bot account under from (renovate[bot]), an empty from_bot, and the
former bot guard from_not: ["*bot*"], which loopkeep no longer injects and which as a
plain glob would also drop people such as abbott. The ownership filter on: takes a
token — my-pr / my-issue (GitHub) or my-message (Slack reaction) — that
scopes the trigger to your own object. To point at someone else's, write the
object form with the key that names the thing: { pr: octocat },
{ issue: octocat }, or { message_from: U0123ABCD }. Those three keys and the
three tokens are the whole vocabulary — anything else cannot be interpreted, so
that condition is left unregistered and raises a warning instead of firing wider
than you wrote it. An event given an empty list (issues: []) matches nothing
and warns for the same reason. me resolves to your linked GitHub or Slack
identity. Slack channels must be channel IDs (they
start with C, G, or D; copy the ID from the channel's details) — names like #ops are
not matched and raise a warning. A bare gh-aw event (on: issues) is accepted
as shorthand for
on.github.issues with no filters. Filters are evaluated in the Console before
anything reaches your daemon, so an event you didn't subscribe to is never
delivered.
on.github.projects_v2_item is a webhook-only event Actions cannot raise, so it
arrives through loopkeep's own App. It takes two coarse filters — actions and
content_type (Issue / PullRequest / DraftIssue) — and no field-value
filtering, because the payload carries node IDs rather than field or option
names; the whole payload reaches the agent as {{ trigger }}.body (raw JSON,
since a board item has no natural title or text). It needs the App's
Projects (read) permission and a projects_v2_item subscription — see the
triggers guide.
on.webhook takes no value (every hook URL on your account) or a list of
entries, each a hook name or { name: … }. An empty list is ambiguous and is
ignored with a warning, as is a declaration carrying secret: — nobody holds
the sender's signing key, so the signature can't be checked. Issue and rotate
the URLs with lk hooks.
Semantics — filters, coalescing defaults, chain depth — are covered in the triggers guide.
engine
engine:
id: claude # gh-aw's engine key; loopkeep runs only the claude engine today
model: claude-sonnet-5
params: {} # engine-specific parameters
permission_mode: auto # the engine's own prompt behavior; loopkeep's gate is active in every mode
engine keeps its gh-aw meaning, but loopkeep runs only the claude engine —
the Claude Code CLI as a subprocess. Omit engine and a run uses claude
anyway (gh-aw's own default is copilot, which loopkeep doesn't run, so a
notice is logged); any other engine id fails the run at start with a clear
message rather than silently substituting claude.
bypassPermissions is refused unless explicitly opted into; plan is not
used for runs. loopkeep also accepts an engine override under
x-loopkeep, for engine ids gh-aw doesn't define.
tools
tools: [edit, bash, web-fetch]
The subset of agent tools the workflow may use. Unsupported tools warn.
permissions
Interpreted as input to the local safe-outputs gate, matching its gh-aw meaning.
safe-outputs
safe-outputs:
create-pull-request: {} # GitHub-native outputs work with your gh auth
local-commit: {}
local-file-write: {}
notify: {}
run-command: {} # always policy-evaluated
slack-post:
channel: C012345 # required, fixed at authoring time
workspace: T012345 # optional; select among several connected workspaces
slack-reply: {} # required before a Slack-origin run may reply
dispatch-workflow:
allowed: ["incident-triage", "inbox-processor"] # explicit allowlist, required
emit-output: {} # structured JSON for chaining; always auto-approved
Declaring a safe-output never bypasses policy
evaluation; emit-output alone is always auto-approved since it only feeds
downstream workflows.
slack-post exposes mcp:loopkeep/slack_post. The call accepts a message only:
the required channel fixes its destination, and the agent cannot replace it.
workspace is optional when exactly one connected Slack workspace can be
resolved; with zero or several candidates, an omitted workspace fails with an
actionable error. An empty or malformed channel is not a declaration.
slack-reply exposes mcp:loopkeep/slack_reply only to a run started by a Slack
message, mention, or reaction, and only when slack-reply: {} is present. This
declaration is required even for existing workflows that could reply before
v0.3. Both Slack tools have a built-in notify floor; policy can raise them to
ask-first. The desktop and terminal UI edit both declarations under workflow
settings.
mcp-servers
# In workspace or daemon config — the definition:
mcp-servers:
notion:
command: "npx -y @notionhq/notion-mcp-server"
env: { NOTION_TOKEN: "secret:notion-token" }
# In the workflow — a reference by name only:
mcp-servers: [notion]
Workflows reference servers by name; connection details live in configuration.
Inline connection info in a workflow is treated as a leaked credential — the
value is never echoed into logs or warnings. MCP tool calls go through policy
evaluation like any other action (tool: mcp:notion/create-page in policy
match terms).
x-loopkeep
loopkeep's own settings live nested under a single x-loopkeep key. Keys at the
workflow's root must stay gh-aw compatible; anything loopkeep adds beyond gh-aw
goes here.
x-loopkeep:
engine: claude # override engine: with a loopkeep engine id; string or { id, model }
concurrency:
max: 1
on_limit: queue # queue (default) | skip | replace
budget:
tokens: 200000
time_ms: 900000
steps: 30 # step N+1 fails the run with reason budget
daily_runs: 300
daily_tokens: 500000
tags: [deploy, migration]
coalesce: 5m # fold GitHub/Slack/webhook firings within the window into one run
branch_template: "{workflow_name}/{run_id}" # git branch this workflow's runs work on
execution_mode: attended # auto (default) | headless | attended
# object form when you want to pick what happens with no terminal,
# or to ask before the agent starts at all:
# execution_mode: { mode: attended, on_no_host: ask, launch: confirm }
mux: herdr # which multiplexer to open in; or the map form below
# mux:
# driver: herdr
# session: work # named multiplexer session to open the pane in
# space: loopkeep # multiplexer session/workspace to place the pane in
# tab: automation # tab/window within it
engine
loopkeep's engine override, for engine ids gh-aw doesn't define. Same shape as
the root engine — a string or { id, model }. When present it fully replaces
engine, reading both id and model from here. It has no gh-aw equivalent, so
lk export --to-gh-aw drops it and reports it as a translated (yellow) item.
concurrency
The gh-aw-native concurrency: key is also honored with its upstream
meaning: cancel-in-progress: true behaves like replace, anything else
like queue.
budget
Integers only. Time waiting on a human doesn't count against time_ms.
tags
Semantic labels for policy matching. Self-declared — hard safety rules should
match paths/tool instead.
coalesce
A window, written as a duration (30s, 5m, 2h). Firings of this workflow
that land inside the window fold into a single run, which receives all of them
through the trigger context described at the end of this page. Off by default:
every event you subscribed to starts its own run. Turn it on for a workflow that
watches a stream rather than reacting to individual events.
It applies to GitHub, Slack, and webhook firings only. Local triggers ignore it
and keep their own per-trigger coalesce: latest | all — see
Triggers.
branch_template
Names the git branch this workflow's runs work on, overriding
run.branch_template in config.yaml.
{workflow_name}, {run_id} and {workspace_name} expand; the default is
{workflow_name}/{run_id}. Leave it out to follow the global setting.
Omitting {run_id} is a real option: a fixed name like agent-work makes this
workflow grow one branch across runs. Because git checks a branch out in one
place at a time, a run that starts while that branch is busy fails and says so.
execution_mode
Where a run's agent lives:
auto(default) picks by trigger: a manual run (you're there) becomesattended; an unattended firing — cron, a webhook — staysheadless.headlessruns the agent as a subprocess; you supervise from the inbox.attendedruns it in a terminal multiplexer pane so you can watch and steer the same session directly. The policy gate still applies to every tool.
attended needs a multiplexer loopkeep can drive. When none is available,
what happens depends on on_no_host, which you set with the object form:
x-loopkeep:
execution_mode:
mode: attended
on_no_host: ask # ask (default for attended) | headless
ask(the default when you declareattended) doesn't start the run silently. It holds the run and drops an approval into your inbox with two choices — run it headless now, or wait for a terminal and run it attended — plus the usual abort. "Wait for a terminal" really waits: the run stays held and starts attended on its own as soon as a multiplexer is running, without asking you again. A held run doesn't take a concurrency slot while it waits.headlesskeeps the old behavior: with no terminal, the run quietly falls back to headless.
auto never asks — running headless when unattended is what auto means. See
the terminal multiplexer guide.
launch
Set under execution_mode, and independent of the mode: it decides whether a
trigger starts the run or asks you first.
x-loopkeep:
execution_mode:
mode: attended
launch: confirm # auto (default) | confirm
confirm holds the run instead of starting it and puts a card in your inbox
showing what wants to run and what triggered it — the issue, the mention, the
schedule. Approve and the run starts; abort and nothing was spent, because the
agent never began. The held run takes no concurrency slot.
This is a gate on the run itself, not on how much it may use, so it sits
alongside budget rather than replacing it. Reach for it when a workflow is
triggered by something outside your control — a Slack mention, an issue anyone
can open — and you want to see each request before an agent acts on it.
mux
Which multiplexer an attended run opens in, and where. A bare value is the driver:
x-loopkeep:
mux: tmux # same as { driver: tmux }
The map form adds placement:
x-loopkeep:
mux:
driver: tmux # herdr | tmux | zellij | cmux
session: work # named multiplexer session to open the pane in
space: loopkeep # session/workspace to place the pane in
tab: automation # tab/window within it
Each key falls back on its own: to this project's .loopkeep/config.yaml, then
to config.yaml, then to the built-in loopkeep / automation. session has
no built-in default — leave it out and the run opens in the session you are
already using. A session you name is started if it isn't running yet. Only herdr
has named sessions; the other multiplexers ignore the key. The pane itself is
named after the run id. See the
terminal multiplexer guide.
Secret references
{{ secret:<name> }} in the body, "secret:<name>" in config values. See
Secrets.
{{ trigger }}
The trigger context describes what woke this run. It reaches the agent only where the body asks for it — write no token and the run learns nothing about why it started.
{{ trigger }}— the whole context, as JSON{{ trigger.<path> }}— one value out of it, addressed with dots:{{ trigger.event.branch }}. A numeric segment indexes an array ({{ trigger.batch.0.title }}). A string lands as itself; objects and arrays land as JSON
A path the context doesn't have is replaced with nothing, so a typo leaves an
empty spot rather than braces the agent could read as an instruction. The editor
and lk trigger explain warn before you save — both about a path none of your
declared triggers can carry, and about a triggered workflow whose body never
mentions the context at all.
When a workflow has several triggers
Only one trigger fires a given run, and the context holds that trigger's fields
and no others. {{ trigger.event.branch }} in a workflow that declares both
git and cron is fine — it fills in on a commit and comes out empty at 3am.
Nothing warns about it, because writing it is the whole point of declaring the
trigger.
Two ways to stay on solid ground. source is the one field every trigger
carries, so "This run was started by {{ trigger.source }}" always says
something true. And {{ trigger }} hands over whatever the firing actually
brought, whichever trigger it was. The editor's completions mark the fields that
only some of your triggers carry, so you can see which ones can come out empty.
Every firing carries source and enough to identify what fired. A local trigger
keeps it short (cron, file-watch, git, run-completed, manual,
dispatch, resume, plus the workflow name and whatever that trigger knows —
the path that changed, the upstream run's status and output). Firings that
arrive through the Console carry the event itself:
| Field | Present for | Value |
|---|---|---|
source | always | github, slack, or webhook |
event | always | the event name you subscribed to — issues, mention, … |
occurred_at | always | when it happened at the source |
repo | GitHub | owner/name |
installation_id | GitHub | the App installation the event came through |
team_id | Slack | the Slack workspace |
hook_id | webhook | which of your hook URLs was posted to |
title | when the event has one | issue or pull request title |
author | when known | who wrote it |
url | when known | permalink back to the issue, comment, or message |
channel | Slack | channel name |
body | when a body was sent | the text — a comment, a Slack message, a POSTed payload |
The last five come from the body the gateway forwards, so a filter-only delivery (no body) simply doesn't have them. Write the body into the prompt and treat it as untrusted input: anyone who can open an issue can put text in it.
When the gateway supplies one, supported GitHub and Slack events also carry a
resource locator. It contains coordinates, not a title or body; when absent,
the corresponding template path expands to an empty value:
resource.type | Other fields |
|---|---|
github-issue | number |
github-pull-request | number |
github-comment | issue_number, comment_id |
slack-message | channel, ts, and optional thread_ts |
Use GitHub coordinates with the checked-out repository, for example
gh issue view {{ trigger.resource.number }} --comments, to collect the current
conversation. For a Slack run, the exact {{ trigger.thread }} token instead
asks the daemon to fetch the thread locally through conversations.replies
before it starts the agent. This lazy read happens only for that exact path:
{{ trigger }} and {{ trigger.thread.author }} do not fetch it. The channel
ID must begin with C; this tests the ID, not current visibility. Channels
created public keep their C ID if later converted, while IDs for channels
created private and DMs resolve to an empty string.
The read waits up to eight seconds and takes at most two pages of 200 messages.
Lines use {user_id}: {text} (with a bot ID or bot when no user ID is
available). If another page exists, the text ends with [Slack thread truncated after the first 2 pages (up to 400 messages).]. Unavailable Slack access and
fetch failures also resolve to an empty string without leaving template braces.
Treat fetched thread text as untrusted input.
Folded firings
A workflow with coalesce can stand for more than one event. When
that happens, two more fields appear:
| Field | Value |
|---|---|
coalesced | how many firings this run stood for |
batch | every one of them, newest last — each entry shaped like the above |
The top-level fields stay those of the newest firing, so a workflow that replies
to what triggered it still answers the right thread. Read batch to see the
whole window. Neither field appears when only one firing was folded in, so their
presence is itself the signal that this run is standing for several.