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.
Add a host
Section titled “Add a host”thurm remote add devbox me@devbox.example.orgthurm 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 sshThe 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.
Check a host
Section titled “Check a host”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.
Supported hosts
Section titled “Supported hosts”| 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.
Host requirements
Section titled “Host requirements”- 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 withssh devboxin 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
Section titled “Tailscale”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
checkaction signs you in through a browser, which a background connection cannot do: use anacceptrule for your own devices. - A regular
sshdreached 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.
Serve over Tailscale
Section titled “Serve over Tailscale”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:
thurm tailscale onThe 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:
thurm set tailscale.enabled trueWho 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.
Headscale
Section titled “Headscale”To sign the node in to a headscale server instead of Tailscale’s, set its address:
[tailscale]enabled = truecontrol_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.
In a browser
Section titled “In a browser”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:
thurm set tailscale.web truethurm 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.
Reach a host over Tailscale
Section titled “Reach a host over Tailscale”A host that serves over Tailscale (thurm tailscale on there) can be reached
from this Mac the same way, without ssh:
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:
Host bastion ProxyCommand thurm tailscale nc %h %pthurm tailscale peers lists the devices Thurm’s device sees.
What runs where
Section titled “What runs where”| 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.
Using remote panes
Section titled “Using remote panes”- 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.
The CLI
Section titled “The CLI”--remote NAME sends any pane command to the host’s daemon through the tunnel
the app keeps open:
thurm --remote devbox agentsthurm --remote devbox capture --pane 3 -n 40thurm --remote devbox wait --pane 3 --agent-done --timeout 600thurm --remote devbox launch claudePane 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.
Hand off a repository
Section titled “Hand off a repository”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:
thurm handoff --remote devbox --preset claudeThurm then:
- 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. - Pushes it over ssh to a bare mirror of the repository on the host
(
~/.local/share/thurm/mirrors), asagent/<name>. - Checks it out in its own worktree (
~/.local/share/thurm/worktrees). - Adds a git remote
thurm-devboxto your local repository. - 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.
Security
Section titled “Security”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]]. Yourterminal.osc52setting caps every host. - Clipboard writes follow your
terminal.osc52setting. - Links. ⌘-click opens
httpandhttpslinks from remote panes, asks for other schemes, and never opensfile://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.
Troubleshooting
Section titled “Troubleshooting”| 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.