Claude Code from Your Phone: A From-Scratch Setup Guide
The whole chain from an iPhone to Claude Code on an Apple Silicon Mac — Termius, Tailscale, hardened OpenSSH, mosh, tmux — copy-paste, with a check and a fix for every step.
This guide builds, from zero, the chain that lets you use Claude Code on your Mac from an iPhone: Termius → Tailscale → OpenSSH → mosh → tmux → Claude Code. Why I set it up this way, and the traps I fell into, are in Claude Code in My Pocket; this is just the recipe. Every step has what you are doing, the commands, how to check it and what goes wrong. You need no repo; it is all copy-paste.
0. Before you start
- An Apple Silicon Mac with admin rights. Your shell should be zsh, the macOS
default. On an Intel Mac, Homebrew lives in
/usr/localinstead of/opt/homebrew; adjust the paths below. - Homebrew, the macOS package manager.
- Claude Code installed and logged in on the Mac:
claudeshould already work in a terminal. - An iPhone, a Tailscale account and Termius from the App Store.
1. Packages
tmux keeps the session alive on the Mac, mosh keeps the connection from dropping, and Tailscale puts the phone and the Mac on the same private network.
brew install tmux mosh
brew install --cask tailscale-app
Check: tmux -V and mosh --version each print a version.
2. Tailscale
Tailscale is a VPN that connects your devices as if they were on the same local network, wherever they are; that private network is called a tailnet. No ports need opening on the router.
- Mac: open the Tailscale app, allow the system extension in System Settings → Privacy & Security, and log in.
- iPhone: install Tailscale, log in with the same account, and allow "Add VPN Configuration".
The app's command-line tool is not on your PATH, so call it by its full path:
/Applications/Tailscale.app/Contents/MacOS/Tailscale status
/Applications/Tailscale.app/Contents/MacOS/Tailscale ping <phone>
If you like, add a shortcut to ~/.zshrc:
alias tailscale=/Applications/Tailscale.app/Contents/MacOS/Tailscale.
The address you will give Termius is the Mac's full MagicDNS name. MagicDNS is
the name Tailscale gives every device, in the form <mac>.<tailnet>.ts.net:
/Applications/Tailscale.app/Contents/MacOS/Tailscale status --json \
| python3 -c 'import json,sys; print(json.load(sys.stdin)["Self"]["DNSName"].rstrip("."))'
Check: status lists both devices and ping answers. via <ip>:<port> in
the answer means a direct connection, via DERP(...) means it goes through
Tailscale's relay servers (DERP); both work.
If it goes wrong: tailscale: command not found → use the full path. If
ping gets no answer, check that both devices are logged in with the same account
and that the VPN is on on the iPhone.
3. PATH for SSH sessions
The sneakiest step in the guide. mosh starts mosh-server in a non-interactive
SSH session; of zsh's startup files that session only reads ~/.zshenv, and
Homebrew's /opt/homebrew/bin is not on the PATH there. The result: Termius
says mosh-server not found.
cat >> ~/.zshenv <<'EOF'
typeset -U path
path=(/opt/homebrew/bin /opt/homebrew/sbin $HOME/.local/bin $path)
export LANG="${LANG:-en_US.UTF-8}"
EOF
The LANG line is required too: mosh-server refuses to run without a UTF-8
locale. The 'EOF' is quoted, so $HOME and $path are written to the file as
they are and resolved every time a session starts. ~/.local/bin is for the
script in the next step.
Check: does a clean, non-interactive shell find mosh-server?
env -i HOME="$HOME" /bin/zsh -c 'command -v mosh-server'
# /opt/homebrew/bin/mosh-server
If it goes wrong: empty output means the lines went into a file other than
~/.zshenv (~/.zshrc is not read by non-interactive sessions).
4. One command to attach: ta
tmux is a terminal session that lives on the Mac independently of the
connection. ta attaches to the session called main, or creates it if it does
not exist (that is what -A does), so every connection from the phone lands on
the same screen.
mkdir -p ~/.local/bin
cat > ~/.local/bin/ta <<'EOF'
#!/bin/sh
exec tmux new-session -A -s main
EOF
chmod +x ~/.local/bin/ta
Check: open a new terminal and type ta: tmux's green status bar appears at
the bottom. Detach with Ctrl-b then d; the session keeps
living in the background.
If it goes wrong: ta: command not found → ~/.local/bin is not on the
PATH; see step 3.
5. tmux settings for a phone
cat >> ~/.tmux.conf <<'EOF'
set -g mouse on
setw -g aggressive-resize on
set -g history-limit 50000
set -s escape-time 10
set -g default-terminal "tmux-256color"
set -g set-clipboard on
EOF
mouse on: scrolling with your finger in Termius. The mouse is off by default in tmux.aggressive-resize on: when the phone and the Mac are attached at the same time, the window fits each of them separately.history-limit 50000: how many lines you can scroll back.escape-time 10: Esc goes through without a delay.default-terminal "tmux-256color": correct colours; this terminal definition ships with current macOS.set-clipboard on: text copied in tmux goes to the terminal's clipboard.
Check: if this command prints nothing, the config file has no errors:
tmux -L check -f /dev/null new -d \; source-file ~/.tmux.conf \; kill-server
If you already have a tmux session running, apply the settings with
tmux source-file ~/.tmux.conf.
If it goes wrong: invalid option: … → there is a typo on that line.
6. SSH key
A key pair instead of a password: the private key stays on the phone, the public key goes to the Mac, and the Mac only lets in whoever holds that private key.
In Termius go to Keychain → + → Generate Key, type ED25519, and give it a name. Open the key and copy the public key (Universal Clipboard brings it straight to the Mac). On the Mac:
mkdir -p ~/.ssh && chmod 700 ~/.ssh
echo 'ssh-ed25519 AAAA…the-whole-key… iphone' >> ~/.ssh/authorized_keys
chmod 600 ~/.ssh/authorized_keys
Paste the key inside the single quotes as literal text, and end it with a
device name like iphone; if the phone gets lost, that tells you which line to
delete.
Check:
ssh-keygen -l -f ~/.ssh/authorized_keys
# 256 SHA256:… iphone (ED25519)
If it goes wrong: is not a public key file → look with
cat ~/.ssh/authorized_keys and fix the broken line with nano. sshd ignores
a ~/.ssh or authorized_keys with loose permissions, so do not skip the two
chmods.
7. Harden sshd
On macOS the SSH server, OpenSSH's sshd service, is called Remote Login in
System Settings. We lock it down before turning it on: keys only, only your
user, only from the tailnet.
U=$(whoami)
sudo tee /etc/ssh/sshd_config.d/010-remote.conf >/dev/null <<EOF
PubkeyAuthentication yes
PasswordAuthentication no
KbdInteractiveAuthentication no
AuthenticationMethods publickey
PermitEmptyPasswords no
PermitRootLogin no
AllowUsers $U@100.64.0.0/10 $U@fd7a:115c:a1e0::/48 $U@127.0.0.1 $U@::1
MaxAuthTries 3
LoginGraceTime 30
ClientAliveInterval 30
ClientAliveCountMax 4
X11Forwarding no
AllowAgentForwarding no
AllowTcpForwarding local
EOF
sudo chmod 644 /etc/ssh/sshd_config.d/010-remote.conf
This time EOF is unquoted, so $U turns into your user name as the file is
written. If you see a literal $U in the file, the quoting slipped.
- Why
010-:/etc/ssh/sshd_configreads this folder in alphabetical order, and for most options the first value seen wins. macOS's own file is100-macos.conf;010-comes before it. - Why keyboard-interactive is off too: macOS ships with
UsePAM yes; PAM is the system's authentication layer, and it can still ask for the password along that route. - Why
AllowUsers: on macOS sshd listens on every network interface, andListenAddressdoes not help. So the source restriction goes here:100.64.0.0/10is Tailscale's IPv4 range,fd7a:115c:a1e0::/48its IPv6 range, and the rest is the Mac itself. - The others: a probe every 30 seconds, closed after four unanswered ones
(a sleeping phone's dead connections are gone in ~2 minutes); agent forwarding
off, and only local
-Lforwarding for TCP.
A Mac that has never had Remote Login on has no host keys, the keys that prove
the server's identity, and the test says no hostkeys available. Generate the
missing ones (existing ones are left alone), then test:
sudo ssh-keygen -A
sudo /usr/sbin/sshd -t
Check: if sshd -t prints nothing, the syntax is right. Look at the
effective values:
sudo /usr/sbin/sshd -T | grep -iE '^(passwordauthentication|kbdinteractiveauthentication|permitrootlogin|authenticationmethods|allowusers)'
# permitrootlogin no
# passwordauthentication no
# kbdinteractiveauthentication no
# allowusers <user>@100.64.0.0/10
# allowusers <user>@fd7a:115c:a1e0::/48
# allowusers <user>@127.0.0.1
# allowusers <user>@::1
# authenticationmethods publickey
If it goes wrong: Bad configuration option → a typo in the file. If the
values come out different, check that grep Include /etc/ssh/sshd_config still
finds the line and that the file name starts with 010-.
8. Turn on Remote Login, close the other doors
sudo systemsetup -setremotelogin on wants the terminal to have Full Disk
Access; rather than opening the whole disk to your terminal, use the GUI:
- System Settings → General → Sharing → Remote Login on.
- The (i) next to it: Allow full disk access for remote users off; Allow access for → Only these users → just your user.
- On the same screen, turn off Remote Management, Remote Application Scripting and Screen Sharing if they are on. They listen on every network interface, accept your macOS login password, and the hardening in step 7 does not cover them.
sshd restarts for every connection, so the settings apply immediately.
Check: of the remote access ports, only 22 should be open:
netstat -an -p tcp | grep LISTEN | grep -E '\.(22|5900|3283|3031) '
# tcp4 0 0 *.22 *.* LISTEN
# tcp6 0 0 *.22 *.* LISTEN
*.22 means listening on every interface; that is expected, AllowUsers does
the filtering. Also make sure passwords are really off:
ssh -o BatchMode=yes -o PubkeyAuthentication=no \
-o PreferredAuthentications=password,keyboard-interactive \
-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \
"$(whoami)@127.0.0.1" true
# <user>@127.0.0.1: Permission denied (publickey).
If it goes wrong: if password or keyboard-interactive also shows up in
the parentheses, the drop-in is not being read; go back to the check in step 7.
9. Termius
A new host in Termius:
- IP or Hostname: the full MagicDNS name from step 2. With MagicDNS off, the
Mac's
100.x.y.zaddress (ip -4in the Tailscale CLI). - Username: the output of
whoamion the Mac. - SSH ID, Key, Certificate, FIDO2: the key you generated in step 6. Password empty.
- Use Mosh: on. SSH Agent Forwarding: off.
mosh uses SSH only to log in and to start mosh-server; the session itself
flows over UDP, so switching from Wi-Fi to 5G or the phone going to sleep does
not kill it.
Then Snippets → +: name ta, command ta. In the host settings pick ta
as the Startup Snippet; every connection lands straight in the main
session.
On the first connection Termius shows the Mac's host key fingerprint and asks whether you trust it. Do not accept blindly; compare it with the value on the Mac:
ssh-keygen -l -f /etc/ssh/ssh_host_ed25519_key.pub
Check: connect: if you see tmux's green status bar, the whole chain works.
If it goes wrong: see the troubleshooting table below.
10. Claude Code
While connected from the phone, type claude. Most of the time it just opens.
In an SSH session the login keychain, the macOS password vault, can look locked, and Claude Code may ask you to log in again. There are two ways out. Unlock it in each session (it asks for your Mac password):
security unlock-keychain ~/Library/Keychains/login.keychain-db
Or create a long-lived token and put it in a file that only SSH sessions read:
claude setup-token
mkdir -p ~/.config/remote && chmod 700 ~/.config/remote
printf 'export CLAUDE_CODE_OAUTH_TOKEN=%s\n' '<token>' > ~/.config/remote/env
chmod 600 ~/.config/remote/env
echo '[[ -n "$SSH_CONNECTION" && -r ~/.config/remote/env ]] && source ~/.config/remote/env' >> ~/.zshenv
Replace <token> with the value setup-token gives you. It is a secret: do not
share the file and never commit it to any repo.
Check: in a new connection, echo ${CLAUDE_CODE_OAUTH_TOKEN:+set} → set,
and claude opens without asking you to log in.
11. Optional: no sleep on the charger
Nothing can reach a sleeping Mac. caffeinate is the macOS command that
prevents sleep; with -s it only applies on the charger. A LaunchAgent, a
background job launchd starts when you log in, keeps it running:
mkdir -p ~/Library/LaunchAgents
cat > ~/Library/LaunchAgents/local.keepawake.plist <<'EOF'
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>local.keepawake</string>
<key>ProgramArguments</key>
<array>
<string>/usr/bin/caffeinate</string>
<string>-s</string>
</array>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<true/>
</dict>
</plist>
EOF
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/local.keepawake.plist
Check: while on the charger:
launchctl print gui/$(id -u)/local.keepawake | grep 'state ='
# state = running
pmset -g assertions | grep PreventSystemSleep
# PreventSystemSleep 1
If it goes wrong: if it says state = not running, start it with
launchctl kickstart gui/$(id -u)/local.keepawake. On battery and with the lid
closed the Mac still sleeps; the only way to keep working with the lid closed is
clamshell mode with an external display.
12. Final test
- Connect from the phone and start
claude. - Lock the phone, wait more than two minutes, come back: same screen.
- Close the connection in Termius completely and reconnect: the same, still-running session.
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
| mosh-server not found | The non-interactive SSH session does not see Homebrew | Step 3 |
| needs a UTF-8 native locale | LANG is not set | The LANG line in step 3 |
| Permission denied (publickey) | Key not in authorized_keys, loose permissions, wrong user name, or the connection is not coming over the tailnet | Step 6; Username = whoami; is Hostname the MagicDNS name, is Tailscale on on the iPhone |
| Timeout | Tailscale is off on the iPhone or the Mac is asleep | Turn Tailscale on; step 11 |
| mosh connects but no screen appears | The macOS firewall blocks mosh UDP | Allow mosh-server with socketfilterfw (below) |
| tailscale: command not found | The app CLI is not on the PATH | Full path or alias, step 2 |
| no hostkeys available | Host keys were never generated | sudo ssh-keygen -A |
| Claude asks to log in | The keychain is locked in the SSH session | Step 10 |
| ta: command not found | ~/.local/bin is not on the PATH | Step 3 |
If the firewall is on, to allow mosh-server:
sudo /usr/libexec/ApplicationFirewall/socketfilterfw --add "$(realpath /opt/homebrew/bin/mosh-server)"
sudo /usr/libexec/ApplicationFirewall/socketfilterfw --unblockapp "$(realpath /opt/homebrew/bin/mosh-server)"
Undo
sudo rm /etc/ssh/sshd_config.d/010-remote.conf
sed -i '' '/ iphone$/d' ~/.ssh/authorized_keys
launchctl bootout gui/$(id -u)/local.keepawake
rm ~/Library/LaunchAgents/local.keepawake.plist ~/.local/bin/ta
rm -r ~/.config/remote
Then turn Remote Login off in System Settings → General → Sharing, delete the
lines you added to ~/.zshenv and ~/.tmux.conf by hand, and remove the phone
in the Tailscale admin console.
Security model
- Network: no open port on the router; the way to the Mac goes through the tailnet.
- Who, from where: only your user, only from the tailnet and localhost
(
AllowUsers). - How: keys only; password, keyboard-interactive (PAM) and root logins are off.
- How far: agent forwarding is off, TCP forwarding is
-Lonly. - No other doors: Remote Management, Remote Application Scripting and Screen Sharing are off; the sshd hardening does not protect them.
- If the phone is lost: delete that device's line from
authorized_keysand remove the device in the Tailscale admin console.
The story behind these decisions, and how I learned each one, is in Claude Code in My Pocket.