Running Claude Code Remote Control 24/7 on Linux: systemd, a Fake TTY, and a Watchdog
Claude Code's Remote Control mode lets you drive a Claude Code session on your own machine from a phone or a browser (claude.ai/code). It's most useful on a machine that's always on. Mine is an ASUS Ascent GX10 (NVIDIA GB10) that sits in a corner running AI projects. So the obvious goal was: keep a Remote Control session running 24/7, survive reboots, and never need a terminal left open.
That turned out to need three fixes and one watchdog. Here is the setup that has been running reliably, along with the failures that shaped it. (Tested with Claude Code 2.1.x on Ubuntu; flags and messages may change in later versions.)
Why a systemd user service
My first version was a shell loop running in a terminal. It worked until the terminal closed or the machine rebooted. A systemd user service fixes both: it starts at boot, restarts on crashes, logs to the journal, and needs no root because it runs as your own user.
One prerequisite: user services normally stop when you log out. Lingering keeps them alive with nobody logged in:
mkdir -p ~/claude-remote-workspace
loginctl enable-linger "$USER" # keep user services alive with nobody logged in
systemctl --user daemon-reload
systemctl --user enable --now claude-remote.service
# day-to-day
systemctl --user status claude-remote.service
journalctl --user -u claude-remote.service -f
systemctl --user restart claude-remote.service
Problem 1: no TTY, so Claude exits immediately
The obvious ExecStart=claude --remote-control doesn't work. A systemd service has no terminal on stdin, and when Claude Code sees that stdin isn't a TTY, it switches to its non-interactive (print) mode, which expects a prompt, errors out, and exits. Restart=always then just restarts it in a loop.
The fix is a classic Unix trick: run it under script, which allocates a pseudo-terminal and runs the command inside it. -q keeps it quiet, -e passes through the exit code, -c gives the command, and /dev/null discards the typescript file it would otherwise write. Claude now believes it's in a normal terminal and stays in interactive Remote Control mode.
Problem 2: started before the network, then stuck offline
After a reboot (a kernel update, in my case) the session showed "Session creation failed" and stayed offline until I restarted it by hand the next day. The service had started before the network was up. The key detail: the process didn't exit. It sat there in a failed state, so Restart=always never fired.
The textbook fix, After=network-online.target, does nothing in a user unit. That target belongs to the system manager, and your user manager can't see it, so the ordering is silently ignored. Instead, I added an ExecStartPre loop that waits until the API is actually reachable, for up to 5 minutes, before starting Claude:
# ~/.config/systemd/user/claude-remote.service
[Unit]
Description=Claude Code Remote Control
StartLimitIntervalSec=0
[Service]
Type=simple
# Wait up to 5 minutes (wall clock) for the network. (After=network-online.target does nothing in a user unit.)
# $$ and %% are how a unit file passes a literal $ and % to the shell.
ExecStartPre=/bin/sh -c 'end=$$(( $$(date +%%s) + 300 )); while [ "$$(date +%%s)" -lt "$$end" ]; do curl -s -o /dev/null --max-time 5 https://api.anthropic.com && exit 0; sleep 5; done; exit 0'
# script(1) gives claude a pseudo-terminal; without one it drops into non-interactive mode and exits.
ExecStart=/usr/bin/script -qec "%h/.local/bin/claude --remote-control --name my-server" /dev/null
WorkingDirectory=%h/claude-remote-workspace
Environment=PATH=%h/.local/bin:/usr/local/bin:/usr/bin:/bin
Restart=always
RestartSec=10
# ExecStartPre counts toward the start timeout (default 90 s), so allow for the 5-minute wait.
TimeoutStartSec=360
[Install]
WantedBy=default.target
%h is systemd's specifier for your home directory, so the unit has no hard-coded username. The pre-start loop exits 0 even if the network never comes up, so Claude still gets a chance to start and Restart=always keeps trying. Update (Oct 28): the loop is now bounded by time rather than by attempt count, and TimeoutStartSec=360 is required, because systemd kills a pre-start step after 90 seconds by default. Details: systemd "start-pre operation timed out".
Problem 3: disconnected mid-session, never recovered
Occasionally the session dropped with "Remote Control disconnected" and didn't come back, again without the process exiting. Restarting fixes it, so I wanted something to do that automatically, without spending tokens (no asking Claude whether it's alive). The answer is a small shell script on a 3-minute systemd timer that reads the service's own journal output:
#!/bin/sh
# Restart claude-remote.service if Remote Control is stuck offline.
# Runs every 3 minutes from a systemd timer. It never calls Claude, so it costs no tokens.
UNIT=claude-remote.service
FLAG="${XDG_RUNTIME_DIR:-/tmp}/claude-remote-watchdog.flag"
IDLE_SEC=300 # the disconnect message must be the LAST output, with nothing new for 5 minutes
systemctl --user is-active -q "$UNIT" || { rm -f "$FLAG"; exit 0; } # not running: Restart=always handles it
INV=$(systemctl --user show -p InvocationID --value "$UNIT") # only look at the current run
LAST_TS=$(journalctl --user _SYSTEMD_INVOCATION_ID="$INV" -n 1 --no-pager -o short-unix 2>/dev/null | awk 'NR==1{print int($1)}')
[ -n "$LAST_TS" ] || { rm -f "$FLAG"; exit 0; }
AGE=$(( $(date +%s) - LAST_TS ))
if [ "$AGE" -ge "$IDLE_SEC" ] && \
journalctl --user _SYSTEMD_INVOCATION_ID="$INV" -n 1 --no-pager -o cat --all 2>/dev/null \
| sed 's/\x1b\[[0-9;?]*[a-zA-Z]//g' | tr -d ' \r\n' | tail -c 3000 \
| grep -aqE 'RemoteControl(disconnected|failed)'; then
# restarting is pointless while the network is down
curl -s -o /dev/null --max-time 10 https://api.anthropic.com || exit 0
if [ -f "$FLAG" ] && [ "$(cat "$FLAG")" = "$INV" ]; then
echo "watchdog: still offline on two consecutive checks, restarting $UNIT"
rm -f "$FLAG"
systemctl --user restart "$UNIT"
else
echo "$INV" > "$FLAG"
echo "watchdog: offline (first check), will restart if still offline next time"
fi
else
rm -f "$FLAG"
fi
# ~/.config/systemd/user/claude-remote-watchdog.service
[Unit]
Description=Restart claude-remote if Remote Control is stuck offline
[Service]
Type=oneshot
ExecStart=%h/.local/bin/claude-remote-watchdog.sh
# ~/.config/systemd/user/claude-remote-watchdog.timer
[Unit]
Description=Run the claude-remote watchdog every 3 minutes
[Timer]
OnBootSec=5min
OnUnitActiveSec=3min
[Install]
WantedBy=timers.target
Make the script executable, then systemctl --user enable --now claude-remote-watchdog.timer. To see what it decided and when: journalctl --user -u claude-remote-watchdog.service.
The watchdog bug that killed a live session
The first version of the watchdog scanned the whole output history of the service for the disconnect message. That looks reasonable, until a brief disconnect happens and the session recovers on its own. The old message is still in the history, so every check said "offline", and two checks later the watchdog restarted a session that was in the middle of a conversation.
The fixed version is deliberately conservative. It only restarts when all of these hold:
- Only the current run counts: it filters the journal by the service's
InvocationID, so messages from before the last restart are ignored. - The disconnect message is the last thing printed: when a session recovers, the interface redraws, so the last output is no longer the error.
- Nothing new for 5 minutes: a live session keeps producing output, and a stuck one doesn't.
- Two consecutive checks: a flag file keyed to the invocation ID means one bad reading never triggers a restart.
- The network is up: if the API isn't reachable, a restart can't help, so it waits.
The output is a terminal UI full of escape codes and line wrapping, so the script strips ANSI sequences and all whitespace before matching. That's why the pattern is RemoteControl(disconnected|failed) with no spaces.
One flag to avoid: --continue
It's tempting to add --continue so a restart resumes the previous conversation. In a fresh working directory with no real session history, it crashed on startup ("No deferred tool marker found") because of leftover metadata, which put the service into a restart loop. Start without it; you can always resume a conversation from the client side.
Summary
- Run it as a systemd user service, with
loginctl enable-linger. - Wrap Claude in
script -qec "…" /dev/nullso it gets a TTY. - In user units, don't rely on
network-online.target. Wait for the network yourself inExecStartPre. Restart=alwaysonly helps when the process exits. For "alive but stuck", add a watchdog that reads the journal, checks only the latest output, and requires two consecutive bad readings.
Since these fixes, the machine has rebooted several times and the session came back online without any help. The one exception was a reboot with a flaky network, which exposed the start-timeout problem described in the update above. That's what a watchdog should look like: silent until it's actually needed.
Comments
Post a Comment