Part 2 of My Agentic Setup. Start from part 1
When an agent runs on my laptop, it stops when I close the laptop. That is fine for a five minute task. It is not fine for an audit that takes an hour, or a crew of agents working through a backlog.
So my long agent work runs on a cloud VM, inside tmux. The laptop is only a window into it.
The whole thing is one command:
agent-session.sh my-vm --gcloud
I'm on the VM. I start omp, give it work, and close the laptop. Tomorrow I run the same command, from the same laptop or the other one, and I'm back in the same place. The agent kept going while I was away.
The idea is simple: the agent runs on the VM inside tmux, and the laptop is only a window, so one command starts the work or brings me back to it from any machine.
Getting there was less simple. Here is what broke first.
What broke
The VM was too small
My first VM was a 1 GB e2-micro. omp got killed by the OOM killer while it was generating its shell completions. Not while running an agent, just while setting up the shell.
Small doesn't mean slow here. It means broken. I moved to a larger VM, and now the troubleshooting table in my docs has this line:
a tool is killed with exit 137 out of memory; check free -h, resize the VM
The login never finished
Every agent CLI logs in on each machine separately. My laptop's logins don't carry over to the VM, and I don't want them to.
claude login opens a browser flow and then waits for the callback on http://localhost:54545/callback. On the VM, that is the VM's localhost. The browser is on my laptop. It authorizes, redirects to port 54545 on the laptop, finds nothing there, and the login never completes. The browser just spins.
The fix is to forward the callback ports back over ssh. The script has a flag for it:
# Ports the agent CLIs listen on for their OAuth callback. Forwarded so the
# browser on this laptop can reach a login running on the VM.
AUTH_PORTS="54545 54546 8976"
if [ "$FORWARD_AUTH" -eq 1 ]; then
for port in $AUTH_PORTS; do
forward_args+=(-L "${port}:localhost:${port}")
done
fi
So the first time on a new VM I connect with --forward-auth, then run claude login and cursor-agent login inside the session. When a tool has a device code flow, like gh auth login --web, I use that instead. It needs no tunnel at all.
Unknown scope: use
Even with the tunnel, a login can fail with Unknown scope: use.
The cause: the login URL was in a boxed TUI panel. The panel wraps the long URL, and copying the visible lines cuts off the end of the scope list. The browser gets a broken URL.
The fix: copy the link from a plain, wide terminal. Or send the output to a file and take the URL from there:
claude login 2>&1 | tee /tmp/login.txt
Quoting in --run
I wanted to start a job without attaching first:
agent-session.sh my-vm --gcloud --run 'omp "audit the auth module and report back"'
That command goes through three layers: ssh, the shell on the VM, and tmux. Each one has its own quoting rules. A command with quotes inside quotes doesn't survive all three.
The fix is to not quote it at all. The script base64-encodes the command on the laptop and decodes it on the VM:
# The command crosses ssh, the remote shell and tmux, each with its own
# quoting. base64 is the one encoding that survives all three unchanged.
encoded="$(printf '%s' "$RUN" | base64 | tr -d '\n')"
remote="tmux new-session -A -d -s ${session_q}; \
tmux new-window -t ${session_q} \"sh -lc 'eval \\\"\\\$(printf %s ${encoded} | base64 -d)\\\"; exec \\\$SHELL -l'\"; \
tmux attach -t ${session_q}"
It is not pretty, but base64 has only letters, digits, +, / and =, so none of the three layers has anything to escape.
The exec $SHELL -l at the end matters too. When the agent finishes, the window stays open with a shell in it, so the final answer is still on screen when I come back.
Running the script on the VM itself
The script is meant to run on the laptop. Run it on the VM by mistake, and gcloud compute ssh tries to ssh from the VM to itself. It generates a new SSH key there and fails on permissions the VM's own service account doesn't have.
The fix is a hostname check. If I'm already on the target machine, the script skips ssh and just attaches:
if [ "$(hostname -s 2>/dev/null)" = "${HOST%%.*}" ]; then
exec tmux new-session -A -s "$SESSION"
fi
Now the same command does the right thing from both places.
command not found: omp
This one happens on a fresh VM. You connect, tmux starts, then you install the tools. Inside the session, omp is not found.
The tmux server started before the tools were installed, so it still has the old PATH, and every new window gets that old PATH too. Reconnecting doesn't help, because the server is still running.
tmux kill-server
Then connect again.
Smaller ones
Some issues came from setting up the VM, not from running agents on it:
- A minimal Debian image has no
make, and my setup runsmake. It is in the first install step now. - Debian ships Node 18. Some of my npm tools need 22. Linux takes Node from mise instead of apt.
- The ssh client forwards a locale the VM didn't generate, which prints a
setlocalewarning on every login. It is harmless. Installing the locale on the VM, or not forwarding it, makes it go away.
The setup
The VM comes from the same dotfiles
The VM is not special. It gets the same dotfiles as my laptops, with a Linux layer on top. This is the whole OS profile:
# profiles/os/linux.toml
# Linux defaults, targeting headless Debian and Ubuntu LTS hosts.
capabilities = ["cli", "linux"]
[shell]
ssh_use_keychain = false
[packages]
packs = ["base", "agents", "formal", "linux"]
cli without desktop keeps desktop applications off a headless machine. ssh_use_keychain = false matters because UseKeychain is a macOS-only SSH option, and on Linux it makes ssh reject the whole config file.
The agents pack
The agent tools come from a package pack. On macOS most of them are Homebrew formulas. On Linux most don't have an apt package, so each row has an upstream installer:
[[package]]
name = "can1357/tap/omp"
provider = { macos = "brew", linux = "upstream" }
binary = "omp"
install = { linux = "...curl the release binary into ~/.local/bin..." }
[[package]]
name = "claude-code"
provider = { macos = "upstream", linux = "upstream" }
install = "curl -fsSL https://claude.ai/install.sh | bash"
binary = "claude"
[[package]]
name = "node"
provider = { macos = "brew", linux = "upstream" }
min_major = 22
install = { linux = "mise use -g node@lts" }
The bootstrap script shows each upstream command and asks before running it. It also exits non-zero until everything is installed, so a half-installed VM doesn't look finished.
What runs on the VM today:
| Tool | What it does there | Installed on Linux by |
|---|---|---|
| tmux | keeps sessions alive | apt |
| omp | my main agent | release binary |
| Claude Code | crew agent | its install script |
| Cursor agent | crew agent | its install script |
| Firstmate | runs the crew | git clone |
| worktrunk | worktrees | release tarball |
| Node | for the npm tools | mise |
New machine, in order
Setting up a VM follows the same eight steps as a laptop:
- Base tools: git, ssh, make, Python, chezmoi
- A new SSH key for this machine, added to GitHub
- Clone the dotfiles
- A dry run into a fake home
- Declare the machine in a profile
- Install the software
- Install the configuration
- Log in to each tool
Configuration and software never mix. Step 7 installs no packages, and step 6 changes no configuration. Logins are always last, and always per machine. Nothing is copied from another machine.
How the script works
One tmux command does most of the work
The whole idea is one tmux flag:
remote="tmux new-session -A -s $(printf %q "$SESSION")"
exec ssh -t "$HOST" "$remote"
new-session -A attaches to the session if it exists and creates it if it doesn't. So the first connection and every connection after it are the same command.
ssh is only the transport. -t gives it a terminal, because tmux refuses to attach without one.
On GCP the script uses gcloud compute ssh with --tunnel-through-iap. My VM has no external IP, so IAP is how I reach it.
Less typing
The machine, project and zone are the same every time, so I set them once:
# ~/.zshrc.local
export AGENT_SESSION_HOST=my-vm
export AGENT_SESSION_PROJECT=my-project
export AGENT_SESSION_ZONE=my-zone
Now it is:
agent-session.sh --gcloud # connect
agent-session.sh --gcloud --list # what is running
--list is my dashboard:
billing: 1 windows (created Mon Sep 14 09:57:17 2026)
payments: 1 windows (created Mon Sep 14 09:56:16 2026)
Sessions, windows and tabs
These three are easy to mix up, so here is how I use each one:
-
A tmux window (
Ctrl-b c, orfm <project>): jobs I want to watch together.Ctrl-b wpicks one. -
A tmux session (
--session <name>): jobs I want kept apart, each one reattachable on its own. - A Ghostty tab: only a way to see two sessions at the same time. Closing it changes nothing on the VM.
One thing surprises people. Two Ghostty tabs attached to the same session show the same screen, live, with both cursors. That is nice for watching a long run on a second monitor, and useless for doing two things at once. Different work needs --session, not another tab.
What survives
- Closing the laptop, losing wifi, disconnecting: keeps running.
- Connecting from the other laptop: same sessions, same screens.
- Two laptops connected at once: both see the same screen.
- Rebooting the VM: the agents stop.
The VM is always on, so the last one almost never happens. I have tmux-resurrect and tmux-continuum installed, so if the VM does reboot, the sessions and windows come back. An agent that was in the middle of its work doesn't.
Where I am now
These days I mostly use herdr for this. It does the same job as tmux on the VM, with its own way of saving machines and reconnecting. Use one or the other, not both: herdr inside tmux makes their keyboard shortcuts fight.
The pattern is the same either way. The agent runs on a machine that stays on, and the laptop is only a window.
Try it
The companion repo has a sanitized copy of agent-session.sh and a test suite. The tests run tmux on a private socket with fake ssh and gcloud, so they don't need a VM and don't touch your own tmux server. They check the --run quoting with quotes, $ and backticks, the local attach, --forward-auth and the IAP flags.
cd 02-remote-agents
make test
golem-notes-examples/02-remote-agents
Where do your long agent runs live: your laptop, a VM, something else? Leave a comment.
This is part 2 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)