Workflows
A workflow is a markdown file: YAML frontmatter declares when it fires and what
it may do, and the body is a natural-language instruction for the agent.
Workflows live in <workspace>/.loopkeep/workflows/*.md and are versioned with
your project like any other file.
---
on:
schedule:
- cron: "0 3 * * *"
---
Review yesterday's commits and update CHANGELOG.md with anything user-visible.
{{ trigger }}
The format is GitHub Agentic Workflows compatible: keys
that exist upstream keep their upstream meaning, loopkeep only adds new keys
(local triggers, local safe-outputs), and loopkeep-specific settings live nested
under x-loopkeep. Unknown keys are preserved with a warning, never
dropped.
Every key is documented in the workflow frontmatter reference.
Editing and running
Open Workflows in the app. + New workflow opens an editor with forms for
Triggers, the prompt Body, and Settings (engine, concurrency, budget,
dispatch, launch, coalesce, and Slack safe-outputs), plus a Raw tab for the whole markdown file —
comments and keys
outside the edited blocks are preserved. Edit opens the same editor on an
existing workflow. ▶ Run starts a manual run (a workflow without a manual
trigger shows a hint instead), and a per-workflow switch turns its automatic
triggers off and on. This all works even while the project is untrusted.
The Raw tab is more than a text box: as you type it completes frontmatter keys and
values, the {{ trigger.* }} fields in the body, and live names pulled from your connected
accounts — repos, channels, labels, and workflows.
Trigger context
A structured description of what fired the run — the trigger type, the source
event, any upstream output — is available to the body. Write {{ trigger }} for
all of it, or {{ trigger.event.branch }} for one value out of it. Nothing is
added on your behalf: a body with no token gets no context. This is how a
run-completed workflow sees its upstream run's output, and how a Slack-fired
workflow sees the message that mentioned it.
When the gateway supplies a locator, supported GitHub and Slack firings also include
it as trigger.resource, pointing to the GitHub issue, pull request, comment, or
Slack message. If it is absent, the path expands to an empty value. For a Slack
run whose channel ID begins with C, the exact token {{ trigger.thread }} asks
the daemon to fetch the current thread locally before starting the agent. The
check is the ID prefix, not current visibility: a channel created public keeps
its C ID if later converted, while IDs for channels created private and DMs
produce an empty value. Treat fetched text as untrusted input.
See Triggers for
the resource shapes and lazy-fetch rules.
Engine
engine: { id: claude, model: claude-sonnet-5 }
loopkeep runs only the claude engine today — it drives 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 rather than silently falling back. model and
params are per-workflow, so cheap heartbeat workflows can run a small model
while the heavy work runs a big one. For an engine id gh-aw doesn't define, set
engine under x-loopkeep, which loopkeep reads in preference to this key —
see the frontmatter reference.
Safe-outputs
safe-outputs declares the effects a workflow is allowed to produce:
safe-outputs:
create-pull-request: {} # GitHub-native, uses your gh auth
local-commit: {} # commit in the run's worktree
notify: {} # post a notification to the inbox
slack-post:
channel: C012345 # required; the agent cannot choose another destination
workspace: T012345 # required to choose among several; omit only when one resolves
slack-reply: {} # reply only to the Slack conversation that started the run
dispatch-workflow:
allowed: [incident-triage] # fire another workflow (explicit allowlist required)
emit-output: {} # structured JSON output for chaining
Declaring a safe-output does not bypass supervision: every action the agent
proposes still goes through policy evaluation before
it executes. emit-output is the one exception — it only produces data for
downstream workflows and is always auto-approved.
Slack calls are declaration-gated. slack_post is unavailable unless
slack-post.channel names a fixed destination, and slack_reply is unavailable
unless slack-reply: {} is present, even for a Slack-triggered run. Both start
at the built-in notify floor; a policy can raise either to ask-first.
Both Slack outputs also require this device to be paired with the Console; an
unpaired device denies either call. slack_post additionally requires a Slack
connection. When its workspace is omitted, an unavailable connector list is a
deny rather than permission to guess a destination.
After a post is attempted, a confirmed Slack failure or an unknown timeout becomes an Inbox attention without failing the run. On timeout the message may already have landed, so loopkeep does not retry it and the agent must not retry it either, which avoids duplicate posts.
Chaining workflows
Runs never talk to each other directly. Three channels connect them:
- Run output — the agent emits JSON with
emit-output; workflows subscribed viarun-completedcan filter on it (if: event.output.severity == "high") and receive it in their trigger context. - Dispatch — an agent decides to fire another workflow through
dispatch-workflow, passing an input payload. The target must be on theallowedlist, and the dispatch itself is policy-evaluated. - The filesystem — write files, let a
file-watchtrigger downstream work. Best for multi-stage pipelines (drop PDF → extract → summarize).
Chains are depth-limited so a loop can't run away — see triggers.
Budgets
x-loopkeep:
budget:
tokens: 200000
time_ms: 900000
steps: 30
daily_runs: 300
daily_tokens: 500000
All values are integers. A run that exceeds its budget fails (recorded as
failed, reason budget exceeded). Time spent waiting for your approval
doesn't count against the time budget.
Concurrency
x-loopkeep:
concurrency:
max: 1
on_limit: skip # queue (default) | skip | replace
queue holds new runs until a slot frees, skip drops the firing (recorded,
not silently), replace aborts the running one in favor of the new — useful
for heartbeats where only the latest matters. A daemon-wide cap across all
workflows is set with lk global-max.
Tags
x-loopkeep:
tags: [deploy, migration]
Tags are semantic labels your policies can match on. They're self-declared by
the workflow, so treat them as a convenience for escalation — hard safety
rules should match on paths and tool, which the workflow can't lie about.