Operations
Once your config works interactively, you usually want the bridge and poller running unattended. This page covers macOS launchd, Linux systemd, what to restart after a config change, and how to inspect runtime state.
Long-lived services
ctrlrelay generates platform-appropriate service unit files for you with
ctrlrelay install. The two daemons it manages:
| Service | Command | Why you want it running |
|---|---|---|
| Bridge | ctrlrelay bridge start |
Delivers BLOCKED questions to Telegram and routes answers back. |
| Poller | ctrlrelay poller start --interval 300 |
Watches GitHub for newly assigned issues and runs the dev pipeline. |
Both should restart on failure and on login.
ctrlrelay bridge start and ctrlrelay poller start daemonize by default
(fork, write a PID file, return to the shell). Under a process supervisor
(launchd, systemd) pass --foreground so the supervisor can track the PID
and restart on failure — the templates below already do this.
macOS — launchd
Recommended: let ctrlrelay install launchd render the plists for you.
It substitutes ${USER}, ${HOME}, the absolute ctrlrelay path,
your working directory, and (when exported) CTRLRELAY_TELEGRAM_TOKEN
into the in-package templates and writes them to
~/Library/LaunchAgents/.
export CTRLRELAY_TELEGRAM_TOKEN=your-bot-token
ctrlrelay install launchd \
--workdir ~/Projects/ctrlrelay \
--label-prefix com.yourname \
--poller-interval 300
# Then load the agents:
mkdir -p ~/.ctrlrelay/logs
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.yourname.ctrlrelay-bridge.plist
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.yourname.ctrlrelay-poller.plist
Pick a reverse-DNS --label-prefix you own (e.g. com.yourname); it
flows into both the filename and the <Label> value so launchctl
list output is unambiguous. Pass --dry-run first to see what would
be written; pass --force to overwrite a previously installed plist.
If you’d rather hand-write the plists, here are the full templates the installer uses.
~/Library/LaunchAgents/com.example.ctrlrelay-bridge.plist:
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.example.ctrlrelay-bridge</string>
<key>ProgramArguments</key>
<array>
<string>/opt/homebrew/bin/ctrlrelay</string>
<string>bridge</string>
<string>start</string>
<string>--foreground</string>
</array>
<key>EnvironmentVariables</key>
<dict>
<key>CTRLRELAY_TELEGRAM_TOKEN</key>
<string>your-bot-token-here</string>
<key>PATH</key>
<string>/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin</string>
</dict>
<key>WorkingDirectory</key>
<string>/Users/YOU/Projects/ctrlrelay</string>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<true/>
<key>StandardOutPath</key>
<string>/Users/YOU/.ctrlrelay/logs/bridge.log</string>
<key>StandardErrorPath</key>
<string>/Users/YOU/.ctrlrelay/logs/bridge.error.log</string>
</dict>
</plist>
~/Library/LaunchAgents/com.example.ctrlrelay-poller.plist:
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.example.ctrlrelay-poller</string>
<key>ProgramArguments</key>
<array>
<string>/opt/homebrew/bin/ctrlrelay</string>
<string>poller</string>
<string>start</string>
<string>--foreground</string>
<string>--interval</string>
<string>300</string>
</array>
<key>EnvironmentVariables</key>
<dict>
<key>CTRLRELAY_TELEGRAM_TOKEN</key>
<string>your-bot-token-here</string>
<key>PATH</key>
<string>/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin</string>
</dict>
<key>WorkingDirectory</key>
<string>/Users/YOU/Projects/ctrlrelay</string>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<true/>
<!-- Give the in-process scheduler time to drain a running secops
sweep on stop (worktree cleanup can take ~120s). launchd's
default ExitTimeOut is 20s, which would SIGKILL mid-cleanup. -->
<key>ExitTimeOut</key>
<integer>180</integer>
<key>StandardOutPath</key>
<string>/Users/YOU/.ctrlrelay/logs/poller.log</string>
<key>StandardErrorPath</key>
<string>/Users/YOU/.ctrlrelay/logs/poller.error.log</string>
</dict>
</plist>
Whether you used ctrlrelay install launchd or hand-wrote the plists,
manage the agents with:
launchctl list | grep ctrlrelay # check loaded
launchctl bootout gui/$(id -u)/com.example.ctrlrelay-poller # stop
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.example.ctrlrelay-poller.plist # start
Linux — systemd
Recommended: ctrlrelay install systemd renders the user unit files
for you (no root needed) and writes them to ~/.config/systemd/user/.
export CTRLRELAY_TELEGRAM_TOKEN=your-bot-token
ctrlrelay install systemd \
--workdir ~/Projects/ctrlrelay \
--poller-interval 300
# Then enable and start:
mkdir -p ~/.ctrlrelay/logs
systemctl --user daemon-reload
systemctl --user enable --now ctrlrelay-bridge.service
systemctl --user enable --now ctrlrelay-poller.service
To survive logout (start at boot, stop at shutdown):
sudo loginctl enable-linger "$USER"
If you’d rather hand-write the units, here are the full templates the
installer uses. Save under ~/.config/systemd/user/.
~/.config/systemd/user/ctrlrelay-bridge.service:
[Unit]
Description=ctrlrelay Telegram bridge
After=network-online.target
[Service]
Type=simple
WorkingDirectory=%h/Projects/ctrlrelay
Environment=CTRLRELAY_TELEGRAM_TOKEN=your-bot-token-here
ExecStart=%h/.local/bin/ctrlrelay bridge start --foreground
Restart=always
RestartSec=5
StandardOutput=append:%h/.ctrlrelay/logs/bridge.log
StandardError=append:%h/.ctrlrelay/logs/bridge.error.log
[Install]
WantedBy=default.target
~/.config/systemd/user/ctrlrelay-poller.service:
[Unit]
Description=ctrlrelay issue poller
After=network-online.target ctrlrelay-bridge.service
[Service]
Type=simple
WorkingDirectory=%h/Projects/ctrlrelay
Environment=CTRLRELAY_TELEGRAM_TOKEN=your-bot-token-here
ExecStart=%h/.local/bin/ctrlrelay poller start --foreground --interval 300
Restart=always
RestartSec=5
# Give the in-process scheduler time to drain a running secops sweep on
# stop (worktree cleanup can take ~120s). systemd's default
# TimeoutStopSec is 90s, which would SIGKILL the poller mid-cleanup and
# leak git worktree admin state.
TimeoutStopSec=180
StandardOutput=append:%h/.ctrlrelay/logs/poller.log
StandardError=append:%h/.ctrlrelay/logs/poller.error.log
[Install]
WantedBy=default.target
Once written, enable and start with the same systemctl --user enable
--now commands shown earlier in the section. Check status / logs:
systemctl --user status ctrlrelay-poller
journalctl --user -u ctrlrelay-poller -f
Scheduled jobs
The poller daemon also hosts an in-process cron (APScheduler). The jobs run
inside the same asyncio loop as the issue poll, so they inherit the poller’s
supervision (launchd KeepAlive on macOS, systemd Restart=always on Linux)
without needing a separate .timer unit.
| Job | Default schedule | What it does |
|---|---|---|
secops |
0 6 * * * (6am daily) |
Runs the secops pipeline across every configured repo — equivalent to ctrlrelay run secops. |
Schedules are configured under schedules: in orchestrator.yaml, using
standard 5-field cron expressions. The top-level timezone: controls how
they’re interpreted:
timezone: "America/Santiago"
schedules:
secops_cron: "0 6 * * *" # daily 6am; use "0 6 * * 1" for weekly (Mondays)
An invalid cron expression fails at config load time rather than silently disabling the job. If the machine is asleep at the fire time, the job runs when it wakes — up to one hour late; older misfires are coalesced into a single run.
On poller stop, the scheduler waits up to 150 seconds for an in-flight
job (e.g. git worktree prune cleanup at the end of a secops sweep) to
finish before letting the asyncio loop close. This requires your
supervisor’s stop timeout to be at least that generous — the example
unit files above set ExitTimeOut=180 (launchd) and
TimeoutStopSec=180 (systemd). Without those, the platform default
(launchd 20s, systemd 90s) SIGKILLs the daemon before cleanup finishes
and leaves stale git worktree admin state behind. If you’re upgrading
from an older plist/unit that didn’t set these, add them and reload.
When to restart
| You changed… | Restart… |
|---|---|
transport.* (Telegram token, chat ID, socket path) |
bridge and poller |
repos[] (added/removed/renamed) |
poller |
claude.* (binary path, timeout) |
poller |
paths.* |
both |
Anything in dashboard.* |
poller |
schedules.* (cron expressions) |
poller |
| Bot token env var | bridge and poller |
After a ctrlrelay package upgrade (uv pip install -e .), restart both.
After merging a PR that the dev pipeline opened, no manual restart is required — the PR is referenced from the open branch in your bare repo, so cleanup is handled by GitHub on merge. The worktree was already removed when the session finished.
Logs
By default, both daemons log to the paths configured in your launchd /
systemd unit files. The recommended location is ~/.ctrlrelay/logs/.
Tail them:
tail -f ~/.ctrlrelay/logs/poller.log
tail -f ~/.ctrlrelay/logs/bridge.log
tail -f ~/.ctrlrelay/logs/poller.error.log
The poller prints one line per detected issue and one line per pipeline outcome. The bridge prints connection events and Telegram API errors.
Inspecting state
ctrlrelay status
The fastest way to see what’s happening:
ctrlrelay status
Shows held repo locks and the 5 most recent sessions with their statuses
(running, done, blocked, failed).
State DB schema
The state DB is plain SQLite at paths.state_db. Open it with sqlite3 for
ad-hoc queries:
sqlite3 ~/.ctrlrelay/state.db
sqlite> .tables
sqlite> .schema sessions
sqlite> SELECT id, pipeline, repo, status, started_at FROM sessions ORDER BY started_at DESC LIMIT 20;
Tables (defined in
src/ctrlrelay/core/state.py):
sessions— every pipeline run. Columns:id,pipeline,repo,issue_number,worktree_path,status,blocked_question,started_at,ended_at,claude_exit_code,summary.repo_locks— currently held per-repo locks. PK onrepo.github_cursor— last-seen issue/PR timestamps per repo (used by the poller to bound API calls).telegram_pending— outstanding bridge questions awaiting an operator reply.automation_decisions— operator decisions onask-policy automation prompts.
Worktrees on disk
Per-session worktrees live under paths.worktrees, named
<owner>-<repo>-<session-id>. ctrlrelay removes them on DONE or terminal
FAILED; BLOCKED sessions keep their worktree so a manual operator can
inspect / resume.
If something goes wrong and you need to reclaim a stuck worktree:
git -C ~/.ctrlrelay/repos/your-org-your-app.git worktree list
git -C ~/.ctrlrelay/repos/your-org-your-app.git worktree remove --force /path/to/orphan
Poller seen-set
The poller’s seen-issue state is a JSON file at
<state_db_dir>/poller_state.json. If you want to force the poller to
re-process a specific issue, delete its number from seen_issues[<repo>] (or
delete the file entirely to reseed from scratch on next start).