diff --git a/backend/ControlCenter/Models/AgentMinionMapping.cs b/backend/ControlCenter/Models/AgentMinionMapping.cs
new file mode 100644
index 0000000..c9886dd
--- /dev/null
+++ b/backend/ControlCenter/Models/AgentMinionMapping.cs
@@ -0,0 +1,72 @@
+namespace ControlCenter.Models;
+
+///
+/// Defines which side of the Control Center dashboard a minion occupies.
+///
+public enum MinionSide
+{
+ /// Development side — Rex, Dex, Hex.
+ Dev,
+
+ /// Business side — Larry, Mel, Buzz.
+ Business
+}
+
+///
+/// Visual state of a minion sprite, derived from the agent's
+/// . Maps Active/Idle/Thinking/Error
+/// to frontend animation states.
+///
+public enum MinionState
+{
+ /// Agent is actively processing — minion shows working animation.
+ Active,
+
+ /// Agent is idle — minion shows idle/patrolling animation.
+ Idle,
+
+ /// Agent is thinking (LLM call in flight) — minion shows thinking animation.
+ Thinking,
+
+ /// Agent encountered an error — minion shows error/distress animation.
+ Error
+}
+
+///
+/// Static mapping entry that associates an agent ID with a minion's
+/// display side and position index within that side.
+///
+/// Position indices are zero-based within each side. The dev side
+/// has Rex at 0, Dex at 1, and Hex at 2. The business side has
+/// Larry at 0, Mel at 1, and Buzz at 2.
+///
+/// Agent identifier, e.g. "rex", "dex".
+/// Which side of the dashboard the minion occupies.
+/// Zero-based position index within the side.
+/// Human-readable name, e.g. "Rex".
+public record AgentMinionMapping(
+ string AgentId,
+ MinionSide Side,
+ int PositionIndex,
+ string DisplayName
+);
+
+///
+/// Real-time minion state update pushed to SignalR clients
+/// when an agent's status changes. Combines the static mapping
+/// (who/where) with the dynamic state (what the minion is doing).
+///
+/// Agent identifier, e.g. "rex".
+/// Human-readable minion name, e.g. "Rex".
+/// Which side of the dashboard — Dev or Business.
+/// Position within the side (0-based).
+/// Current minion animation state.
+/// ISO 8601 timestamp of the state change.
+public record MinionStateUpdate(
+ string AgentId,
+ string DisplayName,
+ MinionSide Side,
+ int PositionIndex,
+ MinionState State,
+ string Timestamp
+);
\ No newline at end of file
diff --git a/backend/ControlCenter/Program.cs b/backend/ControlCenter/Program.cs
index 757b20a..2b322ab 100644
--- a/backend/ControlCenter/Program.cs
+++ b/backend/ControlCenter/Program.cs
@@ -52,6 +52,11 @@ builder.Services.AddSignalR();
builder.Services.AddSingleton();
builder.Services.AddHostedService(sp => sp.GetRequiredService());
+// ── Agent-Minion Mapper Service ────────────────────────────
+// Maps agents to minion sprites/positions and publishes state
+// updates through SignalR.
+builder.Services.AddSingleton();
+
var app = builder.Build();
// ── Middleware ──────────────────────────────────────────────
diff --git a/backend/ControlCenter/Services/AgentMinionMapperService.cs b/backend/ControlCenter/Services/AgentMinionMapperService.cs
new file mode 100644
index 0000000..0dd82ef
--- /dev/null
+++ b/backend/ControlCenter/Services/AgentMinionMapperService.cs
@@ -0,0 +1,193 @@
+using ControlCenter.Hubs;
+using ControlCenter.Models;
+using Microsoft.AspNetCore.SignalR;
+
+namespace ControlCenter.Services;
+
+///
+/// Service that maps Linear agents to minion sprites and positions
+/// in the Control Center dashboard.
+///
+/// Static mappings define where each minion appears:
+///
+/// - Dev side: Rex (0), Dex (1), Hex (2)
+/// - Business side: Larry (0), Mel (1), Buzz (2)
+///
+///
+/// Dynamic state is derived from the agent's :
+///
+/// - Active →
+/// - Idle →
+/// - Thinking →
+/// - Error →
+///
+///
+/// State updates are published through the
+/// SignalR hub so that connected clients can animate minion sprites
+/// in real time.
+///
+public class AgentMinionMapperService
+{
+ private readonly ILogger _logger;
+ private readonly IHubContext _hubContext;
+
+ ///
+ /// Static agent-to-minion mapping table. Defines which side and position
+ /// each agent's minion occupies on the dashboard.
+ ///
+ private static readonly Dictionary Mappings = new()
+ {
+ // ── Dev Side ──────────────────────────────────
+ ["rex"] = new AgentMinionMapping("rex", MinionSide.Dev, 0, "Rex"),
+ ["dex"] = new AgentMinionMapping("dex", MinionSide.Dev, 1, "Dex"),
+ ["hex"] = new AgentMinionMapping("hex", MinionSide.Dev, 2, "Hex"),
+
+ // ── Business Side ─────────────────────────────
+ ["larry"] = new AgentMinionMapping("larry", MinionSide.Business, 0, "Larry"),
+ ["mel"] = new AgentMinionMapping("mel", MinionSide.Business, 1, "Mel"),
+ ["buzz"] = new AgentMinionMapping("buzz", MinionSide.Business, 2, "Buzz"),
+ };
+
+ ///
+ /// Maps string values to .
+ ///
+ private static readonly Dictionary StatusToMinionState = new()
+ {
+ ["active"] = MinionState.Active,
+ ["idle"] = MinionState.Idle,
+ ["thinking"] = MinionState.Thinking,
+ ["error"] = MinionState.Error,
+ };
+
+ public AgentMinionMapperService(
+ ILogger logger,
+ IHubContext hubContext)
+ {
+ _logger = logger;
+ _hubContext = hubContext;
+ }
+
+ ///
+ /// Gets the minion mapping for a given agent ID.
+ /// Returns null if the agent is not mapped to a minion position.
+ ///
+ /// The agent identifier, e.g. "rex", "dex".
+ /// The mapping record, or null if unmapped.
+ public AgentMinionMapping? GetMapping(string agentId)
+ {
+ return Mappings.GetValueOrDefault(agentId?.ToLowerInvariant() ?? string.Empty);
+ }
+
+ ///
+ /// Gets all minion mappings, ordered by side then position index.
+ ///
+ /// All mappings, sorted for consistent display order.
+ public IReadOnlyList GetAllMappings()
+ {
+ return Mappings.Values
+ .OrderBy(m => m.Side)
+ .ThenBy(m => m.PositionIndex)
+ .ToList();
+ }
+
+ ///
+ /// Converts an agent status string to a .
+ /// Falls back to for unrecognized statuses.
+ ///
+ /// Agent status string: "active", "idle", "thinking", or "error".
+ /// The corresponding minion state.
+ public MinionState StatusToState(string status)
+ {
+ return StatusToMinionState.GetValueOrDefault(
+ status?.ToLowerInvariant() ?? string.Empty,
+ MinionState.Idle);
+ }
+
+ ///
+ /// Publishes a minion state update through SignalR when an agent's
+ /// status changes. Only publishes for agents that have a minion mapping.
+ ///
+ /// This is the primary integration point: the
+ /// calls this method
+ /// whenever it detects a status change from the OpenClaw Gateway.
+ ///
+ /// The agent whose status changed, e.g. "dex".
+ /// The new status string: "active", "idle", "thinking", or "error".
+ /// A task that completes when the SignalR message has been sent.
+ public async Task PublishMinionStateUpdateAsync(string agentId, string status)
+ {
+ var mapping = GetMapping(agentId);
+ if (mapping is null)
+ {
+ _logger.LogDebug("No minion mapping for agent {AgentId}; skipping state update", agentId);
+ return;
+ }
+
+ var minionState = StatusToState(status);
+ var update = new MinionStateUpdate(
+ AgentId: mapping.AgentId,
+ DisplayName: mapping.DisplayName,
+ Side: mapping.Side,
+ PositionIndex: mapping.PositionIndex,
+ State: minionState,
+ Timestamp: DateTime.UtcNow.ToString("o")
+ );
+
+ // Broadcast to the fleet group (all subscribers)
+ await _hubContext.Clients.Group(AgentStatusHub.FleetGroupName)
+ .AgentStatusChanged(ToAgentStatusUpdate(agentId, status));
+
+ // Also push to the specific agent's group
+ var agentGroup = AgentStatusHub.AgentGroupName(agentId);
+ await _hubContext.Clients.Group(agentGroup)
+ .AgentStatusChanged(ToAgentStatusUpdate(agentId, status));
+
+ _logger.LogInformation(
+ "Minion state update: {AgentId} → {State} (Side: {Side}, Position: {Index})",
+ agentId, minionState, mapping.Side, mapping.PositionIndex);
+ }
+
+ ///
+ /// Gets the current minion state for all mapped agents, suitable
+ /// for building an initial fleet snapshot.
+ ///
+ /// All minion mappings with their current (idle) state.
+ public IReadOnlyList GetFullMinionState()
+ {
+ return Mappings.Values
+ .OrderBy(m => m.Side)
+ .ThenBy(m => m.PositionIndex)
+ .Select(m => new MinionStateUpdate(
+ AgentId: m.AgentId,
+ DisplayName: m.DisplayName,
+ Side: m.Side,
+ PositionIndex: m.PositionIndex,
+ State: MinionState.Idle,
+ Timestamp: DateTime.UtcNow.ToString("o")))
+ .ToList();
+ }
+
+ ///
+ /// Converts a status string to an
+ /// for SignalR push. Uses the mapping table for display names and roles.
+ ///
+ private AgentStatusUpdate ToAgentStatusUpdate(string agentId, string status)
+ {
+ var mapping = GetMapping(agentId);
+ var displayName = mapping?.DisplayName ?? char.ToUpperInvariant(agentId[0]) + agentId[1..];
+
+ return new AgentStatusUpdate(
+ AgentId: agentId,
+ DisplayName: displayName,
+ Role: mapping is not null
+ ? $"{mapping.Side} Agent"
+ : "Agent",
+ Status: status,
+ CurrentTask: null,
+ SessionKey: string.Empty,
+ Channel: string.Empty,
+ LastActivity: DateTime.UtcNow.ToString("o"),
+ ErrorMessage: status == "error" ? "Agent encountered an error" : null
+ );
+ }
+}
\ No newline at end of file