diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml
index 6575860..8c42c13 100644
--- a/.github/workflows/ci.yaml
+++ b/.github/workflows/ci.yaml
@@ -51,10 +51,10 @@ jobs:
           cache-dependency-path: '**/packages.lock.json'
       - name: dotnet restore
         run: dotnet restore --locked-mode
-      - name: dotnet publish
-        run: dotnet publish --no-restore --configuration Release --output .\publish
-      - name: Upload artifact
-        uses: actions/upload-artifact@v4
-        with:
-          name: publish
-          path: .\publish\
+      #- name: dotnet publish
+      #  run: dotnet publish --no-restore --configuration Release --output .\publish
+      #- name: Upload artifact
+      #  uses: actions/upload-artifact@v4
+      #  with:
+      #    name: publish
+      #    path: .\publish\
diff --git a/.gitignore b/.gitignore
index d378f88..4ea0881 100644
--- a/.gitignore
+++ b/.gitignore
@@ -403,3 +403,5 @@ FodyWeavers.xsd
 .idea/**/shelf
 
 publish
+WindowsAppRuntimeInstall-x64.exe
+wintun.dll
diff --git a/App/App.csproj b/App/App.csproj
index 2adf3f7..f6e3c0d 100644
--- a/App/App.csproj
+++ b/App/App.csproj
@@ -10,18 +10,13 @@
         <PublishProfile>Properties\PublishProfiles\win-$(Platform).pubxml</PublishProfile>
         <UseWinUI>true</UseWinUI>
         <Nullable>enable</Nullable>
-        <EnableMsixTooling>true</EnableMsixTooling>
+        <EnableMsixTooling>false</EnableMsixTooling>
+        <WindowsPackageType>None</WindowsPackageType>
         <RestorePackagesWithLockFile>true</RestorePackagesWithLockFile>
         <!-- To use CommunityToolkit.Mvvm.ComponentModel.ObservablePropertyAttribute: -->
         <LangVersion>preview</LangVersion>
     </PropertyGroup>
 
-    <ItemGroup>
-        <AppxManifest Include="Package.appxmanifest">
-            <SubType>Designer</SubType>
-        </AppxManifest>
-    </ItemGroup>
-
     <ItemGroup>
         <Manifest Include="$(ApplicationManifest)" />
     </ItemGroup>
@@ -40,43 +35,12 @@
         </PackageReference>
         <PackageReference Include="H.NotifyIcon.WinUI" Version="2.2.0" />
         <PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="9.0.1" />
-        <PackageReference Include="Microsoft.Windows.SDK.BuildTools" Version="10.0.26100.1742" />
         <PackageReference Include="Microsoft.WindowsAppSDK" Version="1.6.250108002" />
     </ItemGroup>
 
-    <!--
-      Defining the "Msix" ProjectCapability here allows the Single-project MSIX Packaging
-      Tools extension to be activated for this project even if the Windows App SDK Nuget
-      package has not yet been restored.
-    -->
-    <ItemGroup Condition="'$(DisableMsixProjectCapabilityAddedByProject)'!='true' and '$(EnableMsixTooling)'=='true'">
-        <ProjectCapability Include="Msix" />
-    </ItemGroup>
     <ItemGroup>
         <ProjectReference Include="..\CoderSdk\CoderSdk.csproj" />
         <ProjectReference Include="..\Vpn.Proto\Vpn.Proto.csproj" />
         <ProjectReference Include="..\Vpn\Vpn.csproj" />
     </ItemGroup>
-
-    <!--
-      Defining the "HasPackageAndPublishMenuAddedByProject" property here allows the Solution
-      Explorer "Package and Publish" context menu entry to be enabled for this project even if
-      the Windows App SDK Nuget package has not yet been restored.
-    -->
-    <PropertyGroup Condition="'$(DisableHasPackageAndPublishMenuAddedByProject)'!='true' and '$(EnableMsixTooling)'=='true'">
-        <HasPackageAndPublishMenu>true</HasPackageAndPublishMenu>
-    </PropertyGroup>
-
-    <!-- Publish Properties -->
-    <PropertyGroup>
-        <!--
-        This does not work in CI at the moment, so we need to set it to false
-        Error: C:\Program Files\dotnet\sdk\9.0.102\Sdks\Microsoft.NET.Sdk\targets\Microsoft.NET.Publish.targets(400,5): error NETSDK1094: Unable to optimize assemblies for performance: a valid runtime package was not found. Either set the PublishReadyToRun property to false, or use a supported runtime identifier when publishing. When targeting .NET 6 or higher, make sure to restore packages with the PublishReadyToRun property set to true. [D:\a\coder-desktop-windows\coder-desktop-windows\App\App.csproj]
-        <PublishReadyToRun Condition="'$(Configuration)' == 'Debug'">False</PublishReadyToRun>
-        <PublishReadyToRun Condition="'$(Configuration)' != 'Debug'">True</PublishReadyToRun>
-        -->
-        <PublishReadyToRun>False</PublishReadyToRun>
-        <PublishTrimmed Condition="'$(Configuration)' == 'Debug'">False</PublishTrimmed>
-        <PublishTrimmed Condition="'$(Configuration)' != 'Debug'">True</PublishTrimmed>
-    </PropertyGroup>
 </Project>
diff --git a/App/App.xaml.cs b/App/App.xaml.cs
index cce3650..515d404 100644
--- a/App/App.xaml.cs
+++ b/App/App.xaml.cs
@@ -12,7 +12,6 @@ namespace Coder.Desktop.App;
 public partial class App : Application
 {
     private readonly IServiceProvider _services;
-    private readonly bool _handleClosedEvents = true;
 
     public App()
     {
@@ -49,12 +48,8 @@ protected override void OnLaunched(LaunchActivatedEventArgs args)
         var trayWindow = _services.GetRequiredService<TrayWindow>();
         trayWindow.Closed += (sender, args) =>
         {
-            // TODO: wire up HandleClosedEvents properly
-            if (_handleClosedEvents)
-            {
-                args.Handled = true;
-                trayWindow.AppWindow.Hide();
-            }
+            args.Handled = true;
+            trayWindow.AppWindow.Hide();
         };
     }
 }
diff --git a/App/Converters/VpnLifecycleToBoolConverter.cs b/App/Converters/VpnLifecycleToBoolConverter.cs
index 86e66aa..a2e3805 100644
--- a/App/Converters/VpnLifecycleToBoolConverter.cs
+++ b/App/Converters/VpnLifecycleToBoolConverter.cs
@@ -6,6 +6,7 @@
 
 namespace Coder.Desktop.App.Converters;
 
+[DependencyProperty<bool>("Unknown", DefaultValue = false)]
 [DependencyProperty<bool>("Starting", DefaultValue = false)]
 [DependencyProperty<bool>("Started", DefaultValue = false)]
 [DependencyProperty<bool>("Stopping", DefaultValue = false)]
@@ -18,6 +19,7 @@ public object Convert(object value, Type targetType, object parameter, string la
 
         return lifecycle switch
         {
+            VpnLifecycle.Unknown => Unknown,
             VpnLifecycle.Starting => Starting,
             VpnLifecycle.Started => Started,
             VpnLifecycle.Stopping => Stopping,
diff --git a/App/Models/RpcModel.cs b/App/Models/RpcModel.cs
index 074578f..dacef38 100644
--- a/App/Models/RpcModel.cs
+++ b/App/Models/RpcModel.cs
@@ -1,4 +1,6 @@
 using System.Collections.Generic;
+using System.Linq;
+using Coder.Desktop.Vpn.Proto;
 
 namespace Coder.Desktop.App.Models;
 
@@ -11,6 +13,7 @@ public enum RpcLifecycle
 
 public enum VpnLifecycle
 {
+    Unknown,
     Stopped,
     Starting,
     Started,
@@ -21,9 +24,11 @@ public class RpcModel
 {
     public RpcLifecycle RpcLifecycle { get; set; } = RpcLifecycle.Disconnected;
 
-    public VpnLifecycle VpnLifecycle { get; set; } = VpnLifecycle.Stopped;
+    public VpnLifecycle VpnLifecycle { get; set; } = VpnLifecycle.Unknown;
 
-    public List<object> Agents { get; set; } = [];
+    public List<Workspace> Workspaces { get; set; } = [];
+
+    public List<Agent> Agents { get; set; } = [];
 
     public RpcModel Clone()
     {
@@ -31,7 +36,8 @@ public RpcModel Clone()
         {
             RpcLifecycle = RpcLifecycle,
             VpnLifecycle = VpnLifecycle,
-            Agents = Agents,
+            Workspaces = Workspaces.ToList(),
+            Agents = Agents.ToList(),
         };
     }
 }
diff --git a/App/Properties/launchSettings.json b/App/Properties/launchSettings.json
index 4a35a11..ce91823 100644
--- a/App/Properties/launchSettings.json
+++ b/App/Properties/launchSettings.json
@@ -1,8 +1,5 @@
 {
   "profiles": {
-    "App (Package)": {
-      "commandName": "MsixPackage"
-    },
     "App (Unpackaged)": {
       "commandName": "Project"
     }
diff --git a/App/Services/CredentialManager.cs b/App/Services/CredentialManager.cs
index af1dbae..05dceec 100644
--- a/App/Services/CredentialManager.cs
+++ b/App/Services/CredentialManager.cs
@@ -66,12 +66,14 @@ public async Task SetCredentials(string coderUrl, string apiToken, CancellationT
 
         try
         {
+            var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
+            cts.CancelAfter(TimeSpan.FromSeconds(15));
             var sdkClient = new CoderApiClient(uri);
             sdkClient.SetSessionToken(apiToken);
             // TODO: we should probably perform a version check here too,
             // rather than letting the service do it on Start
-            _ = await sdkClient.GetBuildInfo(ct);
-            _ = await sdkClient.GetUser(User.Me, ct);
+            _ = await sdkClient.GetBuildInfo(cts.Token);
+            _ = await sdkClient.GetUser(User.Me, cts.Token);
         }
         catch (Exception e)
         {
diff --git a/App/Services/RpcController.cs b/App/Services/RpcController.cs
index 70ae8f3..07ae38e 100644
--- a/App/Services/RpcController.cs
+++ b/App/Services/RpcController.cs
@@ -96,6 +96,7 @@ public async Task Reconnect(CancellationToken ct = default)
         {
             state.RpcLifecycle = RpcLifecycle.Connecting;
             state.VpnLifecycle = VpnLifecycle.Stopped;
+            state.Workspaces.Clear();
             state.Agents.Clear();
         });
 
@@ -125,7 +126,8 @@ public async Task Reconnect(CancellationToken ct = default)
             MutateState(state =>
             {
                 state.RpcLifecycle = RpcLifecycle.Disconnected;
-                state.VpnLifecycle = VpnLifecycle.Stopped;
+                state.VpnLifecycle = VpnLifecycle.Unknown;
+                state.Workspaces.Clear();
                 state.Agents.Clear();
             });
             throw new RpcOperationException("Failed to reconnect to the RPC server", e);
@@ -134,10 +136,18 @@ public async Task Reconnect(CancellationToken ct = default)
         MutateState(state =>
         {
             state.RpcLifecycle = RpcLifecycle.Connected;
-            // TODO: fetch current state
-            state.VpnLifecycle = VpnLifecycle.Stopped;
+            state.VpnLifecycle = VpnLifecycle.Unknown;
+            state.Workspaces.Clear();
             state.Agents.Clear();
         });
+
+        var statusReply = await _speaker.SendRequestAwaitReply(new ClientMessage
+        {
+            Status = new StatusRequest(),
+        }, ct);
+        if (statusReply.MsgCase != ServiceMessage.MsgOneofCase.Status)
+            throw new InvalidOperationException($"Unexpected reply message type: {statusReply.MsgCase}");
+        ApplyStatusUpdate(statusReply.Status);
     }
 
     public async Task StartVpn(CancellationToken ct = default)
@@ -234,9 +244,40 @@ private async Task<IDisposable> AcquireOperationLockNowAsync()
         return locker;
     }
 
+    private void ApplyStatusUpdate(Status status)
+    {
+        MutateState(state =>
+        {
+            state.VpnLifecycle = status.Lifecycle switch
+            {
+                Status.Types.Lifecycle.Unknown => VpnLifecycle.Unknown,
+                Status.Types.Lifecycle.Starting => VpnLifecycle.Starting,
+                Status.Types.Lifecycle.Started => VpnLifecycle.Started,
+                Status.Types.Lifecycle.Stopping => VpnLifecycle.Stopping,
+                Status.Types.Lifecycle.Stopped => VpnLifecycle.Stopped,
+                _ => VpnLifecycle.Stopped,
+            };
+            state.Workspaces.Clear();
+            state.Workspaces.AddRange(status.PeerUpdate.UpsertedWorkspaces);
+            state.Agents.Clear();
+            state.Agents.AddRange(status.PeerUpdate.UpsertedAgents);
+        });
+    }
+
     private void SpeakerOnReceive(ReplyableRpcMessage<ClientMessage, ServiceMessage> message)
     {
-        // TODO: this
+        switch (message.Message.MsgCase)
+        {
+            case ServiceMessage.MsgOneofCase.Status:
+                ApplyStatusUpdate(message.Message.Status);
+                break;
+            case ServiceMessage.MsgOneofCase.Start:
+            case ServiceMessage.MsgOneofCase.Stop:
+            case ServiceMessage.MsgOneofCase.None:
+            default:
+                // TODO: log unexpected message
+                break;
+        }
     }
 
     private async Task DisposeSpeaker()
@@ -251,7 +292,14 @@ private async Task DisposeSpeaker()
     private void SpeakerOnError(Exception e)
     {
         Debug.WriteLine($"Error: {e}");
-        Reconnect(CancellationToken.None).Wait();
+        try
+        {
+            Reconnect(CancellationToken.None).Wait();
+        }
+        catch
+        {
+            // best effort to immediately reconnect
+        }
     }
 
     private void AssertRpcConnected()
diff --git a/App/ViewModels/TrayWindowViewModel.cs b/App/ViewModels/TrayWindowViewModel.cs
index a32f24d..5fcd84e 100644
--- a/App/ViewModels/TrayWindowViewModel.cs
+++ b/App/ViewModels/TrayWindowViewModel.cs
@@ -1,10 +1,12 @@
+using System;
 using System.Collections.Generic;
-using System.Collections.ObjectModel;
 using System.Linq;
 using Coder.Desktop.App.Models;
 using Coder.Desktop.App.Services;
 using CommunityToolkit.Mvvm.ComponentModel;
 using CommunityToolkit.Mvvm.Input;
+using Google.Protobuf;
+using Microsoft.UI.Dispatching;
 using Microsoft.UI.Xaml;
 using Microsoft.UI.Xaml.Controls;
 
@@ -17,9 +19,10 @@ public partial class TrayWindowViewModel : ObservableObject
     private readonly IRpcController _rpcController;
     private readonly ICredentialManager _credentialManager;
 
+    private DispatcherQueue? _dispatcherQueue;
+
     [ObservableProperty]
-    public partial VpnLifecycle VpnLifecycle { get; set; } =
-        VpnLifecycle.Stopping; // to prevent interaction until we get the real state
+    public partial VpnLifecycle VpnLifecycle { get; set; } = VpnLifecycle.Unknown;
 
     // VpnSwitchOn needs to be its own property as it is a two-way binding
     [ObservableProperty]
@@ -32,7 +35,7 @@ public partial class TrayWindowViewModel : ObservableObject
     [NotifyPropertyChangedFor(nameof(NoAgents))]
     [NotifyPropertyChangedFor(nameof(AgentOverflow))]
     [NotifyPropertyChangedFor(nameof(VisibleAgents))]
-    public partial ObservableCollection<AgentViewModel> Agents { get; set; } = [];
+    public partial List<AgentViewModel> Agents { get; set; } = [];
 
     public bool NoAgents => Agents.Count == 0;
 
@@ -51,6 +54,11 @@ public TrayWindowViewModel(IRpcController rpcController, ICredentialManager cred
     {
         _rpcController = rpcController;
         _credentialManager = credentialManager;
+    }
+
+    public void Initialize(DispatcherQueue dispatcherQueue)
+    {
+        _dispatcherQueue = dispatcherQueue;
 
         _rpcController.StateChanged += (_, rpcModel) => UpdateFromRpcModel(rpcModel);
         UpdateFromRpcModel(_rpcController.GetState());
@@ -61,11 +69,19 @@ public TrayWindowViewModel(IRpcController rpcController, ICredentialManager cred
 
     private void UpdateFromRpcModel(RpcModel rpcModel)
     {
+        // Ensure we're on the UI thread.
+        if (_dispatcherQueue == null) return;
+        if (!_dispatcherQueue.HasThreadAccess)
+        {
+            _dispatcherQueue.TryEnqueue(() => UpdateFromRpcModel(rpcModel));
+            return;
+        }
+
         // As a failsafe, if RPC is disconnected we disable the switch. The
         // Window should not show the current Page if the RPC is disconnected.
         if (rpcModel.RpcLifecycle is RpcLifecycle.Disconnected)
         {
-            VpnLifecycle = VpnLifecycle.Stopping;
+            VpnLifecycle = VpnLifecycle.Unknown;
             VpnSwitchOn = false;
             Agents = [];
             return;
@@ -73,52 +89,67 @@ private void UpdateFromRpcModel(RpcModel rpcModel)
 
         VpnLifecycle = rpcModel.VpnLifecycle;
         VpnSwitchOn = rpcModel.VpnLifecycle is VpnLifecycle.Starting or VpnLifecycle.Started;
-        // TODO: convert from RpcModel once we send agent data
-        Agents =
-        [
-            new AgentViewModel
-            {
-                Hostname = "pog",
-                HostnameSuffix = ".coder",
-                ConnectionStatus = AgentConnectionStatus.Green,
-                DashboardUrl = "https://dev.coder.com/@dean/pog",
-            },
-            new AgentViewModel
-            {
-                Hostname = "pog2",
-                HostnameSuffix = ".coder",
-                ConnectionStatus = AgentConnectionStatus.Gray,
-                DashboardUrl = "https://dev.coder.com/@dean/pog2",
-            },
-            new AgentViewModel
-            {
-                Hostname = "pog3",
-                HostnameSuffix = ".coder",
-                ConnectionStatus = AgentConnectionStatus.Red,
-                DashboardUrl = "https://dev.coder.com/@dean/pog3",
-            },
-            new AgentViewModel
+
+        // Add every known agent.
+        HashSet<ByteString> workspacesWithAgents = [];
+        List<AgentViewModel> agents = [];
+        foreach (var agent in rpcModel.Agents)
+        {
+            // Find the FQDN with the least amount of dots and split it into
+            // prefix and suffix.
+            var fqdn = agent.Fqdn
+                .Select(a => a.Trim('.'))
+                .Where(a => !string.IsNullOrWhiteSpace(a))
+                .Aggregate((a, b) => a.Count(c => c == '.') < b.Count(c => c == '.') ? a : b);
+            if (string.IsNullOrWhiteSpace(fqdn))
+                continue;
+
+            var fqdnPrefix = fqdn;
+            var fqdnSuffix = "";
+            if (fqdn.Contains('.'))
             {
-                Hostname = "pog4",
-                HostnameSuffix = ".coder",
-                ConnectionStatus = AgentConnectionStatus.Red,
-                DashboardUrl = "https://dev.coder.com/@dean/pog4",
-            },
-            new AgentViewModel
+                fqdnPrefix = fqdn[..fqdn.LastIndexOf('.')];
+                fqdnSuffix = fqdn[fqdn.LastIndexOf('.')..];
+            }
+
+            var lastHandshakeAgo = DateTime.UtcNow.Subtract(agent.LastHandshake.ToDateTime());
+            workspacesWithAgents.Add(agent.WorkspaceId);
+            agents.Add(new AgentViewModel
             {
-                Hostname = "pog5",
-                HostnameSuffix = ".coder",
-                ConnectionStatus = AgentConnectionStatus.Red,
-                DashboardUrl = "https://dev.coder.com/@dean/pog5",
-            },
-            new AgentViewModel
+                Hostname = fqdnPrefix,
+                HostnameSuffix = fqdnSuffix,
+                ConnectionStatus = lastHandshakeAgo < TimeSpan.FromMinutes(5)
+                    ? AgentConnectionStatus.Green
+                    : AgentConnectionStatus.Red,
+                // TODO: we don't actually have any way of crafting a dashboard
+                // URL without the owner's username
+                DashboardUrl = "https://coder.com",
+            });
+        }
+
+        // For every workspace that doesn't have an agent, add a dummy agent.
+        foreach (var workspace in rpcModel.Workspaces.Where(w => !workspacesWithAgents.Contains(w.Id)))
+        {
+            agents.Add(new AgentViewModel
             {
-                Hostname = "pog6",
+                // We just assume that it's a single-agent workspace.
+                Hostname = workspace.Name,
                 HostnameSuffix = ".coder",
-                ConnectionStatus = AgentConnectionStatus.Red,
-                DashboardUrl = "https://dev.coder.com/@dean/pog6",
-            },
-        ];
+                ConnectionStatus = AgentConnectionStatus.Gray,
+                // TODO: we don't actually have any way of crafting a dashboard
+                // URL without the owner's username
+                DashboardUrl = "https://coder.com",
+            });
+        }
+
+        // Sort by status green, red, gray, then by hostname.
+        agents.Sort((a, b) =>
+        {
+            if (a.ConnectionStatus != b.ConnectionStatus)
+                return a.ConnectionStatus.CompareTo(b.ConnectionStatus);
+            return string.Compare(a.FullHostname, b.FullHostname, StringComparison.Ordinal);
+        });
+        Agents = agents;
 
         if (Agents.Count < MaxAgents) ShowAllAgents = false;
     }
@@ -162,7 +193,8 @@ public void ToggleShowAllAgents()
     [RelayCommand]
     public void SignOut()
     {
-        // TODO: this should either be blocked until the VPN is stopped or it should stop the VPN
+        if (VpnLifecycle is not VpnLifecycle.Stopped)
+            return;
         _credentialManager.ClearCredentials();
     }
 }
diff --git a/App/Views/Pages/SignInTokenPage.xaml b/App/Views/Pages/SignInTokenPage.xaml
index dde2d5c..a09efb8 100644
--- a/App/Views/Pages/SignInTokenPage.xaml
+++ b/App/Views/Pages/SignInTokenPage.xaml
@@ -63,7 +63,8 @@
                 HorizontalAlignment="Stretch"
                 PlaceholderText="Paste your token here"
                 LostFocus="{x:Bind ViewModel.ApiToken_FocusLost, Mode=OneWay}"
-                Text="{x:Bind ViewModel.ApiToken, Mode=TwoWay}" />
+                Text="{x:Bind ViewModel.ApiToken, Mode=TwoWay}"
+                InputScope="Password" />
 
             <TextBlock
                 Grid.Column="1"
diff --git a/App/Views/Pages/TrayWindowMainPage.xaml.cs b/App/Views/Pages/TrayWindowMainPage.xaml.cs
index 913de6b..5911092 100644
--- a/App/Views/Pages/TrayWindowMainPage.xaml.cs
+++ b/App/Views/Pages/TrayWindowMainPage.xaml.cs
@@ -14,6 +14,7 @@ public TrayWindowMainPage(TrayWindowViewModel viewModel)
     {
         InitializeComponent();
         ViewModel = viewModel;
+        ViewModel.Initialize(DispatcherQueue);
     }
 
     // HACK: using XAML to populate the text Runs results in an additional
diff --git a/App/Views/TrayWindow.xaml.cs b/App/Views/TrayWindow.xaml.cs
index 0a1744d..224fae2 100644
--- a/App/Views/TrayWindow.xaml.cs
+++ b/App/Views/TrayWindow.xaml.cs
@@ -72,8 +72,8 @@ public TrayWindow(IRpcController rpcController, ICredentialManager credentialMan
         // Ensure the corner is rounded.
         var windowHandle = Win32Interop.GetWindowFromWindowId(AppWindow.Id);
         var value = 2;
-        var result = NativeApi.DwmSetWindowAttribute(windowHandle, 33, ref value, Marshal.SizeOf<int>());
-        if (result != 0) throw new Exception("Failed to set window corner preference");
+        // Best effort. This does not work on Windows 10.
+        _ = NativeApi.DwmSetWindowAttribute(windowHandle, 33, ref value, Marshal.SizeOf<int>());
     }
 
     private void SetPageByState(RpcModel rpcModel, CredentialModel credentialModel)
@@ -108,6 +108,14 @@ private void CredentialManager_CredentialsChanged(object? _, CredentialModel mod
     // trigger when the Page's content changes.
     public void SetRootFrame(Page page)
     {
+        if (!DispatcherQueue.HasThreadAccess)
+        {
+            DispatcherQueue.TryEnqueue(() => SetRootFrame(page));
+            return;
+        }
+
+        if (ReferenceEquals(page, RootFrame.Content)) return;
+
         if (page.Content is not FrameworkElement newElement)
             throw new Exception("Failed to get Page.Content as FrameworkElement on RootFrame navigation");
         newElement.SizeChanged += Content_SizeChanged;
@@ -239,7 +247,7 @@ private void Tray_Open()
     [RelayCommand]
     private void Tray_Exit()
     {
-        // TODO: implement exit
+        Application.Current.Exit();
     }
 
     public class NativeApi
diff --git a/App/packages.lock.json b/App/packages.lock.json
index 66a2a84..ca5e679 100644
--- a/App/packages.lock.json
+++ b/App/packages.lock.json
@@ -35,12 +35,6 @@
           "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1"
         }
       },
-      "Microsoft.Windows.SDK.BuildTools": {
-        "type": "Direct",
-        "requested": "[10.0.26100.1742, )",
-        "resolved": "10.0.26100.1742",
-        "contentHash": "ypcHjr4KEi6xQhgClnbXoANHcyyX/QsC4Rky4igs6M4GiDa+weegPo8JuV/VMxqrZCV4zlqDsp2krgkN7ReAAg=="
-      },
       "Microsoft.WindowsAppSDK": {
         "type": "Direct",
         "requested": "[1.6.250108002, )",
@@ -87,6 +81,11 @@
         "resolved": "9.0.0",
         "contentHash": "z8FfGIaoeALdD+KF44A2uP8PZIQQtDGiXsOLuN8nohbKhkyKt7zGaZb+fKiCxTuBqG22Q7myIAioSWaIcOOrOw=="
       },
+      "Microsoft.Windows.SDK.BuildTools": {
+        "type": "Transitive",
+        "resolved": "10.0.22621.756",
+        "contentHash": "7ZL2sFSioYm1Ry067Kw1hg0SCcW5kuVezC2SwjGbcPE61Nn+gTbH86T73G3LcEOVj0S3IZzNuE/29gZvOLS7VA=="
+      },
       "System.Collections.Immutable": {
         "type": "Transitive",
         "resolved": "9.0.0",
diff --git a/Package/Package.appxmanifest b/Package/Package.appxmanifest
deleted file mode 100644
index 679c072..0000000
--- a/Package/Package.appxmanifest
+++ /dev/null
@@ -1,52 +0,0 @@
-<?xml version="1.0" encoding="utf-8"?>
-
-<Package
-    xmlns:mp="http://schemas.microsoft.com/appx/2014/phone/manifest"
-    xmlns:uap="http://schemas.microsoft.com/appx/manifest/uap/windows10"
-    xmlns:rescap="http://schemas.microsoft.com/appx/manifest/foundation/windows10/restrictedcapabilities"
-    xmlns="http://schemas.microsoft.com/appx/manifest/foundation/windows10"
-    IgnorableNamespaces="uap rescap">
-
-    <Identity
-        Name="925b49fc-4648-4967-b4e6-b5473061ee62"
-        Publisher="CN=dean"
-        Version="1.0.0.0"/>
-
-    <mp:PhoneIdentity PhoneProductId="925b49fc-4648-4967-b4e6-b5473061ee62"
-                      PhonePublisherId="00000000-0000-0000-0000-000000000000"/>
-
-    <Properties>
-        <DisplayName>App (Package)</DisplayName>
-        <PublisherDisplayName>dean</PublisherDisplayName>
-        <Logo>Images\StoreLogo.png</Logo>
-    </Properties>
-
-    <Dependencies>
-        <TargetDeviceFamily Name="Windows.Universal" MinVersion="10.0.17763.0" MaxVersionTested="10.0.19041.0"/>
-        <TargetDeviceFamily Name="Windows.Desktop" MinVersion="10.0.17763.0" MaxVersionTested="10.0.19041.0"/>
-    </Dependencies>
-
-    <Resources>
-        <Resource Language="x-generate"/>
-    </Resources>
-
-    <Applications>
-        <Application Id="App"
-                     Executable="$targetnametoken$.exe"
-                     EntryPoint="$targetentrypoint$">
-            <uap:VisualElements
-                DisplayName="Coder Desktop"
-                Description="Coder"
-                BackgroundColor="transparent"
-                Square150x150Logo="Images\Square150x150Logo.png"
-                Square44x44Logo="Images\Square44x44Logo.png">
-                <uap:DefaultTile Wide310x150Logo="Images\Wide310x150Logo.png"/>
-                <uap:SplashScreen Image="Images\SplashScreen.png"/>
-            </uap:VisualElements>
-        </Application>
-    </Applications>
-
-    <Capabilities>
-        <rescap:Capability Name="runFullTrust"/>
-    </Capabilities>
-</Package>
diff --git a/Package/Package.wapproj b/Package/Package.wapproj
deleted file mode 100644
index 76d48c6..0000000
--- a/Package/Package.wapproj
+++ /dev/null
@@ -1,67 +0,0 @@
-<?xml version="1.0" encoding="utf-8"?>
-<Project ToolsVersion="15.0" DefaultTargets="Build" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
-  <PropertyGroup Condition="'$(VisualStudioVersion)' == '' or '$(VisualStudioVersion)' &lt; '15.0'">
-    <VisualStudioVersion>15.0</VisualStudioVersion>
-  </PropertyGroup>
-  <ItemGroup Label="ProjectConfigurations">
-    <ProjectConfiguration Include="Debug|x86">
-      <Configuration>Debug</Configuration>
-      <Platform>x86</Platform>
-    </ProjectConfiguration>
-    <ProjectConfiguration Include="Release|x86">
-      <Configuration>Release</Configuration>
-      <Platform>x86</Platform>
-    </ProjectConfiguration>
-    <ProjectConfiguration Include="Debug|x64">
-      <Configuration>Debug</Configuration>
-      <Platform>x64</Platform>
-    </ProjectConfiguration>
-    <ProjectConfiguration Include="Release|x64">
-      <Configuration>Release</Configuration>
-      <Platform>x64</Platform>
-    </ProjectConfiguration>
-    <ProjectConfiguration Include="Debug|ARM64">
-      <Configuration>Debug</Configuration>
-      <Platform>ARM64</Platform>
-    </ProjectConfiguration>
-    <ProjectConfiguration Include="Release|ARM64">
-      <Configuration>Release</Configuration>
-      <Platform>ARM64</Platform>
-    </ProjectConfiguration>
-  </ItemGroup>
-  <PropertyGroup>
-    <WapProjPath Condition="'$(WapProjPath)'==''">$(MSBuildExtensionsPath)\Microsoft\DesktopBridge\</WapProjPath>
-    <PathToXAMLWinRTImplementations>App\</PathToXAMLWinRTImplementations>
-  </PropertyGroup>
-  <Import Project="$(WapProjPath)\Microsoft.DesktopBridge.props" />
-  <PropertyGroup>
-    <ProjectGuid>c184988d-56e0-451f-b6a1-e5fe0405c80b</ProjectGuid>
-    <TargetPlatformVersion>10.0.22621.0</TargetPlatformVersion>
-    <TargetPlatformMinVersion>10.0.17763.0</TargetPlatformMinVersion>
-    <AssetTargetFallback>net8.0-windows$(TargetPlatformVersion);$(AssetTargetFallback)</AssetTargetFallback>
-    <DefaultLanguage>en-US</DefaultLanguage>
-    <AppxPackageSigningEnabled>false</AppxPackageSigningEnabled>
-    <EntryPointProjectUniqueName>..\App\App.csproj</EntryPointProjectUniqueName>
-  </PropertyGroup>
-  <ItemGroup>
-    <AppxManifest Include="Package.appxmanifest">
-      <SubType>Designer</SubType>
-    </AppxManifest>
-  </ItemGroup>
-  <ItemGroup>
-    <Content Include="Images\SplashScreen.scale-200.png" />
-    <Content Include="Images\Square150x150Logo.scale-200.png" />
-    <Content Include="Images\Square44x44Logo.scale-200.png" />
-  </ItemGroup>
-  <ItemGroup>
-    <ProjectReference Include="..\App\App.csproj">
-      <SkipGetTargetFrameworkProperties>True</SkipGetTargetFrameworkProperties>
-      <PublishProfile>Properties\PublishProfiles\win-$(Platform).pubxml</PublishProfile>
-    </ProjectReference>
-  </ItemGroup>
-  <ItemGroup>
-    <PackageReference Include="Microsoft.Windows.SDK.BuildTools" Version="10.0.26100.1742" />
-    <PackageReference Include="Microsoft.WindowsAppSDK" Version="1.6.250108002" />
-  </ItemGroup>
-  <Import Project="$(WapProjPath)\Microsoft.DesktopBridge.targets" />
-</Project>
diff --git a/Publish-Alpha.ps1 b/Publish-Alpha.ps1
new file mode 100644
index 0000000..79032b3
--- /dev/null
+++ b/Publish-Alpha.ps1
@@ -0,0 +1,140 @@
+# CD to the directory of this PS script
+Push-Location $PSScriptRoot
+
+# Create a publish directory
+$publishDir = Join-Path $PSScriptRoot "publish"
+if (Test-Path $publishDir) {
+    # prompt the user to confirm the deletion
+    $confirm = Read-Host "The directory $publishDir already exists. Do you want to delete it? (y/n)"
+    if ($confirm -eq "y") {
+        Remove-Item -Recurse -Force $publishDir
+    } else {
+        Write-Host "Aborting..."
+        exit
+    }
+}
+New-Item -ItemType Directory -Path $publishDir
+
+# Build in release mode
+dotnet.exe clean
+dotnet.exe publish .\Vpn.Service\Vpn.Service.csproj -c Release -a x64 -o $publishDir\service
+$msbuildBinary = & "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\vswhere.exe" -latest -requires Microsoft.Component.MSBuild -find MSBuild\**\Bin\MSBuild.exe
+& $msbuildBinary .\App\App.csproj /p:Configuration=Release /p:Platform=x64 /p:OutputPath=..\publish\app /p:GenerateAppxPackageOnBuild=true
+
+$scriptsDir = Join-Path $publishDir "scripts"
+New-Item -ItemType Directory -Path $scriptsDir
+
+# Download the 1.6.250108002 redistributable zip from here and drop the x64
+# version in the root of the repo:
+# https://learn.microsoft.com/en-us/windows/apps/windows-app-sdk/downloads
+$windowsAppSdkInstaller = Join-Path $PSScriptRoot "WindowsAppRuntimeInstall-x64.exe"
+Copy-Item $windowsAppSdkInstaller $scriptsDir
+
+# Acquire wintun.dll and put it in the root of the repo.
+$wintunDll = Join-Path $PSScriptRoot "wintun.dll"
+Copy-Item $wintunDll $scriptsDir
+
+# Add a PS1 script for installing the service
+$installScript = Join-Path $scriptsDir "Install.ps1"
+$installScriptContent = @"
+try {
+    # Install Windows App SDK
+    `$installerPath = Join-Path `$PSScriptRoot "WindowsAppRuntimeInstall-x64.exe"
+    Start-Process `$installerPath -ArgumentList "/silent" -Wait
+
+    # Install wintun.dll
+    `$wintunPath = Join-Path `$PSScriptRoot "wintun.dll"
+    Copy-Item `$wintunPath "C:\wintun.dll"
+
+    # Install and start the service
+    `$name = "Coder Desktop (Debug)"
+    `$binaryPath = Join-Path `$PSScriptRoot "..\service\Vpn.Service.exe" | Resolve-Path
+    New-Service -Name `$name -BinaryPathName `$binaryPath -StartupType Automatic
+    Start-Service -Name `$name
+} catch {
+    Write-Host ""
+    Write-Host -Foreground Red "Error: $_"
+} finally {
+    Write-Host ""
+    Write-Host "Press Return to exit..."
+    Read-Host
+}
+"@
+Set-Content -Path $installScript -Value $installScriptContent
+
+# Add a batch script for running the install script
+$installBatch = Join-Path $publishDir "Install.bat"
+$installBatchContent = @"
+@echo off
+powershell -Command "Start-Process powershell -ArgumentList '-NoProfile -ExecutionPolicy Bypass -File \"%~dp0scripts\Install.ps1\"' -Verb RunAs"
+"@
+Set-Content -Path $installBatch -Value $installBatchContent
+
+# Add a PS1 script for uninstalling the service
+$uninstallScript = Join-Path $scriptsDir "Uninstall.ps1"
+$uninstallScriptContent = @"
+try {
+    # Uninstall the service
+    `$name = "Coder Desktop (Debug)"
+    Stop-Service -Name `$name
+    sc.exe delete `$name
+
+    # Delete wintun.dll
+    Remove-Item "C:\wintun.dll"
+
+    # Maybe delete C:\coder-vpn.exe and C:\CoderDesktop.log
+    Remove-Item "C:\coder-vpn.exe" -ErrorAction SilentlyContinue
+    Remove-Item "C:\CoderDesktop.log" -ErrorAction SilentlyContinue
+} catch {
+    Write-Host ""
+    Write-Host -Foreground Red "Error: $_"
+} finally {
+    Write-Host ""
+    Write-Host "Press Return to exit..."
+    Read-Host
+}
+"@
+Set-Content -Path $uninstallScript -Value $uninstallScriptContent
+
+# Add a batch script for running the uninstall script
+$uninstallBatch = Join-Path $publishDir "Uninstall.bat"
+$uninstallBatchContent = @"
+@echo off
+powershell -Command "Start-Process powershell -ArgumentList '-NoProfile -ExecutionPolicy Bypass -File \"%~dp0scripts\Uninstall.ps1\"' -Verb RunAs"
+"@
+Set-Content -Path $uninstallBatch -Value $uninstallBatchContent
+
+# Add a PS1 script for starting the app
+$startAppScript = Join-Path $publishDir "StartTrayApp.bat"
+$startAppScriptContent = @"
+@echo off
+start /B app\App.exe
+"@
+Set-Content -Path $startAppScript -Value $startAppScriptContent
+
+# Write README.md
+$readme = Join-Path $publishDir "README.md"
+$readmeContent = @"
+# Coder Desktop for Windows
+
+## Install
+1. Install the service by double clicking `Install.bat`.
+2. Start the app by double clicking `StartTrayApp.bat`.
+3. The tray app should be available in the system tray.
+
+## Uninstall
+1. Close the tray app by right clicking the icon in the system tray and
+   selecting "Exit".
+2. Uninstall the service by double clicking `Uninstall.bat`.
+
+## Notes
+- During install and uninstall a User Account Control popup will appear asking
+  for admin permissions. This is normal.
+- During install and uninstall a bunch of console windows will appear and
+  disappear. You will be asked to click "Return" to close the last one once
+  it's finished doing its thing.
+- The system service will start automatically when the system starts.
+- The tray app will not start automatically on startup. You can start it again
+  by double clicking `StartTrayApp.bat`.
+"@
+Set-Content -Path $readme -Value $readmeContent
diff --git a/Vpn.Proto/vpn.proto b/Vpn.Proto/vpn.proto
index a03978a..8a4800d 100644
--- a/Vpn.Proto/vpn.proto
+++ b/Vpn.Proto/vpn.proto
@@ -1,4 +1,4 @@
-syntax = "proto3";
+syntax = "proto3";
 option go_package = "github.com/coder/coder/v2/vpn";
 option csharp_namespace = "Coder.Desktop.Vpn.Proto";
 
@@ -44,21 +44,23 @@ message TunnelMessage {
   }
 }
 
-// ClientMessage is a message from the client (to the service).
+// ClientMessage is a message from the client (to the service). Windows only.
 message ClientMessage {
   RPC rpc = 1;
   oneof msg {
     StartRequest start = 2;
     StopRequest stop = 3;
+    StatusRequest status = 4;
   }
 }
 
-// ServiceMessage is a message from the service (to the client).
+// ServiceMessage is a message from the service (to the client). Windows only.
 message ServiceMessage {
   RPC rpc = 1;
   oneof msg {
     StartResponse start = 2;
     StopResponse stop = 3;
+    Status status = 4; // either in reply to a StatusRequest or broadcasted
   }
 }
 
@@ -210,7 +212,7 @@ message StartResponse {
   string error_message = 2;
 }
 
-// StopRequest is a request from the manager to stop the tunnel. The tunnel replies with a
+// StopRequest is a request to stop the tunnel. The tunnel replies with a
 // StopResponse.
 message StopRequest {}
 
@@ -220,3 +222,26 @@ message StopResponse {
   bool success = 1;
   string error_message = 2;
 }
+
+// StatusRequest is a request to get the status of the tunnel. The manager
+// replies with a Status.
+message StatusRequest {}
+
+// Status is sent in response to a StatusRequest or broadcasted to all clients
+// when the status changes.
+message Status {
+  enum Lifecycle {
+    UNKNOWN = 0;
+    STARTING = 1;
+    STARTED = 2;
+    STOPPING = 3;
+    STOPPED = 4;
+  }
+  Lifecycle lifecycle = 1;
+  string error_message = 2;
+
+  // This will be a FULL update with all workspaces and agents, so clients
+  // should replace their current peer state. Only the Upserted fields will
+  // be populated.
+  PeerUpdate peer_update = 3;
+}
diff --git a/Vpn.Service/Manager.cs b/Vpn.Service/Manager.cs
index 2a7fcca..6ed7b82 100644
--- a/Vpn.Service/Manager.cs
+++ b/Vpn.Service/Manager.cs
@@ -8,11 +8,16 @@
 
 namespace Coder.Desktop.Vpn.Service;
 
-public interface IManager : IDisposable
+public enum TunnelStatus
 {
-    public Task HandleClientRpcMessage(ReplyableRpcMessage<ServiceMessage, ClientMessage> message,
-        CancellationToken ct = default);
+    Starting,
+    Started,
+    Stopping,
+    Stopped,
+}
 
+public interface IManager : IDisposable
+{
     public Task StopAsync(CancellationToken ct = default);
 }
 
@@ -28,6 +33,9 @@ public class Manager : IManager
     private readonly IDownloader _downloader;
     private readonly ILogger<Manager> _logger;
     private readonly ITunnelSupervisor _tunnelSupervisor;
+    private readonly IManagerRpc _managerRpc;
+
+    private volatile TunnelStatus _status = TunnelStatus.Stopped;
 
     // TunnelSupervisor already has protections against concurrent operations,
     // but all the other stuff before starting the tunnel does not.
@@ -35,66 +43,84 @@ public class Manager : IManager
     private SemVersion? _lastServerVersion;
     private StartRequest? _lastStartRequest;
 
+    private readonly RaiiSemaphoreSlim _statusLock = new(1, 1);
+    private readonly List<Workspace> _trackedWorkspaces = [];
+    private readonly List<Agent> _trackedAgents = [];
+
     // ReSharper disable once ConvertToPrimaryConstructor
     public Manager(IOptions<ManagerConfig> config, ILogger<Manager> logger, IDownloader downloader,
-        ITunnelSupervisor tunnelSupervisor)
+        ITunnelSupervisor tunnelSupervisor, IManagerRpc managerRpc)
     {
         _config = config.Value;
         _logger = logger;
         _downloader = downloader;
         _tunnelSupervisor = tunnelSupervisor;
+        _managerRpc = managerRpc;
+        _managerRpc.OnReceive += HandleClientRpcMessage;
     }
 
     public void Dispose()
     {
+        _managerRpc.OnReceive -= HandleClientRpcMessage;
         GC.SuppressFinalize(this);
     }
 
+    public async Task StopAsync(CancellationToken ct = default)
+    {
+        await _tunnelSupervisor.StopAsync(ct);
+        await BroadcastStatus(null, ct);
+    }
+
     /// <summary>
     ///     Processes a message sent from a Client to the ManagerRpcService over the codervpn RPC protocol.
     /// </summary>
     /// <param name="message">Client message</param>
     /// <param name="ct">Cancellation token</param>
-    public async Task HandleClientRpcMessage(ReplyableRpcMessage<ServiceMessage, ClientMessage> message,
+    public async Task HandleClientRpcMessage(ulong clientId, ReplyableRpcMessage<ServiceMessage, ClientMessage> message,
         CancellationToken ct = default)
     {
-        _logger.LogInformation("ClientMessage: {MessageType}", message.Message.MsgCase);
-        switch (message.Message.MsgCase)
+        using (_logger.BeginScope("ClientMessage.{MessageType} (client: {ClientId})", message.Message.MsgCase,
+                   clientId))
         {
-            case ClientMessage.MsgOneofCase.Start:
-                // TODO: these sub-methods should be managed by some Task list and cancelled/awaited on stop
-                var startResponse = await HandleClientMessageStart(message.Message, ct);
-                await message.SendReply(new ServiceMessage
-                {
-                    Start = startResponse,
-                }, ct);
-                break;
-            case ClientMessage.MsgOneofCase.Stop:
-                var stopResponse = await HandleClientMessageStop(message.Message, ct);
-                await message.SendReply(new ServiceMessage
-                {
-                    Stop = stopResponse,
-                }, ct);
-                break;
-            case ClientMessage.MsgOneofCase.None:
-            default:
-                _logger.LogWarning("Received unknown message type {MessageType}", message.Message.MsgCase);
-                break;
+            switch (message.Message.MsgCase)
+            {
+                case ClientMessage.MsgOneofCase.Start:
+                    // TODO: these sub-methods should be managed by some Task list and cancelled/awaited on stop
+                    var startResponse = await HandleClientMessageStart(message.Message, ct);
+                    await message.SendReply(new ServiceMessage
+                    {
+                        Start = startResponse,
+                    }, ct);
+                    break;
+                case ClientMessage.MsgOneofCase.Stop:
+                    var stopResponse = await HandleClientMessageStop(message.Message, ct);
+                    await message.SendReply(new ServiceMessage
+                    {
+                        Stop = stopResponse,
+                    }, ct);
+                    await BroadcastStatus(null, ct);
+                    break;
+                case ClientMessage.MsgOneofCase.Status:
+                    await message.SendReply(new ServiceMessage
+                    {
+                        Status = await CurrentStatus(ct),
+                    }, ct);
+                    break;
+                case ClientMessage.MsgOneofCase.None:
+                default:
+                    _logger.LogWarning("Received unknown message type {MessageType}", message.Message.MsgCase);
+                    break;
+            }
         }
     }
 
-    public async Task StopAsync(CancellationToken ct = default)
-    {
-        await _tunnelSupervisor.StopAsync(ct);
-    }
-
     private async ValueTask<StartResponse> HandleClientMessageStart(ClientMessage message,
         CancellationToken ct)
     {
         var opLock = await _tunnelOperationLock.LockAsync(TimeSpan.FromMilliseconds(500), ct);
         if (opLock == null)
         {
-            _logger.LogWarning("ClientMessage.Start: Tunnel operation lock timed out");
+            _logger.LogWarning("Tunnel operation lock timed out");
             return new StartResponse
             {
                 Success = false,
@@ -109,18 +135,20 @@ private async ValueTask<StartResponse> HandleClientMessageStart(ClientMessage me
                 var serverVersion =
                     await CheckServerVersionAndCredentials(message.Start.CoderUrl, message.Start.ApiToken,
                         ct);
-                if (_tunnelSupervisor.IsRunning && _lastStartRequest != null &&
+                if (_status == TunnelStatus.Started && _lastStartRequest != null &&
                     _lastStartRequest.Equals(message.Start) && _lastServerVersion == serverVersion)
                 {
                     // The client is requesting to start an identical tunnel while
                     // we're already running it.
-                    _logger.LogInformation("ClientMessage.Start: Ignoring duplicate start request");
+                    _logger.LogInformation("Ignoring duplicate start request");
                     return new StartResponse
                     {
                         Success = true,
                     };
                 }
 
+                ClearPeers();
+                await BroadcastStatus(TunnelStatus.Starting, ct);
                 _lastStartRequest = message.Start;
                 _lastServerVersion = serverVersion;
 
@@ -139,11 +167,14 @@ await _tunnelSupervisor.StartAsync(_config.TunnelBinaryPath, HandleTunnelRpcMess
                 }, ct);
                 if (reply.MsgCase != TunnelMessage.MsgOneofCase.Start)
                     throw new InvalidOperationException("Tunnel did not reply with a Start response");
+
+                await BroadcastStatus(reply.Start.Success ? TunnelStatus.Started : TunnelStatus.Stopped, ct);
                 return reply.Start;
             }
             catch (Exception e)
             {
-                _logger.LogWarning(e, "ClientMessage.Start: Failed to start VPN client");
+                await BroadcastStatus(TunnelStatus.Stopped, ct);
+                _logger.LogWarning(e, "Failed to start VPN client");
                 return new StartResponse
                 {
                     Success = false,
@@ -159,7 +190,7 @@ private async ValueTask<StopResponse> HandleClientMessageStop(ClientMessage mess
         var opLock = await _tunnelOperationLock.LockAsync(TimeSpan.FromMilliseconds(500), ct);
         if (opLock == null)
         {
-            _logger.LogWarning("ClientMessage.Stop: Tunnel operation lock timed out");
+            _logger.LogWarning("Tunnel operation lock timed out");
             return new StopResponse
             {
                 Success = false,
@@ -171,6 +202,8 @@ private async ValueTask<StopResponse> HandleClientMessageStop(ClientMessage mess
         {
             try
             {
+                ClearPeers();
+                await BroadcastStatus(TunnelStatus.Stopping, ct);
                 // This will handle sending the Stop message to the tunnel for us.
                 await _tunnelSupervisor.StopAsync(ct);
                 return new StopResponse
@@ -180,19 +213,110 @@ private async ValueTask<StopResponse> HandleClientMessageStop(ClientMessage mess
             }
             catch (Exception e)
             {
-                _logger.LogWarning(e, "ClientMessage.Stop: Failed to stop VPN client");
+                _logger.LogWarning(e, "Failed to stop VPN client");
                 return new StopResponse
                 {
                     Success = false,
                     ErrorMessage = e.ToString(),
                 };
             }
+            finally
+            {
+                // Always assume it's stopped.
+                await BroadcastStatus(TunnelStatus.Stopped, ct);
+            }
         }
     }
 
     private void HandleTunnelRpcMessage(ReplyableRpcMessage<ManagerMessage, TunnelMessage> message)
     {
-        // TODO: this
+        using (_logger.BeginScope("TunnelMessage.{MessageType}", message.Message.MsgCase))
+        {
+            switch (message.Message.MsgCase)
+            {
+                case TunnelMessage.MsgOneofCase.Start:
+                case TunnelMessage.MsgOneofCase.Stop:
+                    _logger.LogWarning("Received unexpected message reply type {MessageType}", message.Message.MsgCase);
+                    break;
+                case TunnelMessage.MsgOneofCase.Log:
+                case TunnelMessage.MsgOneofCase.NetworkSettings:
+                    _logger.LogWarning("Received message type {MessageType} that is not expected on Windows",
+                        message.Message.MsgCase);
+                    break;
+                case TunnelMessage.MsgOneofCase.PeerUpdate:
+                    HandleTunnelMessagePeerUpdate(message.Message);
+                    BroadcastStatus().Wait();
+                    break;
+                case TunnelMessage.MsgOneofCase.None:
+                default:
+                    _logger.LogWarning("Received unknown message type {MessageType}", message.Message.MsgCase);
+                    break;
+            }
+        }
+    }
+
+    private void ClearPeers()
+    {
+        using var _ = _statusLock.Lock();
+        _trackedWorkspaces.Clear();
+        _trackedAgents.Clear();
+    }
+
+    private void HandleTunnelMessagePeerUpdate(TunnelMessage message)
+    {
+        using var _ = _statusLock.Lock();
+        foreach (var newWorkspace in message.PeerUpdate.UpsertedWorkspaces)
+        {
+            _trackedWorkspaces.RemoveAll(w => w.Id == newWorkspace.Id);
+            _trackedWorkspaces.Add(newWorkspace);
+        }
+
+        foreach (var removedWorkspace in message.PeerUpdate.DeletedWorkspaces)
+            _trackedWorkspaces.RemoveAll(w => w.Id == removedWorkspace.Id);
+        foreach (var newAgent in message.PeerUpdate.UpsertedAgents)
+        {
+            _trackedAgents.RemoveAll(a => a.Id == newAgent.Id);
+            _trackedAgents.Add(newAgent);
+        }
+
+        foreach (var removedAgent in message.PeerUpdate.DeletedAgents)
+            _trackedAgents.RemoveAll(a => a.Id == removedAgent.Id);
+
+        _trackedWorkspaces.Sort((a, b) => string.Compare(a.Name, b.Name, StringComparison.Ordinal));
+        _trackedAgents.Sort((a, b) => string.Compare(a.Name, b.Name, StringComparison.Ordinal));
+    }
+
+    private async ValueTask<Status> CurrentStatus(CancellationToken ct = default)
+    {
+        using var _ = await _statusLock.LockAsync(ct);
+        var lifecycle = _status switch
+        {
+            TunnelStatus.Starting => Status.Types.Lifecycle.Starting,
+            TunnelStatus.Started => Status.Types.Lifecycle.Started,
+            TunnelStatus.Stopping => Status.Types.Lifecycle.Stopping,
+            TunnelStatus.Stopped => Status.Types.Lifecycle.Stopped,
+            _ => Status.Types.Lifecycle.Stopped,
+        };
+
+        return new Status
+        {
+            Lifecycle = lifecycle,
+            ErrorMessage = "",
+            PeerUpdate = new PeerUpdate
+            {
+                UpsertedAgents = { _trackedAgents },
+                UpsertedWorkspaces = { _trackedWorkspaces },
+            },
+        };
+    }
+
+    private async Task BroadcastStatus(TunnelStatus? newStatus = null, CancellationToken ct = default)
+    {
+        if (newStatus != null) _status = newStatus.Value;
+        await _managerRpc.BroadcastAsync(new ServiceMessage
+        {
+            Status = await CurrentStatus(ct),
+        }, ct);
     }
 
     private void HandleTunnelRpcError(Exception e)
@@ -201,7 +325,8 @@ private void HandleTunnelRpcError(Exception e)
         try
         {
             _tunnelSupervisor.StopAsync();
-            // TODO: this should broadcast an update to all clients
+            ClearPeers();
+            BroadcastStatus().Wait();
         }
         catch (Exception e2)
         {
diff --git a/Vpn.Service/ManagerRpc.cs b/Vpn.Service/ManagerRpc.cs
new file mode 100644
index 0000000..5d27def
--- /dev/null
+++ b/Vpn.Service/ManagerRpc.cs
@@ -0,0 +1,193 @@
+using System.Collections.Concurrent;
+using System.IO.Pipes;
+using System.Security.AccessControl;
+using System.Security.Principal;
+using Coder.Desktop.Vpn.Proto;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Options;
+
+namespace Coder.Desktop.Vpn.Service;
+
+public class ManagerRpcClient(Speaker<ServiceMessage, ClientMessage> speaker, Task task)
+{
+    public Speaker<ServiceMessage, ClientMessage> Speaker { get; } = speaker;
+    public Task Task { get; } = task;
+}
+
+public interface IManagerRpc : IAsyncDisposable
+{
+    delegate Task OnReceiveHandler(ulong clientId, ReplyableRpcMessage<ServiceMessage, ClientMessage> message,
+        CancellationToken ct = default);
+
+    event OnReceiveHandler? OnReceive;
+
+    Task StopAsync(CancellationToken cancellationToken);
+
+    Task ExecuteAsync(CancellationToken stoppingToken);
+
+    Task BroadcastAsync(ServiceMessage message, CancellationToken ct = default);
+}
+
+/// <summary>
+///     Provides a named pipe server for communication between multiple RpcRole.Client and RpcRole.Manager.
+/// </summary>
+public class ManagerRpc : IManagerRpc
+{
+    private readonly ConcurrentDictionary<ulong, ManagerRpcClient> _activeClients = new();
+    private readonly ManagerConfig _config;
+    private readonly CancellationTokenSource _cts = new();
+    private readonly ILogger<ManagerRpc> _logger;
+    private ulong _lastClientId;
+
+    // ReSharper disable once ConvertToPrimaryConstructor
+    public ManagerRpc(IOptions<ManagerConfig> config, ILogger<ManagerRpc> logger)
+    {
+        _logger = logger;
+        _config = config.Value;
+    }
+
+    public event IManagerRpc.OnReceiveHandler? OnReceive;
+
+    public async ValueTask DisposeAsync()
+    {
+        await _cts.CancelAsync();
+        while (!_activeClients.IsEmpty) await Task.WhenAny(_activeClients.Values.Select(c => c.Task));
+        _cts.Dispose();
+        GC.SuppressFinalize(this);
+    }
+
+    public async Task StopAsync(CancellationToken cancellationToken)
+    {
+        await _cts.CancelAsync();
+        while (!_activeClients.IsEmpty) await Task.WhenAny(_activeClients.Values.Select(c => c.Task));
+    }
+
+    /// <summary>
+    ///     Starts the named pipe server, listens for incoming connections and starts handling them asynchronously.
+    /// </summary>
+    public async Task ExecuteAsync(CancellationToken stoppingToken)
+    {
+        _logger.LogInformation(@"Starting continuous named pipe RPC server at \\.\pipe\{PipeName}",
+            _config.ServiceRpcPipeName);
+
+        // Allow everyone to connect to the named pipe
+        var pipeSecurity = new PipeSecurity();
+        pipeSecurity.AddAccessRule(new PipeAccessRule(
+            new SecurityIdentifier(WellKnownSidType.WorldSid, null),
+            PipeAccessRights.FullControl,
+            AccessControlType.Allow));
+
+        // Starting a named pipe server is not like a TCP server where you can
+        // continuously accept new connections. You need to recreate the server
+        // after accepting a connection in order to accept new connections.
+        using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(stoppingToken, _cts.Token);
+        while (!linkedCts.IsCancellationRequested)
+        {
+            var pipeServer = NamedPipeServerStreamAcl.Create(_config.ServiceRpcPipeName, PipeDirection.InOut,
+                NamedPipeServerStream.MaxAllowedServerInstances, PipeTransmissionMode.Byte, PipeOptions.Asynchronous, 0,
+                0, pipeSecurity);
+
+            try
+            {
+                _logger.LogDebug("Waiting for new named pipe client connection");
+                await pipeServer.WaitForConnectionAsync(linkedCts.Token);
+
+                var clientId = Interlocked.Add(ref _lastClientId, 1);
+                _logger.LogInformation("Handling named pipe client connection for client {ClientId}", clientId);
+                var speaker = new Speaker<ServiceMessage, ClientMessage>(pipeServer);
+                var clientTask = HandleRpcClientAsync(clientId, speaker, linkedCts.Token);
+                _activeClients.TryAdd(clientId, new ManagerRpcClient(speaker, clientTask));
+                _ = clientTask.ContinueWith(task =>
+                {
+                    if (task.IsFaulted)
+                        _logger.LogWarning(task.Exception, "Client {ClientId} RPC task faulted", clientId);
+                    _activeClients.TryRemove(clientId, out _);
+                }, CancellationToken.None);
+            }
+            catch (OperationCanceledException)
+            {
+                await pipeServer.DisposeAsync();
+                throw;
+            }
+            catch (Exception e)
+            {
+                _logger.LogWarning(e, "Failed to accept named pipe client");
+                await pipeServer.DisposeAsync();
+            }
+        }
+    }
+
+    public async Task BroadcastAsync(ServiceMessage message, CancellationToken ct)
+    {
+        // Looping over a ConcurrentDictionary is exception-safe, but any items
+        // added or removed during the loop may or may not be included.
+        foreach (var (clientId, client) in _activeClients)
+            try
+            {
+                var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
+                cts.CancelAfter(5 * 1000);
+                await client.Speaker.SendMessage(message, cts.Token);
+            }
+            catch (ObjectDisposedException)
+            {
+                // The speaker was likely closed while we were iterating.
+            }
+            catch (Exception e)
+            {
+                _logger.LogWarning(e, "Failed to send message to client {ClientId}", clientId);
+                // TODO: this should probably kill the client, but due to the
+                //       async nature of the client handling, calling Dispose
+                //       will not remove the client from the active clients list
+            }
+    }
+
+    private async Task HandleRpcClientAsync(ulong clientId, Speaker<ServiceMessage, ClientMessage> speaker,
+        CancellationToken ct)
+    {
+        var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(ct, _cts.Token);
+        await using (speaker)
+        {
+            var tcs = new TaskCompletionSource();
+            var activeTasks = new ConcurrentDictionary<int, Task>();
+            speaker.Receive += msg =>
+            {
+                var task = HandleRpcMessageAsync(clientId, msg, linkedCts.Token);
+                activeTasks.TryAdd(task.Id, task);
+                task.ContinueWith(t =>
+                {
+                    if (t.IsFaulted)
+                        _logger.LogWarning(t.Exception, "Client {ClientId} RPC message handler task faulted", clientId);
+                    activeTasks.TryRemove(t.Id, out _);
+                }, CancellationToken.None);
+            };
+            speaker.Error += tcs.SetException;
+            speaker.Error += exception =>
+            {
+                _logger.LogWarning(exception, "Client {clientId} RPC speaker error", clientId);
+            };
+            await using (ct.Register(() => tcs.SetCanceled(ct)))
+            {
+                await speaker.StartAsync(ct);
+                await tcs.Task;
+                await linkedCts.CancelAsync();
+                while (!activeTasks.IsEmpty)
+                    await Task.WhenAny(activeTasks.Values);
+            }
+        }
+    }
+
+    private async Task HandleRpcMessageAsync(ulong clientId, ReplyableRpcMessage<ServiceMessage, ClientMessage> message,
+        CancellationToken ct)
+    {
+        _logger.LogInformation("Received RPC message from client {ClientId}: {Message}", clientId, message.Message);
+        foreach (var handler in OnReceive?.GetInvocationList().Cast<IManagerRpc.OnReceiveHandler>() ?? [])
+            try
+            {
+                await handler(clientId, message, ct);
+            }
+            catch (Exception e)
+            {
+                _logger.LogWarning(e, "Failed to handle RPC message from client {ClientId} with handler", clientId);
+            }
+    }
+}
diff --git a/Vpn.Service/ManagerRpcService.cs b/Vpn.Service/ManagerRpcService.cs
index eb3cd0b..06eaa64 100644
--- a/Vpn.Service/ManagerRpcService.cs
+++ b/Vpn.Service/ManagerRpcService.cs
@@ -1,168 +1,24 @@
-using System.Collections.Concurrent;
-using System.IO.Pipes;
-using System.Security.AccessControl;
-using System.Security.Principal;
-using Coder.Desktop.Vpn.Proto;
 using Microsoft.Extensions.Hosting;
-using Microsoft.Extensions.Logging;
-using Microsoft.Extensions.Options;
 
 namespace Coder.Desktop.Vpn.Service;
 
-public class ManagerRpcClient(Speaker<ServiceMessage, ClientMessage> speaker, Task task)
+public class ManagerRpcService : BackgroundService
 {
-    public Speaker<ServiceMessage, ClientMessage> Speaker { get; } = speaker;
-    public Task Task { get; } = task;
-}
-
-/// <summary>
-///     Provides a named pipe server for communication between multiple RpcRole.Client and RpcRole.Manager.
-/// </summary>
-public class ManagerRpcService : BackgroundService, IAsyncDisposable
-{
-    private readonly ConcurrentDictionary<ulong, ManagerRpcClient> _activeClients = new();
-    private readonly ManagerConfig _config;
-    private readonly CancellationTokenSource _cts = new();
-    private readonly ILogger<ManagerRpcService> _logger;
-    private readonly IManager _manager;
-    private ulong _lastClientId;
+    private readonly IManagerRpc _managerRpc;
 
     // ReSharper disable once ConvertToPrimaryConstructor
-    public ManagerRpcService(IOptions<ManagerConfig> config, ILogger<ManagerRpcService> logger, IManager manager)
-    {
-        _logger = logger;
-        _manager = manager;
-        _config = config.Value;
-    }
-
-    public async ValueTask DisposeAsync()
+    public ManagerRpcService(IManagerRpc managerRpc)
     {
-        await _cts.CancelAsync();
-        while (!_activeClients.IsEmpty) await Task.WhenAny(_activeClients.Values.Select(c => c.Task));
-        _cts.Dispose();
-        GC.SuppressFinalize(this);
+        _managerRpc = managerRpc;
     }
 
     public override async Task StopAsync(CancellationToken cancellationToken)
     {
-        await _cts.CancelAsync();
-        while (!_activeClients.IsEmpty) await Task.WhenAny(_activeClients.Values.Select(c => c.Task));
+        await _managerRpc.StopAsync(cancellationToken);
     }
 
-    /// <summary>
-    ///     Starts the named pipe server, listens for incoming connections and starts handling them asynchronously.
-    /// </summary>
     protected override async Task ExecuteAsync(CancellationToken stoppingToken)
     {
-        _logger.LogInformation(@"Starting continuous named pipe RPC server at \\.\pipe\{PipeName}",
-            _config.ServiceRpcPipeName);
-
-        // Allow everyone to connect to the named pipe
-        var pipeSecurity = new PipeSecurity();
-        pipeSecurity.AddAccessRule(new PipeAccessRule(
-            new SecurityIdentifier(WellKnownSidType.WorldSid, null),
-            PipeAccessRights.FullControl,
-            AccessControlType.Allow));
-
-        // Starting a named pipe server is not like a TCP server where you can
-        // continuously accept new connections. You need to recreate the server
-        // after accepting a connection in order to accept new connections.
-        using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(stoppingToken, _cts.Token);
-        while (!linkedCts.IsCancellationRequested)
-        {
-            var pipeServer = NamedPipeServerStreamAcl.Create(_config.ServiceRpcPipeName, PipeDirection.InOut,
-                NamedPipeServerStream.MaxAllowedServerInstances, PipeTransmissionMode.Byte, PipeOptions.Asynchronous, 0,
-                0, pipeSecurity);
-
-            try
-            {
-                _logger.LogDebug("Waiting for new named pipe client connection");
-                await pipeServer.WaitForConnectionAsync(linkedCts.Token);
-
-                var clientId = Interlocked.Add(ref _lastClientId, 1);
-                _logger.LogInformation("Handling named pipe client connection for client {ClientId}", clientId);
-                var speaker = new Speaker<ServiceMessage, ClientMessage>(pipeServer);
-                var clientTask = HandleRpcClientAsync(speaker, linkedCts.Token);
-                _activeClients.TryAdd(clientId, new ManagerRpcClient(speaker, clientTask));
-                _ = clientTask.ContinueWith(task =>
-                {
-                    if (task.IsFaulted)
-                        _logger.LogWarning(task.Exception, "Client {ClientId} RPC task faulted", clientId);
-                    _activeClients.TryRemove(clientId, out _);
-                }, CancellationToken.None);
-            }
-            catch (OperationCanceledException)
-            {
-                await pipeServer.DisposeAsync();
-                throw;
-            }
-            catch (Exception e)
-            {
-                _logger.LogWarning(e, "Failed to accept named pipe client");
-                await pipeServer.DisposeAsync();
-            }
-        }
-    }
-
-    private async Task HandleRpcClientAsync(Speaker<ServiceMessage, ClientMessage> speaker, CancellationToken ct)
-    {
-        var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(ct, _cts.Token);
-        await using (speaker)
-        {
-            var tcs = new TaskCompletionSource();
-            var activeTasks = new ConcurrentDictionary<int, Task>();
-            speaker.Receive += msg =>
-            {
-                var task = HandleRpcMessageAsync(msg, linkedCts.Token);
-                activeTasks.TryAdd(task.Id, task);
-                task.ContinueWith(t =>
-                {
-                    if (t.IsFaulted)
-                        _logger.LogWarning(t.Exception, "Client RPC message handler task faulted");
-                    activeTasks.TryRemove(t.Id, out _);
-                }, CancellationToken.None);
-            };
-            speaker.Error += tcs.SetException;
-            speaker.Error += exception => { _logger.LogWarning(exception, "Client RPC speaker error"); };
-            await using (ct.Register(() => tcs.SetCanceled(ct)))
-            {
-                await speaker.StartAsync(ct);
-                await tcs.Task;
-                await linkedCts.CancelAsync();
-                while (!activeTasks.IsEmpty)
-                    await Task.WhenAny(activeTasks.Values);
-            }
-        }
-    }
-
-    private async Task HandleRpcMessageAsync(ReplyableRpcMessage<ServiceMessage, ClientMessage> message,
-        CancellationToken ct)
-    {
-        _logger.LogInformation("Received RPC message: {Message}", message.Message);
-        await _manager.HandleClientRpcMessage(message, ct);
-    }
-
-    public async Task BroadcastAsync(ServiceMessage message, CancellationToken ct)
-    {
-        // Looping over a ConcurrentDictionary is exception-safe, but any items
-        // added or removed during the loop may or may not be included.
-        foreach (var (clientId, client) in _activeClients)
-            try
-            {
-                var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
-                cts.CancelAfter(5 * 1000);
-                await client.Speaker.SendMessage(message, cts.Token);
-            }
-            catch (ObjectDisposedException)
-            {
-                // The speaker was likely closed while we were iterating.
-            }
-            catch (Exception e)
-            {
-                _logger.LogWarning(e, "Failed to send message to client {ClientId}", clientId);
-                // TODO: this should probably kill the client, but due to the
-                //       async nature of the client handling, calling Dispose
-                //       will not remove the client from the active clients list
-            }
+        await _managerRpc.ExecuteAsync(stoppingToken);
     }
 }
diff --git a/Vpn.Service/Program.cs b/Vpn.Service/Program.cs
index e46e674..c2a1037 100644
--- a/Vpn.Service/Program.cs
+++ b/Vpn.Service/Program.cs
@@ -9,9 +9,9 @@ namespace Coder.Desktop.Vpn.Service;
 public static class Program
 {
 #if DEBUG
-    private const string serviceName = "Coder Desktop (Debug)";
+    private const string ServiceName = "Coder Desktop (Debug)";
 #else
-    const string serviceName = "Coder Desktop";
+    const string ServiceName = "Coder Desktop";
 #endif
 
     private static readonly ILogger MainLogger = Log.ForContext("SourceContext", "Coder.Desktop.Vpn.Service.Program");
@@ -69,14 +69,14 @@ private static async Task BuildAndRun(string[] args)
         // Singletons
         builder.Services.AddSingleton<IDownloader, Downloader>();
         builder.Services.AddSingleton<ITunnelSupervisor, TunnelSupervisor>();
+        builder.Services.AddSingleton<IManagerRpc, ManagerRpc>();
         builder.Services.AddSingleton<IManager, Manager>();
 
         // Services
-        // TODO: is this sound enough to determine if we're a service?
         if (!Environment.UserInteractive)
         {
             MainLogger.Information("Running as a windows service");
-            builder.Services.AddWindowsService(options => { options.ServiceName = serviceName; });
+            builder.Services.AddWindowsService(options => { options.ServiceName = ServiceName; });
         }
         else
         {
diff --git a/Vpn.Service/TunnelSupervisor.cs b/Vpn.Service/TunnelSupervisor.cs
index b02d893..a323cac 100644
--- a/Vpn.Service/TunnelSupervisor.cs
+++ b/Vpn.Service/TunnelSupervisor.cs
@@ -3,13 +3,13 @@
 using Coder.Desktop.Vpn.Proto;
 using Coder.Desktop.Vpn.Utilities;
 using Microsoft.Extensions.Logging;
+using Log = Serilog.Log;
+using Process = System.Diagnostics.Process;
 
 namespace Coder.Desktop.Vpn.Service;
 
 public interface ITunnelSupervisor : IAsyncDisposable
 {
-    public bool IsRunning { get; }
-
     /// <summary>
     ///     Starts the tunnel subprocess with the given executable path. If the subprocess is already running, this method will
     ///     kill it first.
@@ -62,7 +62,6 @@ public class TunnelSupervisor : ITunnelSupervisor
     private AnonymousPipeServerStream? _inPipe;
     private AnonymousPipeServerStream? _outPipe;
     private Speaker<ManagerMessage, TunnelMessage>? _speaker;
-
     private Process? _subprocess;
 
     // ReSharper disable once ConvertToPrimaryConstructor
@@ -71,8 +70,6 @@ public TunnelSupervisor(ILogger<TunnelSupervisor> logger)
         _logger = logger;
     }
 
-    public bool IsRunning => _speaker != null;
-
     public async Task StartAsync(string binPath,
         Speaker<ManagerMessage, TunnelMessage>.OnReceiveDelegate messageHandler,
         Speaker<ManagerMessage, TunnelMessage>.OnErrorDelegate errorHandler,
@@ -101,15 +98,19 @@ public async Task StartAsync(string binPath,
                     RedirectStandardOutput = true,
                 },
             };
+            // TODO: maybe we should change the log format in the inner binary
+            // to something without a timestamp
+            var outLogger = Log.ForContext("SourceContext", "coder-vpn.exe[OUT]");
+            var errLogger = Log.ForContext("SourceContext", "coder-vpn.exe[ERR]");
             _subprocess.OutputDataReceived += (_, args) =>
             {
                 if (!string.IsNullOrWhiteSpace(args.Data))
-                    _logger.LogDebug("OUT: {Data}", args.Data);
+                    outLogger.Debug("{Data}", args.Data);
             };
             _subprocess.ErrorDataReceived += (_, args) =>
             {
                 if (!string.IsNullOrWhiteSpace(args.Data))
-                    _logger.LogDebug("ERR: {Data}", args.Data);
+                    errLogger.Debug("{Data}", args.Data);
             };
 
             // Pass the other end of the pipes to the subprocess and dispose