Guide · October 29, 2026 · 12 min read
AI agents from your phone: Moshi + pi + herdr over Tailscale
How I set up a host that runs coding agents (pi on a local model and Claude Code) inside herdr, drives them from the iPhone with Moshi and approves actions over push, all inside a tailnet without opening a single port to the internet.
The idea is simple: agents run on a powerful machine that is always on, and I drive them from the phone. If the Wi-Fi drops or I switch networks, the session survives; if an agent needs permission for something, I get a notification and approve it from the lock screen.
Every host name, user, address and project in this guide is an example. There is no need (and no reason) to publish yours.
The pieces
- Moshi: an iOS terminal with native mosh, built for agents. It adds an approvals inbox, a Live Activity and a per-agent chat view.
- moshi-hook: the host-side daemon. It installs each agent's hooks, listens on a local socket and keeps the channel to the app for approvals.
- herdr: a tmux-style terminal workspace manager for agents. A background server owns the panes, so nothing dies when you disconnect, and the sidebar shows whether each agent is working, blocked or done.
- pi: a minimal, extensible agent harness (TypeScript extensions, skills, themes). I run it against a local model served by llama.cpp.
- Tailscale: the private WireGuard network that joins the phone and the host. It is what lets SSH and mosh stay off the internet.
iPhone (Moshi)
│ mosh/SSH over WireGuard (Tailscale)
▼
Linux / WSL2 host ── no ports open to the internet
├─ sshd (keys only) + mosh-server
├─ herdr server → owns the terminals (systemd --user)
│ ├─ pane: pi → local model (llama.cpp on 127.0.0.1)
│ └─ pane: claude → Claude Code
├─ llama-server → OpenAI-compatible API on loopback only
└─ moshi-hook serve → receives agent events and pushes them
to the phone (approvals, inbox)1. Network: Tailscale first
Install Tailscale on the host and the Tailscale app on the iPhone, with the same account. From then on the phone reaches the host by its MagicDNS name, and nothing else on the internet does.
curl -fsSL https://tailscale.com/install.sh | sh
sudo tailscale up # browser login, once
tailscale status # the host and the phone must both show upIf the host is WSL2, Tailscale runs inside the distro. Turn on systemd so services start on their own, and stop the VM from suspending:
# /etc/wsl.conf (inside the distro)
[boot]
systemd=true
# %UserProfile%\.wslconfig (on Windows)
[wsl2]
vmIdleTimeout=-1 # keep the VM awake while agents runLock down access
SSH with keys only and no root. Moshi uses SSH to bootstrap mosh, so this covers both:
# /etc/ssh/sshd_config.d/10-hardening.conf
PasswordAuthentication no
PermitRootLogin noIn the tailnet policy, limit who reaches the host and on which ports: 22/TCP for SSH and 60000-61000/UDP for mosh. An example with a tag for agent machines:
{
"tagOwners": { "tag:agents": ["autogroup:admin"] },
"acls": [
{
"action": "accept",
"src": ["autogroup:member"],
"dst": ["tag:agents:22", "tag:agents:60000-61000"]
}
]
}Tag the host when you bring it up, with sudo tailscale up --advertise-tags=tag:agents.
If you also want a local firewall, only allow traffic in on the tailscale0 interface:
sudo ufw default deny incoming
sudo ufw allow in on tailscale0 to any port 22 proto tcp
sudo ufw allow in on tailscale0 to any port 60000:61000 proto udp
sudo ufw enableYou need neither Tailscale Funnel nor port forwarding on the router. All traffic stays inside the tailnet.
2. herdr: terminals that do not die
curl -fsSL https://herdr.dev/install.sh | sh
herdr --versionI run it as a user service so the server stays alive with nobody attached:
# ~/.config/systemd/user/herdr.service
[Unit]
Description=herdr server
After=network-online.target
[Service]
ExecStart=%h/.local/bin/herdr server
Restart=on-failure
RestartSec=5
WorkingDirectory=%h
[Install]
WantedBy=default.targetsystemctl --user daemon-reload
systemctl --user enable --now herdr
sudo loginctl enable-linger "$USER" # keeps running with no login sessionMy base config: ctrl+a as prefix (easier on a phone keyboard than the default) and shortcuts to jump between agents:
# ~/.config/herdr/config.toml
[keys]
prefix = "ctrl+a"
previous_agent = "prefix+alt+k"
next_agent = "prefix+alt+j"
focus_agent = "prefix+ctrl+1..9"
[ui]
status_indicators = "dots"I split contexts into named sessions; each one is an isolated namespace with its own workspaces, so work and personal never share a sidebar:
herdr session attach work # one session per context
herdr session attach personal
herdr agent list # which agents it sees and their stateherdr integrations are optional: depending on the agent they add lifecycle state, session restore or both.
herdr integration install pi
herdr integration install claude
herdr integration status3. pi on a local model
curl -fsSL https://pi.dev/install.sh | sh
pi --versionllama.cpp exposes an OpenAI-compatible API. I keep it bound to 127.0.0.1, so the model is not reachable even from the tailnet:
# ~/.config/systemd/user/llama-router.service
[Service]
ExecStart=%h/.local/bin/llama-server --models-preset %h/.config/llama.cpp/presets.ini \
--host 127.0.0.1 --port 8080
Restart=on-failure
[Install]
WantedBy=default.targetThen I declare the provider in pi. The apiKey is a filler value; the local server does not check it:
// ~/.pi/agent/models.json
{
"providers": {
"local": {
"baseUrl": "http://127.0.0.1:8080/v1",
"api": "openai-completions",
"apiKey": "local",
"models": [
{ "id": "<your-model>", "name": "Local model", "contextWindow": 65536 }
]
}
}
}In ~/.pi/agent/settings.json I make that provider the default (defaultProvider and defaultModel). Without a GPU, the same setup works by pointing pi at any cloud provider.
4. moshi-hook: notifications and approvals
This is the bridge between the agents and the phone. First pair the host with the app and install the hooks:
# On the phone: Moshi → Settings → Hooks → copy the pairing token
moshi-hook pair --token <pairing-token>
moshi-hook host setup # Easy Pair: authorizes Moshi's SSH key
moshi-hook install # hooks for Claude Code and other detected CLIs
moshi-hook install --target pi # extension for piFor Claude Code, install adds hooks to ~/.claude/settings.json (permission requests, questions, end of turn, session start and end). For pi it writes an extension under ~/.pi/agent/extensions/. When the daemon is not running, the hooks fail silently and the agent carries on.
# ~/.config/systemd/user/moshi-hook.service
[Service]
ExecStart=%h/.local/bin/moshi-hook serve
Restart=on-failure
RestartSec=5
[Install]
WantedBy=default.targetsystemctl --user enable --now moshi-hook
moshi-hook doctor # what works on this host and how to fix the rest
moshi-hook logs -fmoshi-hook doctor is the best first step when something does not arrive: it flags outdated hooks (for example after updating pi) and prints the exact fix. Restart agents that were already running: they read their hooks at startup.
5. Connect from the phone
- With Tailscale on in the iPhone, add the host in Moshi by its MagicDNS name and pick mosh.
- Use the key that
moshi-hook host setupauthorized (or your own). No passwords. - On connect, Moshi's session picker lists the herdr sessions; pick one and you are in.
- Allow Moshi notifications in iOS and turn on the Live Activity in Moshi → Settings → Hooks.
# from Moshi, connected over mosh to the tailnet host
herdr session attach work
# prefix+alt+j / prefix+alt+k → jump between agents
# prefix+q → detach, everything keeps runningWhen an agent asks for permission (run a command, leave plan mode, answer a question), it shows up in the Moshi inbox and you approve or reject it there. The diff view shows the changes in the agent's repository without opening anything else.
What not to publish
If you share your setup (a post, a dotfiles repo, a screenshot), check this first:
- Your tailnet name (
*.ts.net), the 100.x addresses and machine names. - Pairing tokens,
auth.json, API keys and SSH keys: never in a repo, not even a private one. - Client or project names in herdr presets, sessions, workspaces or themes. Use generic names.
- Absolute paths with your user (
/home/<user>/...); use~or%hin systemd units. - Screenshots of the herdr sidebar or the Moshi inbox: they show terminal titles, branches and repo names.
Common problems
- No notifications: run
moshi-hook doctor, check iOS notification permissions and restart the agent. - The agent does not show up in Moshi: it has to run inside herdr or tmux; a bare shell cannot be followed.
- mosh fails but SSH works: the 60000-61000 UDP range is not open in the tailnet policy or the firewall.
- On WSL2 everything dies after a while: check
vmIdleTimeoutand that systemd is on (systemctl --user status herdr).