Daemon & configuration

loopkeepd is the resident process that does everything: watches triggers, executes runs, evaluates policies, writes the event log, delivers notifications. The CLI and the desktop app are clients — closing them changes nothing about what's running.

One daemon serves every registered workspace. Each workspace keeps its own independent context: its own event log, policy, trust state, and workflows.

Control

lk start      # alias: up
lk stop       # alias: down — in-flight runs are restored as interrupted on the next start
lk restart
lk status

Clients talk to the daemon over a local socket, /tmp/loopkeepd.sock by default (LOOPKEEP_SOCKET overrides — set it for both the daemon and the clients). No network port is opened; the Console connection, if you pair, is a single outbound WebSocket.

Files on disk

Global, under ~/.config/loopkeep/ (respects XDG_CONFIG_HOME; LOOPKEEP_CONFIG overrides the config file path):

~/.config/loopkeep/
├── config.yaml       # daemon settings (concurrency cap, retention, attended idle timeout, multiplexer, tui.keybindings)
├── policy.yaml       # your home policy layer
└── device.json       # Console pairing credentials, written 0600

device.json appears when you pair with the Console and holds this device's token and its private signing key. Treat it like an SSH key: don't copy it to another machine or into a dotfiles repo — pair that machine instead. lk logout is the clean way to undo a pairing, since it revokes the device and then removes the file; deleting the file by hand only stops this machine from connecting, so revoke from the Console as well if the file may have leaked. Either way local runs keep working — they never depend on the Console.

Per workspace, under <workspace>/.loopkeep/:

.loopkeep/
├── config.yaml           # this project's overrides (concurrency.max, multiplexer)
├── policy.yaml           # project policy (commit this)
├── policy.local.yaml     # your personal overrides (gitignore this)
├── workflows/            # workflow definitions
├── secrets               # secret NAMES only — values live in the OS keychain
├── events.db             # append-only event log (SQLite)
└── worktrees/<run_id>/   # each run's isolated git worktree

config.yaml

concurrency:
  global_max: 4 # runs in flight across every workspace
retention:
  worktree_days: 14 # how long finished runs' worktrees are kept; omit to keep forever
attended:
  idle_timeout: 30m # end an attended run left idle this long (agent stopped, no input from you); omit to never time out
run:
  branch_template: "{workflow_name}/{run_id}" # names the git branch each run works on; omit for this default
notifications:
  slack:
    workspace: T012345 # Slack workspace/team id
    channel: C012345 # Slack channel id
multiplexer:
  id: tmux # herdr | tmux | zellij | cmux — omit to leave it unset, "none" to disable
  options:
    session: work # named herdr session attended panes open in; omit to follow the session you are using
    space: loopkeep # multiplexer session/workspace attended panes open in
    tab: automation # tab/window within it
tui:
  keybindings: # override the TUI's default keys; actions include up/down/approve/deny/steer/take_over/quit
    approve: ["y"]
    down: ["j", "down"]

