diff --git a/Agent/Phantom.Agent.Rpc/KeepAliveLoop.cs b/Agent/Phantom.Agent.Rpc/KeepAliveLoop.cs
index a314a46..47913b9 100644
--- a/Agent/Phantom.Agent.Rpc/KeepAliveLoop.cs
+++ b/Agent/Phantom.Agent.Rpc/KeepAliveLoop.cs
@@ -21,9 +21,11 @@ sealed class KeepAliveLoop {
 
 	private async Task Run() {
 		var cancellationToken = cancellationTokenSource.Token;
-
-		Logger.Information("Started keep-alive loop.");
+		
 		try {
+			await connection.IsReady.WaitAsync(cancellationToken);
+			Logger.Information("Started keep-alive loop.");
+			
 			while (true) {
 				await Task.Delay(KeepAliveInterval, cancellationToken);
 				await connection.Send(new AgentIsAliveMessage()).WaitAsync(cancellationToken);
diff --git a/Agent/Phantom.Agent.Services/Instances/Instance.cs b/Agent/Phantom.Agent.Services/Instances/Instance.cs
index 8977b1e..a8abd07 100644
--- a/Agent/Phantom.Agent.Services/Instances/Instance.cs
+++ b/Agent/Phantom.Agent.Services/Instances/Instance.cs
@@ -17,6 +17,7 @@ sealed class Instance : IAsyncDisposable {
 	private IServerLauncher Launcher { get; set; }
 	private readonly SemaphoreSlim configurationSemaphore = new (1, 1);
 
+	private readonly Guid instanceGuid;
 	private readonly string shortName;
 	private readonly ILogger logger;
 
@@ -29,7 +30,8 @@ sealed class Instance : IAsyncDisposable {
 	
 	private readonly InstanceProcedureManager procedureManager;
 
-	public Instance(string shortName, InstanceServices services, InstanceConfiguration configuration, IServerLauncher launcher) {
+	public Instance(Guid instanceGuid, string shortName, InstanceServices services, InstanceConfiguration configuration, IServerLauncher launcher) {
+		this.instanceGuid = instanceGuid;
 		this.shortName = shortName;
 		this.logger = PhantomLogger.Create<Instance>(shortName);
 
@@ -44,16 +46,16 @@ sealed class Instance : IAsyncDisposable {
 	}
 
 	public void ReportLastStatus() {
-		Services.ControllerConnection.Send(new ReportInstanceStatusMessage(Configuration.InstanceGuid, currentStatus));
+		Services.ControllerConnection.Send(new ReportInstanceStatusMessage(instanceGuid, currentStatus));
 	}
 
 	private void ReportAndSetStatus(IInstanceStatus status) {
 		currentStatus = status;
-		Services.ControllerConnection.Send(new ReportInstanceStatusMessage(Configuration.InstanceGuid, status));
+		Services.ControllerConnection.Send(new ReportInstanceStatusMessage(instanceGuid, status));
 	}
 
 	private void ReportEvent(IInstanceEvent instanceEvent) {
-		Services.ControllerConnection.Send(new ReportInstanceEventMessage(Guid.NewGuid(), DateTime.UtcNow, Configuration.InstanceGuid, instanceEvent));
+		Services.ControllerConnection.Send(new ReportInstanceEventMessage(Guid.NewGuid(), DateTime.UtcNow, instanceGuid, instanceEvent));
 	}
 	
 	internal void TransitionState(IInstanceState newState) {
@@ -99,7 +101,7 @@ sealed class Instance : IAsyncDisposable {
 		
 		await configurationSemaphore.WaitAsync(cancellationToken);
 		try {
-			procedure = new LaunchInstanceProcedure(Configuration, Launcher);
+			procedure = new LaunchInstanceProcedure(instanceGuid, Configuration, Launcher);
 		} finally {
 			configurationSemaphore.Release();
 		}
diff --git a/Agent/Phantom.Agent.Services/Instances/InstanceSessionManager.cs b/Agent/Phantom.Agent.Services/Instances/InstanceSessionManager.cs
index 87d0dab..2289691 100644
--- a/Agent/Phantom.Agent.Services/Instances/InstanceSessionManager.cs
+++ b/Agent/Phantom.Agent.Services/Instances/InstanceSessionManager.cs
@@ -75,9 +75,8 @@ sealed class InstanceSessionManager : IAsyncDisposable {
 		});
 	}
 
-	public async Task<InstanceActionResult<ConfigureInstanceResult>> Configure(InstanceConfiguration configuration, InstanceLaunchProperties launchProperties, bool launchNow, bool alwaysReportStatus) {
+	public async Task<InstanceActionResult<ConfigureInstanceResult>> Configure(Guid instanceGuid, InstanceConfiguration configuration, InstanceLaunchProperties launchProperties, bool launchNow, bool alwaysReportStatus) {
 		return await AcquireSemaphoreAndRun(async () => {
-			var instanceGuid = configuration.InstanceGuid;
 			var instanceFolder = Path.Combine(basePath, instanceGuid.ToString());
 			Directories.Create(instanceFolder, Chmod.URWX_GRX);
 
@@ -106,15 +105,15 @@ sealed class InstanceSessionManager : IAsyncDisposable {
 
 			if (instances.TryGetValue(instanceGuid, out var instance)) {
 				await instance.Reconfigure(configuration, launcher, shutdownCancellationToken);
-				Logger.Information("Reconfigured instance \"{Name}\" (GUID {Guid}).", configuration.InstanceName, configuration.InstanceGuid);
+				Logger.Information("Reconfigured instance \"{Name}\" (GUID {Guid}).", configuration.InstanceName, instanceGuid);
 
 				if (alwaysReportStatus) {
 					instance.ReportLastStatus();
 				}
 			}
 			else {
-				instances[instanceGuid] = instance = new Instance(GetInstanceLoggerName(instanceGuid), instanceServices, configuration, launcher);
-				Logger.Information("Created instance \"{Name}\" (GUID {Guid}).", configuration.InstanceName, configuration.InstanceGuid);
+				instances[instanceGuid] = instance = new Instance(instanceGuid, GetInstanceLoggerName(instanceGuid), instanceServices, configuration, launcher);
+				Logger.Information("Created instance \"{Name}\" (GUID {Guid}).", configuration.InstanceName, instanceGuid);
 
 				instance.ReportLastStatus();
 				instance.IsRunningChanged += OnInstanceIsRunningChanged;
diff --git a/Agent/Phantom.Agent.Services/Instances/Procedures/LaunchInstanceProcedure.cs b/Agent/Phantom.Agent.Services/Instances/Procedures/LaunchInstanceProcedure.cs
index f9552b7..1038f09 100644
--- a/Agent/Phantom.Agent.Services/Instances/Procedures/LaunchInstanceProcedure.cs
+++ b/Agent/Phantom.Agent.Services/Instances/Procedures/LaunchInstanceProcedure.cs
@@ -6,7 +6,7 @@ using Phantom.Common.Data.Instance;
 
 namespace Phantom.Agent.Services.Instances.Procedures;
 
-sealed record LaunchInstanceProcedure(InstanceConfiguration Configuration, IServerLauncher Launcher, bool IsRestarting = false) : IInstanceProcedure {
+sealed record LaunchInstanceProcedure(Guid InstanceGuid, InstanceConfiguration Configuration, IServerLauncher Launcher, bool IsRestarting = false) : IInstanceProcedure {
 	public async Task<IInstanceState?> Run(IInstanceContext context, CancellationToken cancellationToken) {
 		if (!IsRestarting && context.CurrentState is InstanceRunningState) {
 			return null;
@@ -30,7 +30,7 @@ sealed record LaunchInstanceProcedure(InstanceConfiguration Configuration, IServ
 		context.Logger.Information("Session starting...");
 		try {
 			InstanceProcess process = await DoLaunch(context, cancellationToken);
-			return new InstanceRunningState(Configuration, Launcher, process, context);
+			return new InstanceRunningState(InstanceGuid, Configuration, Launcher, process, context);
 		} catch (OperationCanceledException) {
 			context.SetStatus(InstanceStatus.NotRunning);
 		} catch (LaunchFailureException e) {
diff --git a/Agent/Phantom.Agent.Services/Instances/States/InstanceRunningState.cs b/Agent/Phantom.Agent.Services/Instances/States/InstanceRunningState.cs
index 92d36ee..b0af65a 100644
--- a/Agent/Phantom.Agent.Services/Instances/States/InstanceRunningState.cs
+++ b/Agent/Phantom.Agent.Services/Instances/States/InstanceRunningState.cs
@@ -12,6 +12,7 @@ sealed class InstanceRunningState : IInstanceState, IDisposable {
 
 	internal bool IsStopping { get; set; }
 
+	private readonly Guid instanceGuid;
 	private readonly InstanceConfiguration configuration;
 	private readonly IServerLauncher launcher;
 	private readonly IInstanceContext context;
@@ -21,13 +22,14 @@ sealed class InstanceRunningState : IInstanceState, IDisposable {
 
 	private bool isDisposed;
 
-	public InstanceRunningState(InstanceConfiguration configuration, IServerLauncher launcher, InstanceProcess process, IInstanceContext context) {
+	public InstanceRunningState(Guid instanceGuid, InstanceConfiguration configuration, IServerLauncher launcher, InstanceProcess process, IInstanceContext context) {
+		this.instanceGuid = instanceGuid;
 		this.configuration = configuration;
 		this.launcher = launcher;
 		this.context = context;
 		this.Process = process;
 
-		this.logSender = new InstanceLogSender(context.Services.ControllerConnection, context.Services.TaskManager, configuration.InstanceGuid, context.ShortName);
+		this.logSender = new InstanceLogSender(context.Services.ControllerConnection, context.Services.TaskManager, instanceGuid, context.ShortName);
 
 		this.backupScheduler = new BackupScheduler(context.Services.TaskManager, context.Services.BackupManager, process, context, configuration.ServerPort);
 		this.backupScheduler.BackupCompleted += OnScheduledBackupCompleted;
@@ -64,7 +66,7 @@ sealed class InstanceRunningState : IInstanceState, IDisposable {
 		else {
 			context.Logger.Information("Session ended unexpectedly, restarting...");
 			context.ReportEvent(InstanceEvent.Crashed);
-			context.EnqueueProcedure(new LaunchInstanceProcedure(configuration, launcher, IsRestarting: true));
+			context.EnqueueProcedure(new LaunchInstanceProcedure(instanceGuid, configuration, launcher, IsRestarting: true));
 		}
 	}
 
diff --git a/Agent/Phantom.Agent.Services/Rpc/MessageListener.cs b/Agent/Phantom.Agent.Services/Rpc/MessageListener.cs
index 053c20c..22b9a62 100644
--- a/Agent/Phantom.Agent.Services/Rpc/MessageListener.cs
+++ b/Agent/Phantom.Agent.Services/Rpc/MessageListener.cs
@@ -27,19 +27,21 @@ public sealed class MessageListener : IMessageToAgentListener {
 	public async Task<NoReply> HandleRegisterAgentSuccess(RegisterAgentSuccessMessage message) {
 		Logger.Information("Agent authentication successful.");
 
-		void ShutdownAfterConfigurationFailed(InstanceConfiguration configuration) {
-			Logger.Fatal("Unable to configure instance \"{Name}\" (GUID {Guid}), shutting down.", configuration.InstanceName, configuration.InstanceGuid);
+		void ShutdownAfterConfigurationFailed(Guid instanceGuid, InstanceConfiguration configuration) {
+			Logger.Fatal("Unable to configure instance \"{Name}\" (GUID {Guid}), shutting down.", configuration.InstanceName, instanceGuid);
 			shutdownTokenSource.Cancel();
 		}
 		
 		foreach (var configureInstanceMessage in message.InitialInstanceConfigurations) {
 			var result = await HandleConfigureInstance(configureInstanceMessage, alwaysReportStatus: true);
 			if (!result.Is(ConfigureInstanceResult.Success)) {
-				ShutdownAfterConfigurationFailed(configureInstanceMessage.Configuration);
+				ShutdownAfterConfigurationFailed(configureInstanceMessage.InstanceGuid, configureInstanceMessage.Configuration);
 				return NoReply.Instance;
 			}
 		}
 
+		connection.SetIsReady();
+		
 		await connection.Send(new AdvertiseJavaRuntimesMessage(agent.JavaRuntimeRepository.All));
 		await agent.InstanceSessionManager.RefreshAgentStatus();
 		
@@ -62,7 +64,7 @@ public sealed class MessageListener : IMessageToAgentListener {
 	}
 	
 	private Task<InstanceActionResult<ConfigureInstanceResult>> HandleConfigureInstance(ConfigureInstanceMessage message, bool alwaysReportStatus) {
-		return agent.InstanceSessionManager.Configure(message.Configuration, message.LaunchProperties, message.LaunchNow, alwaysReportStatus);
+		return agent.InstanceSessionManager.Configure(message.InstanceGuid, message.Configuration, message.LaunchProperties, message.LaunchNow, alwaysReportStatus);
 	}
 	
 	public async Task<InstanceActionResult<ConfigureInstanceResult>> HandleConfigureInstance(ConfigureInstanceMessage message) {
diff --git a/Common/Phantom.Common.Data.Web/Agent/Agent.cs b/Common/Phantom.Common.Data.Web/Agent/Agent.cs
new file mode 100644
index 0000000..a78e30a
--- /dev/null
+++ b/Common/Phantom.Common.Data.Web/Agent/Agent.cs
@@ -0,0 +1,15 @@
+using MemoryPack;
+using Phantom.Common.Data.Agent;
+
+namespace Phantom.Common.Data.Web.Agent;
+
+[MemoryPackable(GenerateType.VersionTolerant)]
+public sealed partial record Agent(
+	[property: MemoryPackOrder(0)] Guid AgentGuid,
+	[property: MemoryPackOrder(1)] AgentConfiguration Configuration,
+	[property: MemoryPackOrder(2)] AgentStats? Stats,
+	[property: MemoryPackOrder(3)] IAgentConnectionStatus ConnectionStatus
+) {
+	[MemoryPackIgnore]
+	public RamAllocationUnits? AvailableMemory => Configuration.MaxMemory - Stats?.RunningInstanceMemory;
+}
diff --git a/Common/Phantom.Common.Data.Web/Agent/AgentConfiguration.cs b/Common/Phantom.Common.Data.Web/Agent/AgentConfiguration.cs
new file mode 100644
index 0000000..dd118ab
--- /dev/null
+++ b/Common/Phantom.Common.Data.Web/Agent/AgentConfiguration.cs
@@ -0,0 +1,19 @@
+using MemoryPack;
+using Phantom.Common.Data.Agent;
+
+namespace Phantom.Common.Data.Web.Agent;
+
+[MemoryPackable(GenerateType.VersionTolerant)]
+public sealed partial record AgentConfiguration(
+	[property: MemoryPackOrder(0)] string AgentName,
+	[property: MemoryPackOrder(1)] ushort ProtocolVersion,
+	[property: MemoryPackOrder(2)] string BuildVersion,
+	[property: MemoryPackOrder(3)] ushort MaxInstances,
+	[property: MemoryPackOrder(4)] RamAllocationUnits MaxMemory,
+	[property: MemoryPackOrder(5)] AllowedPorts? AllowedServerPorts = null,
+	[property: MemoryPackOrder(6)] AllowedPorts? AllowedRconPorts = null
+) {
+	public static AgentConfiguration From(AgentInfo agentInfo) {
+		return new AgentConfiguration(agentInfo.AgentName, agentInfo.ProtocolVersion, agentInfo.BuildVersion, agentInfo.MaxInstances, agentInfo.MaxMemory, agentInfo.AllowedServerPorts, agentInfo.AllowedRconPorts);
+	}
+}
diff --git a/Common/Phantom.Common.Data.Web/Agent/AgentWithStats.cs b/Common/Phantom.Common.Data.Web/Agent/AgentWithStats.cs
deleted file mode 100644
index 6aba38d..0000000
--- a/Common/Phantom.Common.Data.Web/Agent/AgentWithStats.cs
+++ /dev/null
@@ -1,22 +0,0 @@
-using MemoryPack;
-using Phantom.Common.Data.Agent;
-
-namespace Phantom.Common.Data.Web.Agent;
-
-[MemoryPackable(GenerateType.VersionTolerant)]
-public sealed partial record AgentWithStats(
-	[property: MemoryPackOrder(0)] Guid Guid,
-	[property: MemoryPackOrder(1)] string Name,
-	[property: MemoryPackOrder(2)] ushort ProtocolVersion,
-	[property: MemoryPackOrder(3)] string BuildVersion,
-	[property: MemoryPackOrder(4)] ushort MaxInstances,
-	[property: MemoryPackOrder(5)] RamAllocationUnits MaxMemory,
-	[property: MemoryPackOrder(6)] AllowedPorts? AllowedServerPorts,
-	[property: MemoryPackOrder(7)] AllowedPorts? AllowedRconPorts,
-	[property: MemoryPackOrder(8)] AgentStats? Stats,
-	[property: MemoryPackOrder(9)] DateTimeOffset? LastPing,
-	[property: MemoryPackOrder(10)] bool IsOnline
-) {
-	[MemoryPackIgnore]
-	public RamAllocationUnits? AvailableMemory => MaxMemory - Stats?.RunningInstanceMemory;
-}
diff --git a/Common/Phantom.Common.Data.Web/Agent/IAgentConnectionStatus.cs b/Common/Phantom.Common.Data.Web/Agent/IAgentConnectionStatus.cs
new file mode 100644
index 0000000..59e8080
--- /dev/null
+++ b/Common/Phantom.Common.Data.Web/Agent/IAgentConnectionStatus.cs
@@ -0,0 +1,27 @@
+using MemoryPack;
+
+namespace Phantom.Common.Data.Web.Agent;
+
+[MemoryPackable]
+[MemoryPackUnion(0, typeof(AgentIsOffline))]
+[MemoryPackUnion(1, typeof(AgentIsDisconnected))]
+[MemoryPackUnion(2, typeof(AgentIsOnline))]
+public partial interface IAgentConnectionStatus {}
+
+[MemoryPackable(GenerateType.VersionTolerant)]
+public sealed partial record AgentIsOffline : IAgentConnectionStatus;
+
+[MemoryPackable(GenerateType.VersionTolerant)]
+public sealed partial record AgentIsDisconnected([property: MemoryPackOrder(0)] DateTimeOffset LastPingTime) : IAgentConnectionStatus;
+
+[MemoryPackable(GenerateType.VersionTolerant)]
+public sealed partial record AgentIsOnline : IAgentConnectionStatus;
+
+public static class AgentConnectionStatus {
+	public static readonly IAgentConnectionStatus Offline = new AgentIsOffline();
+	public static readonly IAgentConnectionStatus Online = new AgentIsOnline();
+
+	public static IAgentConnectionStatus Disconnected(DateTimeOffset lastPingTime) {
+		return new AgentIsDisconnected(lastPingTime);
+	}
+}
diff --git a/Common/Phantom.Common.Data.Web/Instance/Instance.cs b/Common/Phantom.Common.Data.Web/Instance/Instance.cs
index fe2ac10..908580d 100644
--- a/Common/Phantom.Common.Data.Web/Instance/Instance.cs
+++ b/Common/Phantom.Common.Data.Web/Instance/Instance.cs
@@ -5,11 +5,12 @@ namespace Phantom.Common.Data.Web.Instance;
 
 [MemoryPackable(GenerateType.VersionTolerant)]
 public sealed partial record Instance(
-	[property: MemoryPackOrder(0)] InstanceConfiguration Configuration,
-	[property: MemoryPackOrder(1)] IInstanceStatus Status,
-	[property: MemoryPackOrder(2)] bool LaunchAutomatically
+	[property: MemoryPackOrder(0)] Guid InstanceGuid,
+	[property: MemoryPackOrder(1)] InstanceConfiguration Configuration,
+	[property: MemoryPackOrder(2)] IInstanceStatus Status,
+	[property: MemoryPackOrder(3)] bool LaunchAutomatically
 ) {
-	public static Instance Offline(InstanceConfiguration configuration, bool launchAutomatically = false) {
-		return new Instance(configuration, InstanceStatus.Offline, launchAutomatically);
+	public static Instance Offline(Guid instanceGuid, InstanceConfiguration configuration, bool launchAutomatically = false) {
+		return new Instance(instanceGuid, configuration, InstanceStatus.Offline, launchAutomatically);
 	}
 }
diff --git a/Common/Phantom.Common.Data/Agent/AgentInfo.cs b/Common/Phantom.Common.Data/Agent/AgentInfo.cs
index 03710d0..911bd2e 100644
--- a/Common/Phantom.Common.Data/Agent/AgentInfo.cs
+++ b/Common/Phantom.Common.Data/Agent/AgentInfo.cs
@@ -4,8 +4,8 @@ namespace Phantom.Common.Data.Agent;
 
 [MemoryPackable(GenerateType.VersionTolerant)]
 public sealed partial record AgentInfo(
-	[property: MemoryPackOrder(0)] Guid Guid,
-	[property: MemoryPackOrder(1)] string Name,
+	[property: MemoryPackOrder(0)] Guid AgentGuid,
+	[property: MemoryPackOrder(1)] string AgentName,
 	[property: MemoryPackOrder(2)] ushort ProtocolVersion,
 	[property: MemoryPackOrder(3)] string BuildVersion,
 	[property: MemoryPackOrder(4)] ushort MaxInstances,
diff --git a/Common/Phantom.Common.Data/Instance/InstanceConfiguration.cs b/Common/Phantom.Common.Data/Instance/InstanceConfiguration.cs
index fee0d45..c072e1a 100644
--- a/Common/Phantom.Common.Data/Instance/InstanceConfiguration.cs
+++ b/Common/Phantom.Common.Data/Instance/InstanceConfiguration.cs
@@ -7,13 +7,12 @@ namespace Phantom.Common.Data.Instance;
 [MemoryPackable(GenerateType.VersionTolerant)]
 public sealed partial record InstanceConfiguration(
 	[property: MemoryPackOrder(0)] Guid AgentGuid,
-	[property: MemoryPackOrder(1)] Guid InstanceGuid,
-	[property: MemoryPackOrder(2)] string InstanceName,
-	[property: MemoryPackOrder(3)] ushort ServerPort,
-	[property: MemoryPackOrder(4)] ushort RconPort,
-	[property: MemoryPackOrder(5)] string MinecraftVersion,
-	[property: MemoryPackOrder(6)] MinecraftServerKind MinecraftServerKind,
-	[property: MemoryPackOrder(7)] RamAllocationUnits MemoryAllocation,
-	[property: MemoryPackOrder(8)] Guid JavaRuntimeGuid,
-	[property: MemoryPackOrder(9)] ImmutableArray<string> JvmArguments
+	[property: MemoryPackOrder(1)] string InstanceName,
+	[property: MemoryPackOrder(2)] ushort ServerPort,
+	[property: MemoryPackOrder(3)] ushort RconPort,
+	[property: MemoryPackOrder(4)] string MinecraftVersion,
+	[property: MemoryPackOrder(5)] MinecraftServerKind MinecraftServerKind,
+	[property: MemoryPackOrder(6)] RamAllocationUnits MemoryAllocation,
+	[property: MemoryPackOrder(7)] Guid JavaRuntimeGuid,
+	[property: MemoryPackOrder(8)] ImmutableArray<string> JvmArguments
 );
diff --git a/Common/Phantom.Common.Data/Replies/ConfigureInstanceResult.cs b/Common/Phantom.Common.Data/Replies/ConfigureInstanceResult.cs
index cb43bb2..70e9fe5 100644
--- a/Common/Phantom.Common.Data/Replies/ConfigureInstanceResult.cs
+++ b/Common/Phantom.Common.Data/Replies/ConfigureInstanceResult.cs
@@ -3,3 +3,12 @@
 public enum ConfigureInstanceResult : byte {
 	Success
 }
+
+public static class ConfigureInstanceResultExtensions {
+	public static string ToSentence(this ConfigureInstanceResult reason) {
+		return reason switch {
+			ConfigureInstanceResult.Success => "Success.",
+			_                               => "Unknown error."
+		};
+	}
+}
diff --git a/Common/Phantom.Common.Data/Replies/InstanceActionGeneralResult.cs b/Common/Phantom.Common.Data/Replies/InstanceActionGeneralResult.cs
index d0dfb1f..2cd08b5 100644
--- a/Common/Phantom.Common.Data/Replies/InstanceActionGeneralResult.cs
+++ b/Common/Phantom.Common.Data/Replies/InstanceActionGeneralResult.cs
@@ -2,6 +2,7 @@
 
 public enum InstanceActionGeneralResult : byte {
 	None,
+	AgentDoesNotExist,
 	AgentShuttingDown,
 	AgentIsNotResponding,
 	InstanceDoesNotExist
diff --git a/Common/Phantom.Common.Data/Replies/InstanceActionResult.cs b/Common/Phantom.Common.Data/Replies/InstanceActionResult.cs
index 0417dce..2ac7b9c 100644
--- a/Common/Phantom.Common.Data/Replies/InstanceActionResult.cs
+++ b/Common/Phantom.Common.Data/Replies/InstanceActionResult.cs
@@ -18,6 +18,7 @@ public sealed partial record InstanceActionResult<T>(
 	public string ToSentence(Func<T, string> concreteResultToSentence) {
 		return GeneralResult switch {
 			InstanceActionGeneralResult.None                 => concreteResultToSentence(ConcreteResult!),
+			InstanceActionGeneralResult.AgentDoesNotExist    => "Agent does not exist.",
 			InstanceActionGeneralResult.AgentShuttingDown    => "Agent is shutting down.",
 			InstanceActionGeneralResult.AgentIsNotResponding => "Agent is not responding.",
 			InstanceActionGeneralResult.InstanceDoesNotExist => "Instance does not exist.",
diff --git a/Common/Phantom.Common.Messages.Agent/ToAgent/ConfigureInstanceMessage.cs b/Common/Phantom.Common.Messages.Agent/ToAgent/ConfigureInstanceMessage.cs
index 66ef533..cc13f29 100644
--- a/Common/Phantom.Common.Messages.Agent/ToAgent/ConfigureInstanceMessage.cs
+++ b/Common/Phantom.Common.Messages.Agent/ToAgent/ConfigureInstanceMessage.cs
@@ -6,9 +6,10 @@ namespace Phantom.Common.Messages.Agent.ToAgent;
 
 [MemoryPackable(GenerateType.VersionTolerant)]
 public sealed partial record ConfigureInstanceMessage(
-	[property: MemoryPackOrder(0)] InstanceConfiguration Configuration,
-	[property: MemoryPackOrder(1)] InstanceLaunchProperties LaunchProperties,
-	[property: MemoryPackOrder(2)] bool LaunchNow = false
+	[property: MemoryPackOrder(0)] Guid InstanceGuid,
+	[property: MemoryPackOrder(1)] InstanceConfiguration Configuration,
+	[property: MemoryPackOrder(2)] InstanceLaunchProperties LaunchProperties,
+	[property: MemoryPackOrder(3)] bool LaunchNow = false
 ) : IMessageToAgent<InstanceActionResult<ConfigureInstanceResult>> {
 	public Task<InstanceActionResult<ConfigureInstanceResult>> Accept(IMessageToAgentListener listener) {
 		return listener.HandleConfigureInstance(this);
diff --git a/Common/Phantom.Common.Messages.Web/ToController/CreateOrUpdateInstanceMessage.cs b/Common/Phantom.Common.Messages.Web/ToController/CreateOrUpdateInstanceMessage.cs
index f10ca68..fc3fea4 100644
--- a/Common/Phantom.Common.Messages.Web/ToController/CreateOrUpdateInstanceMessage.cs
+++ b/Common/Phantom.Common.Messages.Web/ToController/CreateOrUpdateInstanceMessage.cs
@@ -8,7 +8,8 @@ namespace Phantom.Common.Messages.Web.ToController;
 [MemoryPackable(GenerateType.VersionTolerant)]
 public sealed partial record CreateOrUpdateInstanceMessage(
 	[property: MemoryPackOrder(0)] Guid LoggedInUserGuid,
-	[property: MemoryPackOrder(1)] InstanceConfiguration Configuration
+	[property: MemoryPackOrder(1)] Guid InstanceGuid,
+	[property: MemoryPackOrder(2)] InstanceConfiguration Configuration
 ) : IMessageToController<InstanceActionResult<CreateOrUpdateInstanceResult>> {
 	public Task<InstanceActionResult<CreateOrUpdateInstanceResult>> Accept(IMessageToControllerListener listener) {
 		return listener.HandleCreateOrUpdateInstance(this);
diff --git a/Common/Phantom.Common.Messages.Web/ToController/LaunchInstanceMessage.cs b/Common/Phantom.Common.Messages.Web/ToController/LaunchInstanceMessage.cs
index 7b2ab9d..a30e98c 100644
--- a/Common/Phantom.Common.Messages.Web/ToController/LaunchInstanceMessage.cs
+++ b/Common/Phantom.Common.Messages.Web/ToController/LaunchInstanceMessage.cs
@@ -6,7 +6,8 @@ namespace Phantom.Common.Messages.Web.ToController;
 [MemoryPackable(GenerateType.VersionTolerant)]
 public sealed partial record LaunchInstanceMessage(
 	[property: MemoryPackOrder(0)] Guid LoggedInUserGuid,
-	[property: MemoryPackOrder(1)] Guid InstanceGuid
+	[property: MemoryPackOrder(1)] Guid AgentGuid,
+	[property: MemoryPackOrder(2)] Guid InstanceGuid
 ) : IMessageToController<InstanceActionResult<LaunchInstanceResult>> {
 	public Task<InstanceActionResult<LaunchInstanceResult>> Accept(IMessageToControllerListener listener) {
 		return listener.HandleLaunchInstance(this);
diff --git a/Common/Phantom.Common.Messages.Web/ToController/SendCommandToInstanceMessage.cs b/Common/Phantom.Common.Messages.Web/ToController/SendCommandToInstanceMessage.cs
index 8295510..dc677d1 100644
--- a/Common/Phantom.Common.Messages.Web/ToController/SendCommandToInstanceMessage.cs
+++ b/Common/Phantom.Common.Messages.Web/ToController/SendCommandToInstanceMessage.cs
@@ -6,8 +6,9 @@ namespace Phantom.Common.Messages.Web.ToController;
 [MemoryPackable(GenerateType.VersionTolerant)]
 public sealed partial record SendCommandToInstanceMessage(
 	[property: MemoryPackOrder(0)] Guid LoggedInUserGuid,
-	[property: MemoryPackOrder(1)] Guid InstanceGuid,
-	[property: MemoryPackOrder(2)] string Command
+	[property: MemoryPackOrder(1)] Guid AgentGuid,
+	[property: MemoryPackOrder(2)] Guid InstanceGuid,
+	[property: MemoryPackOrder(3)] string Command
 ) : IMessageToController<InstanceActionResult<SendCommandToInstanceResult>> {
 	public Task<InstanceActionResult<SendCommandToInstanceResult>> Accept(IMessageToControllerListener listener) {
 		return listener.HandleSendCommandToInstance(this);
diff --git a/Common/Phantom.Common.Messages.Web/ToController/StopInstanceMessage.cs b/Common/Phantom.Common.Messages.Web/ToController/StopInstanceMessage.cs
index adc986d..c147afd 100644
--- a/Common/Phantom.Common.Messages.Web/ToController/StopInstanceMessage.cs
+++ b/Common/Phantom.Common.Messages.Web/ToController/StopInstanceMessage.cs
@@ -7,8 +7,9 @@ namespace Phantom.Common.Messages.Web.ToController;
 [MemoryPackable(GenerateType.VersionTolerant)]
 public sealed partial record StopInstanceMessage(
 	[property: MemoryPackOrder(0)] Guid LoggedInUserGuid,
-	[property: MemoryPackOrder(1)] Guid InstanceGuid,
-	[property: MemoryPackOrder(2)] MinecraftStopStrategy StopStrategy
+	[property: MemoryPackOrder(1)] Guid AgentGuid,
+	[property: MemoryPackOrder(2)] Guid InstanceGuid,
+	[property: MemoryPackOrder(3)] MinecraftStopStrategy StopStrategy
 ) : IMessageToController<InstanceActionResult<StopInstanceResult>> {
 	public Task<InstanceActionResult<StopInstanceResult>> Accept(IMessageToControllerListener listener) {
 		return listener.HandleStopInstance(this);
diff --git a/Common/Phantom.Common.Messages.Web/ToWeb/RefreshAgentsMessage.cs b/Common/Phantom.Common.Messages.Web/ToWeb/RefreshAgentsMessage.cs
index 807d438..e718d96 100644
--- a/Common/Phantom.Common.Messages.Web/ToWeb/RefreshAgentsMessage.cs
+++ b/Common/Phantom.Common.Messages.Web/ToWeb/RefreshAgentsMessage.cs
@@ -7,7 +7,7 @@ namespace Phantom.Common.Messages.Web.ToWeb;
 
 [MemoryPackable(GenerateType.VersionTolerant)]
 public sealed partial record RefreshAgentsMessage(
-	[property: MemoryPackOrder(0)] ImmutableArray<AgentWithStats> Agents
+	[property: MemoryPackOrder(0)] ImmutableArray<Agent> Agents
 ) : IMessageToWeb {
 	public Task<NoReply> Accept(IMessageToWebListener listener) {
 		return listener.HandleRefreshAgents(this);
diff --git a/Controller/Phantom.Controller.Services/Agents/Agent.cs b/Controller/Phantom.Controller.Services/Agents/Agent.cs
deleted file mode 100644
index 59ee355..0000000
--- a/Controller/Phantom.Controller.Services/Agents/Agent.cs
+++ /dev/null
@@ -1,39 +0,0 @@
-using Phantom.Common.Data;
-using Phantom.Common.Data.Agent;
-
-namespace Phantom.Controller.Services.Agents;
-
-public sealed record Agent(
-	Guid Guid,
-	string Name,
-	ushort ProtocolVersion,
-	string BuildVersion,
-	ushort MaxInstances,
-	RamAllocationUnits MaxMemory,
-	AllowedPorts? AllowedServerPorts = null,
-	AllowedPorts? AllowedRconPorts = null,
-	AgentStats? Stats = null,
-	DateTimeOffset? LastPing = null
-) {
-	internal AgentConnection? Connection { get; init; }
-	
-	public bool IsOnline { get; internal init; }
-	public bool IsOffline => !IsOnline;
-
-	internal Agent(AgentInfo info) : this(info.Guid, info.Name, info.ProtocolVersion, info.BuildVersion, info.MaxInstances, info.MaxMemory, info.AllowedServerPorts, info.AllowedRconPorts) {}
-
-	internal Agent AsOnline(DateTimeOffset lastPing) => this with {
-		LastPing = lastPing,
-		IsOnline = Connection != null
-	};
-	
-	internal Agent AsDisconnected() => this with {
-		IsOnline = false
-	};
-	
-	internal Agent AsOffline() => this with {
-		Connection = null,
-		Stats = null,
-		IsOnline = false
-	};
-}
diff --git a/Controller/Phantom.Controller.Services/Agents/AgentActor.cs b/Controller/Phantom.Controller.Services/Agents/AgentActor.cs
new file mode 100644
index 0000000..3c01cb6
--- /dev/null
+++ b/Controller/Phantom.Controller.Services/Agents/AgentActor.cs
@@ -0,0 +1,348 @@
+using System.Collections.Immutable;
+using Akka.Actor;
+using Microsoft.EntityFrameworkCore;
+using Phantom.Common.Data;
+using Phantom.Common.Data.Agent;
+using Phantom.Common.Data.Instance;
+using Phantom.Common.Data.Java;
+using Phantom.Common.Data.Minecraft;
+using Phantom.Common.Data.Replies;
+using Phantom.Common.Data.Web.Agent;
+using Phantom.Common.Data.Web.Instance;
+using Phantom.Common.Data.Web.Minecraft;
+using Phantom.Common.Messages.Agent;
+using Phantom.Common.Messages.Agent.ToAgent;
+using Phantom.Controller.Database;
+using Phantom.Controller.Minecraft;
+using Phantom.Controller.Services.Instances;
+using Phantom.Utils.Actor;
+using Phantom.Utils.Actor.Mailbox;
+using Phantom.Utils.Actor.Tasks;
+using Phantom.Utils.Logging;
+using Phantom.Utils.Rpc.Runtime;
+using Serilog;
+
+namespace Phantom.Controller.Services.Agents;
+
+sealed class AgentActor : ReceiveActor<AgentActor.ICommand> {
+	private static readonly ILogger Logger = PhantomLogger.Create<AgentActor>();
+
+	private static readonly TimeSpan DisconnectionRecheckInterval = TimeSpan.FromSeconds(5);
+	private static readonly TimeSpan DisconnectionThreshold = TimeSpan.FromSeconds(12);
+
+	public readonly record struct Init(Guid AgentGuid, AgentConfiguration AgentConfiguration, ControllerState ControllerState, MinecraftVersions MinecraftVersions, IDbContextProvider DbProvider, CancellationToken CancellationToken);
+	
+	public static Props<ICommand> Factory(Init init) {
+		return Props<ICommand>.Create(() => new AgentActor(init), new ActorConfiguration { SupervisorStrategy = SupervisorStrategies.Resume, MailboxType = UnboundedJumpAheadMailbox.Name });
+	}
+
+	private readonly ControllerState controllerState;
+	private readonly MinecraftVersions minecraftVersions;
+	private readonly IDbContextProvider dbProvider;
+	private readonly CancellationToken cancellationToken;
+	
+	private readonly Guid agentGuid;
+	
+	private AgentConfiguration configuration;
+	private AgentStats? stats;
+	private ImmutableArray<TaggedJavaRuntime> javaRuntimes = ImmutableArray<TaggedJavaRuntime>.Empty;
+	
+	private readonly AgentConnection connection;
+	
+	private DateTimeOffset? lastPingTime;
+	private bool isOnline;
+
+	private IAgentConnectionStatus ConnectionStatus {
+		get {
+			if (isOnline) {
+				return AgentConnectionStatus.Online;
+			}
+			else if (lastPingTime == null) {
+				return AgentConnectionStatus.Offline;
+			}
+			else {
+				return AgentConnectionStatus.Disconnected(lastPingTime.Value);
+			}
+		}
+	}
+
+	private readonly ActorRef<AgentDatabaseStorageActor.ICommand> databaseStorageActor;
+	
+	private readonly Dictionary<Guid, ActorRef<InstanceActor.ICommand>> instanceActorByGuid = new ();
+	private readonly Dictionary<Guid, Instance> instanceDataByGuid = new ();
+
+	private AgentActor(Init init) {
+		this.controllerState = init.ControllerState;
+		this.minecraftVersions = init.MinecraftVersions;
+		this.dbProvider = init.DbProvider;
+		this.cancellationToken = init.CancellationToken;
+		
+		this.agentGuid = init.AgentGuid;
+		this.configuration = init.AgentConfiguration;
+		this.connection = new AgentConnection(agentGuid, configuration.AgentName);
+		
+		this.databaseStorageActor = Context.ActorOf(AgentDatabaseStorageActor.Factory(new AgentDatabaseStorageActor.Init(agentGuid, init.DbProvider, init.CancellationToken)), "DatabaseStorage");
+
+		NotifyAgentUpdated();
+		
+		ReceiveAsync<InitializeCommand>(Initialize);
+		ReceiveAsyncAndReply<RegisterCommand, ImmutableArray<ConfigureInstanceMessage>>(Register);
+		Receive<UnregisterCommand>(Unregister);
+		Receive<RefreshConnectionStatusCommand>(RefreshConnectionStatus);
+		Receive<NotifyIsAliveCommand>(NotifyIsAlive);
+		Receive<UpdateStatsCommand>(UpdateStats);
+		Receive<UpdateJavaRuntimesCommand>(UpdateJavaRuntimes);
+		ReceiveAndReplyLater<CreateOrUpdateInstanceCommand, InstanceActionResult<CreateOrUpdateInstanceResult>>(CreateOrUpdateInstance);
+		Receive<UpdateInstanceStatusCommand>(UpdateInstanceStatus);
+		ReceiveAndReplyLater<LaunchInstanceCommand, InstanceActionResult<LaunchInstanceResult>>(LaunchInstance);
+		ReceiveAndReplyLater<StopInstanceCommand, InstanceActionResult<StopInstanceResult>>(StopInstance);
+		ReceiveAndReplyLater<SendCommandToInstanceCommand, InstanceActionResult<SendCommandToInstanceResult>>(SendMinecraftCommand);
+		Receive<ReceiveInstanceDataCommand>(ReceiveInstanceData);
+	}
+
+	private void NotifyAgentUpdated() {
+		controllerState.UpdateAgent(new Agent(agentGuid, configuration, stats, ConnectionStatus));
+	}
+
+	protected override void PreStart() {
+		Self.Tell(new InitializeCommand());
+		
+		Context.System.Scheduler.ScheduleTellRepeatedly(DisconnectionRecheckInterval, DisconnectionRecheckInterval, Self, new RefreshConnectionStatusCommand(), Self);
+	}
+
+	private ActorRef<InstanceActor.ICommand> CreateNewInstance(Instance instance) {
+		UpdateInstanceData(instance);
+		
+		var instanceActor = CreateInstanceActor(instance);
+		instanceActorByGuid.Add(instance.InstanceGuid, instanceActor);
+		return instanceActor;
+	}
+
+	private void UpdateInstanceData(Instance instance) {
+		instanceDataByGuid[instance.InstanceGuid] = instance;
+		controllerState.UpdateInstance(instance);
+	}
+
+	private ActorRef<InstanceActor.ICommand> CreateInstanceActor(Instance instance) {
+		var init = new InstanceActor.Init(instance, SelfTyped, connection, dbProvider, cancellationToken);
+		var name = "Instance:" + instance.InstanceGuid;
+		return Context.ActorOf(InstanceActor.Factory(init), name);
+	}
+
+	private void TellInstance(Guid instanceGuid, InstanceActor.ICommand command) {
+		if (instanceActorByGuid.TryGetValue(instanceGuid, out var instance)) {
+			instance.Tell(command);
+		}
+		else {
+			Logger.Warning("Could not deliver command {CommandType} to instance {InstanceGuid}, instance not found.", command.GetType().Name, instanceGuid);
+		}
+	}
+
+	private void TellAllInstances(InstanceActor.ICommand command) {
+		foreach (var instance in instanceActorByGuid.Values) {
+			instance.Tell(command);
+		}
+	}
+
+	private Task<InstanceActionResult<TReply>> RequestInstance<TCommand, TReply>(Guid instanceGuid, TCommand command) where TCommand : InstanceActor.ICommand, ICanReply<InstanceActionResult<TReply>> {
+		if (instanceActorByGuid.TryGetValue(instanceGuid, out var instance)) {
+			return instance.Request(command, cancellationToken);
+		}
+		else {
+			Logger.Warning("Could not deliver command {CommandType} to instance {InstanceGuid}, instance not found.", command.GetType().Name, instanceGuid);
+			return Task.FromResult(InstanceActionResult.General<TReply>(InstanceActionGeneralResult.InstanceDoesNotExist));
+		}
+	}
+
+	private async Task<ImmutableArray<ConfigureInstanceMessage>> PrepareInitialConfigurationMessages() {
+		var configurationMessages = ImmutableArray.CreateBuilder<ConfigureInstanceMessage>();
+		
+		foreach (var (instanceGuid, instanceConfiguration, _, launchAutomatically) in instanceDataByGuid.Values.ToImmutableArray()) {
+			var serverExecutableInfo = await minecraftVersions.GetServerExecutableInfo(instanceConfiguration.MinecraftVersion, cancellationToken);
+			configurationMessages.Add(new ConfigureInstanceMessage(instanceGuid, instanceConfiguration, new InstanceLaunchProperties(serverExecutableInfo), launchAutomatically));
+		}
+
+		return configurationMessages.ToImmutable();
+	}
+
+	public interface ICommand {}
+	
+	private sealed record InitializeCommand : ICommand;
+	
+	public sealed record RegisterCommand(AgentConfiguration Configuration, RpcConnectionToClient<IMessageToAgentListener> Connection) : ICommand, ICanReply<ImmutableArray<ConfigureInstanceMessage>>;
+	
+	public sealed record UnregisterCommand(RpcConnectionToClient<IMessageToAgentListener> Connection) : ICommand;
+	
+	private sealed record RefreshConnectionStatusCommand : ICommand;
+	
+	public sealed record NotifyIsAliveCommand : ICommand;
+	
+	public sealed record UpdateStatsCommand(int RunningInstanceCount, RamAllocationUnits RunningInstanceMemory) : ICommand;
+	
+	public sealed record UpdateJavaRuntimesCommand(ImmutableArray<TaggedJavaRuntime> JavaRuntimes) : ICommand;
+	
+	public sealed record CreateOrUpdateInstanceCommand(Guid AuditLogUserGuid, Guid InstanceGuid, InstanceConfiguration Configuration) : ICommand, ICanReply<InstanceActionResult<CreateOrUpdateInstanceResult>>;
+	
+	public sealed record UpdateInstanceStatusCommand(Guid InstanceGuid, IInstanceStatus Status) : ICommand;
+
+	public sealed record LaunchInstanceCommand(Guid InstanceGuid, Guid AuditLogUserGuid) : ICommand, ICanReply<InstanceActionResult<LaunchInstanceResult>>;
+	
+	public sealed record StopInstanceCommand(Guid InstanceGuid, Guid AuditLogUserGuid, MinecraftStopStrategy StopStrategy) : ICommand, ICanReply<InstanceActionResult<StopInstanceResult>>;
+	
+	public sealed record SendCommandToInstanceCommand(Guid InstanceGuid, Guid AuditLogUserGuid, string Command) : ICommand, ICanReply<InstanceActionResult<SendCommandToInstanceResult>>;
+	
+	public sealed record ReceiveInstanceDataCommand(Instance Instance) : ICommand, IJumpAhead;
+
+	private async Task Initialize(InitializeCommand command) {
+		await using var ctx = dbProvider.Eager();
+		await foreach (var entity in ctx.Instances.Where(instance => instance.AgentGuid == agentGuid).AsAsyncEnumerable().WithCancellation(cancellationToken)) {
+			var instanceConfiguration = new InstanceConfiguration(
+				entity.AgentGuid,
+				entity.InstanceName,
+				entity.ServerPort,
+				entity.RconPort,
+				entity.MinecraftVersion,
+				entity.MinecraftServerKind,
+				entity.MemoryAllocation,
+				entity.JavaRuntimeGuid,
+				JvmArgumentsHelper.Split(entity.JvmArguments)
+			);
+
+			CreateNewInstance(Instance.Offline(entity.InstanceGuid, instanceConfiguration, entity.LaunchAutomatically));
+		}
+	}
+
+	private async Task<ImmutableArray<ConfigureInstanceMessage>> Register(RegisterCommand command) {
+		var configurationMessages = await PrepareInitialConfigurationMessages();
+		
+		configuration = command.Configuration;
+		connection.UpdateConnection(command.Connection, configuration.AgentName);
+		
+		lastPingTime = DateTimeOffset.Now;
+		isOnline = true;
+		NotifyAgentUpdated();
+		
+		Logger.Information("Registered agent \"{Name}\" (GUID {Guid}).", configuration.AgentName, agentGuid);
+		
+		databaseStorageActor.Tell(new AgentDatabaseStorageActor.StoreAgentConfigurationCommand(configuration));
+		
+		return configurationMessages;
+	}
+
+	private void Unregister(UnregisterCommand command) {
+		if (connection.CloseIfSame(command.Connection)) {
+			stats = null;
+			lastPingTime = null;
+			isOnline = false;
+			NotifyAgentUpdated();
+			
+			TellAllInstances(new InstanceActor.SetStatusCommand(InstanceStatus.Offline));
+			
+			Logger.Information("Unregistered agent \"{Name}\" (GUID {Guid}).", configuration.AgentName, agentGuid);
+		}
+	}
+
+	private void RefreshConnectionStatus(RefreshConnectionStatusCommand command) {
+		if (isOnline && lastPingTime != null && DateTimeOffset.Now - lastPingTime >= DisconnectionThreshold) {
+			isOnline = false;
+			NotifyAgentUpdated();
+			
+			Logger.Warning("Lost connection to agent \"{Name}\" (GUID {Guid}).", configuration.AgentName, agentGuid);
+		}
+	}
+	
+	private void NotifyIsAlive(NotifyIsAliveCommand command) {
+		lastPingTime = DateTimeOffset.Now;
+		
+		if (!isOnline) {
+			isOnline = true;
+			NotifyAgentUpdated();
+		}
+	}
+
+	private void UpdateStats(UpdateStatsCommand command) {
+		stats = new AgentStats(command.RunningInstanceCount, command.RunningInstanceMemory);
+		NotifyAgentUpdated();
+	}
+
+	private void UpdateJavaRuntimes(UpdateJavaRuntimesCommand command) {
+		javaRuntimes = command.JavaRuntimes;
+		controllerState.UpdateAgentJavaRuntimes(agentGuid, javaRuntimes);
+	}
+	
+	private Task<InstanceActionResult<CreateOrUpdateInstanceResult>> CreateOrUpdateInstance(CreateOrUpdateInstanceCommand command) {
+		var instanceConfiguration = command.Configuration;
+
+		if (string.IsNullOrWhiteSpace(instanceConfiguration.InstanceName)) {
+			return Task.FromResult(InstanceActionResult.Concrete(CreateOrUpdateInstanceResult.InstanceNameMustNotBeEmpty));
+		}
+		
+		if (instanceConfiguration.MemoryAllocation <= RamAllocationUnits.Zero) {
+			return Task.FromResult(InstanceActionResult.Concrete(CreateOrUpdateInstanceResult.InstanceMemoryMustNotBeZero));
+		}
+		
+		return minecraftVersions.GetServerExecutableInfo(instanceConfiguration.MinecraftVersion, cancellationToken)
+		                        .ContinueOnActor(CreateOrUpdateInstance1, command)
+		                        .Unwrap();
+	}
+
+	private Task<InstanceActionResult<CreateOrUpdateInstanceResult>> CreateOrUpdateInstance1(FileDownloadInfo? serverExecutableInfo, CreateOrUpdateInstanceCommand command) {
+		if (serverExecutableInfo == null) {
+			return Task.FromResult(InstanceActionResult.Concrete(CreateOrUpdateInstanceResult.MinecraftVersionDownloadInfoNotFound));
+		}
+		
+		var instanceConfiguration = command.Configuration;
+		
+		bool isCreatingInstance = !instanceActorByGuid.TryGetValue(command.InstanceGuid, out var instanceActorRef);
+		if (isCreatingInstance) {
+			instanceActorRef = CreateNewInstance(Instance.Offline(command.InstanceGuid, instanceConfiguration));
+		}
+		
+		var configureInstanceCommand = new InstanceActor.ConfigureInstanceCommand(command.AuditLogUserGuid, command.InstanceGuid, instanceConfiguration, new InstanceLaunchProperties(serverExecutableInfo), isCreatingInstance);
+		
+		return instanceActorRef.Request(configureInstanceCommand, cancellationToken)
+		                       .ContinueOnActor(CreateOrUpdateInstance2, configureInstanceCommand);
+	}
+	
+	private InstanceActionResult<CreateOrUpdateInstanceResult> CreateOrUpdateInstance2(InstanceActionResult<ConfigureInstanceResult> result, InstanceActor.ConfigureInstanceCommand command) {
+		var instanceGuid = command.InstanceGuid;
+		var instanceName = command.Configuration.InstanceName;
+		var isCreating = command.IsCreatingInstance;
+
+		if (result.Is(ConfigureInstanceResult.Success)) {
+			string action = isCreating ? "Added" : "Edited";
+			string relation = isCreating ? "to agent" : "in agent";
+			Logger.Information(action + " instance \"{InstanceName}\" (GUID {InstanceGuid}) " + relation + " \"{AgentName}\".", instanceName, instanceGuid, configuration.AgentName);
+		}
+		else {
+			string action = isCreating ? "adding" : "editing";
+			string relation = isCreating ? "to agent" : "in agent";
+			Logger.Information("Failed " + action + " instance \"{InstanceName}\" (GUID {InstanceGuid}) " + relation + " \"{AgentName}\". {ErrorMessage}", instanceName, instanceGuid, configuration.AgentName, result.ToSentence(ConfigureInstanceResultExtensions.ToSentence));
+		}
+		
+		return result.Map(static result => result switch {
+			ConfigureInstanceResult.Success => CreateOrUpdateInstanceResult.Success,
+			_                               => CreateOrUpdateInstanceResult.UnknownError
+		});
+	}
+	
+	private void UpdateInstanceStatus(UpdateInstanceStatusCommand command) {
+		TellInstance(command.InstanceGuid, new InstanceActor.SetStatusCommand(command.Status));
+	}
+
+	private Task<InstanceActionResult<LaunchInstanceResult>> LaunchInstance(LaunchInstanceCommand command) {
+		return RequestInstance<InstanceActor.LaunchInstanceCommand, LaunchInstanceResult>(command.InstanceGuid, new InstanceActor.LaunchInstanceCommand(command.AuditLogUserGuid));
+	}
+
+	private Task<InstanceActionResult<StopInstanceResult>> StopInstance(StopInstanceCommand command) {
+		return RequestInstance<InstanceActor.StopInstanceCommand, StopInstanceResult>(command.InstanceGuid, new InstanceActor.StopInstanceCommand(command.AuditLogUserGuid, command.StopStrategy));
+	}
+
+	private Task<InstanceActionResult<SendCommandToInstanceResult>> SendMinecraftCommand(SendCommandToInstanceCommand command) {
+		return RequestInstance<InstanceActor.SendCommandToInstanceCommand, SendCommandToInstanceResult>(command.InstanceGuid, new InstanceActor.SendCommandToInstanceCommand(command.AuditLogUserGuid, command.Command));
+	}
+
+	private void ReceiveInstanceData(ReceiveInstanceDataCommand command) {
+		UpdateInstanceData(command.Instance);
+	}
+}
diff --git a/Controller/Phantom.Controller.Services/Agents/AgentConnection.cs b/Controller/Phantom.Controller.Services/Agents/AgentConnection.cs
index aabf20e..dec070e 100644
--- a/Controller/Phantom.Controller.Services/Agents/AgentConnection.cs
+++ b/Controller/Phantom.Controller.Services/Agents/AgentConnection.cs
@@ -1,28 +1,65 @@
 using Phantom.Common.Messages.Agent;
+using Phantom.Utils.Logging;
 using Phantom.Utils.Rpc.Runtime;
+using Serilog;
 
 namespace Phantom.Controller.Services.Agents;
 
 sealed class AgentConnection {
-	private readonly RpcConnectionToClient<IMessageToAgentListener> connection;
-
-	internal AgentConnection(RpcConnectionToClient<IMessageToAgentListener> connection) {
-		this.connection = connection;
+	private static readonly ILogger Logger = PhantomLogger.Create<AgentConnection>();
+	
+	private readonly Guid agentGuid;
+	private string agentName;
+	
+	private RpcConnectionToClient<IMessageToAgentListener>? connection;
+	
+	public AgentConnection(Guid agentGuid, string agentName) {
+		this.agentName = agentName;
+		this.agentGuid = agentGuid;
 	}
 
-	public bool IsSame(RpcConnectionToClient<IMessageToAgentListener> connection) {
-		return this.connection.IsSame(connection);
+	public void UpdateConnection(RpcConnectionToClient<IMessageToAgentListener> newConnection, string newAgentName) {
+		lock (this) {
+			connection?.Close();
+			connection = newConnection;
+			agentName = newAgentName;
+		}
 	}
 
-	public void Close() {
-		connection.Close();
+	public bool CloseIfSame(RpcConnectionToClient<IMessageToAgentListener> expected) {
+		lock (this) {
+			if (connection != null && connection.IsSame(expected)) {
+				connection.Close();
+				return true;
+			}
+		}
+		
+		return false;
 	}
 
 	public Task Send<TMessage>(TMessage message) where TMessage : IMessageToAgent {
-		return connection.Send(message);
+		lock (this) {
+			if (connection == null) {
+				LogAgentOffline();
+				return Task.CompletedTask;
+			}
+			
+			return connection.Send(message);
+		}
 	}
 
-	public Task<TReply> Send<TMessage, TReply>(TMessage message, TimeSpan waitForReplyTime, CancellationToken waitForReplyCancellationToken) where TMessage : IMessageToAgent<TReply> where TReply : class {
-		return connection.Send<TMessage, TReply>(message, waitForReplyTime, waitForReplyCancellationToken);
+	public Task<TReply?> Send<TMessage, TReply>(TMessage message, TimeSpan waitForReplyTime, CancellationToken waitForReplyCancellationToken) where TMessage : IMessageToAgent<TReply> where TReply : class {
+		lock (this) {
+			if (connection == null) {
+				LogAgentOffline();
+				return Task.FromResult<TReply?>(default);
+			}
+
+			return connection.Send<TMessage, TReply>(message, waitForReplyTime, waitForReplyCancellationToken)!;
+		}
+	}
+
+	private void LogAgentOffline() {
+		Logger.Error("Could not send message to offline agent \"{Name}\" (GUID {Guid}).", agentName, agentGuid);
 	}
 }
diff --git a/Controller/Phantom.Controller.Services/Agents/AgentDatabaseStorageActor.cs b/Controller/Phantom.Controller.Services/Agents/AgentDatabaseStorageActor.cs
new file mode 100644
index 0000000..2fe95a1
--- /dev/null
+++ b/Controller/Phantom.Controller.Services/Agents/AgentDatabaseStorageActor.cs
@@ -0,0 +1,82 @@
+using Phantom.Common.Data.Web.Agent;
+using Phantom.Controller.Database;
+using Phantom.Utils.Actor;
+using Phantom.Utils.Logging;
+using Serilog;
+
+namespace Phantom.Controller.Services.Agents;
+
+sealed class AgentDatabaseStorageActor : ReceiveActor<AgentDatabaseStorageActor.ICommand> {
+	private static readonly ILogger Logger = PhantomLogger.Create<AgentDatabaseStorageActor>();
+	
+	public readonly record struct Init(Guid AgentGuid, IDbContextProvider DbProvider, CancellationToken CancellationToken);
+	
+	public static Props<ICommand> Factory(Init init) {
+		return Props<ICommand>.Create(() => new AgentDatabaseStorageActor(init), new ActorConfiguration { SupervisorStrategy = SupervisorStrategies.Resume });
+	}
+	
+	private readonly Guid agentGuid;
+	private readonly IDbContextProvider dbProvider;
+	private readonly CancellationToken cancellationToken;
+	
+	private AgentConfiguration? configurationToStore;
+	private bool hasScheduledFlush;
+
+	private AgentDatabaseStorageActor(Init init) {
+		this.agentGuid = init.AgentGuid;
+		this.dbProvider = init.DbProvider;
+		this.cancellationToken = init.CancellationToken;
+		
+		Receive<StoreAgentConfigurationCommand>(StoreAgentConfiguration);
+		ReceiveAsync<FlushChangesCommand>(FlushChanges);
+	}
+
+	public interface ICommand {}
+	
+	public sealed record StoreAgentConfigurationCommand(AgentConfiguration Configuration) : ICommand;
+	
+	private sealed record FlushChangesCommand : ICommand;
+
+	private void StoreAgentConfiguration(StoreAgentConfigurationCommand command) {
+		this.configurationToStore = command.Configuration;
+		ScheduleFlush(TimeSpan.FromSeconds(2));
+	}
+
+	private async Task FlushChanges(FlushChangesCommand command) {
+		hasScheduledFlush = false;
+		
+		if (configurationToStore == null) {
+			return;
+		}
+
+		try {
+			await using var ctx = dbProvider.Eager();
+			var entity = ctx.AgentUpsert.Fetch(agentGuid);
+
+			entity.Name = configurationToStore.AgentName;
+			entity.ProtocolVersion = configurationToStore.ProtocolVersion;
+			entity.BuildVersion = configurationToStore.BuildVersion;
+			entity.MaxInstances = configurationToStore.MaxInstances;
+			entity.MaxMemory = configurationToStore.MaxMemory;
+
+			await ctx.SaveChangesAsync(cancellationToken);
+		} catch (Exception e) {
+			ScheduleFlush(TimeSpan.FromSeconds(10));
+			Logger.Error(e, "Could not store agent \"{AgentName}\" (GUID {AgentGuid}) in database.", configurationToStore.AgentName, agentGuid);
+			return;
+		}
+
+		Logger.Information("Stored agent \"{AgentName}\" (GUID {AgentGuid}) in database.", configurationToStore.AgentName, agentGuid);
+		
+		configurationToStore = null;
+	}
+
+	private void ScheduleFlush(TimeSpan delay) {
+		if (hasScheduledFlush) {
+			return;
+		}
+
+		hasScheduledFlush = true;
+		Context.System.Scheduler.ScheduleTellOnce(delay, Self, new FlushChangesCommand(), Self);
+	}
+}
diff --git a/Controller/Phantom.Controller.Services/Agents/AgentJavaRuntimesManager.cs b/Controller/Phantom.Controller.Services/Agents/AgentJavaRuntimesManager.cs
deleted file mode 100644
index 9083a4c..0000000
--- a/Controller/Phantom.Controller.Services/Agents/AgentJavaRuntimesManager.cs
+++ /dev/null
@@ -1,15 +0,0 @@
-using System.Collections.Immutable;
-using Phantom.Common.Data.Java;
-using Phantom.Utils.Collections;
-
-namespace Phantom.Controller.Services.Agents;
-
-sealed class AgentJavaRuntimesManager {
-	private readonly RwLockedDictionary<Guid, ImmutableArray<TaggedJavaRuntime>> runtimes = new (LockRecursionPolicy.NoRecursion);
-
-	public ImmutableDictionary<Guid, ImmutableArray<TaggedJavaRuntime>> All => runtimes.ToImmutable();
-	
-	internal void Update(Guid agentGuid, ImmutableArray<TaggedJavaRuntime> runtimes) {
-		this.runtimes[agentGuid] = runtimes;
-	}
-}
diff --git a/Controller/Phantom.Controller.Services/Agents/AgentManager.cs b/Controller/Phantom.Controller.Services/Agents/AgentManager.cs
index f373902..977da30 100644
--- a/Controller/Phantom.Controller.Services/Agents/AgentManager.cs
+++ b/Controller/Phantom.Controller.Services/Agents/AgentManager.cs
@@ -1,153 +1,94 @@
-using System.Collections.Immutable;
+using System.Collections.Concurrent;
+using Akka.Actor;
 using Phantom.Common.Data;
 using Phantom.Common.Data.Agent;
 using Phantom.Common.Data.Replies;
+using Phantom.Common.Data.Web.Agent;
 using Phantom.Common.Messages.Agent;
 using Phantom.Common.Messages.Agent.ToAgent;
 using Phantom.Controller.Database;
-using Phantom.Controller.Services.Instances;
-using Phantom.Utils.Collections;
-using Phantom.Utils.Events;
+using Phantom.Controller.Minecraft;
+using Phantom.Utils.Actor;
 using Phantom.Utils.Logging;
 using Phantom.Utils.Rpc.Runtime;
-using Phantom.Utils.Tasks;
 using Serilog;
 
 namespace Phantom.Controller.Services.Agents;
 
 sealed class AgentManager {
 	private static readonly ILogger Logger = PhantomLogger.Create<AgentManager>();
-
-	private static readonly TimeSpan DisconnectionRecheckInterval = TimeSpan.FromSeconds(5);
-	private static readonly TimeSpan DisconnectionThreshold = TimeSpan.FromSeconds(12);
-
-	private readonly ObservableAgents agents = new (PhantomLogger.Create<AgentManager, ObservableAgents>());
-
-	public EventSubscribers<ImmutableArray<Agent>> AgentsChanged => agents.Subs;
-
-	private readonly CancellationToken cancellationToken;
+	
+	private readonly ActorSystem actorSystem;
 	private readonly AuthToken authToken;
+	private readonly ControllerState controllerState;
+	private readonly MinecraftVersions minecraftVersions;
 	private readonly IDbContextProvider dbProvider;
-
-	public AgentManager(AuthToken authToken, IDbContextProvider dbProvider, TaskManager taskManager, CancellationToken cancellationToken) {
+	private readonly CancellationToken cancellationToken;
+	
+	private readonly ConcurrentDictionary<Guid, ActorRef<AgentActor.ICommand>> agentsByGuid = new ();
+	private readonly Func<Guid, AgentConfiguration, ActorRef<AgentActor.ICommand>> addAgentActorFactory;
+	
+	public AgentManager(ActorSystem actorSystem, AuthToken authToken, ControllerState controllerState, MinecraftVersions minecraftVersions, IDbContextProvider dbProvider, CancellationToken cancellationToken) {
+		this.actorSystem = actorSystem;
 		this.authToken = authToken;
+		this.controllerState = controllerState;
+		this.minecraftVersions = minecraftVersions;
 		this.dbProvider = dbProvider;
 		this.cancellationToken = cancellationToken;
-		taskManager.Run("Refresh agent status loop", RefreshAgentStatus);
+		
+		this.addAgentActorFactory = CreateAgentActor;
 	}
 
-	internal async Task Initialize() {
+	private ActorRef<AgentActor.ICommand> CreateAgentActor(Guid agentGuid, AgentConfiguration agentConfiguration) {
+		var init = new AgentActor.Init(agentGuid, agentConfiguration, controllerState, minecraftVersions, dbProvider, cancellationToken);
+		var name = "Agent:" + agentGuid;
+		return actorSystem.ActorOf(AgentActor.Factory(init), name);
+	}
+
+	public async Task Initialize() {
 		await using var ctx = dbProvider.Eager();
 
 		await foreach (var entity in ctx.Agents.AsAsyncEnumerable().WithCancellation(cancellationToken)) {
-			var agent = new Agent(entity.AgentGuid, entity.Name, entity.ProtocolVersion, entity.BuildVersion, entity.MaxInstances, entity.MaxMemory);
-			if (!agents.ByGuid.AddOrReplaceIf(agent.Guid, agent, static oldAgent => oldAgent.IsOffline)) {
-				// TODO
-				throw new InvalidOperationException("Unable to register agent from database: " + agent.Guid);
+			var agentGuid = entity.AgentGuid;
+			var agentConfiguration = new AgentConfiguration(entity.Name, entity.ProtocolVersion, entity.BuildVersion, entity.MaxInstances, entity.MaxMemory);
+
+			if (agentsByGuid.TryAdd(agentGuid, CreateAgentActor(agentGuid, agentConfiguration))) {
+				Logger.Information("Loaded agent \"{AgentName}\" (GUID {AgentGuid}) from database.", agentConfiguration.AgentName, agentGuid);
 			}
 		}
 	}
 
-	public ImmutableDictionary<Guid, Agent> GetAgents() {
-		return agents.ByGuid.ToImmutable();
-	}
-
-	internal async Task<bool> RegisterAgent(AuthToken authToken, AgentInfo agentInfo, InstanceManager instanceManager, RpcConnectionToClient<IMessageToAgentListener> connection) {
+	public async Task<bool> RegisterAgent(AuthToken authToken, AgentInfo agentInfo, RpcConnectionToClient<IMessageToAgentListener> connection) {
 		if (!this.authToken.FixedTimeEquals(authToken)) {
 			await connection.Send(new RegisterAgentFailureMessage(RegisterAgentFailure.InvalidToken));
 			return false;
 		}
-
-		var agent = new Agent(agentInfo) {
-			LastPing = DateTimeOffset.Now,
-			IsOnline = true,
-			Connection = new AgentConnection(connection)
-		};
-
-		if (agents.ByGuid.AddOrReplace(agent.Guid, agent, out var oldAgent)) {
-			oldAgent.Connection?.Close();
-		}
-
-		await using (var ctx = dbProvider.Eager()) {
-			var entity = ctx.AgentUpsert.Fetch(agent.Guid);
-
-			entity.Name = agent.Name;
-			entity.ProtocolVersion = agent.ProtocolVersion;
-			entity.BuildVersion = agent.BuildVersion;
-			entity.MaxInstances = agent.MaxInstances;
-			entity.MaxMemory = agent.MaxMemory;
-
-			await ctx.SaveChangesAsync(cancellationToken);
-		}
-
-		Logger.Information("Registered agent \"{Name}\" (GUID {Guid}).", agent.Name, agent.Guid);
-
-		var instanceConfigurations = await instanceManager.GetInstanceConfigurationsForAgent(agent.Guid);
-		await connection.Send(new RegisterAgentSuccessMessage(instanceConfigurations));
+		
+		var agentProperties = AgentConfiguration.From(agentInfo);
+		var agentActorRef = agentsByGuid.GetOrAdd(agentInfo.AgentGuid, addAgentActorFactory, agentProperties);
+		var configureInstanceMessages = await agentActorRef.Request(new AgentActor.RegisterCommand(agentProperties, connection), cancellationToken);
+		await connection.Send(new RegisterAgentSuccessMessage(configureInstanceMessages));
 		
 		return true;
 	}
 
-	internal bool UnregisterAgent(Guid agentGuid, RpcConnectionToClient<IMessageToAgentListener> connection) {
-		if (agents.ByGuid.TryReplaceIf(agentGuid, static oldAgent => oldAgent.AsOffline(), oldAgent => oldAgent.Connection?.IsSame(connection) == true)) {
-			Logger.Information("Unregistered agent with GUID {Guid}.", agentGuid);
+	public bool TellAgent(Guid agentGuid, AgentActor.ICommand command) {
+		if (agentsByGuid.TryGetValue(agentGuid, out var agent)) {
+			agent.Tell(command);
 			return true;
 		}
 		else {
+			Logger.Warning("Could not deliver command {CommandType} to agent {AgentGuid}, agent not registered.", command.GetType().Name, agentGuid);
 			return false;
 		}
 	}
-	
-	internal Agent? GetAgent(Guid guid) {
-		return agents.ByGuid.TryGetValue(guid, out var agent) ? agent : null;
-	}
 
-	internal void NotifyAgentIsAlive(Guid agentGuid) {
-		agents.ByGuid.TryReplace(agentGuid, static agent => agent.AsOnline(DateTimeOffset.Now));
-	}
-
-	internal void SetAgentStats(Guid agentGuid, int runningInstanceCount, RamAllocationUnits runningInstanceMemory) {
-		agents.ByGuid.TryReplace(agentGuid, agent => agent with { Stats = new AgentStats(runningInstanceCount, runningInstanceMemory) });
-	}
-
-	private async Task RefreshAgentStatus() {
-		static Agent MarkAgentAsOffline(Agent agent) {
-			Logger.Warning("Lost connection to agent \"{Name}\" (GUID {Guid}).", agent.Name, agent.Guid);
-			return agent.AsDisconnected();
+	public async Task<InstanceActionResult<TReply>> DoInstanceAction<TCommand, TReply>(Guid agentGuid, TCommand command) where TCommand : class, AgentActor.ICommand, ICanReply<InstanceActionResult<TReply>> {
+		if (agentsByGuid.TryGetValue(agentGuid, out var agent)) {
+			return await agent.Request(command, cancellationToken);
 		}
-
-		while (!cancellationToken.IsCancellationRequested) {
-			await Task.Delay(DisconnectionRecheckInterval, cancellationToken);
-
-			var now = DateTimeOffset.Now;
-			agents.ByGuid.ReplaceAllIf(MarkAgentAsOffline, agent => agent.IsOnline && agent.LastPing is {} lastPing && now - lastPing >= DisconnectionThreshold);
-		}
-	}
-
-	internal async Task<TReply?> SendMessage<TMessage, TReply>(Guid guid, TMessage message, TimeSpan waitForReplyTime) where TMessage : IMessageToAgent<TReply> where TReply : class {
-		var connection = agents.ByGuid.TryGetValue(guid, out var agent) ? agent.Connection : null;
-		if (connection == null || agent == null) {
-			// TODO handle missing agent?
-			return null;
-		}
-
-		try {
-			return await connection.Send<TMessage, TReply>(message, waitForReplyTime, cancellationToken);
-		} catch (Exception e) {
-			Logger.Error(e, "Could not send message to agent \"{Name}\" (GUID {Guid}).", agent.Name, agent.Guid);
-			return null;
-		}
-	}
-
-	private sealed class ObservableAgents : ObservableState<ImmutableArray<Agent>> {
-		public RwLockedObservableDictionary<Guid, Agent> ByGuid { get; } = new (LockRecursionPolicy.NoRecursion);
-
-		public ObservableAgents(ILogger logger) : base(logger) {
-			ByGuid.CollectionChanged += Update;
-		}
-
-		protected override ImmutableArray<Agent> GetData() {
-			return ByGuid.ValuesCopy;
+		else {
+			return InstanceActionResult.General<TReply>(InstanceActionGeneralResult.AgentDoesNotExist);
 		}
 	}
 }
diff --git a/Controller/Phantom.Controller.Services/ControllerServices.cs b/Controller/Phantom.Controller.Services/ControllerServices.cs
index d56c2a1..fc154ac 100644
--- a/Controller/Phantom.Controller.Services/ControllerServices.cs
+++ b/Controller/Phantom.Controller.Services/ControllerServices.cs
@@ -1,4 +1,5 @@
-using Phantom.Common.Data;
+using Akka.Actor;
+using Phantom.Common.Data;
 using Phantom.Common.Messages.Agent;
 using Phantom.Common.Messages.Web;
 using Phantom.Controller.Database;
@@ -8,19 +9,19 @@ using Phantom.Controller.Services.Events;
 using Phantom.Controller.Services.Instances;
 using Phantom.Controller.Services.Rpc;
 using Phantom.Controller.Services.Users;
+using Phantom.Utils.Actor;
 using Phantom.Utils.Logging;
 using Phantom.Utils.Rpc.Runtime;
 using Phantom.Utils.Tasks;
 
 namespace Phantom.Controller.Services;
 
-public sealed class ControllerServices {
+public sealed class ControllerServices : IAsyncDisposable {
 	private TaskManager TaskManager { get; }
+	private ControllerState ControllerState { get; }
 	private MinecraftVersions MinecraftVersions { get; }
 
 	private AgentManager AgentManager { get; }
-	private AgentJavaRuntimesManager AgentJavaRuntimesManager { get; }
-	private InstanceManager InstanceManager { get; }
 	private InstanceLogManager InstanceLogManager { get; }
 	private EventLogManager EventLogManager { get; }
 
@@ -32,17 +33,23 @@ public sealed class ControllerServices {
 	private UserLoginManager UserLoginManager { get; }
 	private AuditLogManager AuditLogManager { get; }
 	
+	private readonly ActorSystem actorSystem;
 	private readonly IDbContextProvider dbProvider;
 	private readonly AuthToken webAuthToken;
 	private readonly CancellationToken cancellationToken;
 	
 	public ControllerServices(IDbContextProvider dbProvider, AuthToken agentAuthToken, AuthToken webAuthToken, CancellationToken shutdownCancellationToken) {
+		this.dbProvider = dbProvider;
+		this.webAuthToken = webAuthToken;
+		this.cancellationToken = shutdownCancellationToken;
+		
+		this.actorSystem = ActorSystemFactory.Create("Controller");
+
 		this.TaskManager = new TaskManager(PhantomLogger.Create<TaskManager, ControllerServices>());
+		this.ControllerState = new ControllerState();
 		this.MinecraftVersions = new MinecraftVersions();
 		
-		this.AgentManager = new AgentManager(agentAuthToken, dbProvider, TaskManager, shutdownCancellationToken);
-		this.AgentJavaRuntimesManager = new AgentJavaRuntimesManager();
-		this.InstanceManager = new InstanceManager(AgentManager, MinecraftVersions, dbProvider, shutdownCancellationToken);
+		this.AgentManager = new AgentManager(actorSystem, agentAuthToken, ControllerState, MinecraftVersions, dbProvider, cancellationToken);
 		this.InstanceLogManager = new InstanceLogManager();
 		
 		this.UserManager = new UserManager(dbProvider);
@@ -53,25 +60,25 @@ public sealed class ControllerServices {
 		this.UserLoginManager = new UserLoginManager(UserManager, PermissionManager);
 		this.AuditLogManager = new AuditLogManager(dbProvider);
 		this.EventLogManager = new EventLogManager(dbProvider, TaskManager, shutdownCancellationToken);
-		
-		this.dbProvider = dbProvider;
-		this.webAuthToken = webAuthToken;
-		this.cancellationToken = shutdownCancellationToken;
 	}
 
 	public AgentMessageListener CreateAgentMessageListener(RpcConnectionToClient<IMessageToAgentListener> connection) {
-		return new AgentMessageListener(connection, AgentManager, AgentJavaRuntimesManager, InstanceManager, InstanceLogManager, EventLogManager, cancellationToken);
+		return new AgentMessageListener(connection, AgentManager, InstanceLogManager, EventLogManager, cancellationToken);
 	}
 
 	public WebMessageListener CreateWebMessageListener(RpcConnectionToClient<IMessageToWebListener> connection) {
-		return new WebMessageListener(connection, webAuthToken, UserManager, RoleManager, UserRoleManager, UserLoginManager, AuditLogManager, AgentManager, AgentJavaRuntimesManager, InstanceManager, InstanceLogManager, MinecraftVersions, EventLogManager, TaskManager);
+		return new WebMessageListener(actorSystem, connection, webAuthToken, ControllerState, UserManager, RoleManager, UserRoleManager, UserLoginManager, AuditLogManager, AgentManager, InstanceLogManager, MinecraftVersions, EventLogManager);
 	}
 
 	public async Task Initialize() {
 		await DatabaseMigrator.Run(dbProvider, cancellationToken);
+		await AgentManager.Initialize();
 		await PermissionManager.Initialize();
 		await RoleManager.Initialize();
-		await AgentManager.Initialize();
-		await InstanceManager.Initialize();
+	}
+
+	public async ValueTask DisposeAsync() {
+		await actorSystem.Terminate();
+		actorSystem.Dispose();
 	}
 }
diff --git a/Controller/Phantom.Controller.Services/ControllerState.cs b/Controller/Phantom.Controller.Services/ControllerState.cs
new file mode 100644
index 0000000..795da1a
--- /dev/null
+++ b/Controller/Phantom.Controller.Services/ControllerState.cs
@@ -0,0 +1,33 @@
+using System.Collections.Immutable;
+using Phantom.Common.Data.Java;
+using Phantom.Common.Data.Web.Agent;
+using Phantom.Common.Data.Web.Instance;
+using Phantom.Utils.Actor.Event;
+
+namespace Phantom.Controller.Services;
+
+sealed class ControllerState {
+	private readonly ObservableState<ImmutableDictionary<Guid, Agent>> agentsByGuid = new (ImmutableDictionary<Guid, Agent>.Empty);
+	private readonly ObservableState<ImmutableDictionary<Guid, ImmutableArray<TaggedJavaRuntime>>> agentJavaRuntimesByGuid = new (ImmutableDictionary<Guid, ImmutableArray<TaggedJavaRuntime>>.Empty);
+	private readonly ObservableState<ImmutableDictionary<Guid, Instance>> instancesByGuid = new (ImmutableDictionary<Guid, Instance>.Empty);
+
+	public ImmutableDictionary<Guid, Agent> AgentsByGuid => agentsByGuid.State;
+	public ImmutableDictionary<Guid, ImmutableArray<TaggedJavaRuntime>> AgentJavaRuntimesByGuid => agentJavaRuntimesByGuid.State;
+	public ImmutableDictionary<Guid, Instance> InstancesByGuid => instancesByGuid.State;
+
+	public ObservableState<ImmutableDictionary<Guid, Agent>>.Receiver AgentsByGuidReceiver => agentsByGuid.ReceiverSide;
+	public ObservableState<ImmutableDictionary<Guid, ImmutableArray<TaggedJavaRuntime>>>.Receiver AgentJavaRuntimesByGuidReceiver => agentJavaRuntimesByGuid.ReceiverSide;
+	public ObservableState<ImmutableDictionary<Guid, Instance>>.Receiver InstancesByGuidReceiver => instancesByGuid.ReceiverSide;
+	
+	public void UpdateAgent(Agent agent) {
+		agentsByGuid.PublisherSide.Publish(static (agentsByGuid, agent) => agentsByGuid.SetItem(agent.AgentGuid, agent), agent);
+	}
+	
+	public void UpdateAgentJavaRuntimes(Guid agentGuid, ImmutableArray<TaggedJavaRuntime> runtimes) {
+		agentJavaRuntimesByGuid.PublisherSide.Publish(static (agentJavaRuntimesByGuid, agentGuid, runtimes) => agentJavaRuntimesByGuid.SetItem(agentGuid, runtimes), agentGuid, runtimes);
+	}
+
+	public void UpdateInstance(Instance instance) {
+		instancesByGuid.PublisherSide.Publish(static (instancesByGuid, instance) => instancesByGuid.SetItem(instance.InstanceGuid, instance), instance);
+	}
+}
diff --git a/Controller/Phantom.Controller.Services/Instances/InstanceActor.cs b/Controller/Phantom.Controller.Services/Instances/InstanceActor.cs
new file mode 100644
index 0000000..f70d8f1
--- /dev/null
+++ b/Controller/Phantom.Controller.Services/Instances/InstanceActor.cs
@@ -0,0 +1,135 @@
+using Phantom.Common.Data.Instance;
+using Phantom.Common.Data.Minecraft;
+using Phantom.Common.Data.Replies;
+using Phantom.Common.Data.Web.Instance;
+using Phantom.Common.Messages.Agent;
+using Phantom.Common.Messages.Agent.ToAgent;
+using Phantom.Controller.Database;
+using Phantom.Controller.Services.Agents;
+using Phantom.Utils.Actor;
+
+namespace Phantom.Controller.Services.Instances;
+
+sealed class InstanceActor : ReceiveActor<InstanceActor.ICommand> {
+	public readonly record struct Init(Instance Instance, ActorRef<AgentActor.ICommand> AgentActorRef, AgentConnection AgentConnection, IDbContextProvider DbProvider, CancellationToken CancellationToken);
+	
+	public static Props<ICommand> Factory(Init init) {
+		return Props<ICommand>.Create(() => new InstanceActor(init), new ActorConfiguration { SupervisorStrategy = SupervisorStrategies.Resume });
+	}
+
+	private readonly ActorRef<AgentActor.ICommand> agentActorRef;
+	private readonly AgentConnection agentConnection;
+	private readonly CancellationToken cancellationToken;
+
+	private readonly Guid instanceGuid;
+	
+	private InstanceConfiguration configuration;
+	private IInstanceStatus status;
+	private bool launchAutomatically;
+
+	private readonly ActorRef<InstanceDatabaseStorageActor.ICommand> databaseStorageActor;
+	
+	private InstanceActor(Init init) {
+		this.agentActorRef = init.AgentActorRef;
+		this.agentConnection = init.AgentConnection;
+		this.cancellationToken = init.CancellationToken;
+		
+		(this.instanceGuid, this.configuration, this.status, this.launchAutomatically) = init.Instance;
+
+		this.databaseStorageActor = Context.ActorOf(InstanceDatabaseStorageActor.Factory(new InstanceDatabaseStorageActor.Init(instanceGuid, init.DbProvider, init.CancellationToken)), "DatabaseStorage");
+
+		Receive<SetStatusCommand>(SetStatus);
+		ReceiveAsyncAndReply<ConfigureInstanceCommand, InstanceActionResult<ConfigureInstanceResult>>(ConfigureInstance);
+		ReceiveAsyncAndReply<LaunchInstanceCommand, InstanceActionResult<LaunchInstanceResult>>(LaunchInstance);
+		ReceiveAsyncAndReply<StopInstanceCommand, InstanceActionResult<StopInstanceResult>>(StopInstance);
+		ReceiveAsyncAndReply<SendCommandToInstanceCommand, InstanceActionResult<SendCommandToInstanceResult>>(SendMinecraftCommand);
+	}
+
+	private void NotifyInstanceUpdated() {
+		agentActorRef.Tell(new AgentActor.ReceiveInstanceDataCommand(new Instance(instanceGuid, configuration, status, launchAutomatically)));
+	}
+
+	private void SetLaunchAutomatically(bool newValue) {
+		if (launchAutomatically != newValue) {
+			launchAutomatically = newValue;
+			NotifyInstanceUpdated();
+		}
+	}
+
+	private async Task<InstanceActionResult<TReply>> SendInstanceActionMessage<TMessage, TReply>(TMessage message) where TMessage : IMessageToAgent<InstanceActionResult<TReply>> {
+		var reply = await agentConnection.Send<TMessage, InstanceActionResult<TReply>>(message, TimeSpan.FromSeconds(10), cancellationToken);
+		return reply.DidNotReplyIfNull();
+	}
+
+	public interface ICommand {}
+
+	public sealed record SetStatusCommand(IInstanceStatus Status) : ICommand;
+
+	public sealed record ConfigureInstanceCommand(Guid AuditLogUserGuid, Guid InstanceGuid, InstanceConfiguration Configuration, InstanceLaunchProperties LaunchProperties, bool IsCreatingInstance) : ICommand, ICanReply<InstanceActionResult<ConfigureInstanceResult>>;
+
+	public sealed record LaunchInstanceCommand(Guid AuditLogUserGuid) : ICommand, ICanReply<InstanceActionResult<LaunchInstanceResult>>;
+	
+	public sealed record StopInstanceCommand(Guid AuditLogUserGuid, MinecraftStopStrategy StopStrategy) : ICommand, ICanReply<InstanceActionResult<StopInstanceResult>>;
+	
+	public sealed record SendCommandToInstanceCommand(Guid AuditLogUserGuid, string Command) : ICommand, ICanReply<InstanceActionResult<SendCommandToInstanceResult>>;
+
+	private void SetStatus(SetStatusCommand command) {
+		status = command.Status;
+		NotifyInstanceUpdated();
+	}
+
+	private async Task<InstanceActionResult<ConfigureInstanceResult>> ConfigureInstance(ConfigureInstanceCommand command) {
+		var message = new ConfigureInstanceMessage(command.InstanceGuid, command.Configuration, command.LaunchProperties);
+		var result = await SendInstanceActionMessage<ConfigureInstanceMessage, ConfigureInstanceResult>(message);
+		
+		if (result.Is(ConfigureInstanceResult.Success)) {
+			configuration = command.Configuration;
+			NotifyInstanceUpdated();
+
+			var storeCommand = new InstanceDatabaseStorageActor.StoreInstanceConfigurationCommand(
+				command.AuditLogUserGuid,
+				command.IsCreatingInstance,
+				configuration
+			);
+			
+			databaseStorageActor.Tell(storeCommand);
+		}
+		
+		return result;
+	}
+
+	private async Task<InstanceActionResult<LaunchInstanceResult>> LaunchInstance(LaunchInstanceCommand command) {
+		var message = new LaunchInstanceMessage(instanceGuid);
+		var result = await SendInstanceActionMessage<LaunchInstanceMessage, LaunchInstanceResult>(message);
+		
+		if (result.Is(LaunchInstanceResult.LaunchInitiated)) {
+			SetLaunchAutomatically(true);
+			databaseStorageActor.Tell(new InstanceDatabaseStorageActor.StoreInstanceLaunchedCommand(command.AuditLogUserGuid));
+		}
+
+		return result;
+	}
+
+	private async Task<InstanceActionResult<StopInstanceResult>> StopInstance(StopInstanceCommand command) {
+		var message = new StopInstanceMessage(instanceGuid, command.StopStrategy);
+		var result = await SendInstanceActionMessage<StopInstanceMessage, StopInstanceResult>(message);
+		
+		if (result.Is(StopInstanceResult.StopInitiated)) {
+			SetLaunchAutomatically(false);
+			databaseStorageActor.Tell(new InstanceDatabaseStorageActor.StoreInstanceStoppedCommand(command.AuditLogUserGuid, command.StopStrategy));
+		}
+
+		return result;
+	}
+
+	private async Task<InstanceActionResult<SendCommandToInstanceResult>> SendMinecraftCommand(SendCommandToInstanceCommand command) {
+		var message = new SendCommandToInstanceMessage(instanceGuid, command.Command);
+		var result = await SendInstanceActionMessage<SendCommandToInstanceMessage, SendCommandToInstanceResult>(message);
+		
+		if (result.Is(SendCommandToInstanceResult.Success)) {
+			databaseStorageActor.Tell(new InstanceDatabaseStorageActor.StoreInstanceCommandSentCommand(command.AuditLogUserGuid, command.Command));
+		}
+
+		return result;
+	}
+}
diff --git a/Controller/Phantom.Controller.Services/Instances/InstanceDatabaseStorageActor.cs b/Controller/Phantom.Controller.Services/Instances/InstanceDatabaseStorageActor.cs
new file mode 100644
index 0000000..bf58997
--- /dev/null
+++ b/Controller/Phantom.Controller.Services/Instances/InstanceDatabaseStorageActor.cs
@@ -0,0 +1,116 @@
+using Phantom.Common.Data.Instance;
+using Phantom.Common.Data.Minecraft;
+using Phantom.Common.Data.Web.Minecraft;
+using Phantom.Controller.Database;
+using Phantom.Controller.Database.Entities;
+using Phantom.Controller.Database.Repositories;
+using Phantom.Utils.Actor;
+using Phantom.Utils.Logging;
+using Serilog;
+
+namespace Phantom.Controller.Services.Instances;
+
+sealed class InstanceDatabaseStorageActor : ReceiveActor<InstanceDatabaseStorageActor.ICommand> {
+	private static readonly ILogger Logger = PhantomLogger.Create<InstanceDatabaseStorageActor>();
+	
+	public readonly record struct Init(Guid InstanceGuid, IDbContextProvider DbProvider, CancellationToken CancellationToken);
+
+	public static Props<ICommand> Factory(Init init) {
+		return Props<ICommand>.Create(() => new InstanceDatabaseStorageActor(init), new ActorConfiguration { SupervisorStrategy = SupervisorStrategies.Resume });
+	}
+
+	private readonly Guid instanceGuid;
+	private readonly IDbContextProvider dbProvider;
+	private readonly CancellationToken cancellationToken;
+
+	private InstanceDatabaseStorageActor(Init init) {
+		this.instanceGuid = init.InstanceGuid;
+		this.dbProvider = init.DbProvider;
+		this.cancellationToken = init.CancellationToken;
+		
+		ReceiveAsync<StoreInstanceConfigurationCommand>(StoreInstanceConfiguration);
+		ReceiveAsync<StoreInstanceLaunchedCommand>(StoreInstanceLaunched);
+		ReceiveAsync<StoreInstanceStoppedCommand>(StoreInstanceStopped);
+		ReceiveAsync<StoreInstanceCommandSentCommand>(StoreInstanceCommandSent);
+	}
+
+	private ValueTask<InstanceEntity?> FindInstanceEntity(ILazyDbContext db) {
+		return db.Ctx.Instances.FindAsync(new object[] { instanceGuid }, cancellationToken);
+	}
+
+	public interface ICommand {}
+
+	public sealed record StoreInstanceConfigurationCommand(Guid AuditLogUserGuid, bool IsCreatingInstance, InstanceConfiguration Configuration) : ICommand;
+	
+	public sealed record StoreInstanceLaunchedCommand(Guid AuditLogUserGuid) : ICommand;
+	
+	public sealed record StoreInstanceStoppedCommand(Guid AuditLogUserGuid, MinecraftStopStrategy StopStrategy) : ICommand;
+	
+	public sealed record StoreInstanceCommandSentCommand(Guid AuditLogUserGuid, string Command) : ICommand;
+
+	private async Task StoreInstanceConfiguration(StoreInstanceConfigurationCommand command) {
+		var configuration = command.Configuration;
+
+		await using (var db = dbProvider.Lazy()) {
+			InstanceEntity entity = db.Ctx.InstanceUpsert.Fetch(instanceGuid);
+			entity.AgentGuid = configuration.AgentGuid;
+			entity.InstanceName = configuration.InstanceName;
+			entity.ServerPort = configuration.ServerPort;
+			entity.RconPort = configuration.RconPort;
+			entity.MinecraftVersion = configuration.MinecraftVersion;
+			entity.MinecraftServerKind = configuration.MinecraftServerKind;
+			entity.MemoryAllocation = configuration.MemoryAllocation;
+			entity.JavaRuntimeGuid = configuration.JavaRuntimeGuid;
+			entity.JvmArguments = JvmArgumentsHelper.Join(configuration.JvmArguments);
+
+			var auditLogWriter = new AuditLogRepository(db).Writer(command.AuditLogUserGuid);
+			if (command.IsCreatingInstance) {
+				auditLogWriter.InstanceCreated(instanceGuid);
+			}
+			else {
+				auditLogWriter.InstanceEdited(instanceGuid);
+			}
+
+			await db.Ctx.SaveChangesAsync(cancellationToken);
+		}
+
+		Logger.Information("Stored instance \"{InstanceName}\" (GUID {InstanceGuid}) in database.", configuration.InstanceName, instanceGuid);
+	}
+
+	private async Task StoreInstanceLaunched(StoreInstanceLaunchedCommand command) {
+		await using var db = dbProvider.Lazy();
+		
+		var entity = await FindInstanceEntity(db);
+		if (entity != null) {
+			entity.LaunchAutomatically = true;
+		}
+		
+		var auditLogWriter = new AuditLogRepository(db).Writer(command.AuditLogUserGuid);
+		auditLogWriter.InstanceLaunched(instanceGuid);
+		
+		await db.Ctx.SaveChangesAsync(cancellationToken);
+	}
+	
+	private async Task StoreInstanceStopped(StoreInstanceStoppedCommand command) {
+		await using var db = dbProvider.Lazy();
+		
+		var entity = await FindInstanceEntity(db);
+		if (entity != null) {
+			entity.LaunchAutomatically = false;
+		}
+		
+		var auditLogWriter = new AuditLogRepository(db).Writer(command.AuditLogUserGuid);
+		auditLogWriter.InstanceStopped(instanceGuid, command.StopStrategy.Seconds);
+		
+		await db.Ctx.SaveChangesAsync(cancellationToken);
+	}
+
+	private async Task StoreInstanceCommandSent(StoreInstanceCommandSentCommand command) {
+		await using var db = dbProvider.Lazy();
+		
+		var auditLogWriter = new AuditLogRepository(db).Writer(command.AuditLogUserGuid);
+		auditLogWriter.InstanceCommandExecuted(instanceGuid, command.Command);
+		
+		await db.Ctx.SaveChangesAsync(cancellationToken);
+	}
+}
diff --git a/Controller/Phantom.Controller.Services/Instances/InstanceManager.cs b/Controller/Phantom.Controller.Services/Instances/InstanceManager.cs
deleted file mode 100644
index f95bcd6..0000000
--- a/Controller/Phantom.Controller.Services/Instances/InstanceManager.cs
+++ /dev/null
@@ -1,241 +0,0 @@
-using System.Collections.Immutable;
-using System.Diagnostics.CodeAnalysis;
-using Phantom.Common.Data;
-using Phantom.Common.Data.Instance;
-using Phantom.Common.Data.Minecraft;
-using Phantom.Common.Data.Replies;
-using Phantom.Common.Data.Web.Instance;
-using Phantom.Common.Data.Web.Minecraft;
-using Phantom.Common.Messages.Agent;
-using Phantom.Common.Messages.Agent.ToAgent;
-using Phantom.Controller.Database;
-using Phantom.Controller.Database.Entities;
-using Phantom.Controller.Database.Repositories;
-using Phantom.Controller.Minecraft;
-using Phantom.Controller.Services.Agents;
-using Phantom.Utils.Collections;
-using Phantom.Utils.Events;
-using Phantom.Utils.Logging;
-using Serilog;
-
-namespace Phantom.Controller.Services.Instances;
-
-sealed class InstanceManager {
-	private static readonly ILogger Logger = PhantomLogger.Create<InstanceManager>();
-
-	private readonly ObservableInstances instances = new (PhantomLogger.Create<InstanceManager, ObservableInstances>());
-
-	public EventSubscribers<ImmutableDictionary<Guid, Instance>> InstancesChanged => instances.Subs;
-
-	private readonly AgentManager agentManager;
-	private readonly MinecraftVersions minecraftVersions;
-	private readonly IDbContextProvider dbProvider;
-	private readonly CancellationToken cancellationToken;
-	
-	private readonly SemaphoreSlim modifyInstancesSemaphore = new (1, 1);
-
-	public InstanceManager(AgentManager agentManager, MinecraftVersions minecraftVersions, IDbContextProvider dbProvider, CancellationToken cancellationToken) {
-		this.agentManager = agentManager;
-		this.minecraftVersions = minecraftVersions;
-		this.dbProvider = dbProvider;
-		this.cancellationToken = cancellationToken;
-	}
-
-	public async Task Initialize() {
-		await using var ctx = dbProvider.Eager();
-		await foreach (var entity in ctx.Instances.AsAsyncEnumerable().WithCancellation(cancellationToken)) {
-			var configuration = new InstanceConfiguration(
-				entity.AgentGuid,
-				entity.InstanceGuid,
-				entity.InstanceName,
-				entity.ServerPort,
-				entity.RconPort,
-				entity.MinecraftVersion,
-				entity.MinecraftServerKind,
-				entity.MemoryAllocation,
-				entity.JavaRuntimeGuid,
-				JvmArgumentsHelper.Split(entity.JvmArguments)
-			);
-
-			var instance = Instance.Offline(configuration, entity.LaunchAutomatically);
-			instances.ByGuid[instance.Configuration.InstanceGuid] = instance;
-		}
-	}
-
-	[SuppressMessage("ReSharper", "ConvertIfStatementToConditionalTernaryExpression")]
-	public async Task<InstanceActionResult<CreateOrUpdateInstanceResult>> CreateOrUpdateInstance(Guid auditLogUserGuid, InstanceConfiguration configuration) {
-		var agent = agentManager.GetAgent(configuration.AgentGuid);
-		if (agent == null) {
-			return InstanceActionResult.Concrete(CreateOrUpdateInstanceResult.AgentNotFound);
-		}
-
-		if (string.IsNullOrWhiteSpace(configuration.InstanceName)) {
-			return InstanceActionResult.Concrete(CreateOrUpdateInstanceResult.InstanceNameMustNotBeEmpty);
-		}
-		
-		if (configuration.MemoryAllocation <= RamAllocationUnits.Zero) {
-			return InstanceActionResult.Concrete(CreateOrUpdateInstanceResult.InstanceMemoryMustNotBeZero);
-		}
-		
-		var serverExecutableInfo = await minecraftVersions.GetServerExecutableInfo(configuration.MinecraftVersion, cancellationToken);
-		if (serverExecutableInfo == null) {
-			return InstanceActionResult.Concrete(CreateOrUpdateInstanceResult.MinecraftVersionDownloadInfoNotFound);
-		}
-
-		InstanceActionResult<CreateOrUpdateInstanceResult> result;
-		bool isNewInstance;
-
-		await modifyInstancesSemaphore.WaitAsync(cancellationToken);
-		try {
-			isNewInstance = !instances.ByGuid.TryReplace(configuration.InstanceGuid, instance => instance with { Configuration = configuration });
-			if (isNewInstance) {
-				instances.ByGuid.TryAdd(configuration.InstanceGuid, Instance.Offline(configuration));
-			}
-
-			var message = new ConfigureInstanceMessage(configuration, new InstanceLaunchProperties(serverExecutableInfo));
-			var reply = await agentManager.SendMessage<ConfigureInstanceMessage, InstanceActionResult<ConfigureInstanceResult>>(configuration.AgentGuid, message, TimeSpan.FromSeconds(10));
-			
-			result = reply.DidNotReplyIfNull().Map(static result => result switch {
-				ConfigureInstanceResult.Success => CreateOrUpdateInstanceResult.Success,
-				_                               => CreateOrUpdateInstanceResult.UnknownError
-			});
-			
-			if (result.Is(CreateOrUpdateInstanceResult.Success)) {
-				await using var db = dbProvider.Lazy();
-				
-				InstanceEntity entity = db.Ctx.InstanceUpsert.Fetch(configuration.InstanceGuid);
-				entity.AgentGuid = configuration.AgentGuid;
-				entity.InstanceName = configuration.InstanceName;
-				entity.ServerPort = configuration.ServerPort;
-				entity.RconPort = configuration.RconPort;
-				entity.MinecraftVersion = configuration.MinecraftVersion;
-				entity.MinecraftServerKind = configuration.MinecraftServerKind;
-				entity.MemoryAllocation = configuration.MemoryAllocation;
-				entity.JavaRuntimeGuid = configuration.JavaRuntimeGuid;
-				entity.JvmArguments = JvmArgumentsHelper.Join(configuration.JvmArguments);
-				
-				var auditLogWriter = new AuditLogRepository(db).Writer(auditLogUserGuid);
-				if (isNewInstance) {
-					auditLogWriter.InstanceCreated(configuration.InstanceGuid);
-				}
-				else {
-					auditLogWriter.InstanceEdited(configuration.InstanceGuid);
-				}
-
-				await db.Ctx.SaveChangesAsync(cancellationToken);
-			}
-			else if (isNewInstance) {
-				instances.ByGuid.Remove(configuration.InstanceGuid);
-			}
-		} finally {
-			modifyInstancesSemaphore.Release();
-		}
-		
-		if (result.Is(CreateOrUpdateInstanceResult.Success)) {
-			if (isNewInstance) {
-				Logger.Information("Added instance \"{InstanceName}\" (GUID {InstanceGuid}) to agent \"{AgentName}\".", configuration.InstanceName, configuration.InstanceGuid, agent.Name);
-			}
-			else {
-				Logger.Information("Edited instance \"{InstanceName}\" (GUID {InstanceGuid}) in agent \"{AgentName}\".", configuration.InstanceName, configuration.InstanceGuid, agent.Name);
-			}
-		}
-		else {
-			if (isNewInstance) {
-				Logger.Information("Failed adding instance \"{InstanceName}\" (GUID {InstanceGuid}) to agent \"{AgentName}\". {ErrorMessage}", configuration.InstanceName, configuration.InstanceGuid, agent.Name, result.ToSentence(CreateOrUpdateInstanceResultExtensions.ToSentence));
-			}
-			else {
-				Logger.Information("Failed editing instance \"{InstanceName}\" (GUID {InstanceGuid}) in agent \"{AgentName}\". {ErrorMessage}", configuration.InstanceName, configuration.InstanceGuid, agent.Name, result.ToSentence(CreateOrUpdateInstanceResultExtensions.ToSentence));
-			}
-		}
-
-		return result;
-	}
-
-	internal void SetInstanceState(Guid instanceGuid, IInstanceStatus instanceStatus) {
-		instances.ByGuid.TryReplace(instanceGuid, instance => instance with { Status = instanceStatus });
-	}
-
-	internal void SetInstanceStatesForAgent(Guid agentGuid, IInstanceStatus instanceStatus) {
-		instances.ByGuid.ReplaceAllIf(instance => instance with { Status = instanceStatus }, instance => instance.Configuration.AgentGuid == agentGuid);
-	}
-
-	private async Task<InstanceActionResult<TReply>> SendInstanceActionMessage<TMessage, TReply>(Instance instance, TMessage message) where TMessage : IMessageToAgent<InstanceActionResult<TReply>> {
-		var reply = await agentManager.SendMessage<TMessage, InstanceActionResult<TReply>>(instance.Configuration.AgentGuid, message, TimeSpan.FromSeconds(10));
-		return reply.DidNotReplyIfNull();
-	}
-
-	private async Task<InstanceActionResult<TReply>> SendInstanceActionMessage<TMessage, TReply>(Guid instanceGuid, TMessage message) where TMessage : IMessageToAgent<InstanceActionResult<TReply>> {
-		return instances.ByGuid.TryGetValue(instanceGuid, out var instance) ? await SendInstanceActionMessage<TMessage, TReply>(instance, message) : InstanceActionResult.General<TReply>(InstanceActionGeneralResult.InstanceDoesNotExist);
-	}
-
-	public async Task<InstanceActionResult<LaunchInstanceResult>> LaunchInstance(Guid auditLogUserGuid, Guid instanceGuid) {
-		var result = await SendInstanceActionMessage<LaunchInstanceMessage, LaunchInstanceResult>(instanceGuid, new LaunchInstanceMessage(instanceGuid));
-		if (result.Is(LaunchInstanceResult.LaunchInitiated)) {
-			await HandleInstanceManuallyLaunchedOrStopped(instanceGuid, true, auditLogUserGuid, auditLogWriter => auditLogWriter.InstanceLaunched(instanceGuid));
-		}
-
-		return result;
-	}
-
-	public async Task<InstanceActionResult<StopInstanceResult>> StopInstance(Guid auditLogUserGuid, Guid instanceGuid, MinecraftStopStrategy stopStrategy) {
-		var result = await SendInstanceActionMessage<StopInstanceMessage, StopInstanceResult>(instanceGuid, new StopInstanceMessage(instanceGuid, stopStrategy));
-		if (result.Is(StopInstanceResult.StopInitiated)) {
-			await HandleInstanceManuallyLaunchedOrStopped(instanceGuid, false, auditLogUserGuid, auditLogWriter => auditLogWriter.InstanceStopped(instanceGuid, stopStrategy.Seconds));
-		}
-
-		return result;
-	}
-
-	private async Task HandleInstanceManuallyLaunchedOrStopped(Guid instanceGuid, bool wasLaunched, Guid auditLogUserGuid, Action<AuditLogRepository.ItemWriter> addAuditEvent) {
-		await modifyInstancesSemaphore.WaitAsync(cancellationToken);
-		try {
-			instances.ByGuid.TryReplace(instanceGuid, instance => instance with { LaunchAutomatically = wasLaunched });
-
-			await using var db = dbProvider.Lazy();
-			var entity = await db.Ctx.Instances.FindAsync(new object[] { instanceGuid }, cancellationToken);
-			if (entity != null) {
-				entity.LaunchAutomatically = wasLaunched;
-				addAuditEvent(new AuditLogRepository(db).Writer(auditLogUserGuid));
-				await db.Ctx.SaveChangesAsync(cancellationToken);
-			}
-		} finally {
-			modifyInstancesSemaphore.Release();
-		}
-	}
-
-	public async Task<InstanceActionResult<SendCommandToInstanceResult>> SendCommand(Guid auditLogUserId, Guid instanceGuid, string command) {
-		var result = await SendInstanceActionMessage<SendCommandToInstanceMessage, SendCommandToInstanceResult>(instanceGuid, new SendCommandToInstanceMessage(instanceGuid, command));
-		if (result.Is(SendCommandToInstanceResult.Success)) {
-			await using var db = dbProvider.Lazy();
-			var auditLogWriter = new AuditLogRepository(db).Writer(auditLogUserId);
-			
-			auditLogWriter.InstanceCommandExecuted(instanceGuid, command);
-			await db.Ctx.SaveChangesAsync(cancellationToken);
-		}
-
-		return result;
-	}
-
-	internal async Task<ImmutableArray<ConfigureInstanceMessage>> GetInstanceConfigurationsForAgent(Guid agentGuid) {
-		var configurationMessages = ImmutableArray.CreateBuilder<ConfigureInstanceMessage>();
-		
-		foreach (var (configuration, _, launchAutomatically) in instances.ByGuid.ValuesCopy.Where(instance => instance.Configuration.AgentGuid == agentGuid)) {
-			var serverExecutableInfo = await minecraftVersions.GetServerExecutableInfo(configuration.MinecraftVersion, cancellationToken);
-			configurationMessages.Add(new ConfigureInstanceMessage(configuration, new InstanceLaunchProperties(serverExecutableInfo), launchAutomatically));
-		}
-
-		return configurationMessages.ToImmutable();
-	}
-
-	private sealed class ObservableInstances : ObservableState<ImmutableDictionary<Guid, Instance>> {
-		public RwLockedObservableDictionary<Guid, Instance> ByGuid { get; } = new (LockRecursionPolicy.NoRecursion);
-
-		public ObservableInstances(ILogger logger) : base(logger) {
-			ByGuid.CollectionChanged += Update;
-		}
-
-		protected override ImmutableDictionary<Guid, Instance> GetData() {
-			return ByGuid.ToImmutable();
-		}
-	}
-}
diff --git a/Controller/Phantom.Controller.Services/Phantom.Controller.Services.csproj b/Controller/Phantom.Controller.Services/Phantom.Controller.Services.csproj
index 37e7866..41dbfac 100644
--- a/Controller/Phantom.Controller.Services/Phantom.Controller.Services.csproj
+++ b/Controller/Phantom.Controller.Services/Phantom.Controller.Services.csproj
@@ -6,6 +6,7 @@
   </PropertyGroup>
   
   <ItemGroup>
+    <PackageReference Include="Akka" />
     <PackageReference Include="BCrypt.Net-Next.StrongName" />
   </ItemGroup>
   
@@ -14,6 +15,7 @@
     <ProjectReference Include="..\..\Common\Phantom.Common.Data\Phantom.Common.Data.csproj" />
     <ProjectReference Include="..\..\Common\Phantom.Common.Messages.Agent\Phantom.Common.Messages.Agent.csproj" />
     <ProjectReference Include="..\..\Common\Phantom.Common.Messages.Web\Phantom.Common.Messages.Web.csproj" />
+    <ProjectReference Include="..\..\Utils\Phantom.Utils.Actor\Phantom.Utils.Actor.csproj" />
     <ProjectReference Include="..\..\Utils\Phantom.Utils.Events\Phantom.Utils.Events.csproj" />
     <ProjectReference Include="..\Phantom.Controller.Database\Phantom.Controller.Database.csproj" />
     <ProjectReference Include="..\Phantom.Controller.Minecraft\Phantom.Controller.Minecraft.csproj" />
diff --git a/Controller/Phantom.Controller.Services/Rpc/AgentMessageListener.cs b/Controller/Phantom.Controller.Services/Rpc/AgentMessageListener.cs
index 43c14d3..77bd6e0 100644
--- a/Controller/Phantom.Controller.Services/Rpc/AgentMessageListener.cs
+++ b/Controller/Phantom.Controller.Services/Rpc/AgentMessageListener.cs
@@ -1,5 +1,4 @@
-using Phantom.Common.Data.Instance;
-using Phantom.Common.Data.Replies;
+using Phantom.Common.Data.Replies;
 using Phantom.Common.Messages.Agent;
 using Phantom.Common.Messages.Agent.BiDirectional;
 using Phantom.Common.Messages.Agent.ToAgent;
@@ -16,32 +15,28 @@ namespace Phantom.Controller.Services.Rpc;
 public sealed class AgentMessageListener : IMessageToControllerListener {
 	private readonly RpcConnectionToClient<IMessageToAgentListener> connection;
 	private readonly AgentManager agentManager;
-	private readonly AgentJavaRuntimesManager agentJavaRuntimesManager;
-	private readonly InstanceManager instanceManager;
 	private readonly InstanceLogManager instanceLogManager;
 	private readonly EventLogManager eventLogManager;
 	private readonly CancellationToken cancellationToken;
 
 	private readonly TaskCompletionSource<Guid> agentGuidWaiter = AsyncTasks.CreateCompletionSource<Guid>();
 
-	internal AgentMessageListener(RpcConnectionToClient<IMessageToAgentListener> connection, AgentManager agentManager, AgentJavaRuntimesManager agentJavaRuntimesManager, InstanceManager instanceManager, InstanceLogManager instanceLogManager, EventLogManager eventLogManager, CancellationToken cancellationToken) {
+	internal AgentMessageListener(RpcConnectionToClient<IMessageToAgentListener> connection, AgentManager agentManager, InstanceLogManager instanceLogManager, EventLogManager eventLogManager, CancellationToken cancellationToken) {
 		this.connection = connection;
 		this.agentManager = agentManager;
-		this.agentJavaRuntimesManager = agentJavaRuntimesManager;
-		this.instanceManager = instanceManager;
 		this.instanceLogManager = instanceLogManager;
 		this.eventLogManager = eventLogManager;
 		this.cancellationToken = cancellationToken;
 	}
 
 	public async Task<NoReply> HandleRegisterAgent(RegisterAgentMessage message) {
-		if (agentGuidWaiter.Task.IsCompleted && agentGuidWaiter.Task.Result != message.AgentInfo.Guid) {
+		if (agentGuidWaiter.Task.IsCompleted && agentGuidWaiter.Task.Result != message.AgentInfo.AgentGuid) {
 			connection.SetAuthorizationResult(false);
 			await connection.Send(new RegisterAgentFailureMessage(RegisterAgentFailure.ConnectionAlreadyHasAnAgent));
 		}
-		else if (await agentManager.RegisterAgent(message.AuthToken, message.AgentInfo, instanceManager, connection)) {
+		else if (await agentManager.RegisterAgent(message.AuthToken, message.AgentInfo, connection)) {
 			connection.SetAuthorizationResult(true);
-			agentGuidWaiter.SetResult(message.AgentInfo.Guid);
+			agentGuidWaiter.SetResult(message.AgentInfo.AgentGuid);
 		}
 		
 		return NoReply.Instance;
@@ -53,34 +48,31 @@ public sealed class AgentMessageListener : IMessageToControllerListener {
 	
 	public Task<NoReply> HandleUnregisterAgent(UnregisterAgentMessage message) {
 		if (agentGuidWaiter.Task.IsCompleted) {
-			var agentGuid = agentGuidWaiter.Task.Result;
-			if (agentManager.UnregisterAgent(agentGuid, connection)) {
-				instanceManager.SetInstanceStatesForAgent(agentGuid, InstanceStatus.Offline);
-			}
+			agentManager.TellAgent(agentGuidWaiter.Task.Result, new AgentActor.UnregisterCommand(connection));
 		}
-
+		
 		connection.Close();
 		return Task.FromResult(NoReply.Instance);
 	}
 
 	public async Task<NoReply> HandleAgentIsAlive(AgentIsAliveMessage message) {
-		agentManager.NotifyAgentIsAlive(await WaitForAgentGuid());
+		agentManager.TellAgent(await WaitForAgentGuid(), new AgentActor.NotifyIsAliveCommand());
 		return NoReply.Instance;
 	}
 
 	public async Task<NoReply> HandleAdvertiseJavaRuntimes(AdvertiseJavaRuntimesMessage message) {
-		agentJavaRuntimesManager.Update(await WaitForAgentGuid(), message.Runtimes);
+		agentManager.TellAgent(await WaitForAgentGuid(), new AgentActor.UpdateJavaRuntimesCommand(message.Runtimes));
 		return NoReply.Instance;
 	}
 
 	public async Task<NoReply> HandleReportAgentStatus(ReportAgentStatusMessage message) {
-		agentManager.SetAgentStats(await WaitForAgentGuid(), message.RunningInstanceCount, message.RunningInstanceMemory);
+		agentManager.TellAgent(await WaitForAgentGuid(), new AgentActor.UpdateStatsCommand(message.RunningInstanceCount, message.RunningInstanceMemory));
 		return NoReply.Instance;
 	}
 
-	public Task<NoReply> HandleReportInstanceStatus(ReportInstanceStatusMessage message) {
-		instanceManager.SetInstanceState(message.InstanceGuid, message.InstanceStatus);
-		return Task.FromResult(NoReply.Instance);
+	public async Task<NoReply> HandleReportInstanceStatus(ReportInstanceStatusMessage message) {
+		agentManager.TellAgent(await WaitForAgentGuid(), new AgentActor.UpdateInstanceStatusCommand(message.InstanceGuid, message.InstanceStatus));
+		return NoReply.Instance;
 	}
 
 	public async Task<NoReply> HandleReportInstanceEvent(ReportInstanceEventMessage message) {
diff --git a/Controller/Phantom.Controller.Services/Rpc/WebMessageListener.cs b/Controller/Phantom.Controller.Services/Rpc/WebMessageListener.cs
index d2b3ad9..9f60e4d 100644
--- a/Controller/Phantom.Controller.Services/Rpc/WebMessageListener.cs
+++ b/Controller/Phantom.Controller.Services/Rpc/WebMessageListener.cs
@@ -1,9 +1,9 @@
 using System.Collections.Immutable;
+using Akka.Actor;
 using Phantom.Common.Data;
 using Phantom.Common.Data.Java;
 using Phantom.Common.Data.Minecraft;
 using Phantom.Common.Data.Replies;
-using Phantom.Common.Data.Web.Agent;
 using Phantom.Common.Data.Web.AuditLog;
 using Phantom.Common.Data.Web.EventLog;
 using Phantom.Common.Data.Web.Instance;
@@ -17,93 +17,118 @@ using Phantom.Controller.Services.Agents;
 using Phantom.Controller.Services.Events;
 using Phantom.Controller.Services.Instances;
 using Phantom.Controller.Services.Users;
+using Phantom.Utils.Actor;
 using Phantom.Utils.Logging;
 using Phantom.Utils.Rpc.Message;
 using Phantom.Utils.Rpc.Runtime;
-using Phantom.Utils.Tasks;
 using Serilog;
+using Agent = Phantom.Common.Data.Web.Agent.Agent;
 
 namespace Phantom.Controller.Services.Rpc;
 
 public sealed class WebMessageListener : IMessageToControllerListener {
 	private static readonly ILogger Logger = PhantomLogger.Create<WebMessageListener>();
-	
+
+	private static int listenerSequenceId = 0;
+
+	private readonly ActorRef<ICommand> actor;
 	private readonly RpcConnectionToClient<IMessageToWebListener> connection;
 	private readonly AuthToken authToken;
+	private readonly ControllerState controllerState;
 	private readonly UserManager userManager;
 	private readonly RoleManager roleManager;
 	private readonly UserRoleManager userRoleManager;
 	private readonly UserLoginManager userLoginManager;
 	private readonly AuditLogManager auditLogManager;
 	private readonly AgentManager agentManager;
-	private readonly AgentJavaRuntimesManager agentJavaRuntimesManager;
-	private readonly InstanceManager instanceManager;
 	private readonly InstanceLogManager instanceLogManager;
 	private readonly MinecraftVersions minecraftVersions;
 	private readonly EventLogManager eventLogManager;
-	private readonly TaskManager taskManager;
 
 	internal WebMessageListener(
+		IActorRefFactory actorSystem,
 		RpcConnectionToClient<IMessageToWebListener> connection,
 		AuthToken authToken,
+		ControllerState controllerState,
 		UserManager userManager,
 		RoleManager roleManager,
 		UserRoleManager userRoleManager,
 		UserLoginManager userLoginManager,
 		AuditLogManager auditLogManager,
 		AgentManager agentManager,
-		AgentJavaRuntimesManager agentJavaRuntimesManager,
-		InstanceManager instanceManager,
 		InstanceLogManager instanceLogManager,
 		MinecraftVersions minecraftVersions,
-		EventLogManager eventLogManager,
-		TaskManager taskManager
+		EventLogManager eventLogManager
 	) {
+		this.actor = actorSystem.ActorOf(Actor.Factory(this), "Web-" + Interlocked.Increment(ref listenerSequenceId));
 		this.connection = connection;
 		this.authToken = authToken;
+		this.controllerState = controllerState;
 		this.userManager = userManager;
 		this.roleManager = roleManager;
 		this.userRoleManager = userRoleManager;
 		this.userLoginManager = userLoginManager;
 		this.auditLogManager = auditLogManager;
 		this.agentManager = agentManager;
-		this.agentJavaRuntimesManager = agentJavaRuntimesManager;
-		this.instanceManager = instanceManager;
 		this.instanceLogManager = instanceLogManager;
 		this.minecraftVersions = minecraftVersions;
 		this.eventLogManager = eventLogManager;
-		this.taskManager = taskManager;
 	}
 
-	private void OnConnectionReady() {
-		lock (this) {
-			agentManager.AgentsChanged.Subscribe(this, HandleAgentsChanged);
-			instanceManager.InstancesChanged.Subscribe(this, HandleInstancesChanged);
-			instanceLogManager.LogsReceived += HandleInstanceLogsReceived;
+	private sealed class Actor : ReceiveActor<ICommand> {
+		public static Props<ICommand> Factory(WebMessageListener listener) {
+			return Props<ICommand>.Create(() => new Actor(listener), new ActorConfiguration { SupervisorStrategy = SupervisorStrategies.Resume });
+		}
+
+		private readonly WebMessageListener listener;
+
+		private Actor(WebMessageListener listener) {
+			this.listener = listener;
+
+			Receive<StartConnectionCommand>(StartConnection);
+			Receive<StopConnectionCommand>(StopConnection);
+			Receive<RefreshAgentsCommand>(RefreshAgents);
+			Receive<RefreshInstancesCommand>(RefreshInstances);
+		}
+
+		private void StartConnection(StartConnectionCommand command) {
+			listener.controllerState.AgentsByGuidReceiver.Register(SelfTyped, static state => new RefreshAgentsCommand(state));
+			listener.controllerState.InstancesByGuidReceiver.Register(SelfTyped, static state => new RefreshInstancesCommand(state));
+
+			listener.instanceLogManager.LogsReceived += HandleInstanceLogsReceived;
+		}
+
+		private void StopConnection(StopConnectionCommand command) {
+			listener.instanceLogManager.LogsReceived -= HandleInstanceLogsReceived;
+
+			listener.controllerState.AgentsByGuidReceiver.Unregister(SelfTyped);
+			listener.controllerState.InstancesByGuidReceiver.Unregister(SelfTyped);
+		}
+
+		private void RefreshAgents(RefreshAgentsCommand command) {
+			var message = new RefreshAgentsMessage(command.Agents.Values.ToImmutableArray());
+			listener.connection.Send(message);
+		}
+
+		private void RefreshInstances(RefreshInstancesCommand command) {
+			var message = new RefreshInstancesMessage(command.Instances.Values.ToImmutableArray());
+			listener.connection.Send(message);
+		}
+
+		private void HandleInstanceLogsReceived(object? sender, InstanceLogManager.Event e) {
+			listener.connection.Send(new InstanceOutputMessage(e.InstanceGuid, e.Lines));
 		}
 	}
 
-	private void OnConnectionClosed() {
-		lock (this) {
-			agentManager.AgentsChanged.Unsubscribe(this);
-			instanceManager.InstancesChanged.Unsubscribe(this);
-			instanceLogManager.LogsReceived -= HandleInstanceLogsReceived;
-		}
-	}
+	private interface ICommand {}
 
-	private void HandleAgentsChanged(ImmutableArray<Agent> agents) {
-		var message = new RefreshAgentsMessage(agents.Select(static agent => new AgentWithStats(agent.Guid, agent.Name, agent.ProtocolVersion, agent.BuildVersion, agent.MaxInstances, agent.MaxMemory, agent.AllowedServerPorts, agent.AllowedRconPorts, agent.Stats, agent.LastPing, agent.IsOnline)).ToImmutableArray());
-		taskManager.Run("Send agents to web", () => connection.Send(message));
-	}
+	private sealed record StartConnectionCommand : ICommand;
 
-	private void HandleInstancesChanged(ImmutableDictionary<Guid, Instance> instances) {
-		var message = new RefreshInstancesMessage(instances.Values.ToImmutableArray());
-		taskManager.Run("Send instances to web", () => connection.Send(message));
-	}
+	private sealed record StopConnectionCommand : ICommand;
 
-	private void HandleInstanceLogsReceived(object? sender, InstanceLogManager.Event e) {
-		taskManager.Run("Send instance logs to web", () => connection.Send(new InstanceOutputMessage(e.InstanceGuid, e.Lines)));
-	}
+	private sealed record RefreshAgentsCommand(ImmutableDictionary<Guid, Agent> Agents) : ICommand;
+
+	private sealed record RefreshInstancesCommand(ImmutableDictionary<Guid, Instance> Instances) : ICommand;
 
 	public async Task<NoReply> HandleRegisterWeb(RegisterWebMessage message) {
 		if (authToken.FixedTimeEquals(message.AuthToken)) {
@@ -118,7 +143,7 @@ public sealed class WebMessageListener : IMessageToControllerListener {
 		}
 
 		if (!connection.IsClosed) {
-			OnConnectionReady();
+			actor.Tell(new StartConnectionCommand());
 		}
 
 		return NoReply.Instance;
@@ -127,7 +152,7 @@ public sealed class WebMessageListener : IMessageToControllerListener {
 	public Task<NoReply> HandleUnregisterWeb(UnregisterWebMessage message) {
 		if (!connection.IsClosed) {
 			connection.Close();
-			OnConnectionClosed();
+			actor.Tell(new StopConnectionCommand());
 		}
 
 		return Task.FromResult(NoReply.Instance);
@@ -162,19 +187,19 @@ public sealed class WebMessageListener : IMessageToControllerListener {
 	}
 
 	public Task<InstanceActionResult<CreateOrUpdateInstanceResult>> HandleCreateOrUpdateInstance(CreateOrUpdateInstanceMessage message) {
-		return instanceManager.CreateOrUpdateInstance(message.LoggedInUserGuid, message.Configuration);
+		return agentManager.DoInstanceAction<AgentActor.CreateOrUpdateInstanceCommand, CreateOrUpdateInstanceResult>(message.Configuration.AgentGuid, new AgentActor.CreateOrUpdateInstanceCommand(message.LoggedInUserGuid, message.InstanceGuid, message.Configuration));
 	}
 
 	public Task<InstanceActionResult<LaunchInstanceResult>> HandleLaunchInstance(LaunchInstanceMessage message) {
-		return instanceManager.LaunchInstance(message.LoggedInUserGuid, message.InstanceGuid);
+		return agentManager.DoInstanceAction<AgentActor.LaunchInstanceCommand, LaunchInstanceResult>(message.AgentGuid, new AgentActor.LaunchInstanceCommand(message.InstanceGuid, message.LoggedInUserGuid));
 	}
 
 	public Task<InstanceActionResult<StopInstanceResult>> HandleStopInstance(StopInstanceMessage message) {
-		return instanceManager.StopInstance(message.LoggedInUserGuid, message.InstanceGuid, message.StopStrategy);
+		return agentManager.DoInstanceAction<AgentActor.StopInstanceCommand, StopInstanceResult>(message.AgentGuid, new AgentActor.StopInstanceCommand(message.InstanceGuid, message.LoggedInUserGuid, message.StopStrategy));
 	}
 
 	public Task<InstanceActionResult<SendCommandToInstanceResult>> HandleSendCommandToInstance(SendCommandToInstanceMessage message) {
-		return instanceManager.SendCommand(message.LoggedInUserGuid, message.InstanceGuid, message.Command);
+		return agentManager.DoInstanceAction<AgentActor.SendCommandToInstanceCommand, SendCommandToInstanceResult>(message.AgentGuid, new AgentActor.SendCommandToInstanceCommand(message.InstanceGuid, message.LoggedInUserGuid, message.Command));
 	}
 
 	public Task<ImmutableArray<MinecraftVersion>> HandleGetMinecraftVersions(GetMinecraftVersionsMessage message) {
@@ -182,7 +207,7 @@ public sealed class WebMessageListener : IMessageToControllerListener {
 	}
 
 	public Task<ImmutableDictionary<Guid, ImmutableArray<TaggedJavaRuntime>>> HandleGetAgentJavaRuntimes(GetAgentJavaRuntimesMessage message) {
-		return Task.FromResult(agentJavaRuntimesManager.All);
+		return Task.FromResult(controllerState.AgentJavaRuntimesByGuid);
 	}
 
 	public Task<ImmutableArray<AuditLogItem>> HandleGetAuditLog(GetAuditLogMessage message) {
diff --git a/Controller/Phantom.Controller/Program.cs b/Controller/Phantom.Controller/Program.cs
index 429b8d8..af60303 100644
--- a/Controller/Phantom.Controller/Program.cs
+++ b/Controller/Phantom.Controller/Program.cs
@@ -51,26 +51,27 @@ try {
 		return 1;
 	}
 	
-	var dbContextFactory = new ApplicationDbContextFactory(sqlConnectionString);
-	var controllerServices = new ControllerServices(dbContextFactory, agentKeyData.AuthToken, webKeyData.AuthToken, shutdownCancellationToken);
-	
 	PhantomLogger.Root.InformationHeading("Launching Phantom Panel server...");
 	
-	await controllerServices.Initialize();
+	var dbContextFactory = new ApplicationDbContextFactory(sqlConnectionString);
+	
+	await using (var controllerServices = new ControllerServices(dbContextFactory, agentKeyData.AuthToken, webKeyData.AuthToken, shutdownCancellationToken)) {
+		await controllerServices.Initialize();
 
-	static RpcConfiguration ConfigureRpc(string serviceName, string host, ushort port, ConnectionKeyData connectionKey) {
-		return new RpcConfiguration("Rpc:" + serviceName, host, port, connectionKey.Certificate);
-	}
+		static RpcConfiguration ConfigureRpc(string serviceName, string host, ushort port, ConnectionKeyData connectionKey) {
+			return new RpcConfiguration("Rpc:" + serviceName, host, port, connectionKey.Certificate);
+		}
 
-	var rpcTaskManager = new TaskManager(PhantomLogger.Create<TaskManager>("Rpc"));
-	try {
-		await Task.WhenAll(
-			RpcServerRuntime.Launch(ConfigureRpc("Agent", agentRpcServerHost, agentRpcServerPort, agentKeyData), AgentMessageRegistries.Definitions, controllerServices.CreateAgentMessageListener, shutdownCancellationToken),
-			RpcServerRuntime.Launch(ConfigureRpc("Web", webRpcServerHost, webRpcServerPort, webKeyData), WebMessageRegistries.Definitions, controllerServices.CreateWebMessageListener, shutdownCancellationToken)
-		);
-	} finally {
-		await rpcTaskManager.Stop();
-		NetMQConfig.Cleanup();
+		var rpcTaskManager = new TaskManager(PhantomLogger.Create<TaskManager>("Rpc"));
+		try {
+			await Task.WhenAll(
+				RpcServerRuntime.Launch(ConfigureRpc("Agent", agentRpcServerHost, agentRpcServerPort, agentKeyData), AgentMessageRegistries.Definitions, controllerServices.CreateAgentMessageListener, shutdownCancellationToken),
+				RpcServerRuntime.Launch(ConfigureRpc("Web", webRpcServerHost, webRpcServerPort, webKeyData), WebMessageRegistries.Definitions, controllerServices.CreateWebMessageListener, shutdownCancellationToken)
+			);
+		} finally {
+			await rpcTaskManager.Stop();
+			NetMQConfig.Cleanup();
+		}
 	}
 
 	return 0;
diff --git a/Packages.props b/Packages.props
index be83cd1..caf9b64 100644
--- a/Packages.props
+++ b/Packages.props
@@ -1,12 +1,15 @@
 <Project>
   
   <ItemGroup>
-    <PackageReference Update="Microsoft.AspNetCore.Components.Authorization"     Version="8.0.0" />
-    <PackageReference Update="Microsoft.AspNetCore.Components.Web"               Version="8.0.0" />
-    <PackageReference Update="Microsoft.EntityFrameworkCore.Relational"          Version="8.0.0" />
-    <PackageReference Update="Microsoft.EntityFrameworkCore.Tools"               Version="8.0.0" />
-    <PackageReference Update="Npgsql.EntityFrameworkCore.PostgreSQL"             Version="8.0.0" />
-    <PackageReference Update="System.Linq.Async"                                 Version="6.0.1" />
+    <PackageReference Update="Microsoft.AspNetCore.Components.Authorization" Version="8.0.0" />
+    <PackageReference Update="Microsoft.AspNetCore.Components.Web"           Version="8.0.0" />
+  </ItemGroup>
+  
+  <ItemGroup>
+    <PackageReference Update="Microsoft.EntityFrameworkCore.Relational" Version="8.0.0" />
+    <PackageReference Update="Microsoft.EntityFrameworkCore.Tools"      Version="8.0.0" />
+    <PackageReference Update="Npgsql.EntityFrameworkCore.PostgreSQL"    Version="8.0.0" />
+    <PackageReference Update="System.Linq.Async"                        Version="6.0.1" />
   </ItemGroup>
   
   <ItemGroup>
@@ -14,12 +17,13 @@
   </ItemGroup>
   
   <ItemGroup>
-    <PackageReference Update="BCrypt.Net-Next.StrongName" Version="4.0.3" />
+    <PackageReference Update="Akka"                       Version="1.5.17.1" />
   </ItemGroup>
   
   <ItemGroup>
-    <PackageReference Update="MemoryPack" Version="1.10.0" />
-    <PackageReference Update="NetMQ"      Version="4.0.1.13" />
+    <PackageReference Update="BCrypt.Net-Next.StrongName" Version="4.0.3" />
+    <PackageReference Update="MemoryPack"                 Version="1.10.0" />
+    <PackageReference Update="NetMQ"                      Version="4.0.1.13" />
   </ItemGroup>
   
   <ItemGroup>
diff --git a/PhantomPanel.sln b/PhantomPanel.sln
index 75b1dfa..b5e7aff 100644
--- a/PhantomPanel.sln
+++ b/PhantomPanel.sln
@@ -44,6 +44,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Phantom.Controller.Services
 EndProject
 Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Phantom.Utils", "Utils\Phantom.Utils\Phantom.Utils.csproj", "{384885E2-5113-45C5-9B15-09BDA0911852}"
 EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Phantom.Utils.Actor", "Utils\Phantom.Utils.Actor\Phantom.Utils.Actor.csproj", "{BBFF32C1-A98A-44BF-9023-04344BBB896B}"
+EndProject
 Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Phantom.Utils.Events", "Utils\Phantom.Utils.Events\Phantom.Utils.Events.csproj", "{2E81523B-5DBE-4992-A77B-1679758D0688}"
 EndProject
 Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Phantom.Utils.Logging", "Utils\Phantom.Utils.Logging\Phantom.Utils.Logging.csproj", "{FCA141F5-4F18-47C2-9855-14E326FF1219}"
@@ -126,6 +128,10 @@ Global
 		{384885E2-5113-45C5-9B15-09BDA0911852}.Debug|Any CPU.Build.0 = Debug|Any CPU
 		{384885E2-5113-45C5-9B15-09BDA0911852}.Release|Any CPU.ActiveCfg = Release|Any CPU
 		{384885E2-5113-45C5-9B15-09BDA0911852}.Release|Any CPU.Build.0 = Release|Any CPU
+		{BBFF32C1-A98A-44BF-9023-04344BBB896B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+		{BBFF32C1-A98A-44BF-9023-04344BBB896B}.Debug|Any CPU.Build.0 = Debug|Any CPU
+		{BBFF32C1-A98A-44BF-9023-04344BBB896B}.Release|Any CPU.ActiveCfg = Release|Any CPU
+		{BBFF32C1-A98A-44BF-9023-04344BBB896B}.Release|Any CPU.Build.0 = Release|Any CPU
 		{2E81523B-5DBE-4992-A77B-1679758D0688}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
 		{2E81523B-5DBE-4992-A77B-1679758D0688}.Debug|Any CPU.Build.0 = Debug|Any CPU
 		{2E81523B-5DBE-4992-A77B-1679758D0688}.Release|Any CPU.ActiveCfg = Release|Any CPU
@@ -171,6 +177,7 @@ Global
 		{4B3B73E6-48DD-4846-87FD-DFB86619B67C} = {0AB9471E-6228-4EB7-802E-3102B3952AAD}
 		{90F0F1B1-EB0A-49C9-8DF0-1153A87F77C9} = {0AB9471E-6228-4EB7-802E-3102B3952AAD}
 		{384885E2-5113-45C5-9B15-09BDA0911852} = {AA217EB8-E480-456B-BDF3-39419EF2AD85}
+		{BBFF32C1-A98A-44BF-9023-04344BBB896B} = {AA217EB8-E480-456B-BDF3-39419EF2AD85}
 		{2E81523B-5DBE-4992-A77B-1679758D0688} = {AA217EB8-E480-456B-BDF3-39419EF2AD85}
 		{FCA141F5-4F18-47C2-9855-14E326FF1219} = {AA217EB8-E480-456B-BDF3-39419EF2AD85}
 		{BB112660-7A20-45E6-9195-65363B74027F} = {AA217EB8-E480-456B-BDF3-39419EF2AD85}
diff --git a/Utils/Phantom.Utils.Actor/ActorConfiguration.cs b/Utils/Phantom.Utils.Actor/ActorConfiguration.cs
new file mode 100644
index 0000000..1c6a808
--- /dev/null
+++ b/Utils/Phantom.Utils.Actor/ActorConfiguration.cs
@@ -0,0 +1,20 @@
+using Akka.Actor;
+
+namespace Phantom.Utils.Actor;
+
+public readonly struct ActorConfiguration {
+	public SupervisorStrategy? SupervisorStrategy { get; init; }
+	public string? MailboxType { get; init; }
+
+	internal Props Apply(Props props) {
+		if (SupervisorStrategy != null) {
+			props = props.WithSupervisorStrategy(SupervisorStrategy);
+		}
+		
+		if (MailboxType != null) {
+			props = props.WithMailbox(MailboxType);
+		}
+		
+		return props;
+	}
+}
diff --git a/Utils/Phantom.Utils.Actor/ActorExtensions.cs b/Utils/Phantom.Utils.Actor/ActorExtensions.cs
new file mode 100644
index 0000000..936460a
--- /dev/null
+++ b/Utils/Phantom.Utils.Actor/ActorExtensions.cs
@@ -0,0 +1,9 @@
+using Akka.Actor;
+
+namespace Phantom.Utils.Actor;
+
+public static class ActorExtensions {
+	public static ActorRef<TMessage> ActorOf<TMessage>(this IActorRefFactory factory, Props<TMessage> props, string? name) {
+		return new ActorRef<TMessage>(factory.ActorOf(props.Inner, name));
+	}
+}
diff --git a/Utils/Phantom.Utils.Actor/ActorFactory.cs b/Utils/Phantom.Utils.Actor/ActorFactory.cs
new file mode 100644
index 0000000..fc641db
--- /dev/null
+++ b/Utils/Phantom.Utils.Actor/ActorFactory.cs
@@ -0,0 +1,19 @@
+using Akka.Actor;
+
+namespace Phantom.Utils.Actor;
+
+sealed class ActorFactory<TActor> : IIndirectActorProducer where TActor : ActorBase {
+	public Type ActorType => typeof(TActor);
+		
+	private readonly Func<TActor> constructor;
+		
+	public ActorFactory(Func<TActor> constructor) {
+		this.constructor = constructor;
+	}
+
+	public ActorBase Produce() {
+		return constructor();
+	}
+
+	public void Release(ActorBase actor) {}
+}
diff --git a/Utils/Phantom.Utils.Actor/ActorRef.cs b/Utils/Phantom.Utils.Actor/ActorRef.cs
new file mode 100644
index 0000000..a84053d
--- /dev/null
+++ b/Utils/Phantom.Utils.Actor/ActorRef.cs
@@ -0,0 +1,31 @@
+using Akka.Actor;
+
+namespace Phantom.Utils.Actor;
+
+public readonly struct ActorRef<TMessage> {
+	private readonly IActorRef actorRef;
+	
+	internal ActorRef(IActorRef actorRef) {
+		this.actorRef = actorRef;
+	}
+
+	internal bool IsSame<TOtherMessage>(ActorRef<TOtherMessage> other) {
+		return actorRef.Equals(other.actorRef);
+	}
+
+	public void Tell(TMessage message) {
+		actorRef.Tell(message);
+	}
+
+	public void Forward(TMessage message) {
+		actorRef.Forward(message);
+	}
+
+	public Task<TReply> Request<TReply>(ICanReply<TReply> message, TimeSpan? timeout, CancellationToken cancellationToken = default) {
+		return actorRef.Ask<TReply>(message, timeout, cancellationToken);
+	}
+	
+	public Task<TReply> Request<TReply>(ICanReply<TReply> message, CancellationToken cancellationToken = default) {
+		return Request(message, timeout: null, cancellationToken);
+	}
+}
diff --git a/Utils/Phantom.Utils.Actor/ActorSystemFactory.cs b/Utils/Phantom.Utils.Actor/ActorSystemFactory.cs
new file mode 100644
index 0000000..38bfcce
--- /dev/null
+++ b/Utils/Phantom.Utils.Actor/ActorSystemFactory.cs
@@ -0,0 +1,31 @@
+using Akka.Actor;
+using Akka.Configuration;
+
+namespace Phantom.Utils.Actor;
+
+public static class ActorSystemFactory {
+	private const string Configuration =
+		"""
+		akka {
+		    actor {
+		        default-dispatcher = {
+		            executor = task-executor
+		        }
+		        internal-dispatcher = akka.actor.default-dispatcher
+		        debug.unhandled = on
+		    }
+		    loggers = [
+		        "Phantom.Utils.Actor.Logging.SerilogLogger, Phantom.Utils.Actor"
+		    ]
+		}
+		unbounded-jump-ahead-mailbox {
+		    mailbox-type : "Phantom.Utils.Actor.Mailbox.UnboundedJumpAheadMailbox, Phantom.Utils.Actor"
+		}
+		""";
+
+	private static readonly BootstrapSetup Setup = BootstrapSetup.Create().WithConfig(ConfigurationFactory.ParseString(Configuration));
+
+	public static ActorSystem Create(string name) {
+		return ActorSystem.Create(name, Setup);
+	}
+}
diff --git a/Utils/Phantom.Utils.Actor/Event/ObservableState.cs b/Utils/Phantom.Utils.Actor/Event/ObservableState.cs
new file mode 100644
index 0000000..98c4f53
--- /dev/null
+++ b/Utils/Phantom.Utils.Actor/Event/ObservableState.cs
@@ -0,0 +1,113 @@
+namespace Phantom.Utils.Actor.Event;
+
+public sealed class ObservableState<TState> {
+	private readonly ReaderWriterLockSlim rwLock = new (LockRecursionPolicy.NoRecursion);
+	private readonly List<IListener> listeners = new ();
+
+	private TState state;
+
+	public TState State {
+		get {
+			rwLock.EnterReadLock();
+			try {
+				return state;
+			} finally {
+				rwLock.ExitReadLock();
+			}
+		}
+	}
+	
+	public Publisher PublisherSide { get; }
+	public Receiver ReceiverSide { get; }
+	
+	public ObservableState(TState state) {
+		this.state = state;
+		this.PublisherSide = new Publisher(this);
+		this.ReceiverSide = new Receiver(this);
+	}
+
+	private interface IListener {
+		bool IsFor<TMessage>(ActorRef<TMessage> other);
+		void Notify(TState state);
+	}
+	
+	private readonly record struct Listener<TMessage>(ActorRef<TMessage> Actor, Func<TState, TMessage> MessageFactory) : IListener {
+		public bool IsFor<TOtherMessage>(ActorRef<TOtherMessage> other) {
+			return Actor.IsSame(other);
+		}
+
+		public void Notify(TState state) {
+			Actor.Tell(MessageFactory(state));
+		}
+	}
+	
+	public readonly struct Publisher {
+		private readonly ObservableState<TState> owner;
+		
+		internal Publisher(ObservableState<TState> owner) {
+			this.owner = owner;
+		}
+
+		public void Publish(TState state) {
+			Publish(static (_, newState) => newState, state);
+		}
+		
+		public void Publish<TArg>(Func<TState, TArg, TState> stateUpdater, TArg userObject) {
+			owner.rwLock.EnterWriteLock();
+			try {
+				SetInternalState(stateUpdater(owner.state, userObject));
+			} finally {
+				owner.rwLock.ExitWriteLock();
+			}
+		}
+		
+		public void Publish<TArg1, TArg2>(Func<TState, TArg1, TArg2, TState> stateUpdater, TArg1 userObject1, TArg2 userObject2) {
+			owner.rwLock.EnterWriteLock();
+			try {
+				SetInternalState(stateUpdater(owner.state, userObject1, userObject2));
+			} finally {
+				owner.rwLock.ExitWriteLock();
+			}
+		}
+
+		private void SetInternalState(TState state) {
+			owner.state = state;
+
+			foreach (var listener in owner.listeners) {
+				listener.Notify(state);
+			}
+		}
+	}
+
+	public readonly struct Receiver {
+		private readonly ObservableState<TState> owner;
+		
+		internal Receiver(ObservableState<TState> owner) {
+			this.owner = owner;
+		}
+
+		public void Register<TMessage>(ActorRef<TMessage> actor, Func<TState, TMessage> messageFactory) {
+			var listener = new Listener<TMessage>(actor, messageFactory);
+			
+			owner.rwLock.EnterReadLock();
+			try {
+				owner.listeners.Add(listener);
+				listener.Notify(owner.state);
+			} finally {
+				owner.rwLock.ExitReadLock();
+			}
+		}
+
+		public void Unregister<TMessage>(ActorRef<TMessage> actor) {
+			owner.rwLock.EnterWriteLock();
+			try {
+				int index = owner.listeners.FindIndex(listener => listener.IsFor(actor));
+				if (index != -1) {
+					owner.listeners.RemoveAt(index);
+				}
+			} finally {
+				owner.rwLock.ExitWriteLock();
+			}
+		}
+	}
+}
diff --git a/Utils/Phantom.Utils.Actor/ICanReply.cs b/Utils/Phantom.Utils.Actor/ICanReply.cs
new file mode 100644
index 0000000..67fb6ac
--- /dev/null
+++ b/Utils/Phantom.Utils.Actor/ICanReply.cs
@@ -0,0 +1,5 @@
+using System.Diagnostics.CodeAnalysis;
+
+namespace Phantom.Utils.Actor;
+
+public interface ICanReply<[SuppressMessage("ReSharper", "UnusedTypeParameter")] TReply> {}
diff --git a/Utils/Phantom.Utils.Actor/Logging/SerilogLogger.cs b/Utils/Phantom.Utils.Actor/Logging/SerilogLogger.cs
new file mode 100644
index 0000000..91791f5
--- /dev/null
+++ b/Utils/Phantom.Utils.Actor/Logging/SerilogLogger.cs
@@ -0,0 +1,68 @@
+using System.Diagnostics.CodeAnalysis;
+using Akka.Actor;
+using Akka.Dispatch;
+using Akka.Event;
+using Phantom.Utils.Logging;
+using Serilog;
+using Serilog.Core.Enrichers;
+using Serilog.Events;
+using LogEvent = Akka.Event.LogEvent;
+
+namespace Phantom.Utils.Actor.Logging;
+
+[SuppressMessage("ReSharper", "UnusedType.Global")]
+public sealed class SerilogLogger : ReceiveActor, IRequiresMessageQueue<ILoggerMessageQueueSemantics> {
+	private readonly Dictionary<string, ILogger> loggersBySource = new ();
+	
+	public SerilogLogger() {
+		Receive<InitializeLogger>(Initialize);
+		
+		Receive<Debug>(LogDebug);
+		Receive<Info>(LogInfo);
+		Receive<Warning>(LogWarning);
+		Receive<Error>(LogError);
+	}
+
+	private void Initialize(InitializeLogger message) {
+		Sender.Tell(new LoggerInitialized());
+	}
+
+	private void LogDebug(Debug item) {
+		Log(item, LogEventLevel.Debug);
+	}
+
+	private void LogInfo(Info item) {
+		Log(item, LogEventLevel.Information);
+	}
+
+	private void LogWarning(Warning item) {
+		Log(item, LogEventLevel.Warning);
+	}
+
+	private void LogError(Error item) {
+		Log(item, LogEventLevel.Error);
+	}
+
+	private void Log(LogEvent item, LogEventLevel level) {
+		GetLogger(item).Write(level, item.Cause, GetFormat(item), GetArgs(item));
+	}
+
+	private ILogger GetLogger(LogEvent item) {
+		var source = item.LogSource;
+		
+		if (!loggersBySource.TryGetValue(source, out var logger)) {
+			var loggerName = source[(source.IndexOf(':') + 1)..];
+			loggersBySource[source] = logger = PhantomLogger.Create("Akka", loggerName);
+		}
+		
+		return logger;
+	}
+
+	private static string GetFormat(LogEvent item) {
+		return item.Message is LogMessage logMessage ? logMessage.Format : "{Message:l}";
+	}
+
+	private static object[] GetArgs(LogEvent item) {
+		return item.Message is LogMessage logMessage ? logMessage.Parameters().Where(static a => a is not PropertyEnricher).ToArray() : new[] { item.Message };
+	}
+}
diff --git a/Utils/Phantom.Utils.Actor/Mailbox/IJumpAhead.cs b/Utils/Phantom.Utils.Actor/Mailbox/IJumpAhead.cs
new file mode 100644
index 0000000..d29d39e
--- /dev/null
+++ b/Utils/Phantom.Utils.Actor/Mailbox/IJumpAhead.cs
@@ -0,0 +1,6 @@
+namespace Phantom.Utils.Actor.Mailbox;
+
+/// <summary>
+/// Marker interface for messages that jump ahead in the <see cref="UnboundedJumpAheadMailbox"/>.
+/// </summary>
+public interface IJumpAhead {}
diff --git a/Utils/Phantom.Utils.Actor/Mailbox/UnboundedJumpAheadMailbox.cs b/Utils/Phantom.Utils.Actor/Mailbox/UnboundedJumpAheadMailbox.cs
new file mode 100644
index 0000000..bc2d2f3
--- /dev/null
+++ b/Utils/Phantom.Utils.Actor/Mailbox/UnboundedJumpAheadMailbox.cs
@@ -0,0 +1,16 @@
+using Akka.Actor;
+using Akka.Configuration;
+using Akka.Dispatch;
+using Akka.Dispatch.MessageQueues;
+
+namespace Phantom.Utils.Actor.Mailbox;
+
+public sealed class UnboundedJumpAheadMailbox : MailboxType, IProducesMessageQueue<UnboundedJumpAheadMessageQueue> {
+	public const string Name = "unbounded-jump-ahead-mailbox";
+	
+	public UnboundedJumpAheadMailbox(Settings settings, Config config) : base(settings, config) {}
+	
+	public override IMessageQueue Create(IActorRef owner, ActorSystem system) {
+		return new UnboundedJumpAheadMessageQueue();
+	}
+}
diff --git a/Utils/Phantom.Utils.Actor/Mailbox/UnboundedJumpAheadMessageQueue.cs b/Utils/Phantom.Utils.Actor/Mailbox/UnboundedJumpAheadMessageQueue.cs
new file mode 100644
index 0000000..757b89d
--- /dev/null
+++ b/Utils/Phantom.Utils.Actor/Mailbox/UnboundedJumpAheadMessageQueue.cs
@@ -0,0 +1,24 @@
+using Akka.Actor;
+using Akka.Dispatch.MessageQueues;
+
+namespace Phantom.Utils.Actor.Mailbox;
+
+sealed class UnboundedJumpAheadMessageQueue : BlockingMessageQueue {
+	private readonly Queue<Envelope> highPriorityQueue = new ();
+	private readonly Queue<Envelope> lowPriorityQueue = new ();
+
+	protected override int LockedCount => highPriorityQueue.Count + lowPriorityQueue.Count;
+
+	protected override void LockedEnqueue(Envelope envelope) {
+		if (envelope.Message is IJumpAhead) {
+			highPriorityQueue.Enqueue(envelope);
+		}
+		else {
+			lowPriorityQueue.Enqueue(envelope);
+		}
+	}
+
+	protected override bool LockedTryDequeue(out Envelope envelope) {
+		return highPriorityQueue.TryDequeue(out envelope) || lowPriorityQueue.TryDequeue(out envelope);
+	}
+}
diff --git a/Utils/Phantom.Utils.Actor/Phantom.Utils.Actor.csproj b/Utils/Phantom.Utils.Actor/Phantom.Utils.Actor.csproj
new file mode 100644
index 0000000..1158a74
--- /dev/null
+++ b/Utils/Phantom.Utils.Actor/Phantom.Utils.Actor.csproj
@@ -0,0 +1,16 @@
+<Project Sdk="Microsoft.NET.Sdk">
+  
+  <PropertyGroup>
+    <ImplicitUsings>enable</ImplicitUsings>
+    <Nullable>enable</Nullable>
+  </PropertyGroup>
+  
+  <ItemGroup>
+    <PackageReference Include="Akka" />
+  </ItemGroup>
+  
+  <ItemGroup>
+    <ProjectReference Include="..\Phantom.Utils.Logging\Phantom.Utils.Logging.csproj" />
+  </ItemGroup>
+
+</Project>
diff --git a/Utils/Phantom.Utils.Actor/Props.cs b/Utils/Phantom.Utils.Actor/Props.cs
new file mode 100644
index 0000000..4404ec0
--- /dev/null
+++ b/Utils/Phantom.Utils.Actor/Props.cs
@@ -0,0 +1,19 @@
+using Akka.Actor;
+
+namespace Phantom.Utils.Actor;
+
+public sealed class Props<TMessage> {
+	internal Props Inner { get; }
+
+	private Props(Props inner) {
+		Inner = inner;
+	}
+
+	private static Props CreateInner<TActor>(Func<TActor> factory) where TActor : ReceiveActor<TMessage> {
+		return Props.CreateBy(new ActorFactory<TActor>(factory));
+	}
+
+	public static Props<TMessage> Create<TActor>(Func<TActor> factory, ActorConfiguration configuration) where TActor : ReceiveActor<TMessage> {
+		return new Props<TMessage>(configuration.Apply(CreateInner(factory)));
+	}
+}
diff --git a/Utils/Phantom.Utils.Actor/ReceiveActor.cs b/Utils/Phantom.Utils.Actor/ReceiveActor.cs
new file mode 100644
index 0000000..7edc010
--- /dev/null
+++ b/Utils/Phantom.Utils.Actor/ReceiveActor.cs
@@ -0,0 +1,46 @@
+using Akka.Actor;
+
+namespace Phantom.Utils.Actor;
+
+public abstract class ReceiveActor<TMessage> : ReceiveActor {
+	protected ActorRef<TMessage> SelfTyped => new (Self);
+
+	protected void ReceiveAndReply<TReplyableMessage, TReply>(Func<TReplyableMessage, TReply> action) where TReplyableMessage : TMessage, ICanReply<TReply> {
+		Receive<TReplyableMessage>(message => HandleMessageWithReply(action, message));
+	}
+
+	protected void ReceiveAndReplyLater<TReplyableMessage, TReply>(Func<TReplyableMessage, Task<TReply>> action) where TReplyableMessage : TMessage, ICanReply<TReply> {
+		// Must be async to set default task scheduler to actor scheduler.
+		ReceiveAsync<TReplyableMessage>(message => HandleMessageWithReplyLater(action, message));
+	}
+
+	protected void ReceiveAsyncAndReply<TReplyableMessage, TReply>(Func<TReplyableMessage, Task<TReply>> action) where TReplyableMessage : TMessage, ICanReply<TReply> {
+		ReceiveAsync<TReplyableMessage>(message => HandleMessageWithReplyAsync(action, message));
+	}
+
+	private void HandleMessageWithReply<TReplyableMessage, TReply>(Func<TReplyableMessage, TReply> action, TReplyableMessage message) where TReplyableMessage : TMessage, ICanReply<TReply> {
+		try {
+			Sender.Tell(action(message), Self);
+		} catch (Exception e) {
+			Sender.Tell(new Status.Failure(e), Self);
+		}
+	}
+
+	private Task HandleMessageWithReplyLater<TReplyableMessage, TReply>(Func<TReplyableMessage, Task<TReply>> action, TReplyableMessage message) where TReplyableMessage : TMessage, ICanReply<TReply> {
+		try {
+			action(message).PipeTo(Sender, Self);
+		} catch (Exception e) {
+			Sender.Tell(new Status.Failure(e), Self);
+		}
+		
+		return Task.CompletedTask;
+	}
+
+	private async Task HandleMessageWithReplyAsync<TReplyableMessage, TReply>(Func<TReplyableMessage, Task<TReply>> action, TReplyableMessage message) where TReplyableMessage : TMessage, ICanReply<TReply> {
+		try {
+			Sender.Tell(await action(message), Self);
+		} catch (Exception e) {
+			Sender.Tell(new Status.Failure(e), Self);
+		}
+	}
+}
diff --git a/Utils/Phantom.Utils.Actor/SupervisorStrategies.cs b/Utils/Phantom.Utils.Actor/SupervisorStrategies.cs
new file mode 100644
index 0000000..a292393
--- /dev/null
+++ b/Utils/Phantom.Utils.Actor/SupervisorStrategies.cs
@@ -0,0 +1,10 @@
+using Akka.Actor;
+using Akka.Util.Internal;
+
+namespace Phantom.Utils.Actor;
+
+public static class SupervisorStrategies {
+	private static DeployableDecider DefaultDecider { get; } = SupervisorStrategy.DefaultDecider.AsInstanceOf<DeployableDecider>();
+	
+	public static SupervisorStrategy Resume { get; } = new OneForOneStrategy(Decider.From(Directive.Resume, DefaultDecider.Pairs));
+}
diff --git a/Utils/Phantom.Utils.Actor/Tasks/TaskExtensions.cs b/Utils/Phantom.Utils.Actor/Tasks/TaskExtensions.cs
new file mode 100644
index 0000000..4aa01b9
--- /dev/null
+++ b/Utils/Phantom.Utils.Actor/Tasks/TaskExtensions.cs
@@ -0,0 +1,33 @@
+using Akka.Dispatch;
+
+namespace Phantom.Utils.Actor.Tasks;
+
+public static class TaskExtensions {
+	public static Task<TResult> ContinueOnActor<TSource, TResult>(this Task<TSource> task, Func<TSource, TResult> mapper) {
+		if (TaskScheduler.Current is not ActorTaskScheduler actorTaskScheduler) {
+			throw new InvalidOperationException("Task must be scheduled in Actor context!");
+		}
+		
+		var continuationCompletionSource = new TaskCompletionSource<TResult>();
+		var continuationTask = task.ContinueWith(t => MapResult(t, mapper, continuationCompletionSource), CancellationToken.None, TaskContinuationOptions.ExecuteSynchronously, actorTaskScheduler);
+		return continuationTask.Unwrap();
+	}
+	
+	public static Task<TResult> ContinueOnActor<TSource, TArg, TResult>(this Task<TSource> task, Func<TSource, TArg, TResult> mapper, TArg arg) {
+		return task.ContinueOnActor(result => mapper(result, arg));
+	}
+	
+	private static Task<TResult> MapResult<TSource, TResult>(Task<TSource> task, Func<TSource, TResult> mapper, TaskCompletionSource<TResult> completionSource) {
+		if (task.IsFaulted) {
+			completionSource.SetException(task.Exception.InnerExceptions);
+		}
+		else if (task.IsCanceled) {
+			completionSource.SetCanceled();
+		}
+		else {
+			completionSource.SetResult(mapper(task.Result));
+		}
+		
+		return completionSource.Task;
+	}
+}
diff --git a/Utils/Phantom.Utils.Rpc/Runtime/RpcConnectionToServer.cs b/Utils/Phantom.Utils.Rpc/Runtime/RpcConnectionToServer.cs
index 8a771cf..638e0d6 100644
--- a/Utils/Phantom.Utils.Rpc/Runtime/RpcConnectionToServer.cs
+++ b/Utils/Phantom.Utils.Rpc/Runtime/RpcConnectionToServer.cs
@@ -1,16 +1,24 @@
 using NetMQ;
 using NetMQ.Sockets;
 using Phantom.Utils.Rpc.Message;
+using Phantom.Utils.Tasks;
 
 namespace Phantom.Utils.Rpc.Runtime;
 
 public sealed class RpcConnectionToServer<TListener> : RpcConnection<TListener> {
 	private readonly ClientSocket socket;
+	private readonly TaskCompletionSource isReady = AsyncTasks.CreateCompletionSource();
 
+	public Task IsReady => isReady.Task;
+	
 	internal RpcConnectionToServer(string loggerName, ClientSocket socket, MessageRegistry<TListener> messageRegistry, MessageReplyTracker replyTracker) : base(loggerName, messageRegistry, replyTracker) {
 		this.socket = socket;
 	}
 
+	public void SetIsReady() {
+		isReady.TrySetResult();
+	}
+
 	private protected override ValueTask Send(byte[] bytes) {
 		return socket.SendAsync(bytes);
 	}
diff --git a/Utils/Phantom.Utils/Collections/RwLockedObservableDictionary.cs b/Utils/Phantom.Utils/Collections/RwLockedObservableDictionary.cs
deleted file mode 100644
index ecbfce4..0000000
--- a/Utils/Phantom.Utils/Collections/RwLockedObservableDictionary.cs
+++ /dev/null
@@ -1,102 +0,0 @@
-using System.Collections.Immutable;
-using System.Diagnostics.CodeAnalysis;
-
-namespace Phantom.Utils.Collections;
-
-public sealed class RwLockedObservableDictionary<TKey, TValue> where TKey : notnull {
-	public event EventHandler? CollectionChanged;
-
-	private readonly RwLockedDictionary<TKey, TValue> dict;
-
-	public RwLockedObservableDictionary(LockRecursionPolicy recursionPolicy) {
-		this.dict = new RwLockedDictionary<TKey, TValue>(recursionPolicy);
-	}
-
-	public RwLockedObservableDictionary(int capacity, LockRecursionPolicy recursionPolicy) {
-		this.dict = new RwLockedDictionary<TKey, TValue>(capacity, recursionPolicy);
-	}
-
-	private void FireCollectionChanged() {
-		CollectionChanged?.Invoke(this, EventArgs.Empty);
-	}
-
-	private bool FireCollectionChangedIf(bool result) {
-		if (result) {
-			FireCollectionChanged();
-			return true;
-		}
-		else {
-			return false;
-		}
-	}
-
-	public TValue this[TKey key] {
-		get => dict[key];
-		set {
-			dict[key] = value;
-			FireCollectionChanged();
-		}
-	}
-
-	public ImmutableArray<TValue> ValuesCopy => dict.ValuesCopy;
-
-	public void ForEachValue(Action<TValue> action) {
-		dict.ForEachValue(action);
-	}
-
-	public bool TryGetValue(TKey key, [MaybeNullWhen(false)] out TValue value) {
-		return dict.TryGetValue(key, out value);
-	}
-	
-	public bool GetOrAdd(TKey key, Func<TKey, TValue> valueFactory, out TValue value) {
-		return FireCollectionChangedIf(dict.GetOrAdd(key, valueFactory, out value));
-	}
-
-	public bool TryAdd(TKey key, TValue newValue) {
-		return FireCollectionChangedIf(dict.TryAdd(key, newValue));
-	}
-
-	public bool AddOrReplace(TKey key, TValue newValue, [MaybeNullWhen(false)] out TValue oldValue) {
-		return FireCollectionChangedIf(dict.AddOrReplace(key, newValue, out oldValue));
-	}
-
-	public bool AddOrReplaceIf(TKey key, TValue newValue, Predicate<TValue> replaceCondition) {
-		return FireCollectionChangedIf(dict.AddOrReplaceIf(key, newValue, replaceCondition));
-	}
-
-	public bool TryReplace(TKey key, Func<TValue, TValue> replacementValue) {
-		return FireCollectionChangedIf(dict.TryReplace(key, replacementValue));
-	}
-
-	public bool TryReplaceIf(TKey key, Func<TValue, TValue> replacementValue, Predicate<TValue> replaceCondition) {
-		return FireCollectionChangedIf(dict.TryReplaceIf(key, replacementValue, replaceCondition));
-	}
-
-	public bool ReplaceAll(Func<TValue, TValue> replacementValue) {
-		return FireCollectionChangedIf(dict.ReplaceAll(replacementValue));
-	}
-
-	public bool ReplaceAllIf(Func<TValue, TValue> replacementValue, Predicate<TValue> replaceCondition) {
-		return FireCollectionChangedIf(dict.ReplaceAllIf(replacementValue, replaceCondition));
-	}
-
-	public bool Remove(TKey key) {
-		return FireCollectionChangedIf(dict.Remove(key));
-	}
-
-	public bool RemoveIf(TKey key, Predicate<TValue> removeCondition) {
-		return FireCollectionChangedIf(dict.RemoveIf(key, removeCondition));
-	}
-
-	public bool RemoveAll(Predicate<KeyValuePair<TKey, TValue>> removeCondition) {
-		return FireCollectionChangedIf(dict.RemoveAll(removeCondition));
-	}
-
-	public ImmutableDictionary<TKey, TValue> ToImmutable() {
-		return dict.ToImmutable();
-	}
-
-	public ImmutableDictionary<TKey, TNewValue> ToImmutable<TNewValue>(Func<TValue, TNewValue> valueSelector) {
-		return dict.ToImmutable(valueSelector);
-	}
-}
diff --git a/Web/Phantom.Web.Services/Agents/AgentManager.cs b/Web/Phantom.Web.Services/Agents/AgentManager.cs
index 5dd3f5e..4ab2a79 100644
--- a/Web/Phantom.Web.Services/Agents/AgentManager.cs
+++ b/Web/Phantom.Web.Services/Agents/AgentManager.cs
@@ -6,19 +6,19 @@ using Phantom.Utils.Logging;
 namespace Phantom.Web.Services.Agents; 
 
 public sealed class AgentManager {
-	private readonly SimpleObservableState<ImmutableArray<AgentWithStats>> agents = new (PhantomLogger.Create<AgentManager>("Agents"), ImmutableArray<AgentWithStats>.Empty);
+	private readonly SimpleObservableState<ImmutableArray<Agent>> agents = new (PhantomLogger.Create<AgentManager>("Agents"), ImmutableArray<Agent>.Empty);
 
-	public EventSubscribers<ImmutableArray<AgentWithStats>> AgentsChanged => agents.Subs;
+	public EventSubscribers<ImmutableArray<Agent>> AgentsChanged => agents.Subs;
 
-	internal void RefreshAgents(ImmutableArray<AgentWithStats> newAgents) {
+	internal void RefreshAgents(ImmutableArray<Agent> newAgents) {
 		agents.SetTo(newAgents);
 	}
 
-	public ImmutableArray<AgentWithStats> GetAll() {
+	public ImmutableArray<Agent> GetAll() {
 		return agents.Value;
 	}
 	
-	public ImmutableDictionary<Guid, AgentWithStats> ToDictionaryByGuid() {
-		return agents.Value.ToImmutableDictionary(static agent => agent.Guid);
+	public ImmutableDictionary<Guid, Agent> ToDictionaryByGuid() {
+		return agents.Value.ToImmutableDictionary(static agent => agent.AgentGuid);
 	}
 }
diff --git a/Web/Phantom.Web.Services/Instances/InstanceManager.cs b/Web/Phantom.Web.Services/Instances/InstanceManager.cs
index 915b908..578a4ff 100644
--- a/Web/Phantom.Web.Services/Instances/InstanceManager.cs
+++ b/Web/Phantom.Web.Services/Instances/InstanceManager.cs
@@ -23,7 +23,7 @@ public sealed class InstanceManager {
 	public EventSubscribers<InstanceDictionary> InstancesChanged => instances.Subs;
 	
 	internal void RefreshInstances(ImmutableArray<Instance> newInstances) {
-		instances.SetTo(newInstances.ToImmutableDictionary(static instance => instance.Configuration.InstanceGuid));
+		instances.SetTo(newInstances.ToImmutableDictionary(static instance => instance.InstanceGuid));
 	}
 
 	public InstanceDictionary GetAll() {
@@ -34,23 +34,23 @@ public sealed class InstanceManager {
 		return instances.Value.GetValueOrDefault(instanceGuid);
 	}
 
-	public Task<InstanceActionResult<CreateOrUpdateInstanceResult>> CreateOrUpdateInstance(Guid loggedInUserGuid, InstanceConfiguration configuration, CancellationToken cancellationToken) {
-		var message = new CreateOrUpdateInstanceMessage(loggedInUserGuid, configuration);
+	public Task<InstanceActionResult<CreateOrUpdateInstanceResult>> CreateOrUpdateInstance(Guid loggedInUserGuid, Guid instanceGuid, InstanceConfiguration configuration, CancellationToken cancellationToken) {
+		var message = new CreateOrUpdateInstanceMessage(loggedInUserGuid, instanceGuid, configuration);
 		return controllerConnection.Send<CreateOrUpdateInstanceMessage, InstanceActionResult<CreateOrUpdateInstanceResult>>(message, cancellationToken);
 	}
 
-	public Task<InstanceActionResult<LaunchInstanceResult>> LaunchInstance(Guid loggedInUserGuid, Guid instanceGuid, CancellationToken cancellationToken) {
-		var message = new LaunchInstanceMessage(loggedInUserGuid, instanceGuid);
+	public Task<InstanceActionResult<LaunchInstanceResult>> LaunchInstance(Guid loggedInUserGuid, Guid agentGuid, Guid instanceGuid, CancellationToken cancellationToken) {
+		var message = new LaunchInstanceMessage(loggedInUserGuid, agentGuid, instanceGuid);
 		return controllerConnection.Send<LaunchInstanceMessage, InstanceActionResult<LaunchInstanceResult>>(message, cancellationToken);
 	}
 
-	public Task<InstanceActionResult<StopInstanceResult>> StopInstance(Guid loggedInUserGuid, Guid instanceGuid, MinecraftStopStrategy stopStrategy, CancellationToken cancellationToken) {
-		var message = new StopInstanceMessage(loggedInUserGuid, instanceGuid, stopStrategy);
+	public Task<InstanceActionResult<StopInstanceResult>> StopInstance(Guid loggedInUserGuid, Guid agentGuid, Guid instanceGuid, MinecraftStopStrategy stopStrategy, CancellationToken cancellationToken) {
+		var message = new StopInstanceMessage(loggedInUserGuid, agentGuid, instanceGuid, stopStrategy);
 		return controllerConnection.Send<StopInstanceMessage, InstanceActionResult<StopInstanceResult>>(message, cancellationToken);
 	}
 
-	public Task<InstanceActionResult<SendCommandToInstanceResult>> SendCommandToInstance(Guid loggedInUserGuid, Guid instanceGuid, string command, CancellationToken cancellationToken) {
-		var message = new SendCommandToInstanceMessage(loggedInUserGuid, instanceGuid, command);
+	public Task<InstanceActionResult<SendCommandToInstanceResult>> SendCommandToInstance(Guid loggedInUserGuid, Guid agentGuid, Guid instanceGuid, string command, CancellationToken cancellationToken) {
+		var message = new SendCommandToInstanceMessage(loggedInUserGuid, agentGuid, instanceGuid, command);
 		return controllerConnection.Send<SendCommandToInstanceMessage, InstanceActionResult<SendCommandToInstanceResult>>(message, cancellationToken);
 	}
 }
diff --git a/Web/Phantom.Web/Pages/Agents.razor b/Web/Phantom.Web/Pages/Agents.razor
index 6adf9fa..5c273f8 100644
--- a/Web/Phantom.Web/Pages/Agents.razor
+++ b/Web/Phantom.Web/Pages/Agents.razor
@@ -18,42 +18,47 @@
   </HeaderRow>
   <ItemRow Context="agent">
     @{
+      var configuration = agent.Configuration;
       var usedInstances = agent.Stats?.RunningInstanceCount;
       var usedMemory = agent.Stats?.RunningInstanceMemory.InMegabytes;
     }
     <Cell>
-      <p class="fw-semibold">@agent.Name</p>
-      <small class="font-monospace text-uppercase">@agent.Guid.ToString()</small>
+      <p class="fw-semibold">@configuration.AgentName</p>
+      <small class="font-monospace text-uppercase">@agent.AgentGuid.ToString()</small>
     </Cell>
     <Cell class="text-end">
-      <ProgressBar Value="@(usedInstances ?? 0)" Maximum="@agent.MaxInstances">
-        @(usedInstances?.ToString() ?? "?") / @agent.MaxInstances.ToString()
+      <ProgressBar Value="@(usedInstances ?? 0)" Maximum="@configuration.MaxInstances">
+        @(usedInstances?.ToString() ?? "?") / @configuration.MaxInstances.ToString()
       </ProgressBar>
     </Cell>
     <Cell class="text-end">
-      <ProgressBar Value="@(usedMemory ?? 0)" Maximum="@agent.MaxMemory.InMegabytes">
-        @(usedMemory?.ToString() ?? "?") / @agent.MaxMemory.InMegabytes.ToString() MB
+      <ProgressBar Value="@(usedMemory ?? 0)" Maximum="@configuration.MaxMemory.InMegabytes">
+        @(usedMemory?.ToString() ?? "?") / @configuration.MaxMemory.InMegabytes.ToString() MB
       </ProgressBar>
     </Cell>
     <Cell class="text-condensed">
-      Build: <span class="font-monospace">@agent.BuildVersion</span>
+      Build: <span class="font-monospace">@configuration.BuildVersion</span>
       <br>
-      Protocol: <span class="font-monospace">v@(agent.ProtocolVersion.ToString())</span>
+      Protocol: <span class="font-monospace">v@(configuration.ProtocolVersion.ToString())</span>
     </Cell>
-    @if (agent.IsOnline) {
-      <Cell class="fw-semibold text-center text-success">Online</Cell>
-      <Cell class="text-end">-</Cell>
-    }
-    else {
-      <Cell class="fw-semibold text-center">Offline</Cell>
-      <Cell class="text-end">
-      @if (agent.LastPing is {} lastPing) {
-          <TimeWithOffset Time="lastPing" />
-      }
-      else {
-        <text>N/A</text>
-      }
-      </Cell>
+    @switch (agent.ConnectionStatus) {
+      case AgentIsOnline:
+        <Cell class="fw-semibold text-center text-success">Online</Cell>
+        <Cell class="text-end">-</Cell>
+        break;
+      case AgentIsOffline:
+        <Cell class="fw-semibold text-center">Offline</Cell>
+        <Cell class="text-end">N/A</Cell>
+        break;
+      case AgentIsDisconnected status:
+        <Cell class="fw-semibold text-center">Offline</Cell>
+        <Cell class="text-end">
+          <TimeWithOffset Time="status.LastPingTime" />
+        </Cell>
+        break;
+      default:
+        <Cell class="fw-semibold text-center">N/A</Cell>
+        break;
     }
   </ItemRow>
   <NoItemsRow>
@@ -63,12 +68,12 @@
 
 @code {
 
-  private readonly TableData<AgentWithStats, Guid> agentTable = new();
+  private readonly TableData<Agent, Guid> agentTable = new();
 
   protected override void OnInitialized() {
     AgentManager.AgentsChanged.Subscribe(this, agents => {
-      var sortedAgents = agents.Sort(static (a1, a2) => a1.Name.CompareTo(a2.Name));
-      agentTable.UpdateFrom(sortedAgents, static agent => agent.Guid, static agent => agent, static (agent, _) => agent);
+      var sortedAgents = agents.Sort(static (a1, a2) => a1.Configuration.AgentName.CompareTo(a2.Configuration.AgentName));
+      agentTable.UpdateFrom(sortedAgents, static agent => agent.AgentGuid, static agent => agent, static (agent, _) => agent);
       InvokeAsync(StateHasChanged);
     });
   }
diff --git a/Web/Phantom.Web/Pages/Audit.razor b/Web/Phantom.Web/Pages/Audit.razor
index c27477f..478dcff 100644
--- a/Web/Phantom.Web/Pages/Audit.razor
+++ b/Web/Phantom.Web/Pages/Audit.razor
@@ -58,7 +58,7 @@
     try {
       logItems = await AuditLogManager.GetMostRecentItems(50, cancellationToken);
       userNamesByGuid = (await UserManager.GetAll(cancellationToken)).ToImmutableDictionary(static user => user.Guid, static user => user.Name);
-      instanceNamesByGuid = InstanceManager.GetAll().Values.ToImmutableDictionary(static instance => instance.Configuration.InstanceGuid, static instance => instance.Configuration.InstanceName);
+      instanceNamesByGuid = InstanceManager.GetAll().Values.ToImmutableDictionary(static instance => instance.InstanceGuid, static instance => instance.Configuration.InstanceName);
     } finally {
       initializationCancellationTokenSource.Dispose();
     }
diff --git a/Web/Phantom.Web/Pages/Events.razor b/Web/Phantom.Web/Pages/Events.razor
index 575ff4c..11f91a8 100644
--- a/Web/Phantom.Web/Pages/Events.razor
+++ b/Web/Phantom.Web/Pages/Events.razor
@@ -61,8 +61,8 @@
 
     try {
       logItems = await EventLogManager.GetMostRecentItems(50, cancellationToken);
-      agentNamesByGuid = AgentManager.GetAll().ToImmutableDictionary(static kvp => kvp.Guid, static kvp => kvp.Name);
-      instanceNamesByGuid = InstanceManager.GetAll().Values.ToImmutableDictionary(static instance => instance.Configuration.InstanceGuid, static instance => instance.Configuration.InstanceName);
+      agentNamesByGuid = AgentManager.GetAll().ToImmutableDictionary(static kvp => kvp.AgentGuid, static kvp => kvp.Configuration.AgentName);
+      instanceNamesByGuid = InstanceManager.GetAll().Values.ToImmutableDictionary(static instance => instance.InstanceGuid, static instance => instance.Configuration.InstanceName);
     } finally {
       initializationCancellationTokenSource.Dispose();
     }
diff --git a/Web/Phantom.Web/Pages/InstanceCreate.razor b/Web/Phantom.Web/Pages/InstanceCreate.razor
index c1baee6..4d0aa71 100644
--- a/Web/Phantom.Web/Pages/InstanceCreate.razor
+++ b/Web/Phantom.Web/Pages/InstanceCreate.razor
@@ -3,4 +3,4 @@
 @attribute [Authorize(Permission.CreateInstancesPolicy)]
 
 <h1>New Instance</h1>
-<InstanceAddOrEditForm EditedInstanceConfiguration="null" />
+<InstanceAddOrEditForm EditedInstance="null" />
diff --git a/Web/Phantom.Web/Pages/InstanceDetail.razor b/Web/Phantom.Web/Pages/InstanceDetail.razor
index 444b4a1..c6ab320 100644
--- a/Web/Phantom.Web/Pages/InstanceDetail.razor
+++ b/Web/Phantom.Web/Pages/InstanceDetail.razor
@@ -41,10 +41,10 @@ else {
 
   <PermissionView Permission="Permission.ControlInstances">
     <div class="mb-3">
-      <InstanceCommandInput InstanceGuid="InstanceGuid" Disabled="@(!Instance.Status.CanSendCommand())" />
+      <InstanceCommandInput AgentGuid="Instance.Configuration.AgentGuid" InstanceGuid="InstanceGuid" Disabled="@(!Instance.Status.CanSendCommand())" />
     </div>
 
-    <InstanceStopDialog InstanceGuid="InstanceGuid" ModalId="stop-instance" Disabled="@(!Instance.Status.CanStop())" />
+    <InstanceStopDialog AgentGuid="Instance.Configuration.AgentGuid" InstanceGuid="InstanceGuid" ModalId="stop-instance" Disabled="@(!Instance.Status.CanStop())" />
   </PermissionView>
 }
 
@@ -79,7 +79,12 @@ else {
         return;
       }
 
-      var result = await InstanceManager.LaunchInstance(loggedInUserGuid.Value, InstanceGuid, CancellationToken);
+      if (Instance == null) {
+        lastError = "Instance not found.";
+        return;
+      }
+
+      var result = await InstanceManager.LaunchInstance(loggedInUserGuid.Value, Instance.Configuration.AgentGuid, InstanceGuid, CancellationToken);
       if (!result.Is(LaunchInstanceResult.LaunchInitiated)) {
         lastError = result.ToSentence(Messages.ToSentence);
       }
diff --git a/Web/Phantom.Web/Pages/InstanceEdit.razor b/Web/Phantom.Web/Pages/InstanceEdit.razor
index 90a6aa4..20e95b6 100644
--- a/Web/Phantom.Web/Pages/InstanceEdit.razor
+++ b/Web/Phantom.Web/Pages/InstanceEdit.razor
@@ -1,18 +1,18 @@
 @page "/instances/{InstanceGuid:guid}/edit"
 @attribute [Authorize(Permission.CreateInstancesPolicy)]
-@using Phantom.Common.Data.Instance
+@using Phantom.Common.Data.Web.Instance
 @using Phantom.Common.Data.Web.Users
 @using Phantom.Web.Services.Instances
-@inherits Phantom.Web.Components.PhantomComponent
+@inherits PhantomComponent
 @inject InstanceManager InstanceManager
 
-@if (InstanceConfiguration == null) {
+@if (Instance == null) {
   <h1>Instance Not Found</h1>
   <p>Return to <a href="instances">all instances</a>.</p>
 }
 else {
-  <h1>Edit Instance: @InstanceConfiguration.InstanceName</h1>
-  <InstanceAddOrEditForm EditedInstanceConfiguration="InstanceConfiguration" />
+  <h1>Edit Instance: @Instance.Configuration.InstanceName</h1>
+  <InstanceAddOrEditForm EditedInstance="Instance" />
 }
 
 @code {
@@ -20,10 +20,10 @@ else {
   [Parameter]
   public Guid InstanceGuid { get; init; }
 
-  private InstanceConfiguration? InstanceConfiguration { get; set; }
+  private Instance? Instance { get; set; }
 
   protected override void OnInitialized() {
-    InstanceConfiguration = InstanceManager.GetByGuid(InstanceGuid)?.Configuration;
+    Instance = InstanceManager.GetByGuid(InstanceGuid);
   }
 
 }
diff --git a/Web/Phantom.Web/Pages/Instances.razor b/Web/Phantom.Web/Pages/Instances.razor
index d39cf9a..cdb2a5a 100644
--- a/Web/Phantom.Web/Pages/Instances.razor
+++ b/Web/Phantom.Web/Pages/Instances.razor
@@ -16,7 +16,7 @@
   <a href="instances/create" class="btn btn-primary" role="button">New Instance</a>
 </PermissionView>
 
-<Table TItem="Instance" Items="instances" ItemUrl="@(static instance => "instances/" + instance.Configuration.InstanceGuid)">
+<Table TItem="Instance" Items="instances" ItemUrl="@(static instance => "instances/" + instance.InstanceGuid)">
   <HeaderRow>
     <Column Width="40%">Agent</Column>
     <Column Width="40%">Name</Column>
@@ -35,7 +35,7 @@
     </Cell>
     <Cell>
       <p class="fw-semibold">@configuration.InstanceName</p>
-      <small class="font-monospace text-uppercase">@configuration.InstanceGuid.ToString()</small>
+      <small class="font-monospace text-uppercase">@instance.InstanceGuid.ToString()</small>
     </Cell>
     <Cell>
       <InstanceStatusText Status="instance.Status" />
@@ -51,7 +51,7 @@
       <p class="font-monospace">@configuration.MemoryAllocation.InMegabytes.ToString() MB</p>
     </Cell>
     <Cell>
-      <a href="instances/@configuration.InstanceGuid.ToString()" class="btn btn-info btn-sm">Detail</a>
+      <a href="instances/@instance.InstanceGuid.ToString()" class="btn btn-info btn-sm">Detail</a>
     </Cell>
   </ItemRow>
   <NoItemsRow>
@@ -66,7 +66,7 @@
 
   protected override void OnInitialized() {
     AgentManager.AgentsChanged.Subscribe(this, agents => {
-      this.agentNamesByGuid = agents.ToImmutableDictionary(static agent => agent.Guid, static agent => agent.Name);
+      this.agentNamesByGuid = agents.ToImmutableDictionary(static agent => agent.AgentGuid, static agent => agent.Configuration.AgentName);
       InvokeAsync(StateHasChanged);
     });
 
diff --git a/Web/Phantom.Web/Shared/InstanceAddOrEditForm.razor b/Web/Phantom.Web/Shared/InstanceAddOrEditForm.razor
index 60494df..6ac26b6 100644
--- a/Web/Phantom.Web/Shared/InstanceAddOrEditForm.razor
+++ b/Web/Phantom.Web/Shared/InstanceAddOrEditForm.razor
@@ -8,9 +8,9 @@
 @using Phantom.Common.Data.Web.Minecraft
 @using Phantom.Common.Data.Web.Users
 @using Phantom.Common.Messages.Web.ToController
-@using Phantom.Common.Data.Instance
 @using Phantom.Common.Data.Java
 @using Phantom.Common.Data
+@using Phantom.Common.Data.Instance
 @using Phantom.Web.Services
 @using Phantom.Web.Services.Agents
 @using Phantom.Web.Services.Instances
@@ -26,20 +26,21 @@
   <div class="row">
     <div class="col-xl-7 mb-3">
       @{
-        static RenderFragment GetAgentOption(AgentWithStats agent) {
-          return @<option value="@agent.Guid">
-                   @agent.Name
+        static RenderFragment GetAgentOption(Agent agent) {
+          var configuration = agent.Configuration;
+          return @<option value="@agent.AgentGuid">
+                   @configuration.AgentName
                    &bullet;
-                   @(agent.Stats?.RunningInstanceCount.ToString() ?? "?")/@(agent.MaxInstances) @(agent.MaxInstances == 1 ? "Instance" : "Instances")
+                   @(agent.Stats?.RunningInstanceCount.ToString() ?? "?")/@(configuration.MaxInstances) @(configuration.MaxInstances == 1 ? "Instance" : "Instances")
                    &bullet;
-                   @(agent.Stats?.RunningInstanceMemory.InMegabytes.ToString() ?? "?")/@(agent.MaxMemory.InMegabytes) MB RAM
+                   @(agent.Stats?.RunningInstanceMemory.InMegabytes.ToString() ?? "?")/@(configuration.MaxMemory.InMegabytes) MB RAM
                  </option>;
         }
       }
-      @if (EditedInstanceConfiguration == null) {
+      @if (EditedInstance == null) {
         <FormSelectInput Id="instance-agent" Label="Agent" @bind-Value="form.SelectedAgentGuid">
           <option value="" selected>Select which agent will run the instance...</option>
-          @foreach (var agent in allAgentsByGuid.Values.Where(static agent => agent.IsOnline).OrderBy(static agent => agent.Name)) {
+          @foreach (var agent in allAgentsByGuid.Values.Where(static agent => agent.ConnectionStatus is AgentIsOnline).OrderBy(static agent => agent.Configuration.AgentName)) {
             @GetAgentOption(agent)
           }
         </FormSelectInput>
@@ -97,8 +98,8 @@
     </div>
 
     @{
-      string? allowedServerPorts = selectedAgent?.AllowedServerPorts?.ToString();
-      string? allowedRconPorts = selectedAgent?.AllowedRconPorts?.ToString();
+      string? allowedServerPorts = selectedAgent?.Configuration.AllowedServerPorts?.ToString();
+      string? allowedRconPorts = selectedAgent?.Configuration.AllowedRconPorts?.ToString();
     }
     <div class="col-sm-6 col-xl-2 mb-3">
       <FormNumberInput Id="instance-server-port" @bind-Value="form.ServerPort" min="0" max="65535">
@@ -141,7 +142,7 @@
             <text>RAM</text>
           }
           else {
-            <text>RAM &bullet; <code>@(form.MemoryAllocation?.InMegabytes ?? 0) / @(selectedAgent?.MaxMemory.InMegabytes) MB</code></text>
+            <text>RAM &bullet; <code>@(form.MemoryAllocation?.InMegabytes ?? 0) / @(selectedAgent?.Configuration.MaxMemory.InMegabytes) MB</code></text>
           }
         </LabelFragment>
       </FormNumberInput>
@@ -158,18 +159,18 @@
     </div>
   </div>
 
-  <FormButtonSubmit Label="@(EditedInstanceConfiguration == null ? "Create Instance" : "Edit Instance")" class="btn btn-primary" disabled="@(!IsSubmittable)" />
+  <FormButtonSubmit Label="@(EditedInstance == null ? "Create Instance" : "Edit Instance")" class="btn btn-primary" disabled="@(!IsSubmittable)" />
   <FormSubmitError />
 </Form>
 
 @code {
 
   [Parameter, EditorRequired]
-  public InstanceConfiguration? EditedInstanceConfiguration { get; init; }
-
+  public Instance? EditedInstance { get; init; }
+  
   private ConfigureInstanceFormModel form = null!;
 
-  private ImmutableDictionary<Guid, AgentWithStats> allAgentsByGuid = ImmutableDictionary<Guid, AgentWithStats>.Empty;
+  private ImmutableDictionary<Guid, Agent> allAgentsByGuid = ImmutableDictionary<Guid, Agent>.Empty;
   private ImmutableDictionary<Guid, ImmutableArray<TaggedJavaRuntime>> allAgentJavaRuntimes = ImmutableDictionary<Guid, ImmutableArray<TaggedJavaRuntime>>.Empty;
   
   private MinecraftVersionType minecraftVersionType = MinecraftVersionType.Release;
@@ -197,15 +198,15 @@
       }
     }
 
-    private bool TryGetAgent(Guid? agentGuid, [NotNullWhen(true)] out AgentWithStats? agent) {
+    private bool TryGetAgent(Guid? agentGuid, [NotNullWhen(true)] out Agent? agent) {
       return TryGet(page.allAgentsByGuid, agentGuid, out agent);
     }
 
-    public AgentWithStats? SelectedAgent => TryGetAgent(SelectedAgentGuid, out var agent) ? agent : null;
+    public Agent? SelectedAgent => TryGetAgent(SelectedAgentGuid, out var agent) ? agent : null;
 
     public ImmutableArray<TaggedJavaRuntime> JavaRuntimesForSelectedAgent => TryGet(page.allAgentJavaRuntimes, SelectedAgentGuid, out var javaRuntimes) ? javaRuntimes : ImmutableArray<TaggedJavaRuntime>.Empty;
 
-    public ushort MaximumMemoryUnits => SelectedAgent?.MaxMemory.RawValue ?? 0;
+    public ushort MaximumMemoryUnits => SelectedAgent?.Configuration.MaxMemory.RawValue ?? 0;
     public ushort AvailableMemoryUnits => Math.Min((SelectedAgent?.AvailableMemory + editedInstanceRamAllocation)?.RawValue ?? MaximumMemoryUnits, MaximumMemoryUnits);
     private ushort selectedMemoryUnits = 4;
 
@@ -246,12 +247,12 @@
 
     public sealed class ServerPortMustBeAllowedAttribute : FormValidationAttribute<ConfigureInstanceFormModel, int> {
       protected override string FieldName => nameof(ServerPort);
-      protected override bool IsValid(ConfigureInstanceFormModel model, int value) => model.SelectedAgent is not {} agent || agent.AllowedServerPorts?.Contains((ushort) value) == true;
+      protected override bool IsValid(ConfigureInstanceFormModel model, int value) => model.SelectedAgent is not {} agent || agent.Configuration.AllowedServerPorts?.Contains((ushort) value) == true;
     }
 
     public sealed class RconPortMustBeAllowedAttribute : FormValidationAttribute<ConfigureInstanceFormModel, int> {
       protected override string FieldName => nameof(RconPort);
-      protected override bool IsValid(ConfigureInstanceFormModel model, int value) => model.SelectedAgent is not {} agent || agent.AllowedRconPorts?.Contains((ushort) value) == true;
+      protected override bool IsValid(ConfigureInstanceFormModel model, int value) => model.SelectedAgent is not {} agent || agent.Configuration.AllowedRconPorts?.Contains((ushort) value) == true;
     }
 
     public sealed class RconPortMustDifferFromServerPortAttribute : FormValidationAttribute<ConfigureInstanceFormModel, int?> {
@@ -270,7 +271,7 @@
   }
 
   protected override void OnInitialized() {
-    form = new ConfigureInstanceFormModel(this, EditedInstanceConfiguration?.MemoryAllocation);
+    form = new ConfigureInstanceFormModel(this, EditedInstance?.Configuration.MemoryAllocation);
   }
 
   protected override async Task OnInitializedAsync() {
@@ -281,18 +282,19 @@
     allAgentJavaRuntimes = await agentJavaRuntimesTask;
     allMinecraftVersions = await minecraftVersionsTask;
 
-    if (EditedInstanceConfiguration != null) {
-      form.SelectedAgentGuid = EditedInstanceConfiguration.AgentGuid;
-      form.InstanceName = EditedInstanceConfiguration.InstanceName;
-      form.ServerPort = EditedInstanceConfiguration.ServerPort;
-      form.RconPort = EditedInstanceConfiguration.RconPort;
-      form.MinecraftVersion = EditedInstanceConfiguration.MinecraftVersion;
-      form.MinecraftServerKind = EditedInstanceConfiguration.MinecraftServerKind;
-      form.MemoryUnits = EditedInstanceConfiguration.MemoryAllocation.RawValue;
-      form.JavaRuntimeGuid = EditedInstanceConfiguration.JavaRuntimeGuid;
-      form.JvmArguments = JvmArgumentsHelper.Join(EditedInstanceConfiguration.JvmArguments);
+    if (EditedInstance != null) {
+      var configuration = EditedInstance.Configuration;
+      form.SelectedAgentGuid = configuration.AgentGuid;
+      form.InstanceName = configuration.InstanceName;
+      form.ServerPort = configuration.ServerPort;
+      form.RconPort = configuration.RconPort;
+      form.MinecraftVersion = configuration.MinecraftVersion;
+      form.MinecraftServerKind = configuration.MinecraftServerKind;
+      form.MemoryUnits = configuration.MemoryAllocation.RawValue;
+      form.JavaRuntimeGuid = configuration.JavaRuntimeGuid;
+      form.JvmArguments = JvmArgumentsHelper.Join(configuration.JvmArguments);
       
-      minecraftVersionType = allMinecraftVersions.FirstOrDefault(version => version.Id == EditedInstanceConfiguration.MinecraftVersion)?.Type ?? minecraftVersionType;
+      minecraftVersionType = allMinecraftVersions.FirstOrDefault(version => version.Id == configuration.MinecraftVersion)?.Type ?? minecraftVersionType;
     }
 
     form.EditContext.RevalidateWhenFieldChanges(tracked: nameof(ConfigureInstanceFormModel.SelectedAgentGuid), revalidated: nameof(ConfigureInstanceFormModel.MemoryUnits));
@@ -326,10 +328,10 @@
       form.SubmitModel.StopSubmitting("You do not have permission to edit instances.");
       return;
     }
-      
-    var instance = new InstanceConfiguration(
-      EditedInstanceConfiguration?.AgentGuid ?? selectedAgent.Guid,
-      EditedInstanceConfiguration?.InstanceGuid ?? Guid.NewGuid(),
+    
+    var instanceGuid = EditedInstance?.InstanceGuid ?? Guid.NewGuid();
+    var instanceConfiguration = new InstanceConfiguration(
+      EditedInstance?.Configuration.AgentGuid ?? selectedAgent.AgentGuid,
       form.InstanceName,
       (ushort) form.ServerPort,
       (ushort) form.RconPort,
@@ -340,9 +342,9 @@
       JvmArgumentsHelper.Split(form.JvmArguments)
     );
 
-    var result = await InstanceManager.CreateOrUpdateInstance(loggedInUserGuid.Value, instance, CancellationToken);
+    var result = await InstanceManager.CreateOrUpdateInstance(loggedInUserGuid.Value, instanceGuid, instanceConfiguration, CancellationToken);
     if (result.Is(CreateOrUpdateInstanceResult.Success)) {
-      await Navigation.NavigateTo("instances/" + instance.InstanceGuid);
+      await Navigation.NavigateTo("instances/" + instanceGuid);
     }
     else {
       form.SubmitModel.StopSubmitting(result.ToSentence(CreateOrUpdateInstanceResultExtensions.ToSentence));
diff --git a/Web/Phantom.Web/Shared/InstanceCommandInput.razor b/Web/Phantom.Web/Shared/InstanceCommandInput.razor
index 091cfa8..c6bd0ab 100644
--- a/Web/Phantom.Web/Shared/InstanceCommandInput.razor
+++ b/Web/Phantom.Web/Shared/InstanceCommandInput.razor
@@ -16,7 +16,10 @@
 
 @code {
 
-  [Parameter]
+  [Parameter, EditorRequired]
+  public Guid AgentGuid { get; set; }
+  
+  [Parameter, EditorRequired]
   public Guid InstanceGuid { get; set; }
 
   [Parameter]
@@ -39,7 +42,7 @@
       return;
     }
 
-    var result = await InstanceManager.SendCommandToInstance(loggedInUserGuid.Value, InstanceGuid, form.Command, CancellationToken);
+    var result = await InstanceManager.SendCommandToInstance(loggedInUserGuid.Value, AgentGuid, InstanceGuid, form.Command, CancellationToken);
     if (result.Is(SendCommandToInstanceResult.Success)) {
       form.Command = string.Empty;
       form.SubmitModel.StopSubmitting();
diff --git a/Web/Phantom.Web/Shared/InstanceStopDialog.razor b/Web/Phantom.Web/Shared/InstanceStopDialog.razor
index 9beb10b..79df39f 100644
--- a/Web/Phantom.Web/Shared/InstanceStopDialog.razor
+++ b/Web/Phantom.Web/Shared/InstanceStopDialog.razor
@@ -31,6 +31,9 @@
 
 @code {
 
+  [Parameter, EditorRequired]
+  public Guid AgentGuid { get; init; }
+  
   [Parameter, EditorRequired]
   public Guid InstanceGuid { get; init; }
 
@@ -56,7 +59,7 @@
       return;
     }
 
-    var result = await InstanceManager.StopInstance(loggedInUserGuid.Value, InstanceGuid, new MinecraftStopStrategy(form.StopInSeconds), CancellationToken);
+    var result = await InstanceManager.StopInstance(loggedInUserGuid.Value, AgentGuid, InstanceGuid, new MinecraftStopStrategy(form.StopInSeconds), CancellationToken);
     if (result.Is(StopInstanceResult.StopInitiated)) {
       await Js.InvokeVoidAsync("closeModal", ModalId);
       form.SubmitModel.StopSubmitting();