Part 3 of My Agentic Setup. Start from part 1
I have two laptops, a work laptop and a home laptop. Both run herdr, and both have agents working in it. I sit in front of one of them. The agents on the other one keep going, and sometimes they stop and wait for me: a question, an approval, a "done, what next?".
I wanted three things:
- See and answer the agents on both laptops from my phone.
- Get a shell on the home laptop from the work laptop.
- The work laptop holds work credentials, so it accepts no incoming connections. Not from the phone, not from the home laptop, not from anywhere.
The idea: each laptop runs its own relay behind its own permanent address, both relays point the phone at the same app, so one app shows the agents from both laptops, and nothing on either laptop listens on the internet.
The relay was the easy part. The domain, the app's address and the closed lid were not.
What I use
| From | To | How | When |
|---|---|---|---|
| Phone | both laptops | herdr-mobile-relay over a Cloudflare tunnel | every day |
| Work laptop | home laptop | SSH over Tailscale, then hr-home
|
every day |
| Work laptop | home laptop | SSH over Cloudflare Access | the setup with no VPN on the work laptop |
The phone app is herdr-mobile-relay, a herdr plugin by 0cv. It has its own docs and a setup wizard, so I won't repeat them here. This post is about my setup around it, and what broke.
What broke
I couldn't use a domain I already had
I wanted a permanent hostname per laptop, so I used the relay's stable tunnel option. That runs on a Cloudflare tunnel, and a Cloudflare tunnel needs the domain's DNS hosted on Cloudflare.
The catch: below the Enterprise plan, Cloudflare can't take over just one subdomain. It takes the whole zone. My existing domain had its DNS somewhere else, with my website and email forwarding on it. Moving all of that just to get one hostname for a relay didn't make sense.
So I bought a new domain on Cloudflare Registrar. It starts on Cloudflare DNS, so there is nothing to move, and the wizard creates the relay records by itself. Nothing else lives on that domain. In this post it's example.dev.
Before running the wizard, check that the zone is live. It can take up to an hour after the purchase:
dig NS example.dev +short # two *.ns.cloudflare.com names
The second laptop made a second app
The wizard asks two questions that look the same and aren't.
The first is the hostname. That is the laptop's own address, and each laptop gets its own:
- work laptop:
work-relay.example.dev - home laptop:
home-relay.example.dev
The second is "Where should the phone setup link open?". This one is not about the laptop. It picks where the phone app is served from.
That matters on iOS, because iOS ties a home-screen app to the address it was installed from. If each laptop serves its own app, the phone gets two separate Herdr apps, each with one laptop in it. That is the opposite of what I wanted.
So the two laptops answer differently:
| Work laptop | Home laptop | |
|---|---|---|
| Laptop's own address | work-relay.example.dev |
home-relay.example.dev |
| Answer to "where should the phone setup link open?" | 1. This relay |
2. An existing installed Herdr app, then https://work-relay.example.dev
|
| Where the phone app lives |
work-relay.example.dev (creates it) |
work-relay.example.dev (joins it) |
Then, on the phone, import the home laptop's invite from inside the installed app, not with the camera. The app then shows 2/2 relays, with agents from both laptops in one list.
The phone app lives on one laptop
That answer has a cost. The work laptop serves the phone app, so a fresh install of the app, or an app update, needs the work laptop awake and online. The relay setup menu has an option, Configure App Deployment, that can move the app to Cloudflare Pages later and remove that dependency. Run it on one laptop only, the one that publishes app updates.
One more thing not to do: don't put Cloudflare Access in front of the relay hostnames. The relay already accepts only paired devices, and Access blocks the phone app.
Closing the lid still puts the Mac to sleep
A sleeping laptop can't be reached. Not by the relay, not over SSH.
My first setting for the home laptop was the usual one:
sudo pmset -c sleep 0 # never idle-sleep on the charger
That only stops idle sleep. Closing the lid is a separate trigger. macOS ignores the lid only in clamshell mode, and clamshell mode needs three things: the charger, an external display, and an external keyboard or mouse. Without a display, the power log shows a sleep like this, even with sleep 0:
Entering Sleep state due to 'Clamshell Sleep' ... Using AC
My home laptop is a MacBook Pro with an HDMI port, so the fix was an HDMI dummy plug, about $10. It tells macOS there is a monitor. With the plug in and the charger on, the Mac stays awake with the lid closed. Apple documents a keyboard or mouse as required too, but on this Mac it wasn't needed.
I tested it overnight. The lid was closed for about 16.5 hours, with the plug and no mouse. The best check is not the power log, which is huge and noisy. It is this:
sysctl kern.sleeptime kern.waketime
If both times are from before you closed the lid, the Mac didn't sleep. Mine still showed a sleep from before the plug went in.
Two things that didn't help:
-
sudo pmset -c disablesleep 1keeps it awake with the lid closed, but on my Mac it didn't stay limited to the charger. It showed up as a global setting, so a closed Mac on battery would stay awake too. I turned it back off. - Herdr.app has a coffee cup in its status bar that keeps the Mac awake, for example "until agents finish". It only stops idle sleep. Closing the lid still sleeps the Mac.
The full AC settings on the home laptop:
sudo pmset -c sleep 0 # never idle-sleep on the charger
sudo pmset -c disksleep 0
sudo pmset -c womp 1 # wake on network access
sudo pmset -c displaysleep 15 # the screen may still turn off
pmset -g custom # check the "AC Power" section
-c is charger only. Battery stays at its defaults.
One case no sleep setting fixes: with FileVault on, after a reboot the Mac waits at the login screen until someone unlocks it in person.
A warning about pairing on iOS
This is from the setup docs, and it's easy to get wrong. On iOS, the home-screen app and the browser keep separate storage. So if you scan the pairing QR and pair in the browser tab, you paired the tab, not the app.
What to do instead:
- Scan the QR with the camera. The page says "Add Herdr to the Home Screen". Don't tap "Pair this browser instead".
- Add it to the home screen, then open it from the home-screen icon.
- If the app says "refused this device", the one-use invite was already used by the browser tab. Print a fresh one on the laptop (command below) and import it inside the app.
- In the app, open gear > Devices, rename this device, and revoke any extra entry the browser tab left behind.
A fresh invite:
herdr plugin action invoke setup-link --plugin herdr-mobile-relay.events
The setup
One relay per laptop
On each laptop:
herdr --version # 0.7.5 or newer
herdr plugin install 0cv/herdr-mobile-relay
herdr plugin action invoke install-service --plugin herdr-mobile-relay.events
The last command is the stable tunnel wizard. It opens a browser for the Cloudflare login, asks for the hostname and the app question above, then creates the tunnel, the DNS record and a background service, and prints the pairing QR.
The login writes ~/.cloudflared/cert.pem. That file is a credential. It never goes into dotfiles.
To check a relay:
herdr plugin action invoke status --plugin herdr-mobile-relay.events
curl -s http://127.0.0.1:8375/readyz -o /dev/null -w '%{http_code}\n' # 200 when ready
Both laptops dial out to Cloudflare. No port is open on either one, and no router is touched. Traffic through the relay is end-to-end encrypted, so Cloudflare sees timing and frame sizes, not prompts or terminal output.
Devices and roles
My phone is the only controller. A controller can type, answer approvals, and start and stop agents. Any other device is a reader: it can watch, but it can't type, approve or upload.
In the app's settings, device lock is on, so it checks the device when it opens and when it comes back.
The pairing QR and link are passwords. Each one holds a one-use invite that expires after ten minutes. Don't screenshot it and don't paste it anywhere.
The phone and Firstmate
Firstmate drives the same herdr sessions. From the phone, I watch the crew and I work in my own sessions. I don't type into a Firstmate worker's pane: Firstmate treats that as me stepping in, and fixes it up on its next check.
SSH from the work laptop
The phone is for watching and answering. For shell work on the home laptop, I SSH in from the work laptop. I set this up two ways, and I use Tailscale day to day.
Tailscale: three locks on the work laptop
All three devices, both laptops and the phone, are on one personal Tailscale network. The default Tailscale policy lets every device reach every other device. I replaced it with this:
{
"tagOwners": {
"tag:home": ["autogroup:admin"]
},
"acls": [
{ "action": "accept", "src": ["autogroup:member"], "dst": ["tag:home:22"] }
]
}
My devices can reach port 22 on machines tagged tag:home. Nothing else is allowed. Only the home laptop has that tag. A tagged device also has key expiry turned off, so it doesn't drop off the network every 180 days.
That is one lock on the work laptop. There are two more:
- No SSH server. Remote Login stays off in System Settings.
- Shields Up. The Tailscale app refuses incoming connections on this device.
tailscale set --shields-up=true
tailscale debug prefs | grep -i shields # "ShieldsUp": true
netstat -an -p tcp | grep LISTEN | grep '\.22 ' || echo "no ssh server"
And the check that matters, from the home laptop:
nc -vz -G 3 <work-laptop> 22 # must fail
tailscale ping may still answer. Ping isn't covered by the policy.
Tailscale's own SSH server isn't available in the macOS app, so the home laptop uses the Mac's normal OpenSSH, reached only over Tailscale. The SSH config entry on the work laptop:
Host home-ts
HostName home-laptop
IdentityFile ~/.ssh/id_ed25519_<this-machine>
ServerAliveInterval 30
One key per machine, generated on that machine. After ssh-copy-id works, password logins on the home laptop are turned off:
printf 'PasswordAuthentication no\nKbdInteractiveAuthentication no\n' \
| sudo tee /etc/ssh/sshd_config.d/100-keys-only.conf
sudo launchctl kickstart -k system/com.openssh.sshd
Keep that session open and test a new ssh before you close it.
Cloudflare Access: no VPN on the work laptop
The other way uses no VPN on the work laptop at all. The home laptop runs a second cloudflared tunnel, for SSH only, separate from the relay's tunnel. The work laptop's ssh goes through cloudflared access ssh, which only makes outgoing connections:
Host home
HostName ssh-home.example.dev
IdentityFile ~/.ssh/id_ed25519_<this-machine>
ProxyCommand /opt/homebrew/bin/cloudflared access ssh --hostname %h
ServerAliveInterval 30
An Access application in front of ssh-home.example.dev allows one email. So the first ssh home opens a browser login with a code sent to that email, and only then does the SSH key check happen. Two checks before a shell.
Joining the running agents
herdr keeps a server running on the home laptop. Its sessions, and the agents in its panes, keep running whether anyone is attached or not. Attaching adds another view of the same panes.
herdr can attach over SSH by itself:
herdr --remote home-ts # the default session
herdr --remote home-ts --session agents
The shortcut for that is hr-home. Detach with Ctrl+B, then Q.
A few rules I keep:
- The agent in a pane is the same process. I type into its pane. I never start
omp --resumeon a conversation that is already open in a pane, because that runs a second process on the same chat. - Closing SSH only closes my view. The agents keep running.
- A smaller terminal resizes the shared panes while it's attached. They go back when it detaches.
A remote shell should look remote
So a remote shell never looks like a local one, ssh and herdr --remote now tint the Ghostty background and set a REMOTE: <host> title, and reset both when I leave. The core is small:
_remote_tint_run() {
local label color
label=$(_remote_tint_label "$1") # me@home-ts.example.dev -> home
shift
# Only for an interactive shell in Ghostty, with a tty on stdout.
if [[ ! -o interactive || ! -t 1 ]] \
|| [[ $TERM != xterm-ghostty && -z $GHOSTTY_RESOURCES_DIR ]]; then
"$@"; return $?
fi
color=${REMOTE_TINT_COLORS[$label]:-$REMOTE_TINT_DEFAULT}
local rc=0
{
printf '\e]11;%s\a\e]2;REMOTE: %s\a' "$color" "$label" >/dev/tty
"$@"; rc=$?
} always {
printf '\e]111\a' >/dev/tty # back to the theme background
}
return $rc
}
The escape codes go to /dev/tty, so piped output stays clean. The always block resets the color even when the command fails.
The wrapper around ssh is most of the file. It reads the arguments to find the host, and skips the tint when there is no interactive shell to tint: ssh home uptime, ssh -N, ssh -T. -p 22 takes the next word and -p22 doesn't, so it has to know which options take a value.
The color is deep purple. Inside herdr, it shows only where herdr doesn't paint its own panel colors.
Rules for a laptop that holds work credentials
Phone control of agents is a new way into the work laptop. These are the rules I follow for it:
- Device lock on in the phone app.
- My own phone is the only controller.
- If the phone is lost: revoke it in the app, and run
stable-teardownon the work laptop. - The work laptop accepts no incoming connections: Remote Login off, Shields Up on, and the Tailscale policy denies it.
- Tailscale on the work laptop is a personal network, signed in with a personal identity, as an outgoing client only. No exit node and no subnet routes on it.
-
cert.pem, tunnel tokens, and pairing QRs are passwords. They never go into dotfiles, a chat, or a screenshot.
Versions
What runs today:
| Tool | Version |
|---|---|
| herdr | 0.9.5 |
| Herdr.app | 20261013.1 |
| herdr-mobile-relay | commit 6211568
|
| cloudflared | 2026.10.0 |
| Tailscale | 1.102.4 |
Try it
The companion folder has the remote tint as a standalone zsh file, with tests, and the Tailscale policy from this post. The tests run an interactive zsh in a pseudo-terminal with fake ssh and herdr, so they need no Ghostty and no hosts. They check when the tint should happen and when it shouldn't, the host label, custom colors, and that the color resets when ssh fails.
cd 03-mobile-relay
make test
golem-notes-examples/03-mobile-relay
How do you reach your agents when you're not at the laptop they run on? Leave a comment.
This is part 3 of my series on running a real agentic dev setup. I publish each part first on Golem Notes, with the full write-up and what's coming next. Subscribe there to get the next one in your inbox: Golem Notes


Top comments (0)