concurrency and retention are managed from the CLI (lk global-max for the cross-workspace cap, lk workspace-max for a single workspace's concurrency.max, lk retention) and the desktop app; edits are persisted back to this file either way. multiplexer is where attended runs open; a project overrides it with the same keys in its own .loopkeep/config.yaml, and both layers are editable in the app.

notifications.slack adds one Slack notification channel alongside the desktop channel. workspace and channel must both be non-empty or both be absent; absent means desktop-only. Configure the pair under Settings → Slack notifications. Approval requests are sent immediately, while notify-level items are sent only in scheduled digests. These fields select the human notification channel, not a workflow's safe-outputs.slack-post destination.

This notification path requires the device to be paired. If notifications.slack is configured on an unpaired device, Slack receives nothing and the failure is written only to the daemon log; it does not become an Inbox attention.

Before posting, every Slack notification field strips URLs, www. addresses, email addresses, <#C…> channel references, and permalinks, then truncates the field to 300 characters. Consequently a pull-request URL that an agent adds to notification text is intentionally absent from the Slack notification.

Run branch names

run.branch_template names the git branch each run works on in its own worktree. Three variables expand: {workflow_name}, {run_id} and {workspace_name}. Leave it out for the default, {workflow_name}/{run_id}. A workflow overrides it with branch_template under its x-loopkeep: block, so the order is workflow, then this file, then the default.

Values are cleaned up into something git accepts, so a workflow called Nightly deps: fix lands on Nightly-deps--fix/<run_id>. A template that cannot produce a branch name — an unknown variable, an unclosed brace — is reported in the daemon log and the run falls back to the default rather than failing to start. The desktop app rejects such a template when you save it.

Leaving {run_id} out is allowed and useful: a fixed name like agent-work makes a workflow grow one long-lived branch, with each run's checkpoints stacked on the previous run's work. Because git checks a branch out in one place at a time, a run that starts while that branch is busy — held by another run, or by your own checkout — fails with that reason instead of quietly working somewhere else.

attended.idle_timeout caps how long an attended run waits for you after the agent stops before loopkeep ends it — interrupted and resumable, with a note in your inbox. The value is a duration in the same format as a policy timeout — 30m, 2h, 90s, 500ms, a bare number meaning seconds — and it's off unless set. A project sets its own under its .loopkeep/config.yaml, which overrides the global value here. Both are editable in the app: Settings → Background service for the global default, Project settings for the per-project override.

The event log

Everything the system does — trigger firings, state changes, proposed actions, your decisions — is an immutable record in events.db, per workspace. The inbox, the runs list, and the audit trail are derived views of this log, except for the single non-actionable health card shown while event-log storage is degraded. There is no second bookkeeping to drift out of sync. Records are never updated or deleted — corrections are new events. Treat the file as read-only evidence; if it can't be persisted at startup, the daemon refuses to start rather than silently losing history.

Runtime degradation

If event-log access fails at runtime, the daemon stays up but marks that workspace degraded. A read failure can make run history unavailable. When a write fails, an action whose audit record cannot be written is denied without being executed, and the affected run ends in Failed; the audit record whose write failed is absent. The inbox shows one live health card, lk status reports DEGRADED, and lk approve 0 rejects because that card is not an approval request. After storage access recovers, the daemon clears the derived health card and emits a notification about the incident.

This is also where supervision evidence comes from: who approved what, when, with which rule in effect, is a query — not a reconstruction.

Workspace registry

lk workspaces                          # list
lk workspaces add <path> [--name <n>]
lk workspaces remove <id>              # home cannot be removed

Commands addressed at an unregistered path that contains .loopkeep/ register it on the fly.

Troubleshooting

SymptomLikely cause
CLI reports the daemon isn't runninglk start; if you use a custom LOOPKEEP_SOCKET, make sure daemon and CLI agree on it.
A workflow never fires automaticallyWorkspace untrusted (lk trust), the workflow disabled (lk enable <workflow>), or the global pause is on (lk resume --all).
A run sits in waitingIt's waiting for you — check lk inbox, decide with lk approve <seq>.
A run shows interrupted after a restartExpected: lk stop restores in-flight runs that way on the next start. lk rerun it.
run_* worktrees pile upRetention is off or long — lk retention <days>, or sweep now with lk cleanup.
lk status reports DEGRADEDEvent-log storage failed at runtime. Reads may be unavailable; if writes fail, actions that cannot be audited are denied and affected runs end in Failed. One non-actionable health card appears in the inbox. Repair the storage; recovery clears the card and emits a notification. lk approve 0 rejects because the card is not an approval request.
The Console won't connectlk status shows remote: connecting (last error: …) while a pairing is failing; the diagnostics log below has the full reason.

Diagnostics log

The daemon writes a diagnostic log to ~/.local/state/loopkeep/loopkeepd.log (honoring XDG_STATE_HOME), rotated at 5 MB keeping one previous generation as loopkeepd.log.old. It carries adapter spawn errors, Console connection failures, and other daemon-level detail the per-workspace event log doesn't — reach for it when a run fails before producing events, or when pairing won't connect.