Skip to content

Remote workspaces

Thurm can attach to thurmd running on other machines. A remote host’s panes show as a workspace next to your local ones, with the same tabs, splits, agent sidebar, agent switcher (⌘⇧A) and notifications. Agents keep running there when your computer sleeps, changes networks, or quits.

See Use Thurm for the full list of shortcuts.

The connection is the system ssh, one tunnel per host. Thurm has no relay or account of its own; Tailscale or any other network works underneath. Your phone and other devices can also reach a daemon over Tailscale without ssh: see Serve over Tailscale. So can this Mac, for a host that serves there: see Reach a host over Tailscale.

Terminal window
thurm remote add devbox me@devbox.example.org

thurm remote add NAME SSH-TARGET connects once, shows the host’s platform, offers to install this build of Thurm there, and writes the entry to your config. Then it checks the rest the host needs (see Check a host) and offers each fix. In the app, Thurm › Remotes… › Add Host… does the same. Run without a terminal (from a script), it needs --yes or --no, and it says so instead of assuming “no”.

The entry it writes:

[[remote]]
name = "devbox" # shown in the sidebar; `thurm --remote devbox ...`
host = "me@devbox.example.org" # a Host alias, user@host, or ssh://user@host:port
# socket = "/run/user/1000/thurm-1000/thurmd.sock" # discovered when unset
# enabled = true
# clipboard_read = "ask" # "ask", "always" or "never"
# tailnet = "wss://devbox.YOUR-TAILNET.ts.net/v1/connect" # over Tailscale instead of ssh

The app connects to every enabled [[remote]] in the background. Other commands:

Command Purpose
thurm remote list Hosts and their connection state
thurm remote status [NAME] State, remote build, last error, retry countdown
thurm remote install NAME Install this build there, and upgrade its daemon in place
thurm remote doctor [NAME] Check what the host needs; --fix offers each fix, --yes runs them
thurm remote remove NAME Forget a host; its daemon and panes keep running there

Thurm › Remotes… lists the same hosts with their state and build, and the selected host’s checklist with a Fix button for each problem.

thurm remote doctor devbox, and the checklist in Thurm › Remotes…, check:

