spatial-office/ARCHITECTURE.md
Marcus (agent-cos) 0d3879b02f Add ARCHITECTURE.md (42KB) and COMPETITIVE_ANALYSIS.md (25KB)
Delivered by @senior-eng and @market for spatial-office MVP scoping.

Key architecture decisions:
- React + @react-three/fiber + Zustand frontend
- Node.js + ws + Zod backend on port 18790
- Three bridges: OpenClaw Gateway protocol, Hermes ACP, Pi SDK
- Diff-based WebSocket state sync (100ms batched)
- 6 spatial zones for 9 agents + 1 human observer
- Nix flake with systemd module for n97 deployment
- Phase 1: Observable Office (OpenClaw state only, no Hermes/Pi)

Competitive analysis: 12 competitors profiled, conclusive gap in
VR+browser spatial bridge. Zero agent visualization products.
2026-05-19 16:17:05 -07:00

959 lines
46 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Spatial Office — Architecture
> **Status**: Pre-MVP scoping | **Author**: senior-eng agent | **Date**: 2026-05-19
---
## Table of Contents
1. [System Architecture Diagram](#1-system-architecture-diagram)
2. [Component Breakdown](#2-component-breakdown)
3. [Data Model](#3-data-model)
4. [Rendering Approach](#4-rendering-approach)
5. [WebSocket Protocol](#5-websocket-protocol)
6. [Nix Flake Structure](#6-nix-flake-structure)
7. [Integration Points](#7-integration-points)
8. [MVP Scope vs. Future Phases](#8-mvp-scope-vs-future-phases)
---
## 1. System Architecture Diagram
```
┌──────────────────────────────────────────────────────────────────────────────┐
│ SCOTT'S BROWSER │
│ ┌──────────────────────────────────────────────────────────────────────┐ │
│ │ SPATIAL OFFICE FRONTEND │ │
│ │ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────────┐ │ │
│ │ │ Isometric Scene │ │ UI Overlay │ │ State Manager │ │ │
│ │ │ (Three.js) │ │ (React) │ │ (Zustand) │ │ │
│ │ │ - Tile map │ │ - Agent cards │ │ - WS client │ │ │
│ │ │ - Agent sprites │ │ - Zone panels │ │ - Entity cache │ │ │
│ │ │ - Path animation │ │ - Event log │ │ - Diff engine │ │ │
│ │ └────────┬─────────┘ └────────┬─────────┘ └──────────┬──────────┘ │ │
│ │ └─────────────────────┼────────────────────────┘ │ │
│ └─────────────────────────────────┼───────────────────────────────────────┘ │
│ │ WebSocket (wss://n97:18790) │
└────────────────────────────────────┼────────────────────────────────────────────┘
│
┌────────────────────────────────────┼────────────────────────────────────────────┐
│ │HOST: n97 (NixOS, GMKtec NucBoxG5) │
│ ┌────────────────────────────────┼─────────────────────────────────────────┐ │
│ │ SPATIAL OFFICE BACKEND │ │
│ │ │ │
│ │ ┌──────────────────────┐ ┌──────────────────────┐ │ │
│ │ │ WS Server │ │ State Engine │ │ │
│ │ │ (Node.js / ws) │ │ - Agent registry │ │ │
│ │ │ Port 18790 │ │ - Zone assignment │ │ │
│ │ │ - Channel rooms │ │ - Activity tracker │ │ │
│ │ │ - Broadcast diffs │ │ - Event log │ │ │
│ │ └──────────┬───────────┘ └──────────┬───────────┘ │ │
│ │ │ │ │ │
│ │ ┌──────────┴─────────────────────────┴─────────────────────────────┐ │ │
│ │ │ BRIDGE LAYER │ │ │
│ │ │ │ │ │
│ │ │ ┌─────────────────┐ ┌─────────────────┐ ┌──────────────────┐ │ │ │
│ │ │ │ OpenClaw Bridge │ │ Hermes Bridge │ │ Pi Bridge │ │ │ │
│ │ │ │ │ │ │ │ │ │ │ │
│ │ │ │ Connects as WS │ │ Connects as ACP │ │ HTTP API bridge │ │ │ │
│ │ │ │ backend client │ │ client to │ │ to pi coding │ │ │ │
│ │ │ │ to OpenClaw GW │ │ Hermes ACP │ │ agent for │ │ │ │
│ │ │ │ (port 18789) │ │ server │ │ project tasks │ │ │ │
│ │ │ │ │ │ │ │ │ │ │ │
│ │ │ │ Reads: │ │ Reads: │ │ Receives: │ │ │ │
│ │ │ │ - system- │ │ - ACP tool call │ │ - Build status │ │ │ │
│ │ │ │ presence │ │ events │ │ - Task progress │ │ │ │
│ │ │ │ - event:agent │ │ - Task outputs │ │ - PR status │ │ │ │
│ │ │ │ - event:tick │ │ - Skill updates │ │ │ │ │ │
│ │ │ │ - sessions │ │ │ │ Sends: │ │ │ │
│ │ │ │ state via │ │ │ │ - Feature │ │ │ │
│ │ │ │ chat.send │ │ │ │ requests │ │ │ │
│ │ │ │ │ │ │ │ - Bug reports │ │ │ │
│ │ │ └────────┬────────┘ └────────┬────────┘ └────────┬─────────┘ │ │ │
│ │ └───────────┼────────────────────┼────────────────────┼─────────────┘ │ │
│ └──────────────┼────────────────────┼────────────────────┼──────────────────┘ │
│ │ │ │ │
└─────────────────┼────────────────────┼────────────────────┼──────────────────────┘
│ │ │
▼ ▼ ▼
┌──────────────────┐ ┌──────────────────┐ ┌──────────────────┐
│ OpenClaw GW │ │ Hermes Agent │ │ Pi Coding Agent │
│ (ws://127.0.0.1 │ │ (ACP server) │ │ (pi-mono) │
│ :18789) │ │ │ │ │
│ │ │ Self-reflective │ │ CLI / SDK mode │
│ Agent registry: │ │ learning loop │ │ │
│ - cos │ │ │ │ Builds/deploys │
│ - engineering │ │ Inter-agent │ │ spatial-office │
│ - senior-eng │ │ communication │ │ project │
│ - trust │ │ protocol │ │ │
│ - market │ │ │ └──────────────────┘
│ - health │ └──────────────────┘
│ - content │
│ - presentations │
│ - vault │
└──────────────────┘
```
### Key Design Decisions
| Decision | Rationale |
|----------|-----------|
| **Separate WS server** (port 18790) vs. extending OpenClaw GW | OpenClaw GW is a control plane, not an app server. A dedicated WS server keeps the spatial-office protocol clean and lets us iterate without touching the GW. The backend connects to the GW as a `backend` client (trusted loopback, shared gateway token). |
| **Bridge layer** as in-process modules | All three bridges run in the same Node.js process as the WS server. No need for separate microservices at this scale. Each bridge is a module that polls/streams from its source and pushes state diffs into the state engine. |
| **ACP for Hermes integration** | Hermes runs as an ACP server. The Hermes bridge uses `@openclaw/acpx` or a direct ACP client to connect, send tasks, and receive tool-call events. This is the standard path: OpenClaw → ACP → Hermes. |
---
## 2. Component Breakdown
### 2.1 Frontend (`packages/frontend`)
**Stack**: TypeScript, Vite, React 18, Zustand, Three.js (via `@react-three/fiber`)
```
packages/frontend/
├── index.html
├── vite.config.ts
├── src/
│ ├── main.tsx # React entry
│ ├── App.tsx # Root layout (scene + UI overlay)
│ ├── scene/
│ │ ├── OfficeScene.tsx # Three.js canvas + camera setup
│ │ ├── TileMap.tsx # Isometric tile grid renderer
│ │ ├── AgentSprite.tsx # Per-agent animated sprite
│ │ ├── ZoneMarker.tsx # Zone boundary overlays
│ │ └── PathAnimator.ts # Smooth interpolation between grid cells
│ ├── ui/
│ │ ├── HUD.tsx # Top bar: time, agent count, events
│ │ ├── AgentCard.tsx # Click-to-inspect detail panel
│ │ ├── ZonePanel.tsx # Zone occupancy summary
│ │ ├── EventFeed.tsx # Real-time event log
│ │ └── MiniMap.tsx # Small overhead reference map
│ ├── state/
│ │ ├── store.ts # Zustand store (agent states, zones, events)
│ │ ├── wsClient.ts # WebSocket client with reconnect
│ │ └── types.ts # Shared frontend types
│ └── assets/
│ ├── tiles/ # Tile sprites (floor, wall, desk, plant)
│ ├── agents/ # Agent avatar sprites (9 departments)
│ └── zones/ # Zone type indicators
```
**Why `@react-three/fiber`**: It gives us React-idiomatic Three.js with declarative scene composition, and we already get the React ecosystem (state, hooks, devtools) for free. The isometric scene is modest — 50×50 tiles max, ~15 sprites — so the fiber overhead is negligible.
### 2.2 Backend (`packages/backend`)
**Stack**: TypeScript, Node.js 22, `ws` library, Zod for validation
```
packages/backend/
├── tsconfig.json
├── src/
│ ├── index.ts # Entry: starts WS server + bridges
│ ├── server.ts # WebSocket server (port 18790)
│ ├── state/
│ │ ├── engine.ts # In-memory state + diff engine
│ │ ├── agentRegistry.ts # Known agents (9 depts)
│ │ ├── zoneManager.ts # Zone definitions + occupancy
│ │ └── eventLog.ts # Ring buffer of recent events
│ ├── bridges/
│ │ ├── openclaw.ts # OpenClaw GW bridge
│ │ ├── hermes.ts # Hermes ACP bridge
│ │ └── pi.ts # Pi coding agent bridge
│ ├── protocol/
│ │ ├── messages.ts # WS message types (Zod schemas)
│ │ └── diff.ts # State diff computation
│ └── validation/
│ └── schemas.ts # Zod schemas for all entities
```
**Why in-memory state**: 9 agents with simple state (position, activity, status) is trivially small. No database needed for MVP. The event log uses a ring buffer (last 500 events). If persistence is desired later, add SQLite.
**WS library**: `ws` (npm, ~20M weekly downloads) is the de facto Node.js WebSocket library. Fast, well-maintained, no dependencies. We wrap it with a thin protocol layer.
### 2.3 Bridge: OpenClaw (`bridges/openclaw.ts`)
The OpenClaw bridge connects to the Gateway WS on `ws://127.0.0.1:18789` as a `backend` client, authenticating with the shared gateway token.
**Connection handshake** (per [Gateway Protocol](https://docs.openclaw.ai/gateway/protocol)):
```typescript
const connect = {
type: "req",
id: "spatial-office-1",
method: "connect",
params: {
minProtocol: 3,
maxProtocol: 4,
client: { id: "spatial-office", version: "0.1.0", platform: "linux", mode: "backend" },
role: "operator",
scopes: ["operator.read"], // read-only: presence, agent events, sessions
auth: { token: GATEWAY_SHARED_TOKEN },
},
};
```
**What it reads**:
| GW Method/Event | What We Get | Mapped To |
|----------------|-------------|-----------|
| `system-presence` | Connected devices (mac app, webchat, nodes) → gateway uptime, client count | Top-level system health indicator |
| `event:agent` | Streaming agent turns — when an agent starts processing a message, tool calls, completion | Agent activity state (`working`, `thinking`, `idle`) |
| `event:presence` | Periodic presence updates from connected clients | Scott's presence (is he watching?) |
| `event:tick` | Gateway heartbeat every ~15s (configurable) | Liveness signal for the bridge |
| `event:health` | Gateway health snapshot | Gateway status indicator |
| Chat sessions | Polled via `chat.send` with agent-specific queries (or use the sessions_history API) | Current task description per agent |
**Agent state mapping** (how we infer activity from event:agent):
```typescript
// event:agent payload includes:
// - agentId: which agent is working
// - status: "accepted" | "streaming" | "ok" | "error"
// - tool calls: embedded in streaming content
function mapAgentState(event: AgentEvent): AgentActivity {
// When event:agent arrives with status:"accepted" → agent is "thinking"
// When we see tool calls → agent is "working" (extract tool name as context)
// When event:agent completes (status:"ok") → agent is "idle"
// When no agent events for >5min → agent is "idle"
}
```
**Fallback for agents without active sessions**: Poll the QMD session transcript store. Each agent's session directory at `~/.openclaw/agents/<agentId>/sessions` can be read for recent activity. This requires filesystem access (same host, so fine).
### 2.4 Bridge: Hermes (`bridges/hermes.ts`)
The Hermes bridge connects to a Hermes agent running as an ACP server. The bridge:
1. Opens an ACP session with Hermes
2. Periodically queries: "What are you working on? What agents have you communicated with recently?"
3. Parses Hermes responses for inter-agent communication events
4. Maps these to spatial events (agents moving to "huddle" when Hermes facilitates communication between them)
**ACP connection** (using `@openclaw/acpx` as reference):
```typescript
import { ACPClient } from "@agentclientprotocol/sdk"; // or @openclaw/acpx
const client = new ACPClient({
transport: "stdio", // or "ws" if Hermes exposes WS
command: "hermes", // Hermes CLI
args: ["acp", "serve"],
});
```
**What it extracts**:
| Hermes Output | Spatial Office Mapping |
|--------------|----------------------|
| "Agent X asked me to help with Y" | Move Agent X to collaboration zone, show "pairing with Hermes on Y" |
| Skill extraction events | Show Hermes in self-improvement zone, "refining skill X" |
| Cron task execution | Show relevant agents moving to their task zones |
| Learning loop iteration | Self-improvement zone activity |
**Why ACP matters here**: ACP is the standard protocol for driving external harnesses from OpenClaw. Hermes adopted it early. The bridge acts as an ACP *client* that drives Hermes and extracts structured output, rather than scraping logs. This is the same pattern described in the "Hermes drives, OpenClaw contains" architecture, just flipped: OpenClaw contains the spatial office, and Hermes is one of the observed agents within it.
### 2.5 Bridge: Pi (`bridges/pi.ts`)
The Pi bridge tracks the pi coding agent's activity on the spatial-office repo itself (dogfooding).
**Connection**: Pi's `@earendil-works/pi-coding-agent` package exposes an SDK with `createAgentSession()` and `SessionManager`. The bridge can:
1. **Programmatic mode**: Use `createAgentSession()` in SDK mode to spawn a pi session that works on spatial-office tasks
2. **Status polling**: Pi writes session logs; the bridge tails those logs for activity indicators
For MVP, the simplest approach is HTTP polling of pi's task status:
```typescript
// Pi exposes a simple HTTP health/status endpoint (we add this to pi-mono fork)
const status = await fetch("http://localhost:18991/status");
// Response: { currentTask: "implementing tile renderer", progress: 0.4, repo: "spatial-office" }
```
**What it reports**:
- Pi's current task on the spatial-office repo
- Build/deploy status
- PR status (via Forgejo API at `os.scoble.me/forgejo`)
---
## 3. Data Model
### 3.1 Entities
```typescript
// ── Agent ────────────────────────────────────────────
interface Agent {
id: string; // "cos", "engineering", "senior-eng", etc.
name: string; // Display name: "COS Agent"
department: Department; // enum of 9 values
position: GridPosition; // { row: number, col: number }
facing: Direction; // "N" | "NE" | "E" | "SE" | "S" | "SW" | "W" | "NW"
activity: AgentActivity; // Current state
currentTask: string | null; // "Reviewing PR #42", "Memory maintenance", etc.
lastHeartbeat: number; // Unix ms timestamp
sessionKey: string | null; // OpenClaw session key if active
}
interface AgentActivity {
status: AgentStatus; // "idle" | "thinking" | "working" | "blocked" | "offline"
zone: ZoneId; // Which zone the agent is in
detail: string | null; // "Running: web_search", "Calling: sessions_spawn"
startedAt: number; // When this activity began
}
type AgentStatus = "idle" | "thinking" | "working" | "blocked" | "offline";
type Department = "cos" | "engineering" | "senior-eng" | "trust" | "market"
| "health" | "content" | "presentations" | "vault";
// ── Zone ─────────────────────────────────────────────
interface Zone {
id: ZoneId;
name: string; // "Engineering Pod", "Quiet Room", "Coach Corner"
type: ZoneType; // "collaboration" | "solo" | "self-improvement" | "social"
bounds: GridRect; // { rowStart, colStart, rowEnd, colEnd }
capacity: number; // Max agents in zone
occupants: string[]; // Agent IDs currently in zone
features: ZoneFeature[]; // "whiteboard", "terminal", "coffee", "coach-bot"
}
type ZoneId = string;
type ZoneType = "collaboration" | "solo" | "self-improvement" | "social";
type ZoneFeature = "whiteboard" | "terminal" | "coffee" | "coach-bot" | "library";
interface GridRect {
rowStart: number;
colStart: number;
rowEnd: number;
colEnd: number;
}
// ── GridPosition ─────────────────────────────────────
interface GridPosition {
row: number; // 0-indexed, origin at top-left of isometric view
col: number;
}
// ── Event ────────────────────────────────────────────
interface OfficeEvent {
id: string;
timestamp: number; // Unix ms
type: EventType;
agentId: string | null; // Which agent triggered it
zoneId: string | null; // Where it happened
payload: Record<string, unknown>;
}
type EventType =
| "agent.moved" // Agent repositioned
| "agent.activity_changed" // Status or zone changed
| "agent.offline" // Agent heartbeat lost
| "agent.online" // Agent returned
| "hermes.message" // Inter-agent communication observed
| "pi.task_update" // Pi task progress changed
| "zone.full" // Zone hit capacity
| "system.heartbeat"; // Periodic system health
// ── World State (the full snapshot) ──────────────────
interface OfficeState {
version: number; // Monotonic counter for diff-based sync
agents: Record<string, Agent>;
zones: Record<string, Zone>;
events: OfficeEvent[]; // Last N events (ring buffer)
meta: {
gatewayUptime: number;
connectedClients: number;
scottPresent: boolean;
lastUpdate: number;
};
}
```
### 3.2 Zone Map (MVP Office Layout)
```
┌──────────────────────────────────────┐
│ COFFEE / SOCIAL │ (top)
│ [☕ social zone 4x2] │
├──────────┬───────────────┬────────────┤
│ │ COLLABORATION│ │
│ SOLO │ POD A │ SOLO │
│ WORK │ [4 desks] │ WORK │
│ [6 │ Whiteboard │ [6 │
│ desks] │ Terminal │ desks] │
│ ├───────────────┤ │
│ │ COLLABORATION│ │
│ │ POD B │ │
│ │ [4 desks] │ │
│ │ Whiteboard │ │
├──────────┴───────────────┴────────────┤
│ SELF-IMPROVEMENT AREA │ (bottom)
│ [Coach Bot] [Library] [Quiet Pods] │
└──────────────────────────────────────┘
```
**Grid**: 24 columns × 15 rows. Each tile is 64×32px isometric diamond.
**Zone assignments for 9 agents**:
| Zone | Type | Capacity | Default Assignment |
|------|------|----------|-------------------|
| Pod A (left) | collaboration | 4 | engineering, senior-eng, pi (when active) |
| Pod B (right) | collaboration | 4 | market, content, presentations |
| Solo West | solo | 6 | cos, trust |
| Solo East | solo | 6 | health, vault |
| Coach Corner | self-improvement | 3 | Any agent doing memory maintenance / coach review |
| Coffee Bar | social | 4 | Any agents in "idle" or "observing" state |
---
## 4. Rendering Approach
### 4.1 Why Three.js (not Phaser, not Canvas 2D)
| Criterion | Three.js | Phaser | Canvas 2D |
|-----------|----------|--------|-----------|
| Isometric projection | Trivial (orthographic camera at 45° rotation, 35.264° elevation) | Built-in isometric plugin but 2D-only | Manual tile math, no depth sorting |
| 3D depth effects | Native (agents can have height, z-fighting resolved) | 2D render order only | Manual z-index |
| Animation (tweening) | Built-in `gsap` + Three.js animation loop | Built-in tween system | Manual requestAnimationFrame |
| React integration | `@react-three/fiber` (25K+ stars) | `@yonatanmn/react-phaser` (unmaintained) | Manual DOM |
| Bundle size | ~150KB gzipped (with tree-shaking) | ~1MB | 0KB |
| Community/ecosystem | Massive | Large (game dev) | Native |
**Decision**: Three.js with `@react-three/fiber`. The isometric view is fundamentally a 3D scene viewed from a fixed orthographic angle. Three.js gives us proper depth sorting, smooth path animation with `useFrame`, and the React integration means we can use Zustand state directly in the render loop.
### 4.2 Camera Setup
```typescript
// Orthographic camera positioned for true isometric projection
// True isometric: rotate Y by 45°, then X by 35.264° (atan(1/√2))
const camera = new THREE.OrthographicCamera(
-width / 2, width / 2, // left, right
height / 2, -height / 2, // top, bottom
0.1, 1000 // near, far
);
// Position camera above and center
camera.position.set(0, -20, 20);
camera.lookAt(0, 0, 0);
// For a 2:1 isometric pixel ratio (standard in games):
// X angle = 30° from horizontal (this gives the 2:1 diamond shape)
// Actually, use rotY=45°, rotX=-35.264° for true isometric
// But for a cleaner 2:1 pixel ratio (easier art), use rotX=-30°
```
**Alternative — fake isometric with 2D sprites on a tilted plane**:
We can place flat sprites on a plane tilted at 30° rather than building a full 3D office. This is simpler for art assets (flat PNG sprites for agents and furniture). The Three.js `Sprite` object always faces the camera, so for isometric we'd use quads (`PlaneGeometry`) tilted to match the floor plane.
### 4.3 Tile Map
The office is a 2D grid (`Array<Array<Tile>>`) where each cell has:
```typescript
interface Tile {
type: TileType; // "floor" | "wall" | "desk" | "empty" | "door"
zoneId: ZoneId | null; // Which zone this tile belongs to
occupant: string | null; // Agent ID if someone is on this tile
}
type TileType = "floor" | "wall" | "desk" | "empty" | "door" | "plant" | "whiteboard";
```
**Rendering loop** (each frame):
1. Sort tiles by `row + col` for correct isometric depth (farther tiles render first)
2. For each tile: render floor sprite, then desk/wall sprite
3. For each agent: render agent sprite at grid position, apply z-offset for "standing on desk"
4. Apply `gsap` tweens for smooth movement between tiles
**Performance**: 24×15 = 360 tiles max, ~200 visible at a time. Three.js handles this effortlessly without instancing.
### 4.4 Agent Visual Design
Each of the 9 agents gets a simple isometric sprite (64×64 or 96×48px) with:
- **Department color** (distinct hue per department)
- **Department icon** (simple glyph: gear for engineering, shield for trust, book for content, etc.)
- **Status indicator** (small colored dot: green = idle, yellow = thinking, blue = working, red = blocked)
- **Speech bubble** (optional, appears when agent is "talking" via Hermes)
Sprites are PNGs drawn in a 2:1 isometric pixel art style. We can generate these programmatically as a first pass (colored boxes with text labels) and replace with proper art later.
### 4.5 UI Overlay (React)
The UI overlay sits on top of the Three.js canvas (absolute positioned div) and provides:
- **Agent card**: Click an agent → card slides in showing: name, department, current activity, task description, session key, last heartbeat
- **Zone panel**: Sidebar showing zone occupancy and type
- **Event feed**: Scrollable log of recent events (last 50)
- **Minimap**: Small 2D overhead view showing the full office layout
- **Camera controls**: Zoom in/out, pan (though the isometric view is mostly fixed)
---
## 5. WebSocket Protocol
### 5.1 Connection
The frontend connects to `ws://n97:18790` (or `wss://` with TLS proxy later).
```typescript
// No auth needed for MVP (local network only).
// Client sends a hello message:
{
type: "hello",
version: 1,
client: "browser",
}
```
### 5.2 Server → Client Messages
| Message | Payload | Frequency |
|---------|---------|-----------|
| `state.full` | `OfficeState` (complete snapshot) | On connect, after reconnect |
| `state.diff` | `{ version, changes: StateDiff[] }` | On every state change (throttled to 100ms batches) |
| `event` | `OfficeEvent` | Real-time as events occur |
| `heartbeat` | `{ ts: number }` | Every 15 seconds |
**State diff format** (avoids sending the full state on every tick):
```typescript
interface StateDiff {
op: "agent.update" | "agent.move" | "zone.update" | "event.append";
path: string; // e.g., "agents.engineering.activity.status"
value: unknown; // New value
}
// Example diff: agent moved
{
op: "agent.move",
path: "agents.engineering",
value: {
position: { row: 5, col: 12 },
activity: { status: "working", zone: "pod-a", detail: "pairing with senior-eng", startedAt: 1716152400000 }
}
}
// Batch of diffs sent every 100ms:
{
type: "state.diff",
version: 47,
changes: [
{ op: "agent.move", path: "agents.engineering", value: { ... } },
{ op: "agent.update", path: "agents.cos.activity.status", value: "thinking" },
{ op: "event.append", path: "events", value: { type: "agent.moved", agentId: "engineering", ... } },
]
}
```
### 5.3 Client → Server Messages
| Message | Payload | Description |
|---------|---------|-------------|
| `agent.click` | `{ agentId: string }` | Scott clicked an agent (triggers detail expansion in UI, handled locally) |
| `zone.click` | `{ zoneId: string }` | Scott clicked a zone |
| `camera.move` | `{ x, y, zoom }` | Scott panned/zoomed (local state, not broadcast) |
For MVP, client→server messages are minimal. The view is primarily read-only observation.
### 5.4 State Throttling
The backend batches diffs every 100ms. If no changes occur, no message is sent. The heartbeat ensures the client knows the connection is alive.
```typescript
// Backend diff batching
class DiffBatcher {
private diffs: StateDiff[] = [];
private timer: NodeJS.Timeout | null = null;
push(diff: StateDiff) {
this.diffs.push(diff);
if (!this.timer) {
this.timer = setTimeout(() => this.flush(), 100);
}
}
flush() {
if (this.diffs.length > 0) {
broadcast({ type: "state.diff", version: this.version++, changes: this.diffs });
this.diffs = [];
}
this.timer = null;
}
}
```
---
## 6. Nix Flake Structure
### 6.1 `flake.nix`
```nix
{
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 toolchain
nodeToolchain = with pkgs; [
nodejs_22
nodePackages.pnpm
nodePackages.typescript
];
# Build the frontend
buildFrontend = pkgs.stdenv.mkDerivation {
name = "spatial-office-frontend";
src = ./packages/frontend;
nativeBuildInputs = nodeToolchain;
buildPhase = ''
pnpm install --frozen-lockfile
pnpm run build
'';
installPhase = ''
mkdir -p $out
cp -r dist/* $out/
'';
};
# Build the backend
buildBackend = pkgs.stdenv.mkDerivation {
name = "spatial-office-backend";
src = ./packages/backend;
nativeBuildInputs = nodeToolchain;
buildPhase = ''
pnpm install --frozen-lockfile
pnpm run build
'';
installPhase = ''
mkdir -p $out
cp -r dist/* $out/
cp -r node_modules $out/
mkdir -p $out/bin
echo '#!/usr/bin/env bash' > $out/bin/spatial-office-backend
echo 'exec ${pkgs.nodejs_22}/bin/node $out/index.js' >> $out/bin/spatial-office-backend
chmod +x $out/bin/spatial-office-backend
'';
};
in
{
packages = {
frontend = buildFrontend;
backend = buildBackend;
default = pkgs.buildEnv {
name = "spatial-office";
paths = [ buildFrontend buildBackend ];
};
};
devShells.default = pkgs.mkShell {
buildInputs = nodeToolchain ++ (with pkgs; [
# Development tools
nodePackages.vite
nodePackages.eslint
]);
shellHook = ''
echo "🏢 Spatial Office dev shell"
echo " pnpm dev — start frontend + backend in dev mode"
echo " pnpm build — production build"
'';
};
# NixOS module for running as a systemd service on n97
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; };
openclawTokenFile = lib.mkOption { type = lib.types.path; };
};
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;
OPENCLAW_GATEWAY_URL = "ws://127.0.0.1:18789";
OPENCLAW_TOKEN_FILE = cfg.openclawTokenFile;
NODE_ENV = "production";
};
serviceConfig = {
ExecStart = "${self.packages.${system}.backend}/bin/spatial-office-backend";
Restart = "always";
RestartSec = 5;
User = "spatial-office";
Group = "spatial-office";
};
};
users.users.spatial-office = {
isSystemUser = true;
group = "spatial-office";
};
users.groups.spatial-office = {};
};
};
}
);
}
```
### 6.2 Why Nix
- **Reproducibility**: `nix build` produces identical outputs on any machine. The n97 is NixOS, so this is the native deployment path.
- **Node.js pinning**: No "works on my machine" — Node 22, pnpm, and TypeScript versions are locked.
- **Systemd service**: The NixOS module handles service lifecycle, user creation, and restart policy.
- **Dev shell**: `nix develop` gives every developer the exact same toolchain.
### 6.3 Directory Layout
```
spatial-office/
├── flake.nix
├── flake.lock
├── package.json # Workspace root (pnpm workspaces)
├── pnpm-workspace.yaml
├── packages/
│ ├── frontend/
│ │ ├── package.json
│ │ ├── vite.config.ts
│ │ ├── tsconfig.json
│ │ └── src/ # (as described in §2.1)
│ └── backend/
│ ├── package.json
│ ├── tsconfig.json
│ └── src/ # (as described in §2.2)
├── assets/ # Design files, tile templates
├── README.md
└── ARCHITECTURE.md # This file
```
---
## 7. Integration Points
### 7.1 OpenClaw Integration
**What we need from OpenClaw**:
| Data | Source | How |
|------|--------|-----|
| Which agents exist | `agents.list` in Gateway config (`~/.openclaw/openclaw.json`) | Backend reads config file on startup, or GW exposes this via `config.get` RPC |
| Agent online/offline | `system-presence` WS method | Bridge calls this on startup + polls every 30s |
| Agent activity (working/thinking/idle) | `event:agent` WS events | Bridge subscribes to agent events for all 9 agent IDs |
| Current task description | Session transcripts or `chat.send` polling | Bridge reads `~/.openclaw/agents/<id>/sessions` most recent file |
| Scott's presence | `event:presence` with Scott's device ID | Updates `meta.scottPresent` |
**Gateway config read**:
The backend needs to know about the 9 agents. It can either:
1. Read `~/.openclaw/openclaw.json` directly (same host, permissions allowing)
2. Call the Gateway's `config.get` RPC method with `operator.admin` scope
3. Accept a config file listing agent IDs explicitly
Option 3 is simplest for MVP. A `spatial-office.config.json`:
```json
{
"agents": [
{ "id": "cos", "name": "COS Agent", "department": "cos", "color": "#FF6B6B" },
{ "id": "engineering", "name": "Engineering Agent", "department": "engineering", "color": "#4ECDC4" },
...
],
"zones": [ ... ]
}
```
**Security**: The backend needs `operator.read` scope only. It connects via loopback with the shared gateway token. No admin access required.
### 7.2 Hermes Integration
Hermes runs as an ACP server. The spatial-office backend is an ACP *client*.
**Architecture choice**: Hermes is NOT one of the 9 Scobleclaw agents. It's an external reasoning engine that the agents *use* through ACP. In the spatial office:
- Hermes appears as a "utility" — like a whiteboard or a coach bot
- When Agent A asks Hermes something, Agent A moves to a collaboration pod with a Hermes indicator
- When Hermes processes a learning loop, the self-improvement zone shows activity
**ACP protocol flow**:
```
Spatial Office Backend Hermes ACP Server
│ │
├──── initialize ─────────────>│ "What agents have communicated
│ │ through you recently?"
│<─── agent_messages ──────────┤ [{from: "engineering", to: "senior-eng",
│ │ topic: "spatial-office PR review"}]
│ │
├──── tools/list ─────────────>│ Discover available Hermes tools
│<─── tools ───────────────────┤
│ │
├──── tools/call ─────────────>│ "Describe your current task"
│<─── result ──────────────────┤ "Refining the code-review skill
│ │ after 12 evaluations"
```
**For MVP**: The Hermes bridge starts simple — a periodic poll (every 30s) that asks Hermes for its current state. We don't need real-time streaming from Hermes in phase 1. The ACP session stays open, the bridge sends a query, receives a response, and maps it to spatial events.
### 7.3 Pi Integration
Pi (`@earendil-works/pi-coding-agent`) is the coding agent that builds the spatial-office project itself. In the office, Pi appears as a 10th "agent" (or a special entity) that:
- Sits in a designated development workstation
- Shows current task: "Implementing TileMap renderer [▓▓▓▓░░░░] 40%"
- Moves to collaboration pod when doing code review with engineering
- Shows build status from the Forgejo CI
**Integration approach**:
1. **Pi session manager**: The backend spawns a Pi session using the SDK:
```typescript
import { createAgentSession } from "@earendil-works/pi-coding-agent";
const { session } = await createAgentSession({
model: myModel,
tools: ["read", "bash", "write"],
sessionManager: SessionManager.file({ dir: "./pi-sessions" }),
});
```
2. **Status endpoint**: We add a lightweight HTTP server to the Pi session that exposes:
```
GET /status → { task: "...", progress: 0.4, status: "working" }
```
3. **Forgejo webhooks**: The backend listens for Forgejo push/PR events for the spatial-office repo to track build status
**For MVP**: The Pi bridge is a polling loop that checks pi's session log file. The spatial-office repo's CI status is checked via the Forgejo API (`GET /api/v1/repos/sscoble/spatial-office/commits`).
---
## 8. MVP Scope vs. Future Phases
### MVP (Phase 1) — "The Observable Office"
**Goal**: Scott can open a browser tab and see the 9 agents in an isometric office, with their current activity inferred from OpenClaw.
| Feature | Included? | Rationale |
|---------|-----------|-----------|
| Isometric tile map (fixed layout) | ✅ | Core visual |
| 9 agent sprites with department colors | ✅ | Core visual |
| Agent activity state (idle/thinking/working/blocked) from OpenClaw | ✅ | Core data source |
| Agent movement between zones based on activity | ✅ | Core mechanic |
| Click agent → detail card | ✅ | Scott's view |
| Zone occupancy indicators | ✅ | Visual clarity |
| Event feed (last 50 events) | ✅ | Debugging + insight |
| WebSocket real-time sync | ✅ | Required for live updates |
| Nix flake build | ✅ | Deployment requirement |
| OpenClaw GW bridge (operator.read) | ✅ | Primary data |
| **Hermes bridge** | ❌ (deferred) | Adds complexity without visual payoff in MVP |
| **Pi bridge** | ❌ (deferred) | Pi tasks can be inferred from OpenClaw events (if Pi is an agent) |
| Agent path animation (smooth tweening) | ✅ | Quality-of-life, cheap to add |
| Scott presence indicator | ✅ | Trivial (1 presence check) |
| Mobile/responsive layout | ❌ | Desktop-first, n97 serves local network |
| Multiple observers (multi-client) | ❌ | Scott's personal view only |
**MVP deliverables**:
1. Working frontend (Vite + React + Three.js) rendering the office
2. Working backend (Node.js + ws) bridging to OpenClaw
3. Nix flake that builds both and deploys as a systemd service on n97
4. Accessible at `http://n97:18790` on the local network
### Phase 2 — "Hermes + Pi Visibility"
| Feature | Notes |
|---------|-------|
| Hermes ACP bridge | Real-time Hermes state, inter-agent comm visualization |
| Pi bridge | Pi's task status on spatial-office, Forgejo CI status |
| Agent "huddle" visualization | Two agents visibly move together when collaborating |
| Hermes message visualization | Speech bubbles or "Hermes link" lines between agents |
| Coach bot visualization | Self-improvement zone shows active Hermes learning loops |
### Phase 3 — "Interaction"
| Feature | Notes |
|---------|-------|
| Scott can send messages to agents from the UI | `chat.send` through OpenClaw bridge |
| Scott can drag agents between zones | Sends `system-event` to re-route agent tasks |
| Agent "whisper" (private message to one agent) | One-click from agent card |
| Sound cues | Ambient office sounds, notification chimes |
| Agent idle animations | Breathing, looking around, typing |
### Phase 4 — "VR + Multi-User"
| Feature | Notes |
|---------|-------|
| WebXR headset view | First-person perspective in the same office |
| Multiple human observers | Different views, shared state |
| Voice chat integration | Spatial audio between agents and humans |
| Custom office layouts | Drag-and-drop office editor |
| Real collaboration tools | Shared whiteboard, code viewer, screenshare |
### Phase 5 — "Full Product"
| Feature | Notes |
|---------|-------|
| External teams | Multiple organizations, each with their own office |
| Agent marketplace | Drop-in agent "employees" from a catalog |
| Analytics dashboard | Productivity, collaboration metrics, heat maps |
| API for third-party agent systems | Standard spatial-office protocol |
---
## Appendix A: Technology Dependency Graph
```
spatial-office
├── @react-three/fiber (^8.x) → three (^0.170.x) → WebGL
├── @react-three/drei (^9.x) → helpers for fiber (OrbitControls, etc.)
├── react (^18.x)
├── react-dom (^18.x)
├── zustand (^5.x) → state management
├── gsap (^3.x) → smooth path animation
├── vite (^6.x) → build tool
├── typescript (^5.x)
├── ws (^8.x) → WebSocket server
├── zod (^3.x) → validation
├── @agentclientprotocol/sdk → ACP client (Hermes bridge, Phase 2)
└── @earendil-works/pi-coding-agent → Pi SDK (Pi bridge, Phase 2)
```
## Appendix B: OpenClaw Gateway Protocol — Methods We Use
Per the [Gateway Protocol](https://docs.openclaw.ai/gateway/protocol) and [Presence](https://docs.openclaw.ai/concepts/presence) docs:
| Method | Scope Required | Use |
|--------|---------------|-----|
| `connect` | (handshake) | Establish backend client connection |
| `system-presence` | (unrestricted) | Get connected devices/clients |
| `event:agent` (subscription) | `operator.read` | Stream agent turn events |
| `event:presence` (subscription) | (unrestricted) | Stream presence updates |
| `event:health` (subscription) | (unrestricted) | Stream gateway health |
| `event:tick` (subscription) | (unrestricted) | Gateway heartbeat |
| `chat.send` | `operator.write` | (Phase 3) Send messages to agents |
| `config.get` | `operator.admin` | (May not be needed; use config file instead) |
## Appendix C: Open Questions
1. **Agent session polling frequency**: How often should we poll? 5s is responsive but may be noisy. 30s is calmer. Start with 10s and tune.
2. **OpenClaw shared token**: Where is it stored, and how does the NixOS module securely access it? Option: `OPENCLAW_TOKEN_FILE` environment variable pointing to a file readable by the `spatial-office` user.
3. **Hermes ACP endpoint**: Is Hermes currently running on n97? If so, at what port/socket? If not, that's a prerequisite for Phase 2.
4. **Agent sprite art**: Programmatic (CSS-generated or Canvas-drawn) vs. hand-drawn pixel art. Start programmatic, iterate.
5. **n97 firewall**: Does n97 expose port 18790 to the local network? For Scott's browser to connect, this port must be open or tunneled.