Claude Code In My Pocket

A forever-running autonomous server I can talk to from anywhere, built on a Raspberry Pi 4.

A step-by-step setup guide — from bare Pi to autonomous Claude sessions.

Update 2026-02-06:Token expiration solved! The original 6-12 hour authentication issue has been resolved using claude setup-token, which generates a 1-year OAuth token. This is now automated in the Ansible playbook. For manual setup, see the Long-lived authentication section below.

Why bother?#

I found myself carrying my laptop everywhere for pretty menial tasks - research queries, ultrathinking through processes. The Ralph Wiggum plugin had me wondering if I could push more “human out of the loop” agentic work to a dedicated machine.

In terms of mobility, I have also tested the Happy mobile app when I’m on the go, both using its voice mode and text mode. To me it is a really underrated app, but it still depended on me having my laptop running Claude Code somewhere, and it could not easily spawn new sessions as I needed them.

I kept hearing that others found success with tmux and Raspberry Pi setups, so I decided to dust off my old RPi 4 and explore agentic workflows without me always being involved.

At the same time I decided that I would dive into the rabbit hole of early-days sandboxing, meaning I would be able to let my Claude Code instances run completely wild without needing any approvals from me. I could be like those startup gurus on LinkedIn who built a million-dollar company in an hour. (-:

The goal: Trivial work, attended or not — kick off tasks and walk away, or stay and pair, with a trivial switch between modes.


Sub-goals#

Subscription maximization#

Reuse my existing Claude Code subscription instead of paying per-token API costs. Get the most out of the flat fee.

Always-on availability#

Claude Code running 24/7 on a dedicated machine, accessible from anywhere via SSH or the Happy mobile app.

Autonomous execution#

Run with --dangerously-skip-permissions so Claude can work on tasks without waiting for my approval at every step.

Real sandboxing#

Docker-based isolation that I control, not Claude Code’s built-in sandbox. The agent shouldn’t guard its own cage.

Multi-session orchestration#

A control plane that can spawn, monitor, and kill parallel Claude sessions — each working on different repos or tasks.


Decisions#

OS choice: Ubuntu 24 LTS Server Edition. Wanted something slightly more bleeding edge than the usual Debian-based distros. (Note: Raspberry Pi OS is Debian-based, tends to lag behind on packages due to Debian’s stability-focused release cycle. Tradeoff: RPi OS has better hardware-specific optimizations and lighter resource usage — but for a headless server running Node.js, fresh packages mattered more to me. Most “Ubuntu is slow on Pi” complaints are about the desktop; headless Server Edition sidesteps that entirely.)

Pre-flight check: Verified my MCPs support ARM64 — GitHub MCP notably confirmed working.

Update (2025-01-08): The Ansible playbook fully automates deployment including long-lived OAuth tokens (via vault_claude_oauth_token), eliminating the 6-12 hour re-authentication issue. The article documents the manual approach for learning purposes.

Note: All scripts and configurations discussed below are maintained in the pi-pai repository. The article walks through the setup manually; the repo automates deployment via Ansible. Where a script has evolved significantly, a link to the current source is provided.


Preparing the SD card#

Using Raspberry Pi Imager - the simplest and safest method.

  1. Download and open Raspberry Pi Imager
  2. Choose OS: Other general-purpose OS → Ubuntu → Ubuntu Server 24.04 LTS (64-bit)
  3. Select your SD card as storage
  4. Open the ⚙️ Advanced settings (crucial step):
    • Enable SSH
    • Set username/password
    • Configure Wi-Fi (if not using Ethernet)
    • Set hostname (e.g. rpi-dev)
    • Set locale/timezone
  5. Write image and eject card

First boot#

Imaged Ubuntu Server, booted up, logged in. First task: sudo apt update && sudo apt upgrade.

Already clear the RPi 4 is not the fastest - especially for IO operations. Patience required.

Essentials installed:

sudo apt install -y \
  tmux \
  git \
  curl \
  build-essential \
  unzip \
  htop \
  neovim

Again, slow - took 2-3 minutes. Get used to it.


Node.js via nvm#

It is more future-proof to not have to install new versions of Node.js on your host machine without some kind of manager for it. The tool called “nvm” has been a go-to for many years now.

nvm for Node.js version management.

curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.1/install.sh | bash
source ~/.bashrc
nvm install --lts

pnpm#

pnpm is a faster, more disk-efficient package manager. The maintainer is also very attentive to issues and feature requests.

pnpm is unfortunately not compatible with the happy-coder app that we will be using, so we have to mix npm and pnpm a bit. But since I personally use pnpm for all my projects, I know that I’ll need to bundle it in anyway.

corepack enable
corepack prepare pnpm@latest --activate
pnpm setup
source ~/.bashrc
pnpm --version

Removing snapd#

Ubuntu ships with snapd, but on a Pi 4 it’s pure overhead. Snap Store alone can consume 250-400MB RAM even when idle, and there are known memory growth issues concerning for embedded devices. The squashfs loopback mounts also slow boot/shutdown.

We won’t be using snaps here - apt and npm cover everything we need.

sudo systemctl disable snapd --now
sudo apt purge snapd -y
sudo apt autoremove -y

Enabling zram#

SD cards are slow for swap. zram creates compressed swap space in RAM instead - typically ~3:1 compression, so 432MB of swapped data uses only ~165MB actual RAM. Users report going “from unusable and freezing to performant and stable”.

Personal experience: I’ve owned Raspberry Pi models 2, 3, and 4 over the years, and every time I’ve tried to use one for anything beyond light scripting it’s felt sluggish. I once tried setting up a Pi 3 as a simple workstation for my son - it would hang or run out of resources regularly. That history made me paranoid, so I proactively searched for community best practices before even booting this one up. No benchmarks to share, but with zram enabled the system subjectively feels snappier - fewer moments where I’m waiting on the cursor. I’m still running from microSD rather than an SSD, which I suspect would help further.

sudo apt install -y zram-tools

Works out of the box. Verify it’s running:

$ zramctl
NAME       ALGORITHM DISKSIZE DATA COMPR TOTAL STREAMS MOUNTPOINT
/dev/zram0 lz4           256M   4K   63B   20K       4 [SWAP]

$ swapon
NAME       TYPE      SIZE USED PRIO
/dev/zram0 partition 256M   0B  100

Small CPU overhead for compression, but the Pi 4 handles it fine.


CPU governor#

Ubuntu Server defaults to ondemand, which can lag when ramping up for bursty workloads. The modern schedutil governor integrates tightly with the scheduler and is only ~1% slower than locking at max frequency - a sensible middle ground.

Same caveat as above: I haven’t benchmarked, but users on the Raspberry Pi forums report that schedutil feels more responsive than ondemand because it uses all available clock speeds rather than jumping between a few [1] and responds faster to load changes. One quirk: schedutil’s IO-wait boosting means it rarely drops below 1000 MHz even when idle — slightly higher power draw, but for a plugged-in server that’s fine. Given my track record of Pis freezing under load, any low-effort tweak that might help is worth trying. See [2] and [3] for deeper dives into these optimizations.

# Check current
cat /sys/devices/system/cpu/cpufreq/policy0/scaling_governor

# Switch to schedutil
echo schedutil | sudo tee /sys/devices/system/cpu/cpu*/cpufreq/scaling_governor

# Make persistent
echo 'GOVERNOR="schedutil"' | sudo tee /etc/default/cpufrequtils
sudo apt install -y cpufrequtils

# Verify
cat /sys/devices/system/cpu/cpufreq/policy0/scaling_governor

tmux configuration#

Since we want our Claude Code sessions to be resilient and re-attachable, tmux is the timeless tool of choice for this.

tmux is the go-to terminal multiplexer for headless servers. Alternatives like Zellij have memory issues that make them unsuitable for resource-constrained devices.

True color support#

Claude Code’s UI uses 24-bit color for syntax highlighting and status indicators. The color signal must pass through every layer: terminal → SSH → tmux → Docker → Claude. We configure tmux here (via terminal-overrides), and Docker later (via TERM and COLORTERM env vars). Skip either and you’ll get washed-out 256-color fallback.

cat > ~/.tmux.conf << 'EOF'
# True 24-bit color support for Claude Code
# default-terminal MUST be tmux-* or screen-*, never xterm-*
# https://github.com/tmux/tmux/wiki/FAQ
set -g default-terminal "tmux-256color"
set -ag terminal-overrides ",xterm-256color:RGB"

# Interaction
set -g mouse on
setw -g mode-keys vi

# Claude outputs are verbose - increase history (default is 2000)
# https://code.claude.com/docs/en/terminal-config
set -g history-limit 50000

# Start numbering at 1 (easier to reach)
# https://builtin.com/articles/tmux-config
set -g base-index 1
setw -g pane-base-index 1

# Faster escape response (default 500ms is sluggish)
# 10-20ms recommended for remote; 0 locally
set -s escape-time 15

# Required for iTerm2 control mode (-CC)
# https://gitlab.com/gnachman/iterm2/-/issues/2585
setw -g aggressive-resize off

# Clipboard integration via OSC 52 (works over SSH + Docker)
# https://github.com/tmux/tmux/wiki/Clipboard
set -g set-clipboard on
set -g allow-passthrough on
set -ag terminal-overrides ",xterm-256color:Ms=\\E]52;c;%p2%s\\7"

# Easy config reload
bind r source-file ~/.tmux.conf \; display "Reloaded!"

# ... pane resize bindings, vim-style navigation
EOF

tmux ergonomics#

tmux can feel awkward compared to a native terminal - scrolling, copy/paste, and mouse interactions work differently because tmux manages its own buffer and input handling. This is a historical design choice that enables session persistence across disconnections.

If you’re on macOS with iTerm2, there’s a better option: tmux control mode (-CC flag). iTerm2 communicates directly with tmux, rendering windows as native tabs with native scrolling, Cmd+F search, and normal copy/paste. We’ll configure this in the Usage section so ssh claude auto-attaches with control mode enabled.


Session architecture#

The goal: Run --dangerously-skip-permissions for autonomous work, but repos might contain secrets (.env files, credentials) we don’t want to leak.

The solution: Docker-sandboxed sessions, each with its own directory under ~/Sessions. The container can’t touch ~/.ssh, ~/.aws, or system configs. Each session gets an isolated workspace (e.g., ~/Sessions/repo-name--session--a1b2c3) so parallel clones of the same repo don’t collide. When you need to review actions before they execute, toggle off --dangerously-skip-permissions within the session.

Why Docker over Claude Code’s “native” sandboxing?#

Claude Code has built-in sandboxing using bubblewrap and seccomp on Linux. Sounds great in theory - but:

  1. Known bugs with deny permissions - Settings are not reliably enforced, allowing access to files explicitly denied.

  2. Sandbox escape without prompts - Bug report shows Claude retrying with dangerouslyDisableSandbox: true and executing outside sandbox with no permission prompt, even when allowUnsandboxedCommands: false is configured.

  3. Philosophical issue - Letting Claude Code be its own security guard is backwards. The agent deciding when to escape its own sandbox is like letting the prisoner hold the keys.

Docker provides real isolation:

  • OS-level boundaries Claude literally cannot escape
  • Mount only what you want accessible
  • Network restrictions at the container level
  • No “escape hatch” mechanism to bypass

Performance impact on Pi 4:


Docker setup#

Install Docker:

curl -fsSL https://get.docker.com | sh
sudo usermod -aG docker $USER

Security note: Piping curl to sh trusts the remote script entirely — if the server is compromised, you run whatever it serves. The Ansible playbook uses Docker’s official apt repository instead. For manual setup, the convenience script is common practice but worth acknowledging the tradeoff.

Reboot now to apply the docker group membership system-wide (required for systemd services):

sudo reboot

After reboot, verify Docker works:

docker --version
docker run hello-world

Create the Claude Code image:

mkdir -p ~/claude-sandbox-image && cd ~/claude-sandbox-image

cat > Dockerfile << 'EOF'
FROM node:22-slim

RUN apt-get update && apt-get install -y \
    git \
    curl \
    sudo \
    && rm -rf /var/lib/apt/lists/*

# Enable corepack for pnpm (available for projects)
RUN corepack enable && corepack prepare pnpm@latest --activate

# Install global packages with npm (happy-coder has issues with pnpm's isolated home)
# happy-coder (happy.engineering) wraps Claude Code for phone access via text/voice
RUN npm install -g @anthropic-ai/claude-code happy-coder

# Playwright + Chromium for browser automation (reading docs, verifying web apps)
RUN npx -y playwright install --with-deps chromium && \
    rm -rf /var/lib/apt/lists/*

# node user already has UID 1000, matching typical host user for bind mounts
RUN echo "node ALL=(ALL) NOPASSWD:ALL" >> /etc/sudoers

USER node

# Create tmp directory on same filesystem as .claude to avoid EXDEV errors
RUN mkdir -p /home/node/.claude/tmp

# Git config for commits (customize these)
RUN git config --global user.name "Claude (Pi)" && \
    git config --global user.email "claude@localhost"

WORKDIR /workspace

# Using happy-coder wrapper; replace with "claude" if not using happy
ENTRYPOINT ["happy", "--dangerously-skip-permissions"]
EOF

docker build -t claude-code .

Build takes ~6 minutes on Pi 4. (The tmp directory workaround avoids EXDEV errors during plugin installs [4].)

Create directories and configure security:

mkdir -p ~/Sessions              # Each session gets its own subdirectory here
mkdir -p ~/.claude-docker     # Credentials for sandboxed Claude

Create ~/.claude-docker/settings.json with deny rules for .env, credentials, and secret files. Create ~/Sessions/CLAUDE.md documenting the sandboxed environment and tmux navigation shortcuts. Both are maintained in the repo: files/configs/settings.json for the deny rules, files/docs/CLAUDE-repos.md for the Sessions context file.

Create a launcher script (~/claude-sandbox.sh) that wraps docker run with the right mounts and environment. This script evolves as we add features — the final version in the repo handles multi-session orchestration, but the core Docker flags stay the same:

Note: We intentionally avoid passing ANTHROPIC_API_KEY. Claude Code prioritizes API keys over subscription auth - if that env var leaks into the container, you’ll get unexpected pay-per-token charges instead of using your subscription.

Test and authenticate the sandbox:

~/claude-sandbox.sh

First run prompts for login (separate credentials from host) and the bypass permissions warning. Accept, then /exit to continue setup.

Long-lived authentication#

By default, Claude Code tokens expire after 6-12 hours (GitHub issue #12447). For a true “forever-running” setup, use a 1-year token:

Generate the token:

claude setup-token

Copy the token and store it in ~/.claude-docker/.oauth_token (chmod 600). The launcher script reads this token and passes it via -e CLAUDE_CODE_OAUTH_TOKEN="$(cat ~/.claude-docker/.oauth_token)".

Note: The Ansible playbook automates this via vault_claude_oauth_token in the encrypted vault.


Autostart with systemd#

Enable lingering so user services start at boot (not just login):

sudo loginctl enable-linger $(whoami)

Create the tmux service:

mkdir -p ~/.config/systemd/user

cat > ~/.config/systemd/user/claude-tmux.service << 'EOF'
[Unit]
Description=Claude Code tmux session
# Rate limit: max 3 restarts in 60 seconds, then give up
StartLimitIntervalSec=60
StartLimitBurst=3

[Service]
Type=forking
# Two-step start: create the tmux session first, then send the script
# via send-keys after a delay. Running the script directly as a tmux
# window command fails because docker run -it can't negotiate the PTY
# before the pane's shell is fully initialized.
ExecStart=/usr/bin/tmux new-session -d -s main -n main
ExecStartPost=/bin/bash -c 'sleep 2 && /usr/bin/tmux send-keys -t main:main "%h/claude-sandbox.sh" Enter'
ExecStop=/usr/bin/tmux kill-session -t main

# Fallback restart — primary recovery is the retry loop in claude-sandbox.sh
Restart=always
RestartSec=5

[Install]
WantedBy=default.target
EOF

systemctl --user daemon-reload
systemctl --user enable claude-tmux
systemctl --user start claude-tmux

The two-step start is worth explaining: docker run -it needs an allocated PTY, but when tmux creates a new session with a window command, the pane’s shell isn’t fully initialized yet. The result is a cryptic “the input device is not a TTY” error. The fix is to create the session empty, wait for the shell to be ready, then send the script as keystrokes. The launcher script itself has its own retry loop with exponential backoff for control plane crashes — systemd’s Restart=always is just the outer safety net.

Verify:

tmux ls

Remote access with Tailscale#

So far we’ve assumed local network access. But what if you want to reach the Pi from a coffee shop, office, or while traveling?

Why Tailscale: Tailscale creates a private mesh VPN using WireGuard. No router configuration, no port forwarding, no dynamic DNS. The free tier covers personal use with up to 100 devices. It just works.

On the Pi:

curl -fsSL https://tailscale.com/install.sh | sh
sudo tailscale up

Security note: Same curl|sh caveat as Docker above. Tailscale also provides an apt repository for those who prefer it.

This prints an auth URL - open it in your browser and approve the device.

Headless alternative: If you can’t access a browser from the Pi, generate an auth key at login.tailscale.com/admin/settings/keys first:

sudo tailscale up --authkey=tskey-auth-xxxxx

Tailscale installs as a systemd service that auto-starts on boot. You only run tailscale up once - authentication persists across reboots.

On your laptop/phone:

Download from tailscale.com/download, install, sign in. Both devices now see each other on a private 100.x.x.x network.

Find your Pi’s Tailscale IP:

tailscale ip -4

Pro tip: Use the Tailscale IP everywhere. Tailscale is smart - when both devices are on the same LAN, traffic stays on the LAN and establishes direct connections with negligible overhead. So instead of maintaining separate configs for local vs remote, just use the Tailscale IP (100.x.x.x) as your hostname. One config that works from home, office, or anywhere.

Alternative - MagicDNS: Enable it in the admin console and SSH by hostname instead of IP:

ssh your-username@rpi-dev

Usage#

SSH config for auto-attach#

Configure your local machine to auto-attach to tmux on connect. Add to ~/.ssh/config:

# Pi Claude server (use Tailscale IP - works from anywhere)
Host claude-shell claude
    HostName YOUR_TAILSCALE_IP
    User YOUR_USERNAME
    IdentityFile ~/.ssh/id_ed25519

    # Connection multiplexing - ~10x faster subsequent connections
    # https://gist.github.com/rtomayko/502aefc63d26e80ab6d7c0db66f2cb69
    ControlMaster auto
    ControlPath ~/.ssh/sockets/%r@%h-%p
    ControlPersist 10m

    # Keepalive for NAT traversal
    ServerAliveInterval 60
    ServerAliveCountMax 3

# Direct shell access (no tmux)
Host claude-shell

# Auto-attach to tmux session (iTerm2 control mode for native scrolling)
Host claude
    RequestTTY yes
    RemoteCommand tmux -CC attach-session -t main

Note: We use attach-session (not new-session -A) so systemd is the sole creator of sessions. If the session doesn’t exist yet, the SSH command will fail - just wait a few seconds for systemd to restart it.

Note: Avoid enabling SSH compression (Compression yes) on fast local networks. The Pi 4’s CPU is already the bottleneck for SSH throughput (~30 MB/s), and compression overhead makes this worse. Over slow or metered connections, compression might help — but Tailscale’s direct LAN mode means you’re usually on a fast path anyway.

Setup: Create the control socket directory on your local machine:

mkdir -p ~/.ssh/sockets && chmod 700 ~/.ssh/sockets

Now ssh claude drops you directly into the tmux session with native iTerm2 tabs and scrolling - no extra commands needed. The -CC flag enables tmux control mode which iTerm2 renders natively.

Use ssh claude-shell when you need direct shell access (e.g., for scp or debugging).

Non-iTerm2 users: Remove the -CC flag for standard tmux behavior, or use a terminal that supports tmux control mode (like iTerm2).

iTerm2 tmux settings (macOS only)#

Skip this section if you’re not on macOS with iTerm2. The SSH config above works with any terminal - you just won’t get native tabs/scrolling.

For the best experience with tmux control mode, configure these settings in Settings → General → tmux:

SettingValueWhy
When attaching, restore windows asTabs in the attaching windowPi tmux tabs appear alongside your local tabs in the same window
Automatically bury the tmux client session✓ EnabledHides the control session tab, keeping your tab bar clean
Mirror tmux paste buffer to local clipboard✓ EnabledSeamless copy/paste between tmux and macOS

With these settings, running ssh claude from any iTerm2 window adds your Pi’s tmux tabs right next to your existing local tabs - with native scrolling, Cmd+F search, and normal copy/paste.

Note: Since iTerm2 3.3, tmux sessions inherit your active profile’s settings by default. If you want a dedicated appearance for Pi sessions (e.g., different colors to distinguish remote work), enable “Use ‘tmux’ profile” and customize the “tmux” profile.

Starting Claude#

The systemd service auto-starts Claude in tmux at boot. When you ssh claude, you’ll attach directly to the running session - no manual startup needed.

If Claude isn’t running (e.g., after a crash or manual stop), start it with:

~/claude-sandbox.sh

First run will prompt for auth - both Claude Code and happy-coder need separate logins. The happy auth may prompt twice; just follow through. Credentials persist in ~/.claude-docker/.

The sandbox can only access its own directory under ~/Sessions - your host system is protected.

Why no separate “safe mode” window? The --dangerously-skip-permissions flag can be toggled off within a running Claude session. When you need to review actions before they execute, just disable it temporarily. One sandboxed session handles both autonomous and interactive workflows.


Upgrade script and helpers#

The upgrade script (~/upgrade-claude.sh) rebuilds the Docker image with --pull --no-cache and prints the new version:

~/upgrade-claude.sh
# ==> docker build --pull --no-cache -t claude-code .
# ==> Version: 1.0.x

Helper scripts for shell access (~/claude-sandbox-shell.sh) and tmux attachment (~/claude-attach.sh) are also deployed. See files/scripts/ and templates/claude-attach.sh.j2 in the repo.


GitHub MCP server#

To search code, manage issues, and interact with GitHub repos directly from Claude, we’ll add the GitHub MCP server.

Two approaches exist:

  1. HTTP transport — Connect to GitHub’s hosted MCP endpoint at api.githubcopilot.com. Simplest setup, no extra containers.
  2. Self-hosted Docker — Run the official GitHub MCP image locally, wrapped with mcp-proxy to expose it as HTTP.

I started with HTTP transport — it’s simpler and avoids another container on a resource-constrained Pi. It worked well initially, but I hit reliability issues: the endpoint would occasionally go unresponsive, leaving Claude unable to search code or manage PRs until I manually restarted. The self-hosted approach turned out more reliable in practice, with the bonus of keeping the token in an environment file rather than the process list.

Setup (common to both):

First, create a fine-grained Personal Access Token:

  1. Set expiration and repository access (recommend “All repositories”)
  2. Under Repository permissions, add:
    • Contents → Read-only (search code, read files)
    • Issues → Read and write (manage issues)
    • Pull requests → Read and write (manage PRs)
    • Metadata → Read-only (auto-required)

Note: Fine-grained PATs don’t support organization team tools. If you need those, use a classic PAT with repo and read:org scopes instead.

Store the token in ~/.claude-docker/.github_token (chmod 600), then add -e GITHUB_PAT="$(cat ~/.claude-docker/.github_token)" and -e CLAUDE_CONFIG_DIR="/home/node/.claude" to the launcher script. The CLAUDE_CONFIG_DIR variable is required for credential persistence — without it, Claude Code won’t recognize the mounted credentials directory.

Option A: HTTP transport (simpler)#

From within a Claude session, press ! to drop to bash:

claude mcp add -t http github https://api.githubcopilot.com/mcp \
  -H "Authorization: Bearer \${GITHUB_PAT}"

Type exit to return to Claude, then /mcp to verify the server connected. This is the quickest path — but you’re dependent on GitHub’s endpoint availability.

This runs the official GitHub MCP Docker image locally, with mcp-proxy bridging stdio to HTTP so Claude can connect. The setup involves:

  1. Install mcp-proxy via pipx (stdio→HTTP bridge)
  2. Create an env file with the PAT and config (~/.config/github-mcp/env)
  3. Write a start script that uses a temp env file to keep the token off the process list
  4. Wrap it in a systemd service with a watchdog timer that checks the HTTP endpoint every 30 seconds

Then from within Claude: claude mcp add -t http github http://host.docker.internal:3101/mcp — no auth header needed since the token lives in the env file and the server is only reachable from localhost.

The Ansible playbook automates all of this — see files/github-mcp/start.sh, templates/github-mcp.service.j2, and templates/github-mcp-watchdog.timer.j2.


Adding more MCP servers#

The same pattern works for any HTTP-based MCP server:

  1. Store the token in ~/.claude-docker/ with chmod 600
  2. Update claude-sandbox.sh to pass the token via -e ENV_VAR="$(cat ~/.claude-docker/.token_file 2>/dev/null)"
  3. From within Claude, press ! and run claude mcp add

Example adding Context7 (up-to-date library docs):

# Store token on host, add -e CONTEXT7_TOKEN to claude-sandbox.sh,
# then from within Claude session (press ! for bash):
claude mcp add -t http context7 https://mcp.context7.com/mcp \
  -H "CONTEXT7_API_KEY: \${CONTEXT7_TOKEN}"

For stdio-based MCP servers, install them in the Docker image and use claude mcp add <name> -- <command>.


Git SSH access#

The container needs SSH keys to clone private repos. Generate a dedicated keypair in ~/.claude-docker/.ssh/, add the public key at github.com/settings/keys, and create an SSH config with StrictHostKeyChecking accept-new.

Then add volume mounts to the launcher script:

-v ~/.claude-docker/.ssh:/home/node/.ssh:ro \
-v ~/.claude-docker/.happy:/home/node/.happy \

The :ro mount makes SSH keys read-only inside the container — Claude can use them for git operations but can’t modify or exfiltrate them. The .happy mount persists happy-coder authentication between container restarts.

Notice you can pass HTTPS-style URLs — the MCP server auto-converts github.com/user/repo and https://github.com/user/repo to git@github.com:user/repo.git before constructing the clone command. This matters because the containers have SSH keys but no HTTPS credentials. The Ansible playbook also auto-detects your GitHub username from the PAT, so Claude can resolve short repo references.


VS Code Remote SSH#

Edit files on the Pi directly from VS Code on your local machine.

I wanted to briefly test this just to see if it was working, but realistically I will probably not be using it. It’s always nice to know that you can gain the convenience of an IDE with your server, and I’ll forever love the devs who made Remote SSH a thing with VS Code.

Prerequisites:

  • Install the “Remote - SSH” extension in VS Code
  • SSH key access to the Pi (password-less login)

SSH config (on your local machine):

If you followed the Usage section, you already have claude and claude-shell hosts configured. VS Code Remote SSH works with both - use claude-shell if you want VS Code’s integrated terminal to open a regular shell instead of auto-attaching to tmux.

Connect: Open the command palette (Cmd+Shift+P on Mac, Ctrl+Shift+P on Windows/Linux) → “Remote-SSH: Connect to Host” → select claude or claude-shell.

Auto-attach to tmux: Create .vscode/tasks.json (with a runOn: folderOpen task that runs tmux attach -t main) and .vscode/settings.json (setting tmux-main as the default terminal profile) in ~/Sessions. This auto-attaches to the tmux session when you open the folder.

Quick open from terminal (on your local machine):

code --remote ssh-remote+claude /home/YOUR_USERNAME/Sessions

Multi-session architecture#

Spawning additional sessions#

The sandboxed Docker Claude already has access to all of ~/Sessions - so for most cases, you can just cd to different projects within the same session. But there are valid reasons to want separate windows:

  1. Parallel work - run autonomous tasks on multiple repos simultaneously
  2. Context isolation - keep Claude’s context fresh per-project

The launcher script evolves from the single-session version to handle three modes:

  • No args — runs the control plane: isolated workspace, no repo access, no SSH keys. Orchestrates via MCP only.
  • --spawn — MCP entry point: creates a tmux window running a project session with the given instructions.
  • --run — internal: called by tmux to start a project session in its own directory under ~/Sessions.

The key difference between modes is what gets mounted:

# Control plane: isolated workspace, no repos or SSH
-v ~/.claude-control-plane/workspace:/control-plane
-w /control-plane

# Project session: own directory under ~/Sessions, SSH keys for git
-v ~/Sessions${workdir}:${workdir}
-v ~/.claude-docker/.ssh:/home/node/.ssh:ro
-w "$workdir"

Each project session gets its own directory (e.g., /repo-name--session--a1b2c3) so parallel clones of the same repo don’t collide. The directory is created on the host before Docker starts, and mounted at the matching path inside the container.

Key design: Host never touches git. SSH keys stay inside Docker containers only. When you pass a git URL, Claude receives it as an instruction and handles cloning itself.

Isolation model:

  • Control plane (no args): Isolated workspace at ~/.claude-control-plane/workspace. No access to repos or SSH keys. Uses MCP tools to spawn project sessions.
  • Project sessions (--spawn): Own isolated directory under ~/Sessions, plus SSH keys for git operations. Claude handles all git commands.

The --add-host flag lets Docker containers reach the host via host.docker.internal for MCP access.

Resource guardrails: The Pi 4 has 4 GB of RAM, and each Claude session uses around 1–1.5 GB. Without limits, a control plane told to “parallelize this across three repos” will happily spawn containers until the OOM killer intervenes. The MCP server checks two things before every spawn: available memory (minimum 512 MB free) and running container count (default cap of 2 project sessions). If either check fails, spawn_session returns an error explaining why — Claude sees it and adjusts its plan instead of crashing the host.

Set up the control plane workspace:

mkdir -p ~/.claude-control-plane/workspace

cat > ~/.claude-control-plane/workspace/CLAUDE.md << 'EOF'
# Control Plane

You are the orchestration layer for Claude sessions on this Raspberry Pi.

## Your Role
- Spawn project sessions via MCP tools
- Manage running sessions (list, kill)
- Help the user decide what to work on

## What You CAN'T Do
- Access code repositories directly (by design)
- Use git or SSH (no keys mounted)

## Available MCP Tools
- `spawn_session` / `list_sessions` / `kill_session` / `end_session`
- `read_session` / `send_to_session` — peek at or interact with sessions
- `restart_self` / `restart_service` / `check_resources` / `restore_sessions`

# ... session lifecycle examples, self-management notes
EOF

The full version with usage examples and session lifecycle documentation is in templates/CLAUDE-control-plane.md.j2.

Usage:

# Run control plane (isolated - no repo access)
~/claude-sandbox.sh

The --spawn and --run modes are called by the MCP server, not directly. The control plane uses the tmux-control-plane MCP server (set up below) to spawn project sessions — with full flexibility to pass any instructions.

Quick navigation - add to ~/.tmux.conf:

# List all Claude windows
bind C-c choose-tree -s -f '#{==:#{session_name},main}'

Now Ctrl-b Ctrl-c shows all your Claude windows for quick switching.

tmux-control-plane MCP server#

Want Claude to spawn new sessions itself? This MCP server runs on the host and exposes tools for session management. Claude inside Docker connects to it via HTTP.

Architecture:

┌─────────────────────┐         ┌─────────────────────┐
│  Docker (Claude)    │         │  Host (Pi)          │
│                     │  HTTP   │                     │
│  Uses spawn_session │────────▶│  MCP server :3100   │
│  tool natively      │         │  runs claude-sandbox│
└─────────────────────┘         └─────────────────────┘

Tools exposed (10 total — control plane sees all, project sessions get a restricted subset):

  • spawn_session({ repo, instruction, session_name }) — Clone a repo, run a task, or start a blank session
  • list_sessions — Show active tmux windows
  • kill_session(target) / end_session(window) — Stop sessions
  • read_session(target) / send_to_session(target, text) — Peek at or interact with running sessions
  • restart_self(window_name) — Restart current session (e.g., after cloning a repo to pick up CLAUDE.md)
  • restart_service(service) — Restart a systemd service (e.g., unresponsive GitHub MCP)
  • check_resources — Memory and container slot check before spawning
  • restore_sessions — Re-spawn registered sessions after reboot

Create the MCP server at ~/tmux-control-plane/ — a Node.js Express app with a single dependency. See templates/server.js.j2 and files/mcp-server/package.json in the repo.

The server (full source) is a ~500-line Express app (plus extracted helpers in lib.js and registry.js) implementing the MCP protocol over JSON-RPC — Bearer token auth, a tool registry, and handlers that shell out to tmux and claude-sandbox.sh. The most interesting handler is spawn_session:

spawn_session: async ({ repo, instruction, session_name, window_name }) => {
    const prompt = buildPrompt(repo, instruction);
    const sessionDir = generateSessionDir(repo, session_name);
    const windowName = generateWindowName(repo, window_name, session_name);

    // Array args with shell:false — no shell interpolation
    const child = spawn(HOME + "/claude-sandbox.sh",
      ["--spawn", sessionDir, prompt, windowName],
      { timeout: 600000, shell: false });
    // ... reports success/failure via MCP response
}

It assembles a natural language prompt from the repo URL and instructions, derives a window name, and kicks off a new Docker container via the launcher script. The remaining nine handlers cover session lifecycle (list_sessions, kill_session, end_session), self-management (restart_self, restart_service), resource checking (check_resources), persistence (restore_sessions), and session interaction (read_session, send_to_session) — all following the same pattern of shelling out to tmux or systemctl and returning MCP-formatted results.

What’s not obvious from the code above is the bootstrap sequence when a repo URL is involved. The actual prompt Claude receives is more like: “Clone github.com/user/repo into current directory with git clone git@github.com:user/repo.git ., then call restart_self to pick up settings, then fix the failing tests.” Three systems coordinate here: the MCP server constructs the prompt, Claude clones the repo and calls restart_self, which uses tmux respawn-window -k to atomically reboot the session — preserving the window name and index. Claude restarts, discovers the repo’s CLAUDE.md and project settings, and picks up the original task with full context. Without the restart step, Claude would be working in a repo it knows nothing about.

A note on input handling: since these prompts are constructed from user input passed through an AI agent, the server uses a two-layer approach. Repo URLs are sanitized — shell metacharacters like ;, $, and backticks are stripped, but / is preserved (a slash alone can’t cause injection without a command separator). Instructions, on the other hand, are not sanitized — they’re passed as array arguments to spawn() with shell: false, so the OS handles them as literal strings, not shell commands. Instructions are capped at 500 characters. The logic is extracted into pure functions with unit tests covering injection attempts, URL edge cases, and memory parsing — unusual for infrastructure code, but worth it when the inputs come from an autonomous agent.

Install dependencies with pnpm install, then create a wrapper script (start.sh) that sources nvm before running the server — nvm isn’t available in systemd’s PATH. Wrap it in a systemd user service (same pattern as claude-tmux.service). See files/systemd/tmux-control-plane.service, files/mcp-server/start.sh, and files/mcp-server/package.json in the repo.

Configure the control plane to connect by creating .mcp.json in the control plane workspace with the MCP server URL (http://host.docker.internal:3100/mcp) and a Bearer token. The token bypasses Claude Code’s OAuth requirement for HTTP MCP servers. Only the control plane needs this project-level MCP — project sessions use user-level MCPs added via claude mcp add.

Important: Replace tmux-local-dev with a real secret in production. The Ansible playbook generates a random token via vault_mcp_bearer_token and uses a separate, restricted token for project sessions — those only get access to self-management tools (restart_self, restart_service, check_resources), not session-spawning tools. This prevents a runaway project session from creating siblings indefinitely.

Verify:

# Check service is running
systemctl --user status tmux-control-plane

# Test endpoint
curl -s http://localhost:3100/health

# View logs (useful for debugging spawn failures)
journalctl --user -u tmux-control-plane -f

Restart the control plane to pick up the new MCP config:

systemctl --user restart claude-tmux

Then ssh claude and run /mcp to verify tmux-control-plane appears with 10 tools.

Usage from within Claude:

“List my tmux sessions” “Spawn a session to clone github.com/user/repo” “Clone github.com/user/repo and fix the failing tests” “Read the output of the portfolio session” “Check if there’s enough memory for another session” “Kill the portfolio window”


Session persistence#

Running on consumer hardware, I wanted to bump resilience a bit. tmux sessions survive disconnections but not reboots, so there are two layers to handle.

tmux layout persistence#

These plugins save and restore tmux’s own state — window layouts, working directories, pane contents:

  • tmux-resurrect - Manual save/restore of session layout, working directories, pane contents
  • tmux-continuum - Auto-saves every 15 minutes, auto-restores on tmux start

Install TPM (tmux plugin manager):

git clone https://github.com/tmux-plugins/tpm ~/.tmux/plugins/tpm

Add plugins to tmux.conf — append to ~/.tmux.conf:

cat >> ~/.tmux.conf << 'EOF'

# Plugin manager
set -g @plugin 'tmux-plugins/tpm'

# Session persistence
set -g @plugin 'tmux-plugins/tmux-resurrect'
set -g @plugin 'tmux-plugins/tmux-continuum'

# Auto-restore on tmux start
set -g @continuum-restore 'on'

# Initialize TPM (keep at bottom)
run '~/.tmux/plugins/tpm/tpm'
EOF

Install plugins — reload config and press Ctrl-b I (capital I):

tmux source ~/.tmux.conf

Usage:

  • Manual save: Ctrl-b Ctrl-s
  • Manual restore: Ctrl-b Ctrl-r
  • Auto-save: Happens every 15 minutes automatically
  • Auto-restore: Happens when tmux starts (if @continuum-restore is on)

Docker session persistence#

tmux-resurrect handles the tmux layer, but Docker containers started with --rm are gone after reboot — the layout restores, but the containers are empty shells.

The MCP server solves this with a simple session registry — a JSON file that tracks which sessions are running:

{
  "version": 1,
  "sessions": {
    "pi-pai": {
      "dir": "/pi-pai--session--a1b2c3",
      "repo": "github.com/user/pi-pai",
      "window_name": "pi-pai",
      "created_at": "2026-02-10T14:30:00.000Z"
    }
  }
}

When a session is spawned, it’s registered. When the MCP server starts after boot, it waits 15 seconds for tmux to initialize, then walks the registry and re-spawns any sessions that aren’t already running — checking available resources before each one and staggering the startups so the Pi doesn’t choke.

The file is written atomically (write to temp, then rename) to handle power loss without corruption. Stale entries get pruned opportunistically — whenever list_sessions is called, the registry is reconciled against live tmux windows.

Restored sessions are started with Claude Code’s --continue flag, which resumes the most recent conversation scoped to the working directory[5]. Since each session has its own directory on the host, the right conversation is picked up. The net result: a power cycle doesn’t lose your work — sessions come back and resume their conversation context, though any in-flight processes from the old container are obviously gone.


Host-level helper agent#

While it’s possible to SSH from my Mac and pipe commands to the Pi, I realized I wanted a permission-tied agent that could help with simple sysadmin tasks - installing plugins, managing services, updating configs. The sandboxed Docker instances can’t do this by design.

Solution: A separate Claude instance running directly on the host (not in Docker), with normal human-in-the-loop permissions.

Tip: If you’re using Claude Code locally to create scripts on the Pi via SSH (ssh claude-shell 'cat > file << EOF...'), you’ll run into escaping nightmares - nested quotes, heredocs, and special characters like ! get mangled across the SSH boundary. Two better options: (1) write the file locally and scp it over, or (2) SSH in and use the helper agent directly to create the files.

The helper’s context file (~/CLAUDE.md, source: files/docs/CLAUDE-helper.md) gives it awareness of the system layout and available operations. The key section is the architecture overview:

~/                          ← YOU ARE HERE (helper, human-in-loop)
├── Sessions/               ← Autonomous agents live here (Docker sandboxed)
├── claude-sandbox.sh       ← Spawns sandboxed sessions
├── upgrade-claude.sh       ← Rebuilds Docker image
├── tmux-control-plane/     ← MCP server for session orchestration
└── .claude-docker/         ← Credentials for sandboxed instances

The full context file (~/CLAUDE.md) includes service management commands, important paths, and safety guardrails — crucially, a reminder to never run --dangerously-skip-permissions on the host. Install Claude Code on the host with npm install -g @anthropic-ai/claude-code and disable telemetry. The Ansible playbook deploys all of this automatically (see files/docs/CLAUDE-helper.md).

Usage:

ssh claude-shell
cd ~
claude

This gives you a trusted agent for host-level tasks while keeping the autonomous agents safely sandboxed.

Plugins and autonomous loops#

The whole point of this setup is autonomy - I want Claude working on tasks while I’m away from my desk. The Ralph Wiggum technique keeps Claude running in a self-reflective loop, re-evaluating its work until it decides it’s done. Without it, Claude stops after each response and waits for input. With it, I can kick off a task and check back hours later.

Third-party plugins require registering the marketplace first. Clone the marketplace repo into ~/.claude-docker/plugins/marketplaces/ and register it in known_marketplaces.json — the installLocation must match the container’s mount point (/home/node/.claude/...). Then inside a Claude session, run /plugin install <name>.

Note: Host Claude (~/.claude/) and Docker Claude (~/.claude-docker/) have separate plugin directories.

Custom slash commands#

Custom commands add project-agnostic shortcuts for common workflows. The claude-instructions package provides 27+ commands for TDD, git, code review, and more.

The installer is interactive, so use the sandbox shell script:

~/claude-sandbox-shell.sh
# Inside the container:
npx @wbern/claude-instructions --scope=user

This installs commands like /commit, /pr, /red, /green, /refactor to ~/.claude/commands/. Run /help in a Claude session to see what’s available.


Login greeting#

Three months from now, I’ll have forgotten all these commands. A simple MOTD appended to ~/.bashrc helps — it shows quick commands on ssh claude-shell but not ssh claude (which attaches directly to tmux). See templates/bashrc_greeting.j2 in the repo.


Things I haven’t looked into yet#

Push notifications when Claude stops#

Getting notified when Claude finishes a task (or needs input) so you can inspect results remotely. Several options exist:

claudecode-pushover-integration - A daemon that hooks into Claude Code events:

  • Notifies on idle prompts, permission requests, stops
  • Rate limiting (max 1 notification per 30 seconds)
  • Priority queuing for critical events

claude-notify - CLI tool for Claude Code hooks:

  • Configurable BUSY_TIME - only notifies after prompt runs X seconds
  • Avoids spam from quick tasks

DIY with hooks - Claude Code has built-in hooks that trigger shell commands. A simple script can POST to Pushover’s API. See this tutorial for a walkthrough.

Haven’t tested any of these yet - the main use case would be knowing when to check back on autonomous tasks running on the Pi.


Where this leaves me#

What started as a weekend experiment has turned into my daily driver. The setup runs unattended for days — sessions persist across reboots, crash recovery handles the occasional Docker hiccup, and resource guardrails keep the Pi from OOMing when Claude gets enthusiastic about parallelism.

The key for me is still the balance: I can let Claude run wild on tasks without needing my laptop open, but I can still take back control whenever I want. Voice or text. From my computer or from my phone. The Happy app makes this surprisingly seamless — I can check in on a running task from the couch, give it a nudge, or spawn a completely new session while walking to get coffee.

The Ansible playbook automates everything in this article, from Docker image builds to OAuth token management. If you’re setting this up yourself, start there — the article documents the why, the playbook handles the how.