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:

  1. Run output — the agent emits JSON with emit-output; workflows subscribed via run-completed can filter on it (if: event.output.severity == "high") and receive it in their trigger context.
  2. Dispatch — an agent decides to fire another workflow through dispatch-workflow, passing an input payload. The target must be on the allowed list, and the dispatch itself is policy-evaluated.
  3. The filesystem — write files, let a file-watch trigger 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.