One hotkey per client: virtual desktops and workspaces on Windows
Windows has shipped virtual desktops since 2015, and most people who try them drift back to one desktop with forty windows within a week. Not because the feature is bad, but because Windows gives you the place and none of the furniture: nothing starts your apps there, nothing takes you there directly, and every reboot starts the arranging from scratch.
This post is about closing that gap. First the generic building blocks, because they work with any automation you like: what virtual desktops actually persist, how to script them, how to launch apps onto a chosen desktop and monitor, and how to run several isolated instances of one app (one Slack per client, each with its own sign-in). At the end, the whole stack combined into a single declarative config with the windows role, managed, in a plot twist, from inside WSL.
What virtual desktops give you, and what they don't
A virtual desktop is a separate set of windows sharing one session. Switching desktops swaps the visible windows and the wallpaper (Windows 11 lets each desktop have its own) and nothing else: same taskbar settings, same icons, same processes. Windows 11 persists desktops and their names across reboots, so the structure survives; the windows on them don't.
Three gaps keep the feature from being a workflow on its own:
- No direct navigation.
Ctrl+Win+Left/Rightwalks desktops one step at a time andWin+Tabis a mouse trip. There is no built-in "switch to desktop 3" hotkey, so the more desktops you add, the more walking you do. - Nothing populates them. A desktop named Work doesn't start your terminal. After every reboot you launch apps and drag them around, which is exactly the busywork that makes people give up.
- No API. Microsoft never documented the COM interfaces behind virtual desktops, so there's no supported way to script any of this.
Scripting the unscriptable
The third gap has a de facto answer: Markus Scholtes' VirtualDesktop PowerShell module, which wraps the undocumented interfaces and has tracked every Windows build since 2016. It reads like the API Microsoft never shipped:
New-Desktop | Set-DesktopName -Name 'ClientA'
Switch-Desktop 'ClientA'
Set-DesktopWallpaper -Desktop 'ClientA' -Path "$env:USERPROFILE\Pictures\client-a.jpg"
Get-Desktop 'ClientA' | Move-Window $someWindowHandleThe honest caveat comes with the territory: because the interfaces are undocumented, a Windows feature update can break the module until it catches up. It always has caught up, but treat desktop automation as best effort in a way that, say, package installs are not.
Move-Window is the interesting one: given a window handle, it moves the window to a desktop. Which means anything that can obtain a window handle can do the arranging. PowerShell can: start the app, poll for its main window, then place it.
$process = Start-Process -FilePath 'wt.exe' -PassThru
# ... poll $process.MainWindowHandle until it's non-zero ...
Get-Desktop 'Work' | Move-Window $process.MainWindowHandleMonitors are the same trick one layer down. SetWindowPos from user32.dll moves a window to any coordinates, and [System.Windows.Forms.Screen]::AllScreens knows each monitor's working area. Sort the screens by their X position and "put this on monitor 2" becomes a deterministic rectangle, regardless of which screen Windows considers primary. Move first, then maximize, and the window maximizes on the monitor you chose.
The one-shortcut launcher
Wrap those pieces in a script (start each app, wait for its window, move it to its desktop and monitor) and you have a workspace: the set of windows that makes a context, restored by one action. The remaining question is what the action is, and Windows has a forgotten native answer: shortcut hotkeys. A .lnk on the Desktop or Start Menu can carry a global key combination, no AutoHotkey, no background daemon; Explorer itself fires it.
$shell = New-Object -ComObject WScript.Shell
$shortcut = $shell.CreateShortcut("$desktopDir\work.workspace.lnk")
$shortcut.TargetPath = 'powershell.exe'
$shortcut.Arguments = '-NoProfile -WindowStyle Hidden -File "C:\Users\me\bin\workspace-work.ps1"'
$shortcut.Hotkey = 'CTRL+ALT+1'
$shortcut.Save()Ctrl+Alt+1 now rebuilds the whole layout. And the same mechanism fixes the navigation gap: a shortcut whose script only runs Switch-Desktop 'ClientA' is the "jump to desktop" hotkey Windows never shipped.
One Slack per client
The reason to organize desktops per client is usually one app: Slack, signed in to that client's workspace. Slack is an Electron app, and like every Chromium-based app it accepts --user-data-dir, which relocates the entire profile: sign-in, cache, settings. Two launches with two profile directories are two fully independent Slacks, running side by side:
slack.exe --user-data-dir=C:\Users\me\AppData\Roaming\SlackProfiles\client-a
slack.exe --user-data-dir=C:\Users\me\AppData\Roaming\SlackProfiles\client-bA separate profile directory also defeats the single-instance handoff (an Electron app only dedupes launches within one profile), so each launch keeps its own process, which means the launcher keeps its own window handle, which means each Slack lands on its own desktop without any title-matching heuristics. The same trick isolates browsers, or anything else built on Chromium. The dedicated multiple Slack instances post covers this setup, and its trade-offs, in full.
Everything combined: the windows role
Each piece above is a page of PowerShell you could maintain by hand. The windows role turns the whole stack into declared state, and, because it drives everything through WSL interop (powershell.exe called from Linux), it lives in the same Ansible playbook that manages your WSL distro, dotfiles, and CLIs. One dotpro apply converges both sides of the machine:
windows_virtual_desktops:
main: {}
client-a: # shown as "Client A" (title defaults to the capped key)
wallpaper: '%USERPROFILE%\Pictures\Wallpapers\client-a.jpg'
hotkey: CTRL+ALT+7 # jump straight there (created on first use)
on_demand: true
client-b:
wallpaper: '%USERPROFILE%\Pictures\Wallpapers\client-b.jpg'
hotkey: CTRL+ALT+8
on_demand: true
windows_workspaces:
- name: work
hotkey: CTRL+ALT+1
desktop: main # the workspace's desktop: app default + landing spot
apps:
- command: wt.exe
monitor: 1
maximize: true
- command: '%ProgramFiles%\Mozilla Firefox\firefox.exe'
window_match: 'Mozilla Firefox'
monitor: 2
maximize: true
- name: client-a
hotkey: CTRL+ALT+2
desktop: client-a
apps:
- command: '%LOCALAPPDATA%\slack\slack.exe'
args: '--user-data-dir=%APPDATA%\SlackProfiles\client-a'
monitor: 2
maximize: true
- name: client-b
hotkey: CTRL+ALT+3
desktop: client-b
apps:
- command: '%LOCALAPPDATA%\slack\slack.exe'
args: '--user-data-dir=%APPDATA%\SlackProfiles\client-b'
monitor: 2
maximize: trueAn apply installs the VirtualDesktop module (per user, from the PowerShell Gallery), converges the declared desktops and wallpapers idempotently, renders one launcher script per workspace into %USERPROFILE%\bin\, and maintains the hotkeyed shortcuts, including removing the ones whose workspace you deleted. The launchers run entirely Windows-side, so the hotkeys work whether or not WSL is up.
The two hotkey kinds divide the day cleanly. Ctrl+Alt+2 means "bring up client A": desktop created if missing (wallpaper included, thanks to on_demand), an isolated Slack signed in to their workspace, maximized on the big screen. Ctrl+Alt+7 means "take me there": no launches, just the direct switch Windows never gave you. Between the two, the forty-window desktop finally has a reason to stay empty.
Role reference, including the registry, winget, and environment variable management this post skipped: windows. The WSL half of the same machine is the wsl role's job.