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
| Symptom | Likely cause |
|---|---|
| CLI reports the daemon isn't running | lk start; if you use a custom LOOPKEEP_SOCKET, make sure daemon and CLI agree on it. |
| A workflow never fires automatically | Workspace untrusted (lk trust), the workflow disabled (lk enable <workflow>), or the global pause is on (lk resume --all). |
A run sits in waiting | It's waiting for you — check lk inbox, decide with lk approve <seq>. |
A run shows interrupted after a restart | Expected: lk stop restores in-flight runs that way on the next start. lk rerun it. |
run_* worktrees pile up | Retention is off or long — lk retention <days>, or sweep now with lk cleanup. |
lk status reports DEGRADED | Event-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 connect | lk 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.