← Back

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.

MoshipiherdrTailscalemoshAI agents

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

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)
How it fits together

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 up

If 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 run

Lock 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 no

In 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"]
    }
  ]
}
Tailnet policy (example)

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 enable

You 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 --version

I 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.target
systemctl --user daemon-reload
systemctl --user enable --now herdr
sudo loginctl enable-linger "$USER"   # keeps running with no login session

My 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 state

herdr 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 status

3. pi on a local model

curl -fsSL https://pi.dev/install.sh | sh
pi --version

llama.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.target

Then 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 pi

For 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.target
systemctl --user enable --now moshi-hook
moshi-hook doctor     # what works on this host and how to fix the rest
moshi-hook logs -f

moshi-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

  1. With Tailscale on in the iPhone, add the host in Moshi by its MagicDNS name and pick mosh.
  2. Use the key that moshi-hook host setup authorized (or your own). No passwords.
  3. On connect, Moshi's session picker lists the herdr sessions; pick one and you are in.
  4. 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 running
Day to day

When 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:

Common problems