Running Reliable Telegram Bots on a Raspberry Pi
Ali Rezaiyan8 min read

I run several Telegram bots on a Raspberry Pi. For a long time the setup mostly worked: orders came in, messages got processed, Claude Code ran commands. But it was fragile. When a bot crashed, I wouldn't notice for minutes. Commands I sent sometimes just vanished. And the only way to see what was going on was to SSH in.
This post is about how I rebuilt it, and what I learned doing it.
The problem
The original setup was simple:
- Each bot ran as a systemd service and restarted if it crashed.
- Claude Code ran in a tmux session, because it needs an interactive terminal.
- To send Claude a command, a bot typed it straight into tmux.
The big flaw was that there was no queue, no acknowledgment, and no retry. When the admin bot wanted Claude to do something, it ran:
tmux send-keys -t session "your command" Enter
And that was it. If Claude crashed between the keystroke and running the command, the command was gone. If Claude was busy, the input could get mangled. If I restarted Claude to get a fresh session, anything pending disappeared.
The real problem was that I couldn't see anything. I couldn't tell if Claude was alive, busy, or stuck, or whether a command was waiting or lost.
Why a Raspberry Pi?
Fair question: why not a cloud VM? A few reasons:
- It's always on, with no cold starts.
- It uses very little power, about $10 a year in electricity running all day.
- Files are just on local disk, with no cloud storage to set up.
- It's on my network, so it's fast and I control everything.
The downsides are limited RAM (about 4 GB) and no redundancy. Whatever I built had to be light and able to recover on its own.
The plan
Instead of patching the tmux setup, I started over. I wanted to be able to see what the system was doing, stop losing commands, and debug it easily, without adding much complexity.
I added three things:
- Redis, to hold state and queue commands.
- A health monitor, to notice crashes and restart things.
- A status command, to see everything at a glance.
1. Redis for state
Redis became the one place that knows the state of everything. Instead of typing into each other's terminals, the bots read and write Redis keys:
claude:status → "idle" | "processing" | "starting" | "stopped" | "error"
claude:heartbeat → timestamp (TTL 30s)
claude:uptime → startup epoch
claude:error_count → number of crashes
claude:inbox → FIFO queue of pending commands
claude:history → last 20 executed commands
The daemon that manages Claude Code now:
- On startup, sets status to "starting", records the uptime timestamp, and resets the error count.
- Every 10 seconds, writes a heartbeat that expires after 30 seconds.
- When idle, watches the queue for new commands.
- On shutdown, bumps the error count and sets status to "stopped".
That fixed the visibility problem straight away. I could run redis-cli GET claude:status and know.
2. A command queue
Commands no longer go straight into tmux. They go into a Redis list first:
# In the admin bot
def queue_command(command):
r.lpush("claude:inbox", json.dumps({
"id": uuid.uuid4(),
"command": command,
"timestamp": time.time()
}))
return True # Command will be delivered, guaranteed
The daemon has a worker loop that keeps checking it:
while true; do
if [ "$(redis-cli LLEN claude:inbox)" -gt 0 ] && [ "$(redis-cli GET claude:status)" = "idle" ]; then
item=$(redis-cli RPOP claude:inbox)
command=$(echo "$item" | python3 -c "import sys,json; print(json.loads(sys.stdin.read())['command'])")
# Inject to tmux
tmux send-keys -t session "$command" Enter
# Record in history for debugging
redis-cli LPUSH claude:history "$command"
redis-cli LTRIM claude:history 0 19
fi
sleep 2
done
The nice part is that commands survive crashes. If Claude goes down while restarting, the queue is still in Redis, and the commands are waiting when it comes back.
If Redis itself isn't available, the admin bot falls back to typing into tmux like before. Nothing breaks; I just lose the queue until Redis is back.
The queue isn't a message broker. It's a plain Redis list. That's simple, fast, and enough.
3. Health checks and restarts
Next, a small health monitor that watches the heartbeat:
# Health monitor loop (runs every 30 seconds)
def check_heartbeat():
ttl = redis.ttl("claude:heartbeat")
if ttl == -2: # Key missing, not renewed
heartbeat_misses += 1
if heartbeat_misses >= 3:
# Three consecutive misses = Claude is dead
subprocess.run(["sudo", "systemctl", "restart", "claude-code"])
send_telegram_alert("Claude auto-restarted after heartbeat miss")
heartbeat_misses = 0
That one loop fixed crash detection. Claude crashes, the heartbeat stops, the monitor notices within about 90 seconds (three missed checks), restarts it, and sends me a Telegram message. I don't have to do anything.
The monitor also keeps an eye on:
- Queue length, in case commands start piling up.
- The other important services, to make sure they're still running.
- Memory usage and CPU temperature.
Alerts are rate-limited to one every 5 minutes per type, so I don't get spammed.
4. A status command
Last, a /status command in Telegram that shows everything:
Claude Code — Status
──────────────────────
✅ idle · up 2h 14m · queue: 0 · errors: 0
Services
✅ claude-code ✅ admin-bot
✅ ledger ✅ reflect
✅ redis ✅ health-monitor
System
🟢 Memory 58% 🟢 CPU 47.9°C
Today €0.42
Last commands
1. /clear
2. write me a haiku
3. summarise this file
Now I can check on the whole system from Telegram instead of SSHing in.
The startup prompt
About halfway through, I hit an annoying edge case. The first time Claude Code starts in a folder, it asks: "Is this a project you created or one you trust?"
The daemon would start Claude and wait for the "Listening for channels" message, which never came because Claude was sitting on that prompt waiting for input.
Then the health monitor would see no heartbeat and restart it. Claude would start, get stuck on the prompt again, and the whole thing would loop forever.
The fix was small: look for the prompt and answer it.
output=$(tmux capture-pane -t session -p)
if echo "$output" | grep -q "Is this a project you created"; then
tmux send-keys -t session "Enter"
fi
The lesson for me: good monitoring doesn't help if the thing can't start. If a service gets stuck on startup, no amount of restarting will ever get it healthy.
What I learned
1. State beats logs
Before, I had logs. Now I have state, and state is more useful.
A log tells you what happened. State tells you what's happening now. A Redis key with a TTL answers "is this alive right now?" immediately, without digging through logs.
2. Keep working when a piece is missing
Every Redis call is wrapped in a try/except. If Redis goes down, the bots keep running. They lose the queue, but they don't crash.
I prefer that to a system that can't run without Redis. It's simpler, easier to test, and harder to break.
3. Boring tools are fine
No RabbitMQ, Kafka, or Celery. Just Redis strings for state, lists for the queue, and TTLs for heartbeats.
The whole thing is around 400 lines of bash and Python. I can read all of it, debug it, and there's nothing extra to run.
4. Build the status view early
The status command took about an hour to write and has saved me many hours of debugging since. I'd build it first next time.
5. Test the recovery path
I'd have found the startup prompt problem right away if I'd tested what happens when Claude starts but never gets to "ready". Test the paths where things go wrong, not just the happy one.
How it's going
Since the rebuild:
- Commands don't get lost. Everything that's queued eventually runs.
- Crashes get noticed within 90 seconds.
- The health monitor usually restarts the bot before I'd have noticed anything.
- I can see the state of everything from Telegram.
- It adds under 30 MB of memory and less than 0.3% CPU.
It's gone from "works most of the time" to something I actually trust.
What's next
Some things I might add later:
- Running bots across several Pis with one shared Redis.
- Keeping every executed command so I can replay them after a crash.
- Pausing a bot's queue automatically if it gets into a bad state.
- Sending metrics to Prometheus to see trends over time.
For now it does what I need. I can see what's going on, it recovers on its own, and it's simple enough for one person to look after.
You don't need enterprise infrastructure to run reliable services. You need to understand your system and be able to see what it's doing.
If you're running services on a small machine, whether that's a Pi, a VPS, or a VM, the same ideas work: keep state in Redis, use a heartbeat, and build a status view you can check from anywhere.