Check Fix
Thurm Installs this build (see Supported hosts)
Daemon Replaces a daemon of another build in place
Lingering loginctl enable-linger, or in a tab on the host with sudo when that needs your password
Command-line tool Links thurm into ~/.local/bin, so ssh devbox thurm … finds it
Claude Code Runs the official installer (https://claude.ai/install.sh), after asking
Claude Code sign-in Opens a tab running claude on the host, to sign in (Linux hosts)
Claude Code / Codex hooks Installs them for exact agent status

A fix that needs you at the host (a password, a sign-in) runs over ssh -t: in a tab of this Mac from the app, in your terminal from thurm remote doctor --fix.

Host What gets installed
macOS, Apple silicon The app’s own thurm and thurmd, copied over
Linux x86_64 The static x86_64-unknown-linux-musl build of the app’s exact version
Linux aarch64 The static aarch64-unknown-linux-musl build of the app’s exact version
Hosts with Nix nix profile install of the flake’s thurm at the app’s commit

Linux archives are downloaded from the release the app came from and checked against the SHA-256 built into the app. Files go to ~/.local/share/thurm/bin, and thurm is linked into ~/.local/bin when that directory exists. NixOS users can instead add the flake’s packages.thurm to their configuration.

Thurm never installs anything in the background. A host without Thurm, or with another version, shows not installed or upgrade needed until you confirm the install.

  • An ssh key that works without prompting. Thurm runs ssh with BatchMode=yes: a key in an agent (the 1Password SSH agent works, with its own approval prompt), no passphrase prompts, no password or 2FA prompts. Connect once with ssh devbox in a terminal to accept the host key.
  • Unix socket forwarding in the host’s sshd (AllowStreamLocalForwarding yes, the OpenSSH default).
  • Lingering on systemd hosts. The daemon’s socket lives in $XDG_RUNTIME_DIR, which logind deletes when your last session ends. The doctor’s Lingering fix turns it on (loginctl enable-linger $USER).
  • Agent hooks on the host, for exact agent status (Working, Needs input, Done). The host’s daemon adds them when it launches an agent without them, and the doctor installs them for agents that are already there.

Your ~/.ssh/config applies as usual: Host aliases, ProxyJump, and the 1Password IdentityAgent. Thurm refuses a host whose ssh config adds RemoteForward or DynamicForward (see Security).

Tailscale is only the network. Either works:

  • Tailscale SSH, version 1.98 or later. Earlier versions cannot forward a Unix socket, and the host shows needs attention with “unsupported channel type”. Tailscale SSH opens the forward as root; an older Thurm daemon accepts only your own user and closes it, so the host stays reconnecting until you upgrade the daemon. Tailscale SSH’s check action signs you in through a browser, which a background connection cannot do: use an accept rule for your own devices.
  • A regular sshd reached over the tailnet.

To let the Mac reach the devbox but not the reverse, use a grant like this in your tailnet policy (tags are examples):

{
"grants": [
{ "src": ["tag:mac"], "dst": ["tag:devbox"], "ip": ["tcp:22"] }
]
}

With no grant from tag:devbox to tag:mac, nothing on the devbox can open a connection to your Mac.

thurmd can also be a device on your tailnet itself, so your iPhone and other devices reach it without Remote Login, sshd or authorized_keys:

Terminal window
thurm tailscale on

The first time, it prints a sign-in link (also under Settings › Remotes › Tailscale, and as a notification). Sign in with your Tailscale account and the daemon joins your tailnet as thurm (--hostname changes the name), serving wss://thurm.YOUR-TAILNET.ts.net/v1/connect with the tailnet’s HTTPS certificate. Pair iPhone then puts that address in the pairing code; the app connects there first, with ssh as the fallback. The phone needs the Tailscale app, signed in to the same tailnet.

A remote host that serves over Tailscale goes into the code with its own address too: the phone connects to its daemon directly, and over ssh only when this Mac knows its host key. Such a host needs no host key, and no device key on it, when the phone reaches it over Tailscale alone. The Mac must be connected to the host when you open Pair iPhone, to ask it for the address.

Without a Mac, pair the phone on the host itself: thurm pair prints a QR code to scan in the app (and the same link, to paste instead). The code carries only this host’s Tailscale address, and the push relay when the host’s config has one; the phone connects over Tailscale alone, with no ssh fallback. When HTTPS is not ready, thurm pair carries the plain addresses of Headscale instead, and fails only when there are neither.

thurm tailscale on lasts until the daemon restarts. To keep it on, turn on Serve over Tailscale in Settings, or:

Terminal window
thurm set tailscale.enabled true

Who may connect:

  • Only your account (the default): the account that signed the device in. Tailscale tells the daemon who each connection is from; anyone else is refused, and so are tagged devices, which have no owner.
  • Every tailnet member: allow_tailnet_members = true (or --allow-tailnet-members). Needed to enroll with ACL tags (advertise_tags), since a tagged device has no owner.

HTTPS needs MagicDNS and HTTPS certificates turned on for your tailnet (the admin console’s DNS page); thurm tailscale status says when one is off.

To sign the node in to a headscale server instead of Tailscale’s, set its address:

[tailscale]
enabled = true
control_url = "https://headscale.example.com"
plain_listen = ":7297"

control_url (or thurm set tailscale.control_url) is the coordination server the node signs in to; empty is Tailscale’s. Changing it restarts the node, which asks for a sign-in again on the new server. The admin pages in the Mac app and in the status then say “your coordination server” and link to nothing.

A headscale tailnet without MagicDNS and HTTPS certificates has no wss:// address. There, plain_listen (thurm set tailscale.plain_listen :7297) has the node serve the same gateway without TLS, at ws://100.x.y.z:7297/v1/connect for each of its tailnet addresses and at ws://<name>:7297/v1/connect once the name resolves. The tailnet’s WireGuard encrypts that hop, and callers are admitted by their tailnet identity as they are over HTTPS. A browser always sends an Origin header, so browsers are refused there.

The phone needs the same tailnet: a headscale client, or the Tailscale app signed in to the same server. Pair iPhone and thurm pair then put the plain addresses in the code, as tailnet_plain, and the app connects to them over the tailnet.

To serve through a machine’s own Tailscale instead, which is already on the headscale tailnet, use host_listen (:7297, see above). It needs no control_url of its own.

The same address can serve a web client: open https://thurm.YOUR-TAILNET.ts.net/ in a browser on any device of your tailnet and you get your workspaces and tabs, the panes as they are on the Mac and the phone, typing, scrollback and agent notices with Approve and Deny. It is off by default:

Terminal window
thurm set tailscale.web true

thurm tailscale status then prints the page’s address. Release builds include the web client; a Thurm built from source needs just web before building, or the page says the client was not built in. There is no password or token: the page is admitted exactly like the phone, by who Tailscale says the device belongs to, so a browser that can open it has a shell on the host. Only that page itself may talk to the daemon; a page from any other site, including another ts.net address, is refused. Layouts, settings and images stay on the desktop app.

Browsers keep some shortcuts for themselves (Ctrl+W, Ctrl+T, Ctrl+N): the page asks before closing while it is connected, and in fullscreen Chromium hands them to the terminal. Copy is Cmd+C on a Mac and Ctrl+Shift+C elsewhere.

Command What it does
thurm tailscale status State, address, account, and the sign-in link while one is needed
thurm tailscale off Stop serving; tailnet connections close, panes keep running, the device stays signed in
thurm tailscale login Ask for a new sign-in link
thurm tailscale logout Sign the device out and forget it
thurm tailscale rename NAME Rename the device without signing in again
thurm whoami Who the daemon sees on this connection

To enroll without a browser, put an auth key in a file and set auth_key_file (or pass --auth-key-file). The device’s keys are kept in the daemon’s state directory (tailscale/), never in the config.

If your Mac already runs Tailscale, the daemon can listen through it instead of being a device of its own: host_listen = ":7297" serves on this Mac’s tailnet addresses, and serve_socket = true gives tailscale serve a socket to proxy to (thurm tailscale status prints the command). Both admit only this Mac’s owner unless host_allow_identified = true.

The Tailscale part is a separate program, thurm-tailnet, next to thurmd in the app, the Linux archives, and the Nix package; the daemon starts it only while serving over Tailscale. Installing Thurm on a remote host copies it too, from builds that have it.

A host that serves over Tailscale (thurm tailscale on there) can be reached from this Mac the same way, without ssh:

Terminal window
thurm remote add bastion bastion --tailnet

--tailnet takes the host’s name on the tailnet (default: the ssh target’s host), or its address: wss://bastion.YOUR-TAILNET.ts.net/v1/connect for a device of its own, ws://100.x.y.z:7297 for one that listens through its machine’s Tailscale (host_listen). Thurm checks that the daemon answers, then writes tailnet with the full address next to host. In the app, turn on Over Tailscale in Add Host….

The connection goes through this Mac’s own Thurm device when it runs (thurm tailscale on here), so it works with the Tailscale app stopped. When that device is off, a full wss://…/v1/connect address goes through the Tailscale app instead; thurm remote add --tailnet saves that form. A bare peer name written into tailnet by hand, and a ws:// address, work through Thurm’s device only. thurm remote list shows which way a host is reached: tailnet (node) or tailnet (app).

The host decides who gets in, as for your phone: by default only its own account. If you sign this Mac’s Thurm device in with the same account, it is let in. A host that is tagged, or signed in with another account, needs allow_tailnet_members = true (or host_allow_identified = true for host_listen), and then admits everyone your tailnet policy lets reach it: keep that policy tight. Your policy must also let this Mac reach the host’s port 443 (or the host_listen port).

ssh is still what installs, checks and hands off: thurm remote install, thurm remote doctor and thurm handoff use host, as do starting a daemon that isn’t running and the ssh config’s LocalForwards. With the Tailscale app stopped, ssh can go through Thurm’s device too:

~/.ssh/config
Host bastion
ProxyCommand thurm tailscale nc %h %p

thurm tailscale peers lists the devices Thurm’s device sees.

Setting Comes from
Font, theme, appearance, key bindings, notifications, rendering This Mac’s config
Shell, env, term, shell_integration, agent presets, [[agents.define]] The host’s own ~/.config/thurm/config.toml
OSC 52 reads, link opening, ssh forwarding This Mac, enforced in the app; the host’s config can only be stricter

Launch presets in a remote workspace (⌘⇧P, ⌘⇧A) are the host’s.

  • Workspaces. A host’s panes open in a workspace named after it. ⌘⇧O lists New Workspace on devbox. New tabs and splits in a remote workspace run on that host. A pane started on the host from elsewhere (thurm --remote devbox launch) goes to its workspace in the background; the agent sidebar and ⌘⇧A switch to it.
  • Agents. Sidebar and switcher rows show the host: devbox · repo · Needs input. Notifications follow the same focus rules as local panes. A status that changed while the host was unreachable shows when it is back; missed notifications are not replayed.
  • Offline. An unreachable host keeps its tabs. They show the last frame under Disconnected — reconnecting… with the retry countdown, and input is blocked. Thurm retries with backoff up to two minutes, and right away after sleep or a network change.
  • Restore. After the app restarts, remote tabs come back from the saved layout and attach to the host’s still-running panes. Tabs of panes that ended meanwhile close with a notice; panes the host has that the layout doesn’t know open as new tabs.
  • Pasting images. An image pasted or dropped into a remote pane is written to a private file on the host (in the daemon’s runtime directory), and its path is pasted.

--remote NAME sends any pane command to the host’s daemon through the tunnel the app keeps open:

Terminal window
thurm --remote devbox agents
thurm --remote devbox capture --pane 3 -n 40
thurm --remote devbox wait --pane 3 --agent-done --timeout 600
thurm --remote devbox launch claude

Pane IDs are the host’s, so pass --pane (the current pane is a pane of this Mac). When the app is not connected to the host, the command fails with, for example, devbox is not connected in Thurm (state: reconnecting). The CLI never opens an ssh connection of its own for this.

Give a local repository to an agent on a remote host. Your code goes over through git; nothing else from the Mac does.

From a tab in the repository, press ⌘⇧P and choose Hand Off to devbox…, then an agent preset (or a shell). From a terminal:

Terminal window
thurm handoff --remote devbox --preset claude

Thurm then:

  1. Takes a snapshot: HEAD, or with uncommitted changes (untracked files included, ignored files not), a WIP commit on top of it. Your working tree, index, and stash stay as they are.
  2. Pushes it over ssh to a bare mirror of the repository on the host (~/.local/share/thurm/mirrors), as agent/<name>.
  3. Checks it out in its own worktree (~/.local/share/thurm/worktrees).
  4. Adds a git remote thurm-devbox to your local repository.
  5. Opens a tab on the host in that worktree, running the preset. The tab is listed under your local repository in the sidebar. Type the task yourself.

Several handoffs of one repository can run at once, each on its own branch.

Getting the work back. The agent commits. When it reaches Done or Needs input, Thurm fetches agent/<name> into refs/remotes/thurm-devbox/agent/<name>. Fetch Handoff Result (⌘⇧P) and thurm handoff --fetch ID fetch on demand. Thurm never merges, checks out, or rebases anything in your repository; review and merge the branch yourself, and push from the Mac. The remote host cannot push anywhere: it has no access to your keys.

Gitignored files (.env, node_modules, local config) are not copied. Set up what the agent needs on the host.

Cleaning up. Closing a handoff tab offers to remove its worktree. Thurm fetches first; if the worktree has uncommitted changes or commits not fetched yet, it asks before deleting them. The branch on the host is deleted when your default branch already contains it, and kept otherwise. Your local remote-tracking ref stays. A tab closed while the host is offline is cleaned up when it is back. thurm handoff --list and thurm handoff --cleanup ID do the same from a terminal.

Remote hosts are assumed to be yours and single-user. Even so, they cannot reach the Mac’s disk, clipboard, or credentials without you doing something:

  • Clipboard reads (OSC 52). Every read from a remote pane asks: “devbox wants to read your clipboard — Allow Once / Always for devbox / Deny”. “Always” writes clipboard_read = "always" to the host’s [[remote]]. Your terminal.osc52 setting caps every host.
  • Clipboard writes follow your terminal.osc52 setting.
  • Links. ⌘-click opens http and https links from remote panes, asks for other schemes, and never opens file:// links (they are paths on the host): it offers to copy the path instead.
  • ssh. Agent forwarding, X11 forwarding, and remote and dynamic forwards are always off. The only forward is the local socket for the daemon.
  • Files. Only what you paste or drop goes to the host.
  • Over Tailscale. The host admits this Mac by its Tailscale identity. On this Mac, the tunnel’s socket and Thurm’s dial socket (next to the daemon’s) are yours alone, and the dial socket reaches tailnet devices only.
State What to do
needs attention ssh needs you: read the message (host key, authentication, forwarding). Fix it, then Retry. Over Tailscale: the host refused this Mac (the message names the setting), or neither Thurm’s device nor the Tailscale app is on
not installed Fix next to Thurm in Thurm › Remotes…, or thurm remote install NAME
upgrade needed The host runs another protocol version. Install this build; its daemon is replaced in place and its panes keep running (daemons too old for that are restarted after you confirm)
reconnecting The host is unreachable; Thurm retries on its own

The tunnel’s local end is ~/Library/Caches/Thurm/remote/NAME.sock.