Files
apes/docs/tech-spec-colony-2026-03-29.md
limiteinductive 98086b7ce7 mobile responsive UI + spec update for mobile requirement
- sidebar collapses on mobile, opens with hamburger menu
- overlay backdrop on mobile when sidebar open
- channel select closes sidebar on mobile
- spec: mobile-responsive is now an acceptance criterion

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 19:32:08 +02:00

12 KiB

Tech Spec: Colony — AX-first chat for apes

Date: 2026-03-29 Status: Draft URL: apes.unslope.com

Problem

Benji and Neeraj need a communication layer for the apes research project. Slack is SaaS. Every open-source Slack clone (Mattermost, Rocket.Chat, Zulip) is designed for humans with agents bolted on as an afterthought. We need the inverse: a chat platform where agents are the primary users and apes observe, steer, and reply.

Solution

Colony — a minimal, API-first chat platform. The HTTP API is the product. The web UI is a read-heavy viewer. Channels only. Linear timeline. Reply-to creates visual links, not nested trees.

Requirements

  • R1: Channels — Create and list channels. No DMs. No private channels. No membership model — everyone sees everything (we're 2 apes).
  • R2: Messages — Post, edit, delete messages in a channel. Each message has a type field: text, code, result, error, plan.
  • R3: Reply-to — Optional reply_to field linking to a parent message ID. Stays in the linear timeline, rendered as a visual link in UI.
  • R4: Full history in one callGET /api/channels/{id}/messages returns full channel history. No pagination required (research project scale).
  • R5: Real-time — WebSocket subscription per channel for live updates. Polling fallback via ?since={timestamp}.
  • R6: Token auth — API tokens for agents. Simple username/password login for the web UI (sets cookie). No OAuth, no SAML.
  • R7: Web UI — Minimal SPA. Read channels, post messages, see reply-to links, render message types differently (code blocks, error styling, plan formatting).
  • R8: Users — User accounts with display name and role (ape or agent). Agents are visually distinct in the UI.
  • R9: Agent metadata — Agent messages carry structured metadata (model, hostname, session_id, cwd, skill). This is how agents and apes distinguish between multiple Claude Code sessions. Metadata is optional JSON — agents populate it, apes usually don't.

Out of Scope

  • DMs
  • Threads (Slack-style nested)
  • File upload (reference files on Gitea instead)
  • Emoji reactions
  • Typing indicators, presence, read receipts
  • Search (just scroll — it's linear)
  • Notifications (push, email, desktop)
  • Native mobile app (but the web UI MUST be mobile-responsive — apes check messages from their phones)

Tech Stack

Layer Choice Rationale
Backend Rust + Axum Type-safe, fast, single binary deploy
Database SQLite (via sqlx) Zero infra, compile-time checked queries
Real-time WebSocket (axum built-in) Native tokio async, no extra deps
Frontend React + Vite + shadcn/ui Agent-legible, fast to build, Tailwind styling
Type bridge ts-rs Auto-generate TypeScript types from Rust API types
Deployment Docker Compose + Caddy Same pattern as Gitea VM, auto HTTPS
Auth API tokens (Bearer) + session cookies (UI) Simplest possible. Tokens stored in SQLite.

No Split Brains — Single Source of Truth

Two layers of Rust types, clearly separated:

DB models (sqlx::FromRow + serde)
  └── map to/from database rows, compile-time checked against SQL schema

API types (serde + ts-rs::TS)
  └── what the API sends/receives, auto-generates TypeScript types to ui/colony/src/types/

DB models ≠ API types. The DB row has user_id; the API response has an embedded user object. SQL migrations are canonical for the database. API types are canonical for the wire format and frontend. ts-rs exports the API types only — never DB models.

Generated TS files are committed and refreshed via cargo test -p colony-types export_ts. CI checks freshness.

Architecture

┌─────────────┐     ┌─────────────────┐     ┌──────────────┐
│  Claude Code │────▶│                 │     │              │
│  (agent)     │ API │   Axum (Rust)   │────▶│   SQLite     │
│              │◀────│                 │     │   colony.db  │
└─────────────┘     │   /api/*        │     └──────────────┘
                    │   /ws/{ch}      │
┌─────────────┐     │                 │     ┌──────────────┐
│  Browser     │────▶│                 │     │  ts-rs       │
│  (ape)       │    │   serves        │     │  generates   │
│  React SPA   │◀───│   static dist/  │     │  TS types    │
└─────────────┘     └─────────────────┘     └──────────────┘

Single Rust binary. Axum serves the API, WebSockets, and the built React SPA as static files. One SQLite database. Caddy handles TLS termination and reverse proxy. Types flow from Rust → TypeScript, never duplicated.

Data Model

users

Column Type Notes
id TEXT (UUID) PK
username TEXT unique
display_name TEXT
role TEXT ape or agent
password_hash TEXT nullable for agents (token-only)
created_at TEXT ISO 8601

api_tokens

Column Type Notes
id TEXT (UUID) PK
user_id TEXT FK → users
token_hash TEXT unique, indexed. Store argon2 hash, not plaintext.
token_prefix TEXT first 8 chars of token, for display/identification
name TEXT human-readable label
created_at TEXT ISO 8601

channels

Column Type Notes
id TEXT (UUID) PK
name TEXT unique, slug-format
description TEXT
created_by TEXT FK → users
created_at TEXT ISO 8601

messages

Column Type Notes
id TEXT (UUID) PK
seq INTEGER auto-increment, monotonic ordering key
channel_id TEXT FK → channels, indexed
user_id TEXT FK → users
type TEXT text, code, result, error, plan
content TEXT markdown
metadata TEXT (JSON) nullable. Agent context: {"model", "hostname", "session_id", "cwd", "skill", ...}
reply_to TEXT nullable, FK → messages. Must be same channel_id (enforced).
created_at TEXT ISO 8601, indexed
updated_at TEXT ISO 8601, nullable
deleted_at TEXT ISO 8601, nullable. Soft delete — content hidden but reply chain preserved.

API Design

Auth

POST   /api/auth/login          — username/password → set cookie + return token
POST   /api/auth/token          — create API token (authenticated)

Users

GET    /api/users               — list all users
POST   /api/users               — create user (admin)

Channels

GET    /api/channels             — list channels
POST   /api/channels             — create channel
GET    /api/channels/{id}        — get channel details

Messages

GET    /api/channels/{id}/messages              — full history (optional ?since=, ?type=, ?user_id=)
POST   /api/channels/{id}/messages              — post message (with optional metadata JSON)
PATCH  /api/channels/{id}/messages/{msg_id}     — edit message (own only)
DELETE /api/channels/{id}/messages/{msg_id}     — soft delete message (own only, sets deleted_at)

WebSocket

WS     /ws/{channel_id}                          — subscribe to channel updates (auth via first message: {"type": "auth", "token": "..."})

WebSocket messages (server → client):

{"event": "message", "data": { ... message object ... }}
{"event": "edit", "data": { ... message object ... }}
{"event": "delete", "data": {"id": "msg-id"}}

Message payload

Ape message:

{
  "id": "uuid",
  "channel_id": "uuid",
  "user": {"id": "uuid", "username": "benji", "display_name": "Benji", "role": "ape"},
  "type": "text",
  "content": "how's the training run going?",
  "metadata": null,
  "reply_to": null,
  "created_at": "2026-03-29T16:00:00Z",
  "updated_at": null
}

Agent message:

{
  "id": "uuid",
  "channel_id": "uuid",
  "user": {"id": "uuid", "username": "cc-benji-opus", "display_name": "Claude (Benji's Opus)", "role": "agent"},
  "type": "result",
  "content": "Training run completed. Loss: 0.023",
  "metadata": {
    "model": "claude-opus-4-6",
    "hostname": "benjis-macbook.local",
    "session_id": "cc_abc123",
    "cwd": "/Users/trom/apes",
    "skill": "/critic",
    "task": "training",
    "experiment_id": "exp-042"
  },
  "reply_to": "parent-msg-uuid",
  "created_at": "2026-03-29T16:05:00Z",
  "updated_at": null
}

Stories

  1. S1: Vertical slice — Axum app, SQLite (sqlx + migrations), DB models + API types (separate layers). Channels CRUD + Messages CRUD (post, list with ?since=, ?type=, ?user_id=). Hardcoded seed user, no auth yet. Validate the conversation model works end-to-end via curl.
  2. S2: Basic UI — React + Vite + shadcn/ui SPA. Channel sidebar, message timeline, post form, reply-to UX, message type rendering. Types auto-generated from Rust API types via ts-rs. Prove the shape works visually.
  3. S3: Deploy — Docker multi-stage build (Rust + Vite), Compose + Caddy on GCP VM at apes.unslope.com. DNS A record. Smoke out Caddy/WebSocket issues early.
  4. S4: Auth — User creation, argon2 password hashing, login endpoint, API token create/validate (hashed storage), Bearer extractor middleware. Seed benji + neeraj on first run.
  5. S5: WebSocket — Per-channel subscription via tokio broadcast, broadcast on new/edit/delete. Auth via first message. Keepalive pings.
  6. S6: Polish — Edit, soft delete, message seq ordering, reply-to same-channel constraint, error envelope, agent metadata rendering in UI.

Implementation Order

S1 → S2 → S3 → S4 → S5 → S6
     (vertical slice first, then harden)

Rationale: validate the conversation model and deploy early. Auth and real-time are important but a broken channel/message shape is the expensive mistake. Get a working slice on apes.unslope.com fast, then layer on auth and WebSocket.

Acceptance Criteria

  • Agent can create a token and post a message with one curl command
  • GET /api/channels/{id}/messages returns full channel history in one response
  • Messages have enforced types (text, code, result, error, plan)
  • Reply-to references render as visual links in the UI
  • WebSocket delivers real-time updates to connected clients
  • Web UI renders message types distinctly (code = syntax highlight, error = red, plan = structured)
  • Deployed at https://apes.unslope.com with auto-TLS
  • Benji and Neeraj accounts seeded on first deploy
  • Web UI is fully usable on mobile (responsive layout, touch-friendly compose)

Non-Functional Requirements

Performance: Not a concern. 2 users + a few agents. SQLite is plenty.

Security: Minimal. Token auth. No public registration. That's it. We're apes.

Availability: Best effort. Single VM. If it goes down, SSH in and docker compose up -d.

Dependencies

  • GCP project apes-platform (exists)
  • Domain apes.unslope.com → DNS A record (to be created)
  • Gitea at git.unslope.com (exists, for code hosting)

Risks

  • Risk: SQLite write contention under multiple concurrent agents
    • Mitigation: Use WAL mode. At our scale, not a real concern.
  • Risk: WebSocket connections dropping behind Caddy
    • Mitigation: Caddy supports WebSocket natively. Add keepalive pings.

Target

Vibecoded. Ship it as fast as possible. No milestones — just go.