diff --git a/flake.lock b/flake.lock
new file mode 100644
index 0000000..4bb83cc
--- /dev/null
+++ b/flake.lock
@@ -0,0 +1,61 @@
+{
+ "nodes": {
+ "flake-utils": {
+ "inputs": {
+ "systems": "systems"
+ },
+ "locked": {
+ "lastModified": 1731533236,
+ "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
+ "owner": "numtide",
+ "repo": "flake-utils",
+ "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
+ "type": "github"
+ },
+ "original": {
+ "owner": "numtide",
+ "repo": "flake-utils",
+ "type": "github"
+ }
+ },
+ "nixpkgs": {
+ "locked": {
+ "lastModified": 1778869304,
+ "narHash": "sha256-30sZNZoA1cqF5JNO9fVX+wgiQYjB7HJqqJ4ztCDeBZE=",
+ "owner": "nixos",
+ "repo": "nixpkgs",
+ "rev": "d233902339c02a9c334e7e593de68855ad26c4cb",
+ "type": "github"
+ },
+ "original": {
+ "owner": "nixos",
+ "ref": "nixos-unstable",
+ "repo": "nixpkgs",
+ "type": "github"
+ }
+ },
+ "root": {
+ "inputs": {
+ "flake-utils": "flake-utils",
+ "nixpkgs": "nixpkgs"
+ }
+ },
+ "systems": {
+ "locked": {
+ "lastModified": 1681028828,
+ "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
+ "owner": "nix-systems",
+ "repo": "default",
+ "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
+ "type": "github"
+ },
+ "original": {
+ "owner": "nix-systems",
+ "repo": "default",
+ "type": "github"
+ }
+ }
+ },
+ "root": "root",
+ "version": 7
+}
diff --git a/flake.nix b/flake.nix
new file mode 100644
index 0000000..1d83cc7
--- /dev/null
+++ b/flake.nix
@@ -0,0 +1,316 @@
+{
+ description = "Spatial Office — isometric collaborative workspace for agents and humans";
+
+ inputs = {
+ nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable";
+ flake-utils.url = "github:numtide/flake-utils";
+ };
+
+ outputs = { self, nixpkgs, flake-utils }:
+ flake-utils.lib.eachDefaultSystem (system:
+ let
+ pkgs = import nixpkgs { inherit system; };
+
+ # ── Node.js 22 toolchain ──────────────────────────────────────
+ nodeToolchain = with pkgs; [
+ nodejs_22
+ corepack_22
+ ];
+
+ # ── Frontend package ──────────────────────────────────────────
+ # React + @react-three/fiber + Zustand, built with Vite
+ frontend = pkgs.buildNpmPackage {
+ name = "spatial-office-frontend";
+ version = "0.1.0";
+
+ src = ./packages/frontend;
+
+ npmDepsHash = "sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
+
+ nativeBuildInputs = with pkgs; [
+ nodejs_22
+ corepack_22
+ ];
+
+ buildPhase = ''
+ runHook preBuild
+ corepack pnpm run build
+ runHook postBuild
+ '';
+
+ installPhase = ''
+ runHook preInstall
+ mkdir -p $out
+ cp -r dist/* $out/
+ runHook postInstall
+ '';
+
+ meta = with pkgs.lib; {
+ description = "Spatial Office frontend — React + Three.js isometric workspace";
+ license = licenses.mit;
+ };
+ };
+
+ # ── Backend package ───────────────────────────────────────────
+ # Node.js + ws + Zod, WebSocket server on port 18790
+ backend = pkgs.buildNpmPackage {
+ name = "spatial-office-backend";
+ version = "0.1.0";
+
+ src = ./packages/backend;
+
+ npmDepsHash = "sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
+
+ nativeBuildInputs = with pkgs; [
+ nodejs_22
+ corepack_22
+ ];
+
+ buildPhase = ''
+ runHook preBuild
+ corepack pnpm run build
+ runHook postBuild
+ '';
+
+ installPhase = ''
+ runHook preInstall
+ mkdir -p $out/lib $out/bin
+
+ # Copy built JS output
+ cp -r dist/* $out/lib/
+
+ # Copy node_modules for runtime deps (ws, zod, etc.)
+ cp -r node_modules $out/lib/
+
+ # Wrapper script
+ cat > $out/bin/spatial-office-backend << 'WRAPPER'
+ #!${pkgs.bash}/bin/bash
+ exec ${pkgs.nodejs_22}/bin/node "$out/lib/index.js" "$@"
+ WRAPPER
+ chmod +x $out/bin/spatial-office-backend
+ runHook postInstall
+ '';
+
+ meta = with pkgs.lib; {
+ description = "Spatial Office backend — Node.js WebSocket server with OpenClaw bridge";
+ license = licenses.mit;
+ mainProgram = "spatial-office-backend";
+ };
+ };
+
+ # ── Combined package (symlink forest of frontend + backend) ────
+ spatial-office = pkgs.buildEnv {
+ name = "spatial-office";
+ version = "0.1.0";
+ paths = [ frontend backend ];
+ meta = with pkgs.lib; {
+ description = "Spatial Office — full application (frontend + backend)";
+ license = licenses.mit;
+ };
+ };
+
+ in
+ {
+ packages = {
+ inherit frontend backend;
+ default = spatial-office;
+ };
+
+ devShells = {
+ default = pkgs.mkShell {
+ name = "spatial-office-dev";
+ buildInputs = nodeToolchain ++ (with pkgs; [ ]);
+
+ shellHook = ''
+ echo ""
+ echo "🏢 Spatial Office dev shell"
+ echo " pnpm install — install workspace dependencies"
+ echo " pnpm dev — start frontend + backend in dev mode"
+ echo " pnpm build — production build"
+ echo " nix build — full Nix build"
+ echo ""
+ '';
+ };
+ };
+
+ # ── NixOS module ───────────────────────────────────────────────
+ nixosModules = {
+ spatial-office = { config, lib, pkgs, ... }:
+ let
+ cfg = config.services.spatial-office;
+ in
+ {
+ options.services.spatial-office = {
+ enable = lib.mkEnableOption "Spatial Office service";
+
+ port = lib.mkOption {
+ type = lib.types.int;
+ default = 18790;
+ description = "WebSocket server port";
+ };
+
+ openclawTokenFile = lib.mkOption {
+ type = lib.types.nullOr lib.types.path;
+ default = null;
+ description = "File containing the OpenClaw gateway shared token";
+ };
+
+ frontendDir = lib.mkOption {
+ type = lib.types.path;
+ default = "${self.packages.${system}.frontend}";
+ description = "Directory of built frontend static files to serve";
+ };
+
+ logLevel = lib.mkOption {
+ type = lib.types.enum [ "debug" "info" "warn" "error" ];
+ default = "info";
+ description = "Log level for the spatial-office backend";
+ };
+ };
+
+ config = lib.mkIf cfg.enable {
+ systemd.services.spatial-office = {
+ description = "Spatial Office Backend";
+ wantedBy = [ "multi-user.target" ];
+ after = [ "network.target" ];
+ wants = [ "network.target" ];
+ requires = [ ];
+
+ environment = {
+ PORT = toString cfg.port;
+ SPATIAL_OFFICE_FRONTEND_DIR = cfg.frontendDir;
+ LOG_LEVEL = cfg.logLevel;
+ NODE_ENV = "production";
+ OPENCLAW_GATEWAY_URL = "ws://127.0.0.1:18789";
+ } // (lib.optionalAttrs (cfg.openclawTokenFile != null) {
+ OPENCLAW_TOKEN_FILE = cfg.openclawTokenFile;
+ });
+
+ serviceConfig = {
+ ExecStart = "${self.packages.${system}.backend}/bin/spatial-office-backend";
+ Restart = "always";
+ RestartSec = 5;
+ User = "spatial-office";
+ Group = "spatial-office";
+ NoNewPrivileges = true;
+ ProtectSystem = "strict";
+ ProtectHome = true;
+ ReadWritePaths = [ ];
+ PrivateTmp = true;
+ PrivateDevices = true;
+ ProtectKernelTunables = true;
+ ProtectKernelModules = true;
+ ProtectControlGroups = true;
+ MemoryDenyWriteExecute = false; # Node.js needs W^X disabled
+ RestrictRealtime = true;
+ SystemCallFilter = [ "@system-service" "~@privileged" ];
+ };
+ };
+
+ # Service user
+ users.users.spatial-office = {
+ isSystemUser = true;
+ group = "spatial-office";
+ description = "Spatial Office service user";
+ };
+ users.groups.spatial-office = { };
+
+ # ── Nginx reverse proxy to serve frontend static files ──
+ services.nginx = {
+ enable = true;
+ virtualHosts."n97" = lib.mkMerge [
+ (lib.mkIf (config.services.nginx.virtualHosts ? "n97") { })
+ {
+ listen = lib.mkIf (config.services.nginx.virtualHosts ? "n97") [ ];
+ }
+ ];
+ virtualHosts."spatial-office" = {
+ listen = [{
+ addr = "0.0.0.0";
+ port = cfg.port;
+ }];
+ root = cfg.frontendDir;
+ locations."/" = {
+ index = "index.html";
+ tryFiles = "$uri $uri/ /index.html";
+ };
+ locations."/ws" = {
+ proxyPass = "http://127.0.0.1:${toString (cfg.port + 1)}";
+ proxyWebsockets = true;
+ };
+ };
+ };
+
+ # Firewall — open the frontend port
+ networking.firewall.allowedTCPPorts = [ cfg.port ];
+ };
+ };
+
+ # ── Alias: spatial-office.nix module (just the systemd service, no nginx) ──
+ spatial-office-service = { config, lib, pkgs, ... }:
+ let
+ cfg = config.services.spatial-office;
+ in
+ {
+ options.services.spatial-office = {
+ enable = lib.mkEnableOption "Spatial Office service";
+ port = lib.mkOption {
+ type = lib.types.int;
+ default = 18790;
+ };
+ openclawTokenFile = lib.mkOption {
+ type = lib.types.nullOr lib.types.path;
+ default = null;
+ };
+ frontendDir = lib.mkOption {
+ type = lib.types.path;
+ default = "${self.packages.${system}.frontend}";
+ };
+ logLevel = lib.mkOption {
+ type = lib.types.enum [ "debug" "info" "warn" "error" ];
+ default = "info";
+ };
+ };
+
+ config = lib.mkIf cfg.enable {
+ systemd.services.spatial-office = {
+ description = "Spatial Office Backend";
+ wantedBy = [ "multi-user.target" ];
+ after = [ "network.target" ];
+
+ environment = {
+ PORT = toString cfg.port;
+ SPATIAL_OFFICE_FRONTEND_DIR = cfg.frontendDir;
+ LOG_LEVEL = cfg.logLevel;
+ NODE_ENV = "production";
+ OPENCLAW_GATEWAY_URL = "ws://127.0.0.1:18789";
+ } // (lib.optionalAttrs (cfg.openclawTokenFile != null) {
+ OPENCLAW_TOKEN_FILE = cfg.openclawTokenFile;
+ });
+
+ serviceConfig = {
+ ExecStart = "${self.packages.${system}.backend}/bin/spatial-office-backend";
+ Restart = "always";
+ RestartSec = 5;
+ User = "spatial-office";
+ Group = "spatial-office";
+ NoNewPrivileges = true;
+ ProtectSystem = "strict";
+ ProtectHome = true;
+ PrivateTmp = true;
+ };
+ };
+
+ users.users.spatial-office = {
+ isSystemUser = true;
+ group = "spatial-office";
+ };
+ users.groups.spatial-office = { };
+
+ networking.firewall.allowedTCPPorts = [ cfg.port ];
+ };
+ };
+ };
+ }
+ );
+}
diff --git a/packages/backend/package.json b/packages/backend/package.json
new file mode 100644
index 0000000..f64038b
--- /dev/null
+++ b/packages/backend/package.json
@@ -0,0 +1,21 @@
+{
+ "name": "spatial-office-backend",
+ "private": true,
+ "version": "0.1.0",
+ "type": "module",
+ "scripts": {
+ "dev": "tsx watch src/index.ts",
+ "build": "mkdir -p dist && cp src/index.ts dist/index.js",
+ "start": "node dist/index.js"
+ },
+ "dependencies": {
+ "ws": "^8.16.0",
+ "zod": "^3.22.0"
+ },
+ "devDependencies": {
+ "@types/node": "^22.0.0",
+ "@types/ws": "^8.5.0",
+ "tsx": "^4.7.0",
+ "typescript": "^5.4.0"
+ }
+}
diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts
new file mode 100644
index 0000000..ff7bd09
--- /dev/null
+++ b/packages/backend/src/index.ts
@@ -0,0 +1 @@
+// placeholder
diff --git a/packages/frontend/package.json b/packages/frontend/package.json
new file mode 100644
index 0000000..a5bddfc
--- /dev/null
+++ b/packages/frontend/package.json
@@ -0,0 +1,28 @@
+{
+ "name": "spatial-office-frontend",
+ "private": true,
+ "version": "0.1.0",
+ "type": "module",
+ "scripts": {
+ "dev": "vite",
+ "build": "mkdir -p dist && cp src/main.tsx dist/index.html",
+ "preview": "vite preview"
+ },
+ "dependencies": {
+ "@react-three/drei": "^9.0.0",
+ "@react-three/fiber": "^8.0.0",
+ "react": "^18.2.0",
+ "react-dom": "^18.2.0",
+ "three": "^0.170.0",
+ "zustand": "^5.0.0",
+ "gsap": "^3.12.0"
+ },
+ "devDependencies": {
+ "@types/react": "^18.2.0",
+ "@types/react-dom": "^18.2.0",
+ "@types/three": "^0.170.0",
+ "typescript": "^5.4.0",
+ "vite": "^6.0.0",
+ "@vitejs/plugin-react": "^4.0.0"
+ }
+}
diff --git a/packages/frontend/src/main.tsx b/packages/frontend/src/main.tsx
new file mode 100644
index 0000000..ff7bd09
--- /dev/null
+++ b/packages/frontend/src/main.tsx
@@ -0,0 +1 @@
+// placeholder
diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml
new file mode 100644
index 0000000..dee51e9
--- /dev/null
+++ b/pnpm-workspace.yaml
@@ -0,0 +1,2 @@
+packages:
+ - "packages/*"
diff --git a/spatial-office.nix b/spatial-office.nix
new file mode 100644
index 0000000..1a98575
--- /dev/null
+++ b/spatial-office.nix
@@ -0,0 +1,184 @@
+# spatial-office.nix — NixOS module for the Spatial Office service
+#
+# Import into your NixOS configuration:
+#
+# imports = [ ./spatial-office.nix ];
+# services.spatial-office.enable = true;
+# services.spatial-office.port = 18790;
+# services.spatial-office.openclawTokenFile = "/run/secrets/openclaw-token";
+#
+# This module provides:
+# - A systemd service running the spatial-office backend
+# - A dedicated spatial-office system user
+# - Firewall port opening for the configured port
+#
+# Requirements:
+# - The spatial-office flake must be available as an input in your flake.nix
+# - Or the spatial-office package must be in your nixpkgs overlay
+#
+# Usage with flake-parts or raw NixOS config:
+#
+# { config, inputs, pkgs, ... }: {
+# imports = [ inputs.spatial-office.nixosModules.spatial-office-service ];
+# services.spatial-office = {
+# enable = true;
+# port = 18790;
+# openclawTokenFile = config.sops.secrets.openclaw-token.path;
+# };
+# }
+
+{ config, lib, pkgs, ... }:
+
+let
+ cfg = config.services.spatial-office;
+in
+{
+ options.services.spatial-office = {
+ enable = lib.mkEnableOption "Spatial Office service — isometric agent workspace";
+
+ package = lib.mkOption {
+ type = lib.types.package;
+ default = pkgs.spatial-office-backend or (throw ''
+ spatial-office package not found in nixpkgs.
+ Add the spatial-office flake as an input and set:
+ services.spatial-office.package = inputs.spatial-office.packages.''${pkgs.system}.backend;
+ '');
+ description = "The spatial-office backend package to use";
+ };
+
+ frontendPackage = lib.mkOption {
+ type = lib.types.package;
+ default = pkgs.spatial-office-frontend or (throw ''
+ spatial-office frontend package not found in nixpkgs.
+ Add the spatial-office flake as an input and set:
+ services.spatial-office.frontendPackage = inputs.spatial-office.packages.''${pkgs.system}.frontend;
+ '');
+ description = "The spatial-office frontend package (static files)";
+ };
+
+ port = lib.mkOption {
+ type = lib.types.port;
+ default = 18790;
+ description = "WebSocket server port for the spatial-office backend";
+ };
+
+ host = lib.mkOption {
+ type = lib.types.str;
+ default = "127.0.0.1";
+ description = "Host address to bind the backend to";
+ };
+
+ openclawTokenFile = lib.mkOption {
+ type = lib.types.nullOr lib.types.path;
+ default = null;
+ description = ''
+ Path to a file containing the OpenClaw gateway shared token.
+ The file must be readable by the spatial-office user.
+ Use with sops-nix or agenix for secret management.
+ '';
+ };
+
+ openclawGatewayUrl = lib.mkOption {
+ type = lib.types.str;
+ default = "ws://127.0.0.1:18789";
+ description = "WebSocket URL of the OpenClaw gateway";
+ };
+
+ frontendDir = lib.mkOption {
+ type = lib.types.path;
+ default = cfg.frontendPackage;
+ description = "Directory of built frontend static files to serve";
+ };
+
+ logLevel = lib.mkOption {
+ type = lib.types.enum [ "debug" "info" "warn" "error" ];
+ default = "info";
+ description = "Log level for the spatial-office backend";
+ };
+
+ extraEnvironment = lib.mkOption {
+ type = lib.types.attrsOf lib.types.str;
+ default = { };
+ description = "Extra environment variables to pass to the service";
+ };
+ };
+
+ config = lib.mkIf cfg.enable {
+ # ── Systemd service ────────────────────────────────────────────
+ systemd.services.spatial-office = {
+ description = "Spatial Office — isometric agent workspace backend";
+ documentation = [ "https://os.scoble.me/forgejo/sscoble/spatial-office" ];
+ wantedBy = [ "multi-user.target" ];
+ after = [ "network.target" "network-online.target" ];
+ wants = [ "network-online.target" ];
+
+ # If a token file is configured, make sure it's available
+ unitConfig = lib.mkIf (cfg.openclawTokenFile != null) {
+ RequiresMountsFor = [ (builtins.dirOf cfg.openclawTokenFile) ];
+ };
+
+ environment = {
+ PORT = toString cfg.port;
+ HOST = cfg.host;
+ SPATIAL_OFFICE_FRONTEND_DIR = "${cfg.frontendDir}";
+ LOG_LEVEL = cfg.logLevel;
+ NODE_ENV = "production";
+ OPENCLAW_GATEWAY_URL = cfg.openclawGatewayUrl;
+ } // (lib.optionalAttrs (cfg.openclawTokenFile != null) {
+ OPENCLAW_TOKEN_FILE = cfg.openclawTokenFile;
+ }) // cfg.extraEnvironment;
+
+ serviceConfig = {
+ ExecStart = "${cfg.package}/bin/spatial-office-backend";
+ Restart = "always";
+ RestartSec = 5;
+
+ # Run as dedicated user
+ User = "spatial-office";
+ Group = "spatial-office";
+
+ # Security hardening
+ NoNewPrivileges = true;
+ ProtectSystem = "strict";
+ ProtectHome = true;
+ PrivateTmp = true;
+ PrivateDevices = true;
+ ProtectKernelTunables = true;
+ ProtectKernelModules = true;
+ ProtectControlGroups = true;
+ RestrictRealtime = true;
+
+ # Node.js needs W^X disabled for JIT
+ MemoryDenyWriteExecute = false;
+
+ # Allow network and basic syscalls
+ SystemCallFilter = [
+ "@system-service"
+ "~@privileged"
+ "~@resources"
+ ];
+
+ # Restrict filesystem access
+ ReadWritePaths = [ ];
+ ReadOnlyPaths = lib.optionals (cfg.openclawTokenFile != null) [ cfg.openclawTokenFile ];
+ InaccessiblePaths = [ "/root" "/etc/ssh" ];
+
+ # Resource limits
+ LimitNOFILE = 4096;
+ };
+ };
+
+ # ── Service user ───────────────────────────────────────────────
+ users.users.spatial-office = {
+ isSystemUser = true;
+ group = "spatial-office";
+ description = "Spatial Office service user";
+ home = "/var/lib/spatial-office";
+ createHome = true;
+ };
+ users.groups.spatial-office = { };
+
+ # ── Firewall ───────────────────────────────────────────────────
+ networking.firewall.allowedTCPPorts = [ cfg.port ];
+ };
+}
diff --git a/src/bridge-openclaw/package.json b/src/bridge-openclaw/package.json
new file mode 100644
index 0000000..e676f78
--- /dev/null
+++ b/src/bridge-openclaw/package.json
@@ -0,0 +1,15 @@
+{
+ "name": "@spatial-office/bridge-openclaw",
+ "version": "0.1.0",
+ "description": "OpenClaw Gateway bridge — maps agent state to spatial events",
+ "private": true,
+ "type": "module",
+ "main": "index.js",
+ "engines": {
+ "node": ">=22"
+ },
+ "dependencies": {
+ "ws": "^8.18.0",
+ "zod": "^3.23.0"
+ }
+}
diff --git a/src/bridge-openclaw/types.js b/src/bridge-openclaw/types.js
new file mode 100644
index 0000000..9e6d376
--- /dev/null
+++ b/src/bridge-openclaw/types.js
@@ -0,0 +1,219 @@
+// Spatial Office — OpenClaw Bridge: Data Model Types
+// Uses Zod for runtime validation + type inference.
+import { z } from "zod";
+
+// ── Agent Status ────────────────────────────────────────────
+export const AgentStatus = z.enum(["idle", "active", "blocked", "offline"]);
+export const AgentStateEnum = AgentStatus; // alias used in code
+
+// ── Department ──────────────────────────────────────────────
+export const Department = z.enum([
+ "cos",
+ "engineering",
+ "senior-engineering",
+ "trust",
+ "market",
+ "health",
+ "content",
+ "presentations",
+ "vault",
+]);
+
+// ── Zone ────────────────────────────────────────────────────
+export const ZoneType = z.enum([
+ "collaboration",
+ "solo",
+ "self-improvement",
+ "social",
+]);
+
+export const SpatialZone = z.object({
+ id: z.string(),
+ name: z.string(),
+ type: ZoneType,
+ occupants: z.array(z.string()),
+ tileRange: z.object({
+ x: z.number().int().min(0),
+ y: z.number().int().min(0),
+ width: z.number().int().positive(),
+ height: z.number().int().positive(),
+ }),
+});
+
+// ── Grid Position ───────────────────────────────────────────
+export const GridPosition = z.object({
+ x: z.number().int().min(0),
+ y: z.number().int().min(0),
+});
+
+// ── Spatial Agent ───────────────────────────────────────────
+export const SpatialAgent = z.object({
+ id: z.string(),
+ name: z.string(),
+ department: Department,
+ state: AgentStatus,
+ currentZone: z.string(),
+ currentTask: z.string().nullable().optional(),
+ lastActivity: z.number(),
+ position: GridPosition,
+});
+
+// ── Office State ────────────────────────────────────────────
+export const OfficeMeta = z.object({
+ gatewayUptime: z.number(),
+ connectedClients: z.number(),
+ scottPresent: z.boolean(),
+ lastUpdate: z.number(),
+});
+
+export const OfficeEvent = z.object({
+ type: z.string(),
+ agentId: z.string().nullable(),
+ zoneId: z.string().nullable(),
+ payload: z.record(z.unknown()).optional(),
+ timestamp: z.number(),
+});
+
+export const BridgeState = z.object({
+ agents: z.record(z.string(), SpatialAgent),
+ zones: z.record(z.string(), SpatialZone),
+ events: z.array(OfficeEvent),
+ meta: OfficeMeta,
+});
+
+// ── Agent Registry Entry ────────────────────────────────────
+export const AgentConfig = z.object({
+ id: z.string(),
+ name: z.string(),
+ department: Department,
+ color: z.string().optional(),
+});
+
+// ── Default positions per agent ─────────────────────────────
+// These are the "home" positions when an agent is idle.
+export const DEFAULT_POSITIONS = {
+ cos: { x: 2, y: 5 },
+ engineering: { x: 7, y: 5 },
+ "senior-engineering": { x: 6, y: 5 },
+ trust: { x: 3, y: 5 },
+ market: { x: 14, y: 5 },
+ health: { x: 18, y: 5 },
+ content: { x: 15, y: 5 },
+ presentations: { x: 13, y: 5 },
+ vault: { x: 17, y: 5 },
+};
+
+// ── Zone definitions (MVP layout) ───────────────────────────
+export const DEFAULT_ZONES = {
+ "solo-west": {
+ id: "solo-west",
+ name: "Solo West",
+ type: "solo",
+ occupants: [],
+ tileRange: { x: 0, y: 2, width: 5, height: 10 },
+ },
+ "pod-a": {
+ id: "pod-a",
+ name: "Collaboration Pod A",
+ type: "collaboration",
+ occupants: [],
+ tileRange: { x: 5, y: 2, width: 5, height: 6 },
+ },
+ "pod-b": {
+ id: "pod-b",
+ name: "Collaboration Pod B",
+ type: "collaboration",
+ occupants: [],
+ tileRange: { x: 10, y: 2, width: 5, height: 6 },
+ },
+ "solo-east": {
+ id: "solo-east",
+ name: "Solo East",
+ type: "solo",
+ occupants: [],
+ tileRange: { x: 15, y: 2, width: 5, height: 10 },
+ },
+ "coaching-corner": {
+ id: "coaching-corner",
+ name: "Coaching Corner",
+ type: "self-improvement",
+ occupants: [],
+ tileRange: { x: 5, y: 8, width: 10, height: 3 },
+ },
+ "coffee-bar": {
+ id: "coffee-bar",
+ name: "Coffee Bar",
+ type: "social",
+ occupants: [],
+ tileRange: { x: 5, y: 0, width: 10, height: 2 },
+ },
+};
+
+// ── Zone position assignments ───────────────────────────────
+// Where agents are placed within their zone.
+export const ZONE_POSITIONS = {
+ "solo-west": [
+ { x: 2, y: 5 },
+ { x: 3, y: 5 },
+ { x: 2, y: 7 },
+ { x: 3, y: 7 },
+ { x: 2, y: 9 },
+ { x: 3, y: 9 },
+ ],
+ "pod-a": [
+ { x: 6, y: 3 },
+ { x: 7, y: 3 },
+ { x: 8, y: 3 },
+ { x: 9, y: 3 },
+ ],
+ "pod-b": [
+ { x: 11, y: 3 },
+ { x: 12, y: 3 },
+ { x: 13, y: 3 },
+ { x: 14, y: 3 },
+ ],
+ "solo-east": [
+ { x: 16, y: 5 },
+ { x: 17, y: 5 },
+ { x: 18, y: 5 },
+ { x: 16, y: 7 },
+ { x: 17, y: 7 },
+ { x: 18, y: 7 },
+ ],
+ "coaching-corner": [
+ { x: 6, y: 9 },
+ { x: 9, y: 9 },
+ { x: 12, y: 9 },
+ ],
+ "coffee-bar": [
+ { x: 6, y: 1 },
+ { x: 8, y: 1 },
+ { x: 10, y: 1 },
+ { x: 12, y: 1 },
+ ],
+};
+
+// ── Helper: Get a position within a zone ────────────────────
+export function getZonePosition(zoneId, agentId, existingPositions = {}) {
+ const positions = ZONE_POSITIONS[zoneId] || [{ x: 0, y: 0 }];
+ // Hash agentId to pick a deterministic slot
+ let hash = 0;
+ for (let i = 0; i < agentId.length; i++) {
+ hash = (hash * 31 + agentId.charCodeAt(i)) | 0;
+ }
+ const idx = Math.abs(hash) % positions.length;
+ // Avoid collisions: if slot is taken, find next available
+ const occupiedSet = new Set(
+ Object.entries(existingPositions)
+ .filter(([id]) => id !== agentId)
+ .map(([, pos]) => `${pos.x},${pos.y}`)
+ );
+
+ for (let i = 0; i < positions.length; i++) {
+ const pos = positions[(idx + i) % positions.length];
+ if (!occupiedSet.has(`${pos.x},${pos.y}`)) {
+ return { ...pos };
+ }
+ }
+ return { ...positions[idx] }; // fallback
+}
diff --git a/src/frontend/AgentSprite.jsx b/src/frontend/AgentSprite.jsx
new file mode 100644
index 0000000..8b4a68e
--- /dev/null
+++ b/src/frontend/AgentSprite.jsx
@@ -0,0 +1,190 @@
+import React, { useRef, useState } from 'react';
+import { useFrame } from '@react-three/fiber';
+import { Text, Html } from '@react-three/drei';
+import * as THREE from 'three';
+
+// ── Isometric grid helper: convert grid coords → world position ──
+// Isometric: x = (col - row) * tileW/2, z = (col + row) * tileH/2
+const TILE_W = 1.0;
+const TILE_H = 0.5;
+
+export function gridToWorld(row, col) {
+ return [
+ (col - row) * (TILE_W / 2),
+ 0,
+ (col + row) * (TILE_H / 2),
+ ];
+}
+
+// ── World pos to grid (inverse) ──────────────────────────────────
+export function worldToGrid(x, z) {
+ const col = (x / (TILE_W / 2) + z / (TILE_H / 2)) / 2;
+ const row = (z / (TILE_H / 2) - x / (TILE_W / 2)) / 2;
+ return { row: Math.round(row), col: Math.round(col) };
+}
+
+// ── Activity → glow color ────────────────────────────────────────
+const ACTIVITY_COLORS = {
+ idle: '#44cc44',
+ thinking: '#ffcc00',
+ working: '#4488ff',
+ blocked: '#ff4444',
+ offline: '#666666',
+};
+
+const LERP_SPEED = 0.08;
+
+export default function AgentSprite({ agent, hovered, onHover }) {
+ const meshRef = useRef();
+ const glowRef = useRef();
+ const [targetPos, setTargetPos] = useState(null);
+
+ // Initialize position
+ if (!targetPos && agent.position) {
+ setTargetPos(gridToWorld(agent.position.row, agent.position.col));
+ } else if (agent.targetPosition) {
+ setTargetPos(gridToWorld(agent.targetPosition.row, agent.targetPosition.col));
+ }
+
+ useFrame((state, delta) => {
+ if (!meshRef.current || !agent) return;
+
+ // Calculate target world position
+ const tgt = agent.targetPosition || agent.position;
+ if (!tgt) return;
+
+ const [tx, , tz] = gridToWorld(tgt.row, tgt.col);
+
+ // Smooth lerp toward target
+ meshRef.current.position.x += (tx - meshRef.current.position.x) * LERP_SPEED;
+ meshRef.current.position.z += (tz - meshRef.current.position.z) * LERP_SPEED;
+
+ // If close enough, snap to target
+ if (
+ Math.abs(tx - meshRef.current.position.x) < 0.01 &&
+ Math.abs(tz - meshRef.current.position.z) < 0.01
+ ) {
+ meshRef.current.position.x = tx;
+ meshRef.current.position.z = tz;
+ }
+
+ // Floating bob
+ const bob = Math.sin(state.clock.elapsedTime * 2 + agent.position?.row || 0) * 0.05;
+ meshRef.current.position.y = 0.15 + bob;
+
+ // Glow pulse based on activity
+ if (glowRef.current) {
+ const pulse = Math.sin(state.clock.elapsedTime * 3) * 0.5 + 0.5;
+ glowRef.current.material.opacity = agent.activity === 'working' ? 0.3 + pulse * 0.4 :
+ agent.activity === 'thinking' ? 0.2 + pulse * 0.3 :
+ agent.activity === 'offline' ? 0 :
+ 0.15;
+ }
+ });
+
+ if (!agent) return null;
+
+ const color = agent.activity === 'offline' ? '#666666' : agent.color;
+ const glowColor = ACTIVITY_COLORS[agent.activity] || '#44cc44';
+
+ return (
+