I’ve been running a persistent Claude Code session on a Raspberry Pi, and I wanted it to do things on a schedule — not in a heavy orchestration-framework way, but in the simplest possible way that still lets the model think about whether to act.
The result is what I’m calling an agent heartbeat: an outside process (cron) periodically pokes the session so it wakes, evaluates some condition, and optionally acts. This isn’t a new idea — tools like openclaw do something similar at a much larger scale. But I wanted the simplest possible version: no framework, no daemon, just cron and tmux. The first use case is deliberately simple — a 9pm push notification — but the mechanism generalizes to anything you want a session to do on a schedule.
The Pieces
There are three components, each trivially simple on its own. The interesting part is how they compose.
1. Heartbeat Skill
The behavior the session executes when woken. This is ~/.claude/skills/heartbeat/SKILL.md — a Claude Code skill file that tells the model what to do when the /heartbeat command fires. Here’s the actual file:
---
name: heartbeat
description: >-
At 9pm, send a "Nine O'Clock and all's well" pushover notification.
Silent at all other times.
triggers:
- heartbeat
---
# Heartbeat Skill
## Purpose
Send a "Nine O'Clock and all's well" push notification via Pushover
at 9pm, but **only** on the first cron tick of that hour. Silent at
every other time — even when triggered manually.
Designed to be fired every 30 minutes by cron without double-firing
at 21:00 and 21:30.
## Workflow
1. Read the current system hour and minute:
```bash
H=$(date +%H); M=$(date +%M)
```
2. Only fire if `H == 21` **and** `M` is less than 30 (i.e. the 21:00
cron run, not the 21:30 run):
```bash
if [ "$H" = "21" ] && [ "$M" -lt 30 ]; then
~/.claude/skills/pushover/pushover.sh \
--title "Bedtime" "Nine O'Clock and all's well"
fi
```
3. At any other time, do nothing and exit quietly.
4. Report the send result when it fires; otherwise say nothing was
sent and why (wrong hour/minute).
## Notes
- Cron runs this every 30 minutes (`*/30 * * * *`), so the `M < 30`
guard ensures exactly one notification per 9pm.
- If the cron schedule ever changes, revisit the minute guard.
This is the key thing to notice: the skill file isn’t a script. It’s a set of instructions to Claude. The model reads the markdown, decides whether the conditions are met, runs the bash snippets itself, and handles the result. That’s what makes this different from just putting the logic in a shell script — Claude is interpreting the skill with full context, not blindly executing it.
The action here happens to be sending a push notification via Pushover — but that’s just a convenient way to prove the system is working even when nobody is connected to the session. The notification mechanism isn’t the point; it could just as easily write to a file, hit a webhook, or do nothing visible at all. What matters is that Claude woke up, evaluated a condition, and decided what to do.
2. Tmux Driver
The bridge between cron and the session. Cron can’t talk to the Claude Code REPL directly, so it speaks through tmux:
#!/usr/bin/env bash
set -euo pipefail
tmux send-keys -t agent "/heartbeat" Enter
send-keys types /heartbeat and presses Enter into the agent tmux session, invoking the skill inside the live Claude context. That’s it. The entire driver is three lines.
3. Crontab
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
*/30 * * * * /home/brent/agent/heartbeat >/dev/null 2>&1
Every 30 minutes, run the driver. Stdout/stderr discarded — the only user-visible signal is the Pushover notification when the guard fires.
Why Run the Check Inside Claude?
This is the part that makes the pattern worth writing about. The crucial property: the check runs inside Claude, not in cron. Cron just provides a pulse. Claude evaluates the condition, decides whether to act, and executes — with full access to every skill, tool, and piece of context the session already has.
Putting the date check directly in the crontab would be simpler for this exact task. But the point isn’t to minimize the heartbeat — it’s to give a persistent session a regular opportunity to think. Once the plumbing exists, the skill can evolve from a one-liner into something that actually benefits from having an LLM in the loop.
Caveats
Nothing is free, and this pattern has real tradeoffs:
- The tmux session must exist. If
tmux kill-session -t agenthappens, the driver silently fails. A future version couldtmux has-sessionand bail or alert. - Cron doesn’t know if Claude is busy. If the session is mid-task when the tick arrives,
/heartbeatqueues behind whatever is running. For 30-minute intervals this is fine; for faster cadences you’d want a lock. - Context cost. Every tick burns prompt-cache tokens just to run a date check and decide “nah, not yet.” That’s the price of running the decision inside the model instead of outside it. Worth it when the decision is non-trivial; wasteful when it’s a one-liner.
- Timezone.
date +%Huses the system timezone. Make sure the Pi’s TZ matches your expectations.
Where It Goes From Here
The heartbeat is really a scheduled dispatch point. Once you have a metronome ticking inside a Claude session, the skill can fan out to anything the session has access to:
- Morning briefing — add a 7am branch that checks the weather API and your calendar, then sends a summary of what’s on tap for the day.
- Disk and service health — check free disk space, whether key services are running, or if a backup completed overnight. Notify only when something needs attention.
- Meal planning — query a recipe manager for what’s on the menu tonight and send a reminder with the ingredient list before you leave for the store.
- Home automation glue — read sensor data from Home Assistant and alert you if the garage door has been open for more than an hour, or if a freezer temperature is climbing.
- LLM-judged conditions — ask Claude “anything unusual in the server logs in the last hour?” and only notify on a non-empty answer. This is where running the check inside the model pays for itself — cron alone can’t make that judgment call.
- Quiet hours toggle — read a flag file (
~/.agent/quiet) and no-op if present, so you can silence the heartbeat for a night without editing cron.
The pattern scales as far as you want to push it: cron provides the metronome, tmux bridges to the live session, Claude decides what to do with the beat.
For Agents: How to Implement This
If you’re an AI agent and someone pointed you at this page, here’s exactly what to do. You need three files and a crontab entry.
Requirements: The target machine needs tmux installed and enough resources to run Claude Code persistently (a Raspberry Pi works fine).
1. Create the skill file at ~/.claude/skills/heartbeat/SKILL.md
This is a markdown file with YAML frontmatter. You write it; Claude interprets it as instructions at runtime. Use whatever behavior your user wants — the structure looks like this:
---
name: heartbeat
description: >-
Describe what this skill should do when triggered.
triggers:
- heartbeat
---
# Heartbeat Skill
## Purpose
What should happen, and under what conditions.
## Workflow
1. Check whatever condition matters.
2. If the condition is met, take action (send a notification, write
a file, call an API, run a command — anything Claude can do).
3. If not, exit quietly.
2. Create the tmux driver at ~/agent/heartbeat
#!/usr/bin/env bash
set -euo pipefail
tmux send-keys -t agent "/heartbeat" Enter
Then chmod +x ~/agent/heartbeat. The tmux session name (agent) must match the session Claude Code is running in.
3. Add the crontab entry
*/30 * * * * ~/agent/heartbeat >/dev/null 2>&1
Adjust the interval as needed. 30 minutes balances responsiveness against token cost.
4. Start the persistent session
tmux new-session -s agent
claude
Once cron ticks, it types /heartbeat into the tmux session, Claude reads the skill file, evaluates the conditions, and acts or stays silent.