I have two Claude Code agents running on my Mac right now. One checks for Bazel BUILD drift every 10 minutes and fixes it when CI is red. The other polls Slack for CI alerts every 10 minutes and auto-replies with triage. Both are managed by a bash cron daemon that I can attach to mid-run.
This is the post about how that works.
The two loops
Bazel BUILD drift fixer. Our monorepo has ~1,200 Python packages. Gazelle generates BUILD files, but they drift as people add imports without updating deps. Every morning, a Claude Code session launches with /fix-bazel-ci, which runs bazel build, reads the errors, patches the BUILD files, pushes, waits for CI, and repeats until green. This runs in a tmux session so I can attach and steer it if it goes sideways.
Oncall alert poller. Every 15 minutes, a headless claude --print invocation polls our #ci-cd-alerts Slack channel, diagnoses new alerts against a knowledge base of known failures, and posts a triage reply. No TTY needed. Pure fire-and-forget.
Same daemon runs both. The difference is one config field: "interactive": true.
What came before: the bespoke poller
The daemon didn’t start as a daemon. It started as a single-purpose Python script.
When I first set up the oncall auto-triage, I wrote oncall-poll.py. A standalone poller designed to run every 60 seconds via launchd (macOS’s native scheduler). It would hit the Slack API, check for new bot messages in #ci-cd-alerts, and shell out to a triage script if it found any.
#!/usr/bin/env python3
"""
oncall-poll.py — Poll #ci-cd-alerts for new bot messages
and auto-triage via oncall-triage.sh.
Designed to run every 60s via launchd (com.zoe.oncall-poll).
State directory: ~/.local/share/oncall-poll/
- last-seen.txt — ts of last processed message
- active.pid — PID of current triage subprocess
- poll.log — append-only log
"""
import json, os, subprocess, sys, time, urllib.request
from pathlib import Path
CHANNEL_ID = "C09L2FNM687" # #ci-cd-alerts
STATE_DIR = Path.home() / ".local" / "share" / "oncall-poll"
LAST_SEEN_FILE = STATE_DIR / "last-seen.txt"
ACTIVE_PID_FILE = STATE_DIR / "active.pid"
TOKEN_FILE = Path.home() / ".slack-mcp-tokens.json"
TRIAGE_SCRIPT = Path.home() / "zcode" / "zclaude" / "scripts" / "oncall-triage.sh"
The design was deliberate. Zero pip dependencies. urllib.request for Slack API calls, plain files for state. last-seen.txt tracked the high-water mark so each poll only fetched new messages. active.pid prevented double-triage: if a triage was already running, skip. launchd handled restarts across reboots. macOS notifications via osascript so I’d see a banner when an alert came in. Rate limiting was simple: process at most one alert per poll cycle, leave the rest for the next tick.
It worked. For oncall polling, it was perfectly fine.
Then I needed the Bazel BUILD drift fixer. Same pattern (run on a schedule, do AI work, track state), completely different task. I could have written another standalone script. But staring at oncall-poll.py, I could see the generic bones: a scheduler, a state machine, rate limiting, stale PID detection. All the plumbing was there, just hardcoded to one use case.
So instead of copying the script, I extracted the pattern. The cron daemon is what fell out: a generic scheduler that reads flow definitions from JSON files and handles all the lifecycle stuff (locking, circuit breaking, timeout enforcement) in one place. The oncall poller became a flow config. The Bazel fixer became another. Adding a third agent means adding a JSON file, not writing another 300-line Python script.
The evolution from “bespoke poller” to “generic daemon” wasn’t planned. It was the second use case that forced the abstraction.
The implementation
The abstraction has three pieces: flow definitions, a daemon loop, and a control CLI. Here’s what each one looks like in practice.
Flow definitions
Each agent is a JSON file in ~/.mai/cron/flows/. This is the Bazel drift fixer:
{
"id": "bazel-drift-fixer",
"name": "Bazel BUILD Drift Auto-Fixer",
"schedule": { "cron": "*/10 * * * *" },
"preflight": {
"command": "gh run list --repo infinity-microsoft/yolo --workflow 'Bazel Build and Test (NON-BLOCKING)' --limit 1 --json conclusion -q '.[0].conclusion'",
"skip_when": "success"
},
"prompt": "/fix-bazel-ci",
"safety": { "tier": "elevated", "max_turns": 100, "timeout_seconds": 3600 },
"context": { "working_directory": "~/mcode/yolomain", "model": "opus" },
"interactive": true
}
The schedule.cron is a standard five-field cron expression. */10 means “check every 10 minutes,” but the preflight gate means it only actually launches when the latest Bazel CI run is red. Most ticks are no-ops. interactive: true means tmux session (attachable). interactive: false means headless claude --print. The oncall poller looks identical except "schedule": { "cron": "*/10 * * * *" }, "interactive": false, and no preflight (it always polls).
The daemon loop
cron-daemon.sh is a while true loop that ticks every 60 seconds. When a flow’s cron expression matches the current time, the interesting bit is the tmux launch logic:
if [[ "$interactive" == "true" ]]; then
session_name="cron-${flow_id}"
if tmux has-session -t "$session_name" 2>/dev/null; then
log "[$flow_id] tmux session already exists, skipping"
return 0
fi
tmux new-session -d -s "$session_name" "bash -c '...'"
tmux pipe-pane -t "$session_name" -o "cat >> $log_file"
fi
The has-session check is the idempotency guard. If the previous run is still going (Bazel fixer can take 90 minutes), the daemon skips it. pipe-pane captures all terminal output to a log file, so even if you never attach, you still get a record of what happened.
For headless flows, it’s simpler: timeout $timeout_seconds claude --print "$prompt" > "$log_file" 2>&1.
The control CLI
cron-ctl.sh is the interface for everything:
cron-ctl.sh start # start the daemon
cron-ctl.sh run bazel-drift-fixer # manual trigger (ignore schedule)
cron-ctl.sh attach bazel-drift-fixer # jump into the tmux session
cron-ctl.sh list-sessions # what's running right now?
cron-ctl.sh kill bazel-drift-fixer # stop a running flow
run is the one I use most. When I want to trigger the Bazel fixer outside its 8am schedule, cron-ctl.sh run bazel-drift-fixer launches it immediately. Same tmux session, same logging, same safety checks. Just bypasses the cron expression.
Architecture
Everything lives in ~/.mai/cron/. The core pieces:
Flow definitions are JSON files in flows/. Each one declares a cron schedule, a prompt, a safety tier (readonly/standard/elevated), a timeout, max turns, the model, and a working directory. Adding a new agent means adding a new JSON file.
cron-daemon.sh is a background loop that ticks every 60 seconds. Each tick, it evaluates every flow’s cron expression against the current time. When a flow matches, it runs through a series of gates before launching anything.
Here’s the actual decision tree for the Bazel drift fixer:
┌─────────────────────────────────────┐
│ CRON TICK (*/10 min) │
└──────────────┬──────────────────────┘
│
▼
┌─────────────────────────────────────┐
│ Lock check: is previous run │
│ still going? (stale PID detection) │
└──────────────┬──────────────────────┘
┌────┴────┐
│ locked? │
└────┬────┘
YES │ NO
▼ │ │
[SKIP] │ ▼
│ ┌───────────────────────────────────┐
│ │ PREFLIGHT: check main branch │
│ │ Bazel workflow (NON-BLOCKING) + │
│ │ open bazel-fix PR status │
│ └──────────────┬────────────────────┘
│ ┌────┴─────┐
│ │ all green? │
│ └────┬─────┘
│ YES │ NO / failure
│ ▼ │ │
│ [SKIP] │ ▼
│ │ ┌─────────────────────────────┐
│ │ │ Spawn Claude Code session │
│ │ │ cwd: ~/mcode/yolomain │
│ │ │ model: opus (1M context) │
│ │ │ prompt: /fix-bazel-ci │
│ │ └──────────────┬──────────────┘
│ │ │
│ │ ▼
│ │ ┌─────────────────────────────┐
│ │ │ /fix-bazel-ci phases: │
│ │ │ 1. Read KB (known fixes) │
│ │ │ 2. bazel fetch //... │
│ │ │ 3. bazel build //... │
│ │ │ 4. format check │
│ │ │ 5. bazel test //... │
│ │ │ 6. Close stale drift PRs │
│ │ │ 7. Push + create PR │
│ │ │ 8. Wait for PR CI │
│ │ │ 9. If required CI or bazel │
│ │ │ workflow red → fix loop │
│ │ │ 10. Update KB with new fix │
│ │ └──────────────┬──────────────┘
│ │ │
│ │ ▼
│ │ Log output, release lock
└─────────────────┘
Two gates prevent wasted runs. The lock gate: if the previous run is still fixing (the Bazel fixer can hold a session for 90 minutes), skip. The preflight gate: checks two things. First, is the latest Bazel workflow run on main green? If yes, skip. Second, is there already an open bazel-fix PR with passing bazel checks? If yes, also skip (the fix is already in flight). The daemon only burns tokens when there’s actually something broken to fix.
When a flow passes both gates:
- If
"interactive": true, it launches Claude Code inside a detached tmux session:tmux new-session -d -s cron-bazel-drift-fixer "claude ...". The session runs autonomously. I attach withcron-ctl.sh attach bazel-drift-fixerwhenever I want to see what it’s doing. - If
"interactive": false, it runsclaude --printwith timeout enforcement. Output goes to a log file. No TTY, no tmux.
cron-ctl.sh is the CLI. start/stop the daemon, run/pause/resume individual flows, attach/kill tmux sessions, view logs. Standard stuff.
The key insight: interactive attach
Most agent schedulers run headless. You fire off claude --print, capture the output, done. That works for the oncall poller. Fifteen minutes of Slack fetching and diagnosis. No human needed.
But the Bazel fixer is a 30-to-90-minute repair loop. It makes judgment calls about which deps to add, whether to restructure a target, when to give up on a flaky test. Sometimes it goes down the wrong path. I want to be able to jump in, say “skip that package, it’s not ours”, and detach.
tmux makes this trivial:
# daemon launches it detached
tmux new-session -d -s cron-bazel-drift-fixer "claude ..."
# I attach from any terminal
tmux attach -t cron-bazel-drift-fixer
# I type corrections, then detach
# Ctrl+B, D
# it keeps running
This survives iTerm crashes. It works over SSH. The session is there whether I’m looking at it or not.
The mental model: fire and steer, not fire and forget. The agent runs autonomously, but you can grab the wheel anytime. This is different from both “fully autonomous” (no human oversight) and “copilot” (human drives). It’s somewhere in between. The agent drives, you’re in the passenger seat with a steering wheel.
Safety features
Letting agents run unsupervised on a cron schedule sounds terrifying until you add guardrails.
Circuit breaker. If a flow fails 5 times in a row, the daemon auto-pauses it and writes paused_reason: "circuit_breaker" to the flow state. No more retries until you manually cron-ctl.sh resume <id>, which resets the failure counter. This prevents a broken flow from burning tokens in a loop all day.
Semantic failure detection. Some Claude Code invocations exit 0 but actually failed. The output contains “API Error:” or “Reached max turns” or “I wasn’t able to complete”. The daemon scans the last 20 lines of output for known error patterns and marks these as failures. Exit code alone is not enough.
Lock files with stale PID detection. Before launching a flow, the daemon checks for an existing lock file. If the PID in that lock file is still alive, it skips. If the PID is dead (stale lock from a crash), it cleans up and proceeds. No double-launches.
Pre-flight proxy health check. Our corp network requires a proxy. Before launching any flow, the daemon pings the proxy endpoint. If it’s down (laptop just woke from sleep, VPN reconnecting), the flow is skipped for this tick. Better to skip than to burn 10 minutes of agent time on network errors.
Why local, not cloud
Running agents as local daemons on your laptop is a different paradigm from cloud-hosted agent platforms. The tradeoff is simple.
What you get: Your full dev environment. Git config, SSH keys, local repo checkouts with uncommitted changes. Your MCP servers (Slack, Azure DevOps, browser sessions). Your auth tokens. Everything “just works” because the agent runs as you.
What you lose: Your laptop needs to be on. If it sleeps, the daemon sleeps. If you’re on a plane, nothing runs.
For a daily Bazel fixer and a 15-minute oncall poller, this is fine. These aren’t mission-critical pipelines. They’re personal automation. The Bazel fixer saves me from spending my first hour every morning babysitting CI. The oncall poller saves me from checking Slack every 15 minutes during my oncall rotation. If either one misses a tick because my laptop was asleep, nothing bad happens. I just do it manually when I wake up.
The bar for “should this be a cloud agent?” is: does it need to run when I’m not around? For these two, the answer is no.
Technical details
A few things that were more interesting to implement than I expected.
Cron expression parsing in pure bash. I didn’t want to pull in Python or a library just for cron scheduling. The daemon parses standard cron expressions (five fields: minute, hour, day-of-month, month, day-of-week) with support for *, */N, N, N-M, and comma-separated values. It’s about 60 lines of bash. Not elegant, but it works and has zero dependencies.
Flow state is just files. Each flow gets a state directory with last_run, last_result, failure_count, paused, lock. All plain text files. No database, no SQLite. cron-ctl.sh status just reads these files. Dead simple to debug: cat ~/.mai/cron/state/bazel-drift-fixer/failure_count.
Timeout enforcement for headless flows. claude --print doesn’t have a built-in timeout. The daemon wraps it in a timeout command and also monitors wall-clock time. If a flow exceeds its configured timeout, the daemon kills the process group and marks it as failed (which feeds into the circuit breaker).
Comparing the three approaches
Before landing on cron-daemon.sh, I considered (and tried) two other approaches. Here’s how they stack up.
| Aspect | v1: oncall-poll.py + launchd | v2: cron-daemon.sh + cron-ctl.sh | Considered: iTerm AppleScript (zfarm) |
|---|---|---|---|
| Scheduler | macOS launchd (native, survives reboot) | Custom bash daemon (must be started manually or wrapped in launchd) | AppleScript + iTerm tabs |
| Agent launch | Calls oncall-triage.sh (single purpose) | claude --print (headless) or tmux session (interactive) |
Opens iTerm tab with claude |
| Interactive attach | No (fire and forget) | Yes (tmux attach/detach) | Yes (visible tab, but fragile) |
| Survives app crash | Yes (launchd restarts) | tmux: yes. Daemon itself: needs launchd wrapper | No (dies with iTerm) |
| SSH-attachable | No | Yes (tmux) | No |
| Multi-agent | No (one script per job) | Yes (JSON flow definitions) | Yes (multiple tabs) |
| Safety | Rate limiting, PID locks | Circuit breaker, semantic failure detection, locks, timeouts | None built-in |
| Dependencies | Python, launchd plist | bash, tmux, jq | AppleScript, iTerm |
v1 was purpose-built and it worked. The oncall poller did exactly one thing and did it well. But when the Bazel fixer appeared as a second use case, the choice was: copy-paste another 300-line script, or extract the pattern. v2 fell out of that extraction.
The iTerm/AppleScript approach (which I use in zfarm for other things) was tempting because it gives you visible tabs you can click on. But it dies when iTerm crashes, you can’t reach it over SSH, and there’s no built-in safety. For a quick one-shot, it’s fine. For something that runs every 15 minutes while I’m in meetings, I wanted something more resilient.
The lesson: the first agent doesn’t need infrastructure. The second one forces you to build it.
Future directions
The cron daemon is v2. The bespoke Python poller was v1. Here’s what v3 might look like.
The auto-merge gap
Right now the Bazel drift fixer’s flow is: detect drift → fix → push → create PR → wait. Then it stalls. PRs need a review stamp and a merge. The agent can’t close the loop.
I’ve been considering a few options:
- Self-approve + auto-merge.
gh pr merge --auto --squashhandles the merge side, but GitHub still requires at least one approving review. We could set up a CODEOWNERS bypass for BUILD files specifically, since those changes are mechanical. - Direct push to main. For BUILD-only changes that are purely additive (adding a missing dep), skip the PR entirely. This is faster but risky. Needs team buy-in, and “purely additive” is a judgment call the agent might get wrong.
- Rubber-stamp bot. A second bot that auto-approves PRs matching certain criteria (only BUILD files changed, all CI green, diff is below N lines). Feels like over-engineering, but it’s the cleanest separation of concerns.
The real blocker isn’t technical. All three options work mechanically. The blocker is organizational trust. The team needs to believe that an agent pushing BUILD file changes without human review won’t break things. The circuit breaker and CI gates help build that trust, but we’re not there yet.
Server-side agents replace local polling
We already have a failure investigation workflow in CI. When a Bazel job fails, it spawns a Claude Code agent with elevated permissions (push access, merge capability). That agent reads the failure logs, diagnoses the issue, and attempts a fix.
If that agent can reliably detect Bazel CI failure → diagnose → fix → run CI → merge when green, the local cron daemon becomes unnecessary for the BUILD drift use case. Instead of my laptop polling on a schedule, a server-side agent fires on actual CI failure events.
The benefits are obvious. Always on (not laptop-dependent). Has the right permissions natively (no personal SSH keys or git config). Triggered by real events instead of polling. No “my laptop was asleep” gaps.
The local daemon is a bridge until that server-side workflow is reliable enough. For the oncall alert poller, though, local is likely permanent. That flow needs personal Slack tokens and access to my MCP servers. Server-side agents don’t have those, and I’m not sure I want them to.
Dev containers as the runtime
Our team is building MSI Dev Containers: cloud workspaces with persistent volumes, the full yolo repo, Claude Code pre-installed, and Zellij for remote attach via browser. For always-on agents, this is strictly better than a laptop. Runs 24/7. Attachable from a phone via Zellij’s web client. No “laptop must be open” constraint.
The migration path is clean. Same cron daemon code, same flow definitions, just cron-ctl.sh start in a Zellij session instead of a tmux session. The daemon doesn’t care where it runs.
Current alpha blockers: our mai-agents setup.sh breaks Coder’s environment assumptions, Slack MCP needs manual token extraction (no SSO flow in headless containers), and Docker-in-Docker isn’t working yet for flows that need container builds. When the C2 milestone lands and those blockers clear, moving the daemon into a dev container would be the natural upgrade.
The trajectory
The evolution looks like: single-purpose script → generic local scheduler → cloud-native always-on agents. Each step trades bespoke simplicity for generality. The cron daemon was the second step. The third step is some combination of server-side CI agents, dev containers, and enough organizational trust to let agents merge their own work.
The daemon infrastructure will keep working regardless. It’s just bash, tmux, and JSON files. But the goal is to make it unnecessary for the cases where “always on” matters more than “runs on my machine.”