Writing ·
AI agent on a NAS
I wanted Claude Code on call from my phone without leaving my laptop on. So it lives in a Docker container on my home NAS, and I talk to it through a private Telegram bot. An agent that can run shell commands is exactly the kind of identity you want to give the least access possible, so most of the work went into deciding what it can't reach.
The setup
It's a small project and most of it isn't mine. Anthropic's official Telegram plugin for Claude Code (part of "Channels", a research preview) does the messaging: I send my bot a message, the one Claude Code session in the container reads it, does the work and replies in the chat. It's signed in with my Claude Pro plan, so there's no API bill. It started on Haiku to keep usage low and now defaults to Sonnet. A small start script runs it in a tmux session that survives restarts and reboots. If Claude exits or crashes, the script relaunches it within seconds and picks up the last conversation where it left off, starting a fresh one only if that fails right away. Remote Control is on too, so I can open the same session from the Claude app.
What I built around it is the box it lives in, the script that keeps it running, a hook that shows me what it's doing, and the fixes for the things that broke along the way.
What I was guarding against
Before writing the Dockerfile I listed what could go wrong. An agent that can run commands is a privileged identity, and the questions are the same ones you'd ask about any service account:
- Someone else talking to it. Anyone who finds a Telegram bot can message it.
- It doing more than I meant. A misread request, or instructions hidden in a web page it reads, could have it delete or change things.
- Reaching past the container. The NAS holds my files and runs other services on my network.
- Leaking secrets. The bot token and my Claude login are credentials, and anything it prints might end up somewhere I didn't plan.
Least privilege, layer by layer
- One allowed user. The bot pairs with my phone using a one-time code, and then the plugin's allowlist policy means it only answers my Telegram account. Everyone else is ignored.
- Nothing mounted. No NAS shares, no other services' config and no Docker socket go into the container (the socket would effectively be root on the host). The only volume is its own home folder, for its login and settings. It can't see my files because they aren't there.
- No root. It runs as an ordinary
botuser, andno-new-privilegesstops anything inside from gaining more, even through a setuid program. - Bounded. A 2 GB memory cap means a runaway process can't starve the NAS.
- No way in. No ports are opened on the container, the NAS or the router. The bot only makes outgoing connections to Telegram and Anthropic, so there's nothing on my network for anyone to reach.
- Its permission checks stay on, but I've loosened them. Claude Code asks before risky actions unless you switch that off with "bypass permissions", and I never have. Day to day it now runs in auto mode with shell and file tools pre-approved, and deleting still asks. I cover exactly what changed, and what it costs, below.
- Credentials treated as passwords. If the bot token leaks, it's revoked in @BotFather and replaced in one command.
# docker-compose.yml
services:
claude-bot:
build: .
restart: unless-stopped
mem_limit: 2g
security_opt:
- no-new-privileges:true
volumes:
- claude-home:/home/bot # its own home, and nothing else
What I loosened since
The walls around the container haven't moved: no root, nothing mounted from the NAS, no open ports. What I changed is what the agent is trusted to do inside them, because tapping "yes" on every edit made it less useful from a phone.
- It can work on my repos. It's signed in to GitHub with the GitHub CLI, using a token with repo access, so it can clone, push and merge to any of my repositories, public or private. That token is now the most powerful credential it holds, and it lives in the agent's home folder. My standing rules are that pushes and merges go ahead without asking, and anything that deletes data always asks.
- Auto mode, with deleting still gated. Shell, file and web tools are pre-approved, while
rm,rmdir,unlink,sudo rm,git reset --hardandgit cleanstill ask. That list only matches obvious commands, so a script orfind -deletecould still remove files. The real safety net is that the repos are committed and pushed, so a mistake is something I can revert. - It can't change its own rules. When the agent tried to edit its own permission settings, Claude Code's safety check refused. That's a useful backstop, and it means loosening permissions stays something I do myself.
- Tools without root. It has no
sudo, so when it needed a headless browser, Ghidra, a JDK and a reverse-engineering MCP, it unpacked them into its own home folder instead of installing them system-wide. That kept the container's walls where they were.
Watching what it does, without leaking
From a phone, I only saw the bot's final answer, not what it ran to get there. That's the wrong way round for anything with a shell, so I wrote a 40-line PostToolUse hook in TypeScript that sends every tool call and its raw output to my chat as a silent message, like an audit log I can scroll. It calls the Telegram Bot API directly, so the feed costs no Claude usage. It skips the bot's own Telegram replies, trims long output to fit, gives up after 8 seconds, and never blocks Claude if Telegram is down.
The catch is the obvious one: anything a command prints goes to Telegram, secrets included. So the bot's instructions tell it never to print keys, tokens or .env files, and the feed can be muted with one file (or by asking the bot to mute it). I'm honest with myself that an instruction is a request, not a control: the real fix is for the hook to redact anything shaped like a token before sending, and that's next on the list.
Three things that broke
- The plugin wouldn't start after a restart. The container started Claude through a login shell, and a login shell resets
PATHfrom/etc/profile, which hid Bun, the runtime the Telegram plugin needs. A plain shell keeps the container'sPATH. - It asked to be trusted on every start. Claude Code doesn't remember folder trust for a home directory, so after each restart it waited on a prompt nobody could answer. It now runs from
~/bot, where trust sticks. - Haiku sometimes answered into the void. It would show "typing" in Telegram and then reply in the terminal, where I can't see it. A short
CLAUDE.mdin its working folder fixed it: every Telegram message must be answered with the Telegram reply tool, kept short and phone-friendly.
What it costs, and what's next
It shares my Pro allowance with the Claude app, so it defaults to Sonnet and I switch models for harder or lighter jobs; if I hit the limit it just pauses until usage resets. Because it resumes the same conversation, each reply re-reads the whole chat, so I clear it when I change topics. The Telegram plugin is a research preview, so its commands may change.
Next: redacting secrets in the feed hook, and giving the bot read-only access to my Pi-hole's stats ("what got blocked today?") through a scoped API token rather than its config files, so it can answer without being able to change anything.