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) becomes attended; an unattended firing — cron, a webhook — stays headless.
  • headless runs the agent as a subprocess; you supervise from the inbox.
  • attended runs 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 declare attended) 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.
  • headless keeps 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:

FieldPresent forValue
sourcealwaysgithub, slack, or webhook
eventalwaysthe event name you subscribed to — issues, mention, …
occurred_atalwayswhen it happened at the source
repoGitHubowner/name
installation_idGitHubthe App installation the event came through
team_idSlackthe Slack workspace
hook_idwebhookwhich of your hook URLs was posted to
titlewhen the event has oneissue or pull request title
authorwhen knownwho wrote it
urlwhen knownpermalink back to the issue, comment, or message
channelSlackchannel name
bodywhen a body was sentthe 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.typeOther fields
github-issuenumber
github-pull-requestnumber
github-commentissue_number, comment_id
slack-messagechannel, 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:

FieldValue
coalescedhow many firings this run stood for
batchevery 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.