# Koda — AI Assistant Platform
## Specification Document — Version 2.0

> **Naming:** The agent is named **Koda**. The Signal bot identity is **KodaBot** (shortened to **Koda** in commands and @mentions). These terms are used interchangeably throughout this document.

| Field | Value |
|-------|-------|
| Version | 2.0 |
| Status | Production — Live system. This document is the canonical build specification. |
| Last Updated | March 2026 |
| Supersedes | v1.0 |
| Repository | github.com/cliaz/koda |
| Dashboard | koda.systems |
| Console | console.koda.systems |

---

## Executive Summary

**What this document is:**
This is the build specification for Koda — a Signal Messenger ↔ Claude Code bridge that runs locally on a Mac. It is written to be handed directly to a Claude Code agent, which reads it and builds the entire system autonomously. It is a living document: each production deployment updates it to reflect what was actually built, lessons learned, and any deviations from the previous version.

**When this was generated:**
Version 1.0 was produced in March 2026, following the first complete production build from the v0.9 spec. All deviations, installation gotchas, and implementation decisions discovered during that build have been incorporated.

**What this document is NOT:**
This is not a user guide. It is not a reference for operating or interacting with Koda day-to-day. For that, see the **Koda Technical Reference** (companion document, also at koda.systems/docs). The Technical Reference covers commands, usage patterns, and operational guidance without the build scaffolding.

**How to use this document:**
If you are rebuilding Koda from scratch, or building a new version: hand this document to a Claude Code agent running with `--dangerously-skip-permissions`. It will ask you the configuration questions in Section 22 and then build the system. See Section 22.2 for the exact steps.

If you are extending or modifying the running system: read the relevant section, make changes, and update this document to reflect what was built.

**What changed in v2.0:**
Koda has evolved from a Signal-only interface into a platform that can build, deploy, and manage Progressive Web Apps. The core Signal ↔ Claude Code bridge is unchanged. Two new capabilities have been added: (1) a management console PWA (Koda Console at `console.koda.systems`) providing a graphical interface to all Koda functions, and (2) a framework for building standalone PWAs deployed on Cloudflare's edge that work independently of Koda. A natural language intent pipeline allows users to manage app data conversationally — Koda parses intent from Claude's responses, confirms with the user, and executes against app APIs.

---

## 1. Overview

### 1.1 Purpose

Koda is a bridge service that allows authorised users to interact with a locally running Claude Code instance via Signal Messenger — sending instructions, receiving responses, handling files and images, automating browsers, deploying web content, persisting project data, reading and sending email, and managing calendar events — as if sitting at the terminal, from anywhere. Additionally, Koda can build and deploy Progressive Web Apps via Cloudflare Pages, Workers, and D1, managed through the Koda Console PWA or conversational commands via Signal.

### 1.2 Guiding Principles

- Multi-user support with role-based access control — Owner has full access by default; all other users require explicit capability grants
- Interactions are asynchronous — users may not be available in real time
- Claude Code runs agentically and unattended; `--dangerously-skip-permissions` is always on — the bridge is non-interactive by design
- All components run locally on the host Mac — no external dependencies except Cloudflare
- Database access is always mediated through PostgREST — never direct SQL from Claude Code
- Each Signal group chat maps to an isolated project context in the database
- A global memory layer enables cross-project learning without breaking isolation
- AI Instructions files shape Claude Code's behaviour per session and globally
- Skills files define repeatable SOPs — Claude Code reads the relevant skill before executing a matching task
- Credentials never pass through Claude Code or the message log
- Browser automation runs headed (required for claude.ai); Claude Code verifies its own deployed output
- Errors are surfaced immediately and explicitly — silent failures are not acceptable
- Everything testable is tested; every test has a defined expected result
- Source code is version-controlled via git; all self-modifications follow the self-modification protocol
- AI builds things that work without AI — apps must be fully functional standalone; AI is an accelerator, not a dependency
- Two-tier architecture — Tier 1 (Console PWA) uses Koda Core via tunnel; Tier 2 (standalone PWAs) runs entirely on Cloudflare's edge
- Data sovereignty maintained — Koda's internal data stays on the local Mac; only app-specific domain data may live on Cloudflare

### 1.3 Scope — In

- Bidirectional text messaging between Signal and Claude Code
- 1500ms inactivity timer batching in direct chats; @mention-triggered responses in group chats
- Role-based access control — Owner, Admin, User tiers with per-role capability enforcement
- Persistent session context per group, resumable across restarts
- Auto-approve mode (Owner/Admin only)
- Comprehensive error handling with user-facing alerts at every failure point
- Multi-group / multi-project support with isolated data contexts
- Global cross-project memory layer — written and curated by Claude Code
- AI Instructions files — global and per-session, with auto-condensation
- Skills system — SOP workflow definitions stored as markdown, indexed and version-controlled
- Self-modification protocol with git safety checkpoints
- Persistent database layer (local PostgreSQL + PostgREST)
- Inbound file handling — images, PDFs, CSVs received via Signal and stored locally
- Outbound file handling — screenshots and generated files sent back via Signal
- Secure credential management via bridge-intercepted slash commands
- .env management via Signal (/config command)
- Route selection via Signal (/route command)
- Web dashboard and site generation, deployed to Cloudflare Pages
- Browser automation via Playwright MCP — navigation, interaction, screenshot capture
- Autonomous build-deploy-verify loop for web content
- Email integration — inbound read/search via Gmail MCP, outbound via SMTP (deferred — see Section 11)
- Google Calendar management — full CRUD via Google Calendar MCP (deferred — see Section 12)
- Concurrency control — configurable MAX_CONCURRENT_CLAUDE limit
- /help command with role-appropriate capability display
- /status command with full log access (Owner only)
- Claude Code plan usage monitoring — session and weekly limits scraped from claude.ai, proactive alerts via Signal, on-demand query command
- Automated test suite (62 tests)
- User acceptance test plan with expected results
- Cloudflare Tunnel exposing Koda Core API (`api.koda.systems`)
- Cloudflare Access zero-trust authentication gateway
- REST API on Koda Core (`/api/*` endpoints) for programmatic access
- Koda Console PWA at `console.koda.systems` — installable management interface
- Standalone PWA framework (Cloudflare Pages + Workers + D1)
- App manifest and registration system (`koda_apps` table, `manifest.koda.json`)
- Natural language → structured intent pipeline with confirmation UX
- App scaffolding (`scaffold-app.sh`) and deployment (`deploy-app.sh`) scripts

### 1.4 Scope — Out

- Voice messages
- Receipt scanning or optical character recognition
- Cloud file storage — all files stored on local Mac disk
- Approval prompt relay — dead code; `--dangerously-skip-permissions` bypasses all Claude prompts
- Signal message edit event handling — not implemented, not a priority
- Per-app organic authentication (using Cloudflare Access for now; clean migration path documented)
- Server-side rendered frontend (all PWAs are static SPA + client-side JS via CDN)

---

## 2. System Architecture

### 2.1 Components

| Component | Technology | Port | Managed By |
|-----------|-----------|------|------------|
| Signal interface | signal-cli REST API (Docker, json-rpc mode) | 8080 | Docker (restart: unless-stopped) |
| Bridge / orchestrator | Node.js (server.js) | 3033 | launchd (KeepAlive) |
| AI agent | Claude Code CLI | — | Spawned by bridge |
| Database | PostgreSQL 16 | 5432 | Homebrew services |
| DB API | PostgREST | 3000 | launchd (KeepAlive) |
| Browser automation | Playwright MCP (@playwright/mcp) | 8931 | launchd (KeepAlive) |
| Web deployment | Cloudflare Pages via Wrangler CLI | — | fswatch + deploy-watch.sh |
| Cloudflare Tunnel | cloudflared | — | launchd (KeepAlive) |
| Cloudflare Access | Cloudflare Zero Trust | — | Cloudflare dashboard |
| Koda Console PWA | Alpine.js + Tailwind | console.koda.systems | Cloudflare Pages |
| Standalone PWA framework | Workers + D1 | *.koda.systems | Cloudflare (per app) |

### 2.2 High-Level Message Flow

```
User (Signal app)
 | @mention in group, or any message in DM
 v
signal-cli REST API (Docker, port 8080, json-rpc mode)
 | Bridge maintains persistent WebSocket to ws://localhost:8080/v1/receive/{number}
 v
Bridge (server.js, port 3033)
 | resolve sender UUID → role (PostgREST lookup)
 | log message to DB
 | if attachment: save to ~/koda/attachments/<gid>/, log metadata
 | check trigger: @mention (group) or 1500ms inactivity (DM)
 | if slash command: handle internally, do not forward to Claude Code
 | if not triggered: stop here (message logged)
 | enforce role/capability permissions
 | check MAX_CONCURRENT_CLAUDE — queue if limit reached
 | load memories + instructions for system prompt
 | send "Working..." to Signal
 v
Claude Code CLI (--resume <session-id> --dangerously-skip-permissions)
 | system prompt: group_id, role, capabilities, PostgREST URL + JWT,
 |   Cloudflare config, Playwright MCP config, credential key path,
 |   global memories, condensed instructions, skills index path
 | may read files from ~/koda/attachments/, ~/koda/instructions/, ~/koda/skills/
 | may query/write DB via PostgREST (koda_agent role, RLS enforced)
 | may write memories to memories table
 | may control browser via Playwright MCP
 | may read/search email via Gmail MCP (when enabled)
 | may manage calendar events via Google Calendar MCP (when enabled)
 | may deploy to Cloudflare via Wrangler
 v
Bridge
 | check for errors in Claude Code output
 | strip markdown, chunk at 1500 chars [1/N]
 | if file path in output: send as Signal attachment
 v
signal-cli REST API → User (Signal app)
```

### 2.3 Concurrency Control

```
MAX_CONCURRENT_CLAUDE=1   # default; one Claude session at a time
```

Requests beyond the limit are queued (FIFO, not dropped). User notified: `Koda is busy — your request is queued.`

### 2.4 MCP Tool Search

Claude Code 2.1.7+ auto-activates MCP Tool Search when tools exceed 10% of context. Tool definitions load on-demand — adding MCP integrations does not proportionally reduce available context. Minimum Claude Code version: 2.1.7. No configuration required.

---

## 3. Signal Integration

### 3.1 Signal Account Setup

**⚠ Important:** Programmatic registration via signal-cli is unreliable. Signal's session-based auth and captcha requirements have broken the `POST /v1/register` flow. The primary path is phone registration + device linking.

**Primary setup path:**

1. Register the bot number on a physical phone using the Signal app
2. Install signal-cli temporarily on the Mac: `brew install signal-cli`
3. Run: `signal-cli link --name "KodaBot"` — this outputs a `sgnl://linkdevice?...` URI
4. Install qrencode: `brew install qrencode`
5. Generate QR code **immediately** (codes expire in ~30 seconds):
   ```bash
   qrencode -t PNG -s 10 -o /tmp/koda-link.png "$URI"
   open /tmp/koda-link.png
   ```
   > Use `qrencode` CLI directly — do not use online QR APIs (they double-encode the URI and produce invalid codes)
6. In Signal app: Settings → Linked Devices → Link New Device → scan QR
7. Copy account data from signal-cli config to Docker volume
8. **Uninstall native signal-cli**: `brew uninstall signal-cli`
   > ⚠ Both Docker and native signal-cli will try to hold Signal's WebSocket for the linked device. They will fight and both fail. Uninstall native signal-cli before starting the Docker container.

> **Note:** If you prefer Docker-only device linking (no native signal-cli), verify whether your version of signal-cli-rest-api supports it — the Docker container may support `link` via its REST API in newer versions. Check the signal-cli-rest-api docs before installing natively.

### 3.2 Docker Configuration

signal-cli REST API **must** run in Docker with `MODE=json-rpc`. Message reception is via WebSocket only — do not set `WEBHOOK_URL`.

```yaml
# docker-compose.yml
services:
  signal-cli-rest-api:
    image: bbernhard/signal-cli-rest-api:latest
    environment:
      - MODE=json-rpc
      # WEBHOOK_URL removed — using WebSocket receiver only (prevents double-processing)
    ports:
      - "8080:8080"
    volumes:
      - ./signal-cli-config:/home/.local/share/signal-cli
    restart: unless-stopped
```

> **Note:** The bridge maintains an active WebSocket client (see Section 3.3) as the sole message receiver. Do not set `WEBHOOK_URL` — it can cause duplicate message processing.

### 3.3 Bridge WebSocket Client

The bridge connects a persistent WebSocket to receive inbound messages:

```javascript
// server.js — startup
const ws = new WebSocket(`ws://localhost:8080/v1/receive/${SIGNAL_PHONE}`, {
  headers: { Authorization: `Basic ${SIGNAL_AUTH}` }
});
ws.on('message', data => processMessage(JSON.parse(data)));
ws.on('close', () => reconnectWithBackoff());
```

### 3.4 Message Trigger Behaviour

| Context | Trigger | Behaviour |
|---------|---------|-----------|
| Group chat | @KodaBot mention | Message containing @KodaBot processed immediately |
| Direct message (DM) | 1500ms inactivity timer | Messages accumulate; 1.5 seconds after the last message, the full batch is sent to Claude Code |
| Any context | Slash command (starts with /) | Handled by bridge directly, never forwarded to Claude Code |

> **DM batching design note:** The 1500ms timer approach is simpler than requiring an explicit @mention trigger, and functionally equivalent for single-owner use. The tradeoff: if you type slowly or have a laggy connection, the timer may fire before you finish. This is the canonical implementation — not a deviation from spec.

### 3.5 Message Handling Details

- All messages logged to messages table regardless of trigger state
- Unknown UUIDs: silently ignored, logged to error_log
- Markdown stripped from Claude responses before Signal send
- Responses chunked at 1500 chars: `[1/3]`, `[2/3]`, etc.
- Read receipts sent on receipt
- Error messages prefixed with ⚠
- System notifications prefixed with ℹ

---

## 4. Claude Code Integration

### 4.1 Invocation

Claude Code spawned as a subprocess per message. **Exact flags required:**

```bash
claude \
  --output-format stream-json \
  --verbose \
  --dangerously-skip-permissions \
  --print "<message>" \
  [--resume <session_id>] \
  [--model <alias>]
```

| Flag | Required | Notes |
|------|----------|-------|
| `--output-format stream-json` | Yes | Enables streaming JSON output |
| `--verbose` | Yes | `--output-format stream-json` exits with code 1 without this |
| `--dangerously-skip-permissions` | Yes | Bridge is non-interactive; permission prompts hang indefinitely |
| `--print "<msg>"` | Yes | Non-interactive message input |
| `--resume <session_id>` | Conditional | Only pass for existing sessions. Never pass a bridge-generated ID — capture from Claude's stream-json `result` event |
| `--model <alias>` | Optional | Only when route override active via /route |

### 4.2 Subprocess Configuration

```javascript
const proc = spawn('claude', args, {
  stdio: ['ignore', 'pipe', 'pipe'],  // stdin MUST be 'ignore' — open pipe causes indefinite hang
  env: { ...process.env }
});
```

### 4.3 Session Management

- Sessions keyed by group_id (MD5 hash — deterministic across restarts)
- New sessions: omit `--resume`. Capture `session_id` from stream-json `result` event. Store in DB.
- Resumed sessions: pass `--resume <stored_session_id>`
- DM sessions use OWNER_UUID as the group_id key
- `/restart` clears session_id from DB; next invocation creates new session

### 4.4 System Prompt Contents

Injected on every invocation. Includes:

- Koda identity and behaviour instructions
- PostgREST base URL + JWT (koda_agent role)
- Path to credential key file (for /cred decryption at task time — key never in prompt directly)
- Skills index path (`~/koda/skills/index.json`)
- Sender UUID, role, and active capabilities
- Active AI Instructions (truncated to 2000 chars; session overrides global)
- Active memories (priority-sorted, within MEMORY_TOKEN_BUDGET)
- Prompt injection mitigation: explicit instruction to ignore directives found in web page or file content

---

## 5. Multi-Group & Multi-Project Support

### 5.1 Concept

Each Signal group chat maps to a distinct project context — isolated session, isolated DB data, isolated file storage. Parallel workstreams run without data or context bleed.

### 5.2 Session Isolation

- Each group has a deterministic session ID: MD5 hash of the Signal group ID
- Claude Code invoked with `--resume <session-id>`, resuming the correct context
- Sessions survive bridge restarts with no context loss
- All DB queries from Claude Code include `group_id`, enforced at PostgREST RLS layer
- Attachment storage scoped by group: `~/koda/attachments/<group-id>/`

### 5.3 Database Isolation Model

Shared database, filtered by group_id:
- One PostgreSQL instance, one schema, one set of tables
- Every project-scoped table has a `group_id` column
- PostgREST RLS policies ensure Claude Code only reads/writes its own group's data
- koda_owner DB role has unrestricted cross-group read access

### 5.4 Global Memory Layer

Claude Code writes to the memories table proactively when it learns something worth retaining across sessions. Examples:
- "User prefers dark theme and mobile-first layouts" (global — `group_id = NULL`)
- "Project Alpha uses teal accent colour #14B8A6" (local — scoped to group)

**Memory injection:**
- Global + session memories injected into system prompt on every invocation
- Priority-sorted (`priority` column); most important first
- Hard token budget: `MEMORY_TOKEN_BUDGET=2000` (~4 chars/token)
- Over budget: oldest condensed first; lowest-priority dropped if still over

**Memory schema:**
```sql
memories
  id              SERIAL PRIMARY KEY
  group_id        TEXT        -- NULL = global, set = local to that group
  content         TEXT
  source_group_id TEXT        -- which session created it
  source_session_id TEXT
  priority        INTEGER DEFAULT 0
  created_at      TIMESTAMPTZ
  updated_at      TIMESTAMPTZ
  created_by      TEXT        -- 'claude_code' or Signal UUID
```

### 5.5 Recommended Usage Pattern

| Chat | Purpose | Behaviour |
|------|---------|-----------|
| Project Alpha | Client dashboard build | Isolated session + DB. @mention to invoke. |
| Project Beta | Marketing site | Mockup → live site. Deployments scoped here. |
| Dev Tools | Internal automation | Scripts, tasks, reports — group-scoped. |
| Direct chat | Quick / general tasks | 1500ms timer triggers processing. Not project-scoped. |

---

## 6. Role-Based Access Control (RBAC)

### 6.1 User Identity

- All users identified by Signal UUID (stable across number changes)
- Phone number stored as optional metadata only
- OWNER_UUID set in .env

> **Important:** OWNER_UUID can only be determined after receiving the first inbound message. The bridge logs the `sourceUuid` of all inbound messages. After first message, extract the UUID, set `OWNER_UUID` in .env, add to users table, restart bridge.

### 6.2 Roles

| Role | Description | Default Access |
|------|-------------|----------------|
| Owner | System operator. Only one. | Full access to all commands and capabilities |
| Admin | Elevated user | Explicitly granted capabilities only |
| User | Standard user | Explicitly granted capabilities only |

Default-deny: Admin and User have no capabilities until explicitly granted by Owner.

### 6.3 Grantable Capabilities

| Capability | Description |
|-----------|-------------|
| `send_instructions` | Send natural language instructions to Claude Code |
| `read_db` | Query project data via Claude Code |
| `write_db` | Write or modify project data via Claude Code |
| `upload_files` | Send file attachments |
| `web_deploy` | Trigger web deployments |
| `browser_automation` | Trigger Playwright browser sessions |
| `email_read` | Read and search email via Gmail MCP |
| `email_send` | Send email via SMTP |
| `calendar_read` | Read calendar events |
| `calendar_write` | Create, update, delete calendar events |
| `autoapprove` | Toggle auto-approve mode |
| `approve_deny` | Respond to approval prompts |
| `restart_session` | Restart the Claude Code session |
| `set_instructions_session` | Set session-scoped AI Instructions |
| `usage_query` | Query current plan usage on demand |
| `run_scripts` | Script execution |
| `modify_skills` | Create / update skills |
| `manage_credentials` | Access /cred commands |
| `git_push` | Git push operations |

**Owner-only, non-grantable:**
`set_instructions_global`, `manage_users`, `manage_memory`, `status_full`, `cross_group_query`, `self_modify`

### 6.4 User Management Commands (Owner only)

| Command | Action |
|---------|--------|
| `/user add <phone> <role>` | Register new user — phone number provided, bridge generates UUID via Signal lookup |
| `/user remove <uuid>` | Deactivate user (sets active=false) |
| `/user role <uuid> <role>` | Change role label |
| `/user grant <uuid> <capability>` | Grant capability (validated against GRANTABLE_CAPABILITIES list) |
| `/user revoke <uuid> <capability>` | Revoke capability |
| `/user grants <uuid>` | List all grants for user |
| `/user list` | List all users with roles and grants |

> **Deviation from v0.9:** `/user add` takes a phone number (bridge generates UUID) rather than UUID directly. Phone is what the owner knows; UUID is derived automatically. Aliases `/adduser`, `/removeuser`, `/setrole`, `/grant`, `/revoke`, `/listusers` also supported.

### 6.5 DB Schema

```sql
users
  uuid         TEXT PRIMARY KEY
  display_name TEXT
  phone        TEXT
  role         TEXT        -- 'owner', 'admin', 'user'
  created_at   TIMESTAMPTZ
  active       BOOLEAN DEFAULT true

permissions
  id           SERIAL PRIMARY KEY
  uuid         TEXT
  capability   TEXT
  granted_by   TEXT
  granted_at   TIMESTAMPTZ

audit_log
  id           SERIAL PRIMARY KEY
  timestamp    TIMESTAMPTZ
  actor_uuid   TEXT
  action       TEXT
  target_uuid  TEXT
  group_id     TEXT
  outcome      TEXT        -- 'success', 'denied', 'error'
  detail       TEXT
```

---

## 7. AI Instructions Files

### 7.1 Purpose

Markdown files that shape Claude Code's behaviour — tone, constraints, workflow rules, project context. Equivalent to a persistent system prompt the owner controls.

| Scope | Effect | Who can set |
|-------|--------|-------------|
| Global | Applied to every Claude Code session | Owner only |
| Session | Applied to this group only; overrides global | `set_instructions_session` capability |

### 7.2 Special Marker

`<!-- ALWAYS INCLUDE -->` placed before a section heading. Content under this marker is always preserved in the condensed version regardless of length budget.

### 7.3 Processing

1. SHA-256 hash of new content compared to stored hash — if unchanged, skip condensation
2. If changed: truncate to 2000 chars, preserving `<!-- ALWAYS INCLUDE -->` sections verbatim
3. Full content, condensed version, and hash stored in instructions table
4. Condensed version injected into every system prompt; full file path included for first-invocation full read

### 7.4 Commands

| Command | Capability | Action |
|---------|-----------|--------|
| `/instructions set global\|session` | Owner / `set_instructions_session` | Set instructions (inline text or .md/.txt attachment) |
| `/instructions clear global\|session` | Owner / `set_instructions_session` | Remove instructions |

`fs.watch` on `~/koda/instructions/` — file drops trigger auto-recondensation.

### 7.5 DB Schema

```sql
instructions
  id                SERIAL PRIMARY KEY
  scope             TEXT        -- 'global' or group_id for session-scoped
  full_content      TEXT
  condensed_content TEXT
  content_hash      TEXT        -- SHA-256
  file_path         TEXT
  version           INTEGER DEFAULT 1
  created_at        TIMESTAMPTZ
  updated_at        TIMESTAMPTZ
  set_by            TEXT
```

---

## 8. Skills System

### 8.1 Purpose

Skills are .md files defining repeatable SOPs. Claude Code reads the relevant skill before executing a matching task, ensuring consistent execution rather than improvising.

Skills ≠ Memories: Memories are facts and preferences; Skills are repeatable workflows.

### 8.2 Skill File Format

```markdown
# Skill: [Name]
Version: 1.0
Trigger: [Intent phrases that activate this skill]
Tools required: [e.g. Playwright MCP, Wrangler, PostgREST]

## Workflow
1. Step one
2. Step two

## Output format
[Expected output description]

## Quality checks
[What Claude Code must verify before reporting completion]
```

### 8.3 Storage and Indexing

```
~/koda/skills/
  index.json         Skills registry (JSON — faster to parse than markdown)
  <skill-name>.md    Individual skill SOPs
```

**index.json format:**
```json
{
  "skills": [
    {
      "name": "research-and-publish",
      "file": "research-and-publish.md",
      "version": "1.0",
      "triggers": ["research and publish", "find and publish"],
      "created_at": "2026-03-09",
      "created_by": "claude_code"
    }
  ]
}
```

### 8.4 Management Commands

| Command | Access | Action |
|---------|--------|--------|
| `/skills list` (or `/skill`) | All | List all available skills |
| `/skills show <name>` | All | Display full skill content |
| `/skills delete <name>` | Owner only | Delete skill and remove from index |

Skill creation is via natural language instruction to Claude Code (requires `modify_skills` capability). Claude Code writes the .md file, updates index.json, commits to git.

### 8.5 DB Schema

```sql
skills
  id          SERIAL PRIMARY KEY
  name        TEXT UNIQUE
  file_path   TEXT
  version     TEXT
  triggers    TEXT[]
  created_at  TIMESTAMPTZ
  updated_at  TIMESTAMPTZ
  created_by  TEXT
```

---

## 9. Self-Modification Protocol

### 9.1 Purpose

Koda can modify its own source code via `/modify`. A strict git-based safety protocol prevents unrecoverable failures.

### 9.2 Protocol Steps

1. **Pre-edit checkpoint:** `git add -A && git commit -m "pre-edit checkpoint: [description]"`
2. **Apply edit** — one file per operation
3. **Restart bridge:** `launchctl unload` + `sleep 2` + `launchctl load`
4. **Verify:** `lsof -ti:3033` must return a PID within 10 seconds
5. **Post-edit commit:** `git add -A && git commit -m "[descriptive message]"`
6. **On failure:** `git checkout <file>`, restart, re-verify, notify Owner via Signal

**Implementation note:** The bridge is `self-modify.sh`'s parent process. When the bridge restarts, the script is killed. The git commit happens before restart, so changes are preserved. If the restart itself fails and rollback is needed, manual intervention is required (`git checkout` + `launchctl load`).

Script: `scripts/self-modify.sh`

### 9.3 Rules

- One file per change, one commit per change
- Never edit bridge server and system prompt in the same operation
- Skill files and scripts can be modified without bridge restart

### 9.4 Command

```
/modify <instruction>   (Owner only, 10-minute timeout, auto-approved)
```

---

## 10. Database Layer

### 10.1 PostgreSQL

- Installed via Homebrew: `brew services start postgresql@16`
- **postgresql@16 is keg-only** — its binaries are not symlinked into `/opt/homebrew/bin`. The bin path must be explicitly added to PATH in all execution contexts (launchd plists, shell profiles, scripts).
- Listens on localhost only; Unix socket trust auth configured in pg_hba.conf

### 10.2 PostgREST

- Runs on port 3000 via launchd (KeepAlive)
- All Claude Code DB access goes through PostgREST — never direct psql
- **Schema cache:** PostgREST caches schema at startup. After any table creation or schema change, reload: `launchctl stop com.koda.postgrest && launchctl start com.koda.postgrest`
- Read-only query results cached in-memory for 60s; invalidated on writes

### 10.3 DB Roles

| Role | Access | Used By |
|------|--------|---------|
| koda_core | Full access, bypasses RLS | Koda Core (server.js) |
| koda_agent | RLS-filtered read/write on project tables | Claude Code (via PostgREST JWT) |
| koda_owner | SELECT on all tables | Manual inspection |

### 10.4 Full Table Reference

| Table | Scope | Purpose |
|-------|-------|---------|
| users | Global | Signal UUIDs, roles, display names |
| groups | Global | Signal group registry — group_id, name, session_id |
| messages | Per-group | Full message log |
| sessions | Per-group | Claude Code session state |
| tasks | Per-group | Project tasks |
| memories | Global + per-group | Cross-project and local memories |
| attachments | Per-group | File metadata |
| instructions | Global + per-group | AI Instructions (full + condensed, versioned) |
| skills | Global | Skills index metadata |
| deployments | Per-group | Web deployment history |
| credentials | Per-group | Encrypted credentials |
| browser_sessions | Per-group | Playwright session log |
| email_actions | Per-group | Email action items (deferred) |
| audit_log | Global | RBAC and system events |
| error_log | Global | All system errors |
| usage_alert_config | Global | Usage monitoring config |
| system_config | Global | Host metadata (hardware, version info) |
| available_models | Global | Claude model list + cache |

### 10.5 Configuration

```
POSTGREST_URL=http://localhost:3000
POSTGREST_AGENT_JWT=<jwt-for-koda_agent-role>
CORE_JWT=<jwt-for-koda_core-role>
```

---

## 11. Security Architecture

### 11.1 Credential Encryption Key

- Stored in `.secrets/credential_key` (chmod 600, gitignored)
- Also in `.env` as `CREDENTIAL_ENCRYPTION_KEY` for bridge-side encryption — never passed to Claude Code directly
- Claude Code reads the key file at task time, uses for decryption, does not retain in context

### 11.2 PostgreSQL Authentication

Unix socket trust auth — no database password in DSN or `~/.claude.json`:

```
# pg_hba.conf
local   all   koda_agent   trust
local   all   koda_core    trust
local   all   koda_owner   trust
```

```
# PostgREST config
db-uri = "postgresql:///kodadb?user=koda_agent"
```

### 11.3 File Permissions

```bash
chmod 600 .env
chmod 600 ~/.claude.json
chmod 700 .secrets/
chmod 600 .secrets/*
```

Bridge checks these on startup and alerts Owner if any are incorrect.

### 11.4 `--dangerously-skip-permissions` Flag

Always on for bridge-spawned Claude Code sessions. The bridge is non-interactive — Claude prompts would hang indefinitely. This is a canonical design decision, not a workaround.

### 11.5 Per-User Rate Limiting

```
RATE_LIMIT_PER_USER=20        # max messages per minute per UUID
RATE_LIMIT_GLOBAL=100         # max messages per minute, all users combined
```

Owner UUID exempt from per-user limit. On breach: message dropped, user notified, logged to audit_log.

---

## 12. Email Integration

### 12.1 Status

Deferred. SMTP vars present in .env (empty values). Gmail MCP not configured. Implement when ready.

### 12.2 Inbound Email (Gmail MCP) — When Implemented

Gmail MCP (claude.ai marketplace OAuth connector) provides read/search/thread access.

**Action Item Extraction:** When Claude Code reads email, it extracts action items to email_actions table:
```sql
email_actions
  id          SERIAL PRIMARY KEY
  group_id    TEXT
  description TEXT
  source      TEXT
  category    TEXT
  priority    TEXT        -- 'high', 'medium', 'low'
  due_date    DATE
  status      TEXT DEFAULT 'open'
  created_at  TIMESTAMPTZ
```

### 12.3 Outbound Email (SMTP) — When Implemented

Via Gmail App Password + SMTP. Confirmation required before send unless `/emailsend autoapprove on`.

```
EMAIL_SMTP_HOST=smtp.gmail.com
EMAIL_SMTP_PORT=587
EMAIL_SMTP_USER=<gmail-address>
EMAIL_SMTP_APP_PASSWORD=<app-password>
EMAIL_SENDER_NAME=<display name>
EMAIL_SENDER_ADDRESS=<from address>
EMAIL_SEND_AUTOAPPROVE=false
```

---

## 13. Calendar Integration

### 13.1 Status

Deferred. Google Calendar MCP not configured. Implement when ready.

### 13.2 Capabilities — When Implemented

Full event lifecycle via Google Calendar MCP (OAuth, claude.ai marketplace):
- Read events, create, update, delete
- Availability checks
- Natural language date inference ("next Thursday at 3pm")
- Create/update/delete confirm with user unless auto-approve ON

---

## 14. Credential Management

### 14.1 Design Principle

Credentials must never appear in a Signal message, Claude Code's session context, or any log file. The bridge intercepts /cred commands before Claude Code is invoked.

### 14.2 Commands

| Command | Action |
|---------|--------|
| `/cred set <domain> <username> <password>` | Encrypt (AES-256-GCM) and store in credentials table |
| `/cred get <domain>` | Decrypt and return — message not logged |
| `/cred delete <domain>` | Remove from credentials table |

> **Deviation from v0.9:** Commands are `/cred set|get|delete` rather than `/addcredential`, `/removecredential`. Cleaner naming; no aliases.

Bridge behaviour on `/cred set`:
1. Intercepts — not forwarded to Claude Code
2. Replaces log entry with `[CREDENTIAL COMMAND — REDACTED]`
3. AES-256-GCM encryption using CREDENTIAL_ENCRYPTION_KEY
4. Stores in credentials table
5. Confirms: `ℹ Credential saved for <domain>. Message redacted from log.`

**Heuristic detection:** Messages resembling credentials (base64 blob, long alphanumeric near domain/username) are auto-redacted with a warning.

### 14.3 DB Schema

```sql
credentials
  id                 SERIAL PRIMARY KEY
  group_id           TEXT
  domain             TEXT
  username           TEXT
  password_encrypted TEXT
  created_at         TIMESTAMPTZ
  created_by         TEXT
```

---

## 15. .env Configuration Management (/config)

`/config` manages .env values via Signal, avoiding the need for terminal access during operations.

| Command | Action |
|---------|--------|
| `/config set <KEY> <value>` | Update .env value and hot-reload into process.env |
| `/config get <KEY>` | Show current value (sensitive values masked) |
| `/config list` | Show all configurable keys and current values |

- Allowlisted keys only (CLOUDFLARE_*, SMTP_*, rate limits, usage thresholds, etc.)
- Sensitive values (CLOUDFLARE_API_TOKEN, SMTP_PASS) encrypted at rest with `ENC:` prefix
- Owner only; all changes audit-logged

> **Addition to v0.9:** Not in the original spec. Added during build to enable remote configuration without SSH/terminal access. Essential for the Cloudflare deployment workflow.

---

## 16. File Handling

### 16.1 Design Principle

Files never stored as binary blobs in DB. All files on disk under `~/koda/`. Only metadata in DB.

### 16.2 Directory Structure

```
~/koda/
  attachments/<group-id>/<timestamp>-<filename>   Inbound user files
  instructions/global.md                           Global AI instructions (full)
  instructions/<group-id>.md                       Session AI instructions (full)
  skills/index.json                                Skills registry
  skills/<skill-name>.md                           Individual skill SOPs
  logs/core-YYYY-MM-DD.log                          Daily core logs
  screenshots/<group-id>/<timestamp>-screenshot.png Playwright screenshots
```

### 16.3 Inbound Files

- Saved to `~/koda/attachments/<group-id>/<timestamp>-<filename>`
- Metadata written to attachments table
- File path + type included in Claude Code message context
- `upload_files` capability required; Owner always permitted
- Max size: 20MB
- Supported types: PNG, JPG, WEBP, PDF, CSV, JSON, TXT, MD

### 16.4 Outbound Files

- File paths detected in Claude Code output → sent as Signal attachments
- Primary type: PNG screenshots from Playwright
- Any file Claude Code generates during a task can be sent back

### 16.5 Mockup-to-Site Workflow

1. User sends mockup image: "Build a site that looks like this"
2. Bridge saves image, passes path to Claude Code
3. Claude Code analyses via native vision capability
4. Generates HTML/CSS/JS to match mockup
5. Deploys to Cloudflare via Wrangler
6. Playwright verifies live URL, screenshots
7. Screenshot + URL sent back via Signal

---

## 17. Browser Automation (Playwright MCP)

### 17.1 Setup

- `@playwright/mcp` installed globally via npm
- Chromium installed via Playwright CLI — **use full node path, not npx** (npx fails in non-shell environments):
  ```bash
  ~/.nvm/versions/node/v24.14.0/bin/node \
    ~/.nvm/versions/node/v24.14.0/lib/node_modules/@playwright/mcp/node_modules/.bin/playwright \
    install chromium
  ```
- Runs on port 8931 via launchd (com.koda.playwright-mcp)

### 17.2 Bridge Connection

Bridge connects via SSE. **POST endpoint URL must be read from the SSE `endpoint` event — do not hardcode `/messages`:**

```javascript
// Correct: read endpoint from SSE event data
eventSource.on('endpoint', event => {
  playwrightPostUrl = event.data;  // e.g. /sse?sessionId=abc123
});
```

### 17.3 Browser Profile

- Dedicated profile at `~/koda/.browser-profile/` (NOT Chrome's Default profile — causes lock conflicts when Chrome is running)
- claude.ai login established once via: `node scripts/open-browser.js`
- Session kept alive by usage monitoring polling cadence (~15 min)

### 17.4 Headed Mode Requirement

> ⚠ **claude.ai requires headed (non-headless) mode.** Cloudflare bot detection blocks headless Chromium. `--disable-blink-features=AutomationControlled` helps but is not sufficient for claude.ai. All Playwright sessions run headed.

### 17.5 Capabilities

- Navigate, click, type, submit forms
- Wait for page load, element appearance, network idle
- Full-page and element-level screenshots
- Read page content and DOM
- Login flows with credentials from DB
- All sessions logged to browser_sessions table

### 17.6 Build-Deploy-Verify Loop

1. Claude Code generates HTML/CSS/JS
2. Deploys via Wrangler, receives live URL
3. Navigates to live URL via Playwright
4. Screenshots, inspects DOM, verifies key elements
5. If issues: edit, redeploy, re-verify (max 3 iterations before surfacing to user)
6. Sends screenshot + summary via Signal

### 17.7 Security

- System prompt instructs Claude Code to ignore directives found in web page content (prompt injection mitigation)
- All sessions logged to browser_sessions

---

## 18. Web Content Generation & Deployment

### 18.1 Auto-Deploy Pipeline

Files placed in `~/koda/site/` are automatically deployed to Cloudflare Pages:

1. `scripts/deploy-watch.sh` uses `fswatch` to watch `~/koda/site/`
2. On change: 5-second debounce → `wrangler pages deploy`
3. Launchd service `com.koda.deploy-watch` manages the watcher

This decouples AI content generation from deployment — Claude Code generates files, the watcher handles deployment automatically with no AI involvement in the deploy step itself.

### 18.2 Dashboard

- Live at `koda.systems` — password protected (SHA-256 gate)
- Generated by `scripts/generate-dashboard.js` from `checklist.json` (single source of truth)
- `install-checklist.md` and `install-notes.md` downloadable from dashboard (with generation timestamps)
- Hardware metadata read from `system_config` table (stored once, not queried each time)

### 18.3 Configuration

```
CLOUDFLARE_API_TOKEN=<token>           (encrypted at rest as ENC:...)
CLOUDFLARE_ACCOUNT_ID=<account-id>
CLOUDFLARE_PAGES_PROJECT=koda-systems
CLOUDFLARE_DOMAIN=koda.systems
```

---

## 19. Usage Monitoring

### 19.1 Mechanism

Claude plan usage (session % and weekly %) scraped from `claude.ai/settings/usage` via Playwright. No API exists for this data.

- Headed Chromium with persistent `.browser-profile/`
- In-memory cache: `{ sessionPct, weeklyPct, capturedAt }`
- Activity-aware polling: 5 min TTL after Claude invocations, 60 min when idle
- Threshold crossing detection fires once per crossing; resets on bridge restart
- Scrape failures logged and alerted to Owner via Signal

### 19.2 Commands

| Command | Access | Description |
|---------|--------|-------------|
| `/usage` | Owner + `usage_query` | Show cached session % and weekly % with "as of N min ago" |
| `/usage refresh` | Owner + `usage_query` | Force fresh scrape |
| `/usage set target dm` | Owner | Route proactive alerts to Owner DM |
| `/usage set target group <group_id>` | Owner | Route alerts to a group chat |
| `/usage set session-threshold <0-100>` | Owner | Alert threshold for session (default 80) |
| `/usage set weekly-threshold <0-100>` | Owner | Alert threshold for weekly (default 75) |
| `/usage alerts on\|off` | Owner | Enable/disable proactive alerts |
| `/usage status` | Owner | Show alert config: target, thresholds, enabled, cache age |

### 19.3 DB Schema

```sql
usage_alert_config
  id                  SERIAL PRIMARY KEY
  alert_target        TEXT        -- Signal UUID (DM) or group_id
  target_type         TEXT        -- 'dm' or 'group'
  session_threshold   INTEGER DEFAULT 80
  weekly_threshold    INTEGER DEFAULT 75
  enabled             BOOLEAN DEFAULT true
  set_by              TEXT
  updated_at          TIMESTAMPTZ
```

---

## 20. Route & Model Selection

### 20.1 Overview

Two commands manage message processing: `/route` switches between pathways (Claude Code CLI vs VS Code), and `/model` selects the AI model within the active route.

| Command | Action |
|---------|--------|
| `/route` | Show current active route |
| `/route claude` | Switch to Claude Code CLI |
| `/route vscode` | Switch to VS Code (Copilot) |
| `/route clear` | Reset to Claude route |
| `/model` | Show current model for active route |
| `/model <name>` | Set model within current route |
| `/model set-default <alias>` | Set default Claude model |
| `/model clear` | Revert model to default |
| `/model refresh` | Force model list re-fetch from Claude CLI |

- `ACTIVE_ROUTE` in .env sets initial route on startup (`claude` or `vscode`)
- `CLAUDE_MODEL` in .env sets initial Claude model override
- Route persisted to `system_config` table (key: `active_route`) — survives restarts
- Model persisted to `system_config` table (key: `claude_model`) — survives restarts
- Available models fetched from Claude CLI, stored in available_models table
- Models refresh weekly automatically
- `--model <alias>` passed to every `invokeClaudeCode` spawn when override is active

### 20.2 DB Schema

```sql
available_models
  id          SERIAL PRIMARY KEY
  alias       TEXT UNIQUE
  full_name   TEXT
  fetched_at  TIMESTAMPTZ
```

---

## 21. Error Handling

### 21.1 Principles

Every failure must be:
1. Caught explicitly — no unhandled exceptions
2. Logged to error_log
3. Surfaced to user via Signal with ⚠ prefix
4. Followed by automatic recovery where possible

### 21.2 Error Handling Table

| Error Source | User Notification | Recovery |
|-------------|-------------------|---------|
| uncaughtException | ⚠ Bridge error: [msg] | Log, notify Owner |
| unhandledRejection | ⚠ Bridge error: [msg] | Log, notify Owner |
| Claude Code non-zero exit | ⚠ Claude Code exited unexpectedly (code N) | Log, notify user + Owner |
| Claude Code timeout | ⚠ Claude Code timed out after N min | Kill, notify |
| PostgREST unreachable | ⚠ Database unavailable | Retry 3× exponential backoff |
| PostgREST 4xx | None (code bug) | Log only |
| Playwright MCP unreachable | ⚠ Browser automation unavailable | Log, alert |
| Wrangler deploy failure | ⚠ Deployment failed: [error] | Log, surface to user |
| signal-cli unreachable | None | Retry 3× exponential backoff |
| Role permission denial | ⚠ You don't have permission to do that | Log to audit_log |
| Unknown sender UUID | Silent | Log to error_log |
| Credential in plain message | ⚠ Message looked like credentials. Redacted. | Auto-redact from log |
| Max concurrency reached | ℹ Koda is busy — your request is queued | Queue in FIFO order |
| Self-modification failure | ⚠ Self-modification failed. Rolled back. | Auto-rollback via git |
| Empty Claude response | ℹ "I processed your message but had no output to return." | Log warning |
| Signal send failure | None (logged) | `sendChunked` tracks per-chunk failures, logs summary |

### 21.3 Graceful Shutdown

On SIGTERM/SIGINT:
1. Set `isShuttingDown` flag (rejects new messages immediately)
2. Poll `activeClaudeCount` every 500ms until zero
3. Hard timeout: 30 seconds → `process.exit(1)`
4. All in-flight Claude invocations complete before exit

### 21.4 Error Log Schema

```sql
error_log
  id          SERIAL PRIMARY KEY
  timestamp   TIMESTAMPTZ
  source      TEXT    -- 'bridge', 'claude_code', 'playwright', 'postgrest', 'wrangler', 'signal_cli'
  severity    TEXT    -- 'warning', 'error', 'critical'
  message     TEXT
  stack       TEXT
  group_id    TEXT
  resolved    BOOLEAN DEFAULT false
  resolved_at TIMESTAMPTZ
```

---

## 22. Resilience & Operations

### 22.1 Service Management

| Service | Management |
|---------|-----------|
| Bridge | launchd (com.koda.bridge) — KeepAlive |
| PostgREST | launchd (com.koda.postgrest) — KeepAlive |
| Playwright MCP | launchd (com.koda.playwright-mcp) — KeepAlive |
| Deploy watcher | launchd (com.koda.deploy-watch) — KeepAlive |
| signal-cli REST API | Docker (restart: unless-stopped) |
| PostgreSQL | Homebrew services (brew services) |

### 22.2 PATH Management

> ⚠ **Critical:** `postgresql@16` is keg-only (bins not in `/opt/homebrew/bin`). NVM node is not in PATH for non-interactive shells (launchd, Claude Code subprocesses). All scripts must source `scripts/koda-env.sh`.

```bash
# scripts/koda-env.sh
export PATH="/opt/homebrew/opt/postgresql@16/bin:$HOME/.nvm/versions/node/v24.14.0/bin:/opt/homebrew/bin:/usr/local/bin:$PATH"
```

- **launchd plists:** set `PATH` explicitly in `EnvironmentVariables` (full NVM node path + PostgreSQL bin)
- **`~/.bashrc`:** add PostgreSQL and NVM node paths (used by interactive shells including Claude Code)

### 22.3 Log Rotation

- Daily log files: `logs/core-YYYY-MM-DD.log` with symlink at `logs/core.log`
- logrotate.conf configured
- `scripts/prune-logs.sh` runs daily at 3am via crontab (with koda-env.sh sourced)
- Retain 30 days file logs, 90 days DB logs

---

## 23. Automated Testing

### 23.1 Infrastructure

- 62 tests in `tests/run_tests.js`
- Separate test DB: `koda_test`
- Results written to `tests/results.json`
- Tests clean up after themselves

**Run:**
```bash
node tests/run_tests.js
```

### 23.2 Test Coverage

| Category | Tests |
|----------|-------|
| Bridge startup & connectivity | T1.1–T1.5 |
| Message pipeline | T2.1–T2.7 |
| RBAC | T3.1–T3.10 |
| Session management | T4.1–T4.4 |
| File handling | T5.1–T5.5 |
| Credential management | T6.1–T6.5 |
| Memory system | T7.1–T7.5 |
| AI Instructions | T8.1–T8.6 |
| Skills system | T9.1–T9.5 |
| Self-modification | T10.1–T10.4 |
| Browser automation | T11.1–T11.4 |
| Web deployment | T12.1–T12.4 |
| Usage monitoring | T15.1–T15.11 |
| Error handling | T16.1–T16.5 |

> **Note:** Email (T13) and Calendar (T14) tests deferred pending integration setup.

---

## 24. User Acceptance Test Plan

For human validation after automated tests pass. Performed manually via Signal.

**Prerequisites:** All automated tests passed; Owner UUID registered; at least one test group exists.

**UAT-1 — Basic Message & Response**
1. @Koda Hello → receives a response
2. Long response (>1500 chars) → arrives chunked [1/N]
3. /help → shows capability-appropriate command list

**UAT-2 — Session Persistence**
1. @Koda Remember the number 42
2. Restart bridge
3. @Koda What number did I tell you to remember? → responds "42"

**UAT-3 — RBAC**
1. Register second account as User with no grants
2. From User: @Koda question → no response (no send_instructions grant)
3. /grant <uuid> send_instructions → confirmed
4. From User: @Koda question → responds
5. From User: /autoapprove on → permission denied
6. /grant <uuid> autoapprove → confirmed
7. From User: /autoapprove on → enabled
8. /revoke <uuid> autoapprove → confirmed
9. From User: /autoapprove on → permission denied again
10. /user list → user appears with grants listed

**UAT-4 — File Handling**
1. Send PNG: "What's in this image?" → Koda describes it
2. Send CSV: "Summarise this data" → Koda summarises

**UAT-5 — Credential Management**
1. /cred set example.com myuser mypassword → "Credential saved. Message redacted."
2. /cred get example.com → returns decrypted credential
3. Check messages table → log shows [CREDENTIAL COMMAND — REDACTED]
4. /cred delete example.com → confirmed

**UAT-6 — Memory System**
1. @Koda Remember globally that I prefer concise responses
2. In a different group: @Koda → response is noticeably concise
3. /memory list → shows the global memory
4. /memory delete <id> → confirmed deleted

**UAT-7 — AI Instructions**
1. Create test.md: "Always end every response with 'Koda out.'"
2. /instructions set session → attach file
3. @Koda Say hello → response ends with "Koda out."
4. /instructions clear session → confirmed
5. @Koda Say hello → "Koda out." no longer appears

**UAT-8 — Skills System**
1. @Koda Create a skill for checking the weather → skill created, committed
2. /skills list → new skill appears
3. /skills show <name> → full content displayed
4. @Koda Check the weather → Koda reads skill, follows workflow

**UAT-9 — Web Deployment**
1. @Koda Create a simple landing page for a coffee shop called "The Daily Grind" and deploy it
2. Koda generates, deploys, returns URL + screenshot
3. Visit URL → page loads correctly

**UAT-10 — Browser Automation**
1. @Koda Take a screenshot of example.com → screenshot returned via Signal
2. @Koda Search for "koda.systems" on Google and tell me what you find

**UAT-11 — Usage Monitoring**
1. /usage → returns session % and weekly %
2. /usage refresh → "refreshed just now"
3. /usage status → shows config

**UAT-12 — Self-Modification**
1. @Koda Add a comment at the top of server.js saying '// modified via Signal'
2. Check git log → two commits (pre-edit checkpoint + post-edit)
3. Check server.js → comment present

**UAT-13 — /config Command**
1. /config list → shows configurable keys
2. /config set RATE_LIMIT_GLOBAL 120 → confirmed
3. /config get RATE_LIMIT_GLOBAL → returns 120

---

## 25. Deployment & Setup

### 25.1 Prerequisites

#### What you must do manually (Claude Code cannot do these)

| Task | Why |
|------|-----|
| Install Docker Desktop for Mac | GUI installer (.dmg) |
| Authenticate Claude Code (`claude login`) | Browser OAuth tied to claude.ai Pro subscription |
| Register the Signal bot number on a phone | Requires receiving an SMS |
| Link the Mac as a secondary Signal device | Requires scanning QR code in Signal app |
| Add Mac SSH public key to git host | Requires browser login to GitHub/GitLab |
| Create Cloudflare account and add domain | Browser-based account creation |
| `wrangler login` | Browser OAuth flow |
| Authenticate Gmail MCP + Calendar MCP (when enabling) | Browser OAuth via claude.ai marketplace |

#### macOS System Requirements

- M-series Mac, 24GB RAM recommended
- **Auto-login enabled:** System Settings → Users & Groups → Automatically log in
- **Sleep disabled:** `sudo pmset -a sleep 0 disksleep 0`
- **FileVault: do not enable** — blocks launchd services after unattended reboot
- Admin user named `koda`
- iTerm2, bash, Homebrew pre-installed

#### Claude Code Authentication

```bash
claude login    # browser OAuth — uses claude.ai Pro subscription
                # no ANTHROPIC_API_KEY required
```

#### Homebrew Packages (Claude Code installs)

| Package | Purpose |
|---------|---------|
| `nvm` | Node Version Manager — do not use brew Node directly |
| `postgresql@16` | Database — keg-only, must add bin to PATH explicitly |
| `postgrest` | REST API layer |
| `git` | Version control |
| `jq` | JSON processing |
| `logrotate` | Log rotation |
| `qrencode` | QR code generation for Signal device linking |
| `fswatch` | File watching for deploy pipeline |

#### Node.js Global Packages (Claude Code installs)

| Package | Purpose |
|---------|---------|
| `@anthropic-ai/claude-code` | Claude Code CLI (min version 2.1.7) |
| `wrangler` | Cloudflare deployment CLI |
| `@playwright/mcp` | Browser automation MCP server |

Chromium: install via full node path (not npx — see Section 17.1).

### 25.2 Setup Steps

**Before handing to Claude Code:**

| Step | Who | Action |
|------|-----|--------|
| 1 | YOU | Install Docker Desktop for Mac |
| 2 | YOU | `npm install -g @anthropic-ai/claude-code` |
| 3 | YOU | `claude login` |
| 4 | YOU | Verify: `claude --version` (must be ≥ 2.1.7) |
| 5 | YOU | Provide Claude Code with this spec document |

> **Run without permission interruptions:**
> ```bash
> claude --dangerously-skip-permissions
> ```

**Claude Code collects configuration (interactive, before building):**

6. Signal bot phone number (E.164 format)
7. Cloudflare account (existing or new)
8. Git repository URL, user.name, user.email
9. Any other .env values

**Claude Code executes:**

10. `sudo pmset -a sleep 0 disksleep 0`
11. Print auto-login instructions; user confirms in System Settings
12. Install brew packages (including qrencode, fswatch)
13. Configure NVM in `~/.bash_profile`; install Node.js LTS
14. Add PostgreSQL bin + NVM node to `~/.bashrc`
15. Create `scripts/koda-env.sh`
16. Install global npm packages; install Chromium via full node path
17. Create all project directories; set permissions
18. Create PostgreSQL database + roles; run schema migrations
19. Configure pg_hba.conf for Unix socket trust auth
20. Install and configure PostgREST; generate JWTs; write launchd plist
21. Configure Docker Compose for signal-cli REST API (MODE=json-rpc)
22. **Signal device linking:**
    a. Install signal-cli temporarily: `brew install signal-cli`
    b. Run `signal-cli link --name "KodaBot"` — capture URI
    c. Generate QR: `qrencode -t PNG -s 10 -o /tmp/koda-link.png "$URI"`
    d. Pause: instruct user to scan QR in Signal app → Settings → Linked Devices → Link New Device
    e. Wait for user confirmation
    f. Copy account data to Docker volume
    g. `brew uninstall signal-cli`
    h. Start Docker container
    i. Verify WebSocket connection to `ws://localhost:8080/v1/receive/+<number>`
23. Generate SSH key; output public key; pause for user to add to git host
24. Initialise git repo; configure remote; initial commit
25. Write `.env` from collected config; write `.env.example`
26. Create `~/koda/skills/index.json` (empty)
27. Write `server.js` with all spec behaviours
28. Install bridge npm dependencies
29. Write and install launchd plists (bridge, PostgREST, Playwright MCP, deploy-watch)
30. Configure logrotate; write cron entries (with koda-env.sh sourced)
31. Write scripts: `self-modify.sh`, `deploy-watch.sh`, `generate-dashboard.js`, `prune-logs.sh`, `open-browser.js`

**Requires user action mid-setup:**

| Step | Who | Action |
|------|-----|--------|
| 32 | YOU | `wrangler login` |
| 33 | YOU | Add SSH public key to git host |

**Claude Code finalises:**

34. Start all services; health check
35. **Get OWNER_UUID:** bridge starts in degraded mode; instruct user to send any Signal message from their account; bridge logs the UUID; Claude Code sets OWNER_UUID in .env and adds to users table; restart bridge
36. Set up claude.ai login in Playwright browser: `node scripts/open-browser.js`; instruct user to log into claude.ai
37. Run automated test suite: `node tests/run_tests.js`
38. Fix any failures; re-run until all 62 tests pass
39. Generate and deploy initial dashboard to koda.systems
40. Prompt user to begin UAT (Section 24)

### 25.3 Git Setup

```bash
cd ~/koda
git init
git remote add origin <repository-url>
git config user.name "<name>"
git config user.email "<email>"
git add -A
git commit -m "initial commit: Koda v1.0"
git push -u origin main
```

### 25.4 Full .env Reference

```
# Signal
SIGNAL_PHONE=+<bot-number>
OWNER_UUID=                        # set after first inbound message
BRIDGE_PORT=3033

# Processing
BATCH_DELAY_MS=1500                # DM inactivity timer (ms)
MAX_SIGNAL_CHUNK=1500
CLAUDE_TIMEOUT_MINUTES=5
MAX_CONCURRENT_CLAUDE=1
MEMORY_TOKEN_BUDGET=2000
LOG_RETENTION_DAYS=90
AUTO_APPROVE_DEFAULT=false
APPROVAL_TIMEOUT_MINUTES=30

# Rate limiting
RATE_LIMIT_PER_USER=20
RATE_LIMIT_GLOBAL=100

# Database
POSTGREST_URL=http://localhost:3000
POSTGREST_AGENT_JWT=<jwt>
CORE_JWT=<jwt>

# Credentials
CREDENTIAL_ENCRYPTION_KEY=<32-byte-hex-key>

# Playwright
PLAYWRIGHT_MCP_PORT=8931

# Cloudflare
CLOUDFLARE_API_TOKEN=ENC:<encrypted>
CLOUDFLARE_ACCOUNT_ID=<account-id>
CLOUDFLARE_PAGES_PROJECT=koda-systems
CLOUDFLARE_DOMAIN=koda.systems

# Email (deferred — vars present, empty)
SMTP_HOST=
SMTP_USER=
SMTP_PASS=
EMAIL_SENDER_NAME=
EMAIL_SENDER_ADDRESS=
EMAIL_SEND_AUTOAPPROVE=false

# Usage monitoring
USAGE_SCRAPE_INTERVAL_ACTIVE_MS=300000
USAGE_SCRAPE_INTERVAL_IDLE_MS=3600000
USAGE_SESSION_THRESHOLD=80
USAGE_WEEKLY_THRESHOLD=75

# Model
CLAUDE_MODEL=                      # leave empty to use CLI default

# Testing
TEST_DB_NAME=koda_test
TEST_SIGNAL_GROUP_ID=<test-group-id>
TEST_OWNER_UUID=<uuid>
```

> **Note:** No `ANTHROPIC_API_KEY`. Claude Code authenticates via `claude login` using the claude.ai Pro subscription.

---

## 26. Known Deviations from v0.9

These are canonical. They are not deviations from v1.0.

| Area | v0.9 Spec | v1.0 (canonical) | Rationale |
|------|-----------|-----------------|-----------|
| `--dangerously-skip-permissions` | Restricted to cron scripts | Always on for all bridge-spawned sessions | Bridge is non-interactive; permission prompts hang indefinitely |
| DM trigger | @mention to trigger processing | 1500ms inactivity timer | Simpler; functionally equivalent for single-owner use |
| Credential commands | `/addcredential`, `/removecredential` | `/cred set\|get\|delete` | Cleaner naming |
| `/user add` | `/adduser <uuid> <role>` | `/user add <phone> <role>` (bridge generates UUID) | Phone is what the owner knows |
| Approval relay | Relay Claude prompts to Signal | Dead code — skip-permissions bypasses all prompts | Non-interactive bridge design |
| Env var names | SIGNAL_BOT_NUMBER, MESSAGE_SPLIT_LENGTH, etc. | SIGNAL_PHONE, MAX_SIGNAL_CHUNK, etc. | Clearer names chosen during implementation |
| New: /config | Not in spec | Full .env management via Signal | Required for remote config |
| New: /route, /model | Not in spec | Route selection (/route) + model selection (/model) | Useful operational feature |

---

## 27. Open Questions

| # | Question | Status |
|---|---------|--------|
| 1 | PostgREST JWT secret rotation process? | Pending |
| 2 | Should Claude Code propose new skills automatically, or only when instructed? | Pending |
| 3 | Instructions condensation max length — 20 bullet points appropriate? | Resolved — replaced with 2000-char truncation (Round 4) |
| 4 | Claude Code timeout — 5 min appropriate for long agentic tasks? | May need per-task override |
| 5 | Gmail MCP — single inbox or multiple forwarded inboxes? | Confirm when enabling email |
| 6 | Google Calendar — primary calendar only or multiple? | Confirm when enabling calendar |
| 7 | Default capability grants for new users — use Section 6.3 suggestions or start at zero? | Confirm with owner |
| 8 | Git commit signing required? | Confirm with owner |

---

## 28. App Platform Architecture

This section documents the platform capabilities added in v2.0. The core Signal ↔ Claude Code bridge (Sections 1–27) is unchanged. Everything below is additive.

### 28.1 Architecture Decision Records

**ADR-001: Two-Tier App Architecture.**
Koda serves two fundamentally different app types: a management console that controls Koda itself (requires Koda online), and standalone apps that work independently. The decision is a two-tier model: Tier 1 (Console PWA) is a Cloudflare Pages static SPA connecting to Koda Core through a Cloudflare Tunnel — if Koda is offline, the UI loads but shows "Koda offline". Tier 2 (Standalone PWAs) uses Cloudflare Pages + Workers + D1 and runs entirely on the edge with no dependency on the local Mac. This cleanly maps to data sovereignty: Koda's internal data stays on Mac, app domain data lives on Cloudflare.

**ADR-002: Console PWA — Koda Core Only (No PostgREST Exposed).**
Only Koda Core (port 3033) is exposed through the tunnel. PostgREST is never externally accessible. Koda Core already queries the DB internally for all read operations, and action operations require business logic only Koda Core can perform. This gives a single auth surface, single API contract, and smallest attack surface.

**ADR-003: Authentication — Cloudflare Access + Koda JWT (Layered).**
Layer 1: Cloudflare Access sits in front of all apps, blocking unauthenticated traffic. Users authenticate via email OTP. Free tier supports 50 unique users across all apps. Layer 2: After CF Access passes the request, Koda Core (or Worker) checks a Koda JWT mapping to a user in the relevant user system. Existing roles and capabilities apply. If CF Access is ever replaced with organic per-app auth, it is a clean swap — app logic, API endpoints, and database schema remain unchanged.

**ADR-004: Frontend Approach — Alpine.js + Tailwind (No Build Step).**
All PWAs use Alpine.js for reactivity, Tailwind CSS for styling, all via CDN with no build pipeline. This extends Koda's existing pattern of generating self-contained HTML files. No `npm build` step means Koda can generate and deploy an app in a single operation. PWA wrappers (manifest.json + service worker) make apps installable on iPhone. For very complex UIs, a heavier framework may be warranted — this is a revisit trigger, not a blocker.

**ADR-005: Console PWA API — New Endpoints on Koda Core.**
A `/api/*` route namespace is added to Koda Core for the Console PWA and programmatic access. Endpoints map 1:1 to existing Signal slash commands — same operations, different transport. All endpoints require valid JWT, check user role/capabilities, return JSON, and are audit logged.

**ADR-006: Standalone PWA Data — Cloudflare Workers + D1.**
Each standalone PWA uses Cloudflare Workers (API logic) + D1 (edge SQLite database). D1 is never directly exposed — the Worker is the API boundary. Koda interacts with standalone apps by calling the Worker's HTTP API as a privileged client using a service token. App data lives entirely on Cloudflare, separate from Koda's PostgreSQL. Schema migrations are part of the app deploy via `wrangler d1 migrations apply`.

**ADR-007: App Registration — Manifest + DB.**
Each app has a directory under `apps/<app-name>/`, a `manifest.koda.json` declaring name, version, tier, URL, API base, intents, and entities, and is registered in a `koda_apps` DB table on deploy. Koda Core reads manifests to route intents to the correct app.

**ADR-008: NL Intent Pipeline — Claude Extraction + Koda Executor.**
When a user says something like "mark Dave's deposit as received", Claude receives the message with app manifests injected as context. Claude returns a structured JSON intent block. If confidence is high and all required params are present, Koda asks for confirmation. On confirmation, Koda executes the action by calling the app's Worker API. Claude does NL understanding; Koda does validation, execution, and audit. Claude never has direct database access to app data.

**ADR-009: App-Scoped DB Conventions.**
Console PWA (Tier 1) uses Koda's existing PostgreSQL tables — no new schema conventions needed. Standalone PWAs (Tier 2) each have their own D1 database instance. Table names are app-scoped naturally since each D1 is isolated. Schema migrations stored in `apps/<app-name>/migrations/` and applied via `wrangler d1 migrations apply` on deploy.

### 28.2 Koda Core REST API

#### Endpoint Listing

| Group | Endpoints |
|-------|-----------|
| Auth | `POST /api/auth/token` — exchange CF Access JWT for Koda JWT |
| Status | `GET /api/status` — system status (services, uptime, version) |
| Usage | `GET /api/usage` — Claude usage data |
| Sessions | `GET /api/sessions`, `POST /api/sessions/:id/reset` |
| Model | `GET /api/model`, `POST /api/model` |
| Route | `GET /api/route`, `POST /api/route` |
| Memories | `GET /api/memories`, `POST /api/memories`, `DELETE /api/memories/:id` |
| Users | `GET /api/users`, `POST /api/users`, `PATCH /api/users/:id` (admin+) |
| Messages | `GET /api/messages`, `POST /api/messages` (send to Claude — same as Signal input, different transport) |
| Groups | `GET /api/groups` |
| Jobs | `GET /api/jobs` — queued/active Claude invocations |
| Config | `GET /api/config`, `PATCH /api/config` (owner only) |
| Apps | `GET /api/apps`, `GET /api/apps/:name`, `POST /api/apps/:name/deploy` |
| Intents | `GET /api/intents`, `POST /api/intents/:id/confirm`, `POST /api/intents/:id/cancel` |

#### Authentication Flow

1. User authenticates with Cloudflare Access (email OTP or configured identity provider)
2. CF Access sets a signed JWT in the `CF-Authorization` cookie/header
3. Console PWA sends `POST /api/auth/token` with the CF Access JWT
4. Koda Core validates the CF Access JWT against Cloudflare's public keys
5. Koda Core looks up the user by email in the `users` table
6. Koda Core issues a Koda JWT (24-hour expiry) containing user UUID, role, and capabilities
7. All subsequent API calls include the Koda JWT in the `Authorization: Bearer <token>` header

#### Permission Model

The REST API uses the same permission model as Signal commands. Each endpoint maps to one or more capabilities from Section 6.3. The `role` and `capabilities` from the Koda JWT are checked on every request. Owner has full access; Admin and User require explicit capability grants.

#### Rate Limiting

Per-user rate limiting applies to API requests using the same `RATE_LIMIT_PER_USER` and `RATE_LIMIT_GLOBAL` settings from `.env`. Owner UUID is exempt from per-user limits. On breach: request rejected with HTTP 429, logged to audit_log.

#### Response Format

All API endpoints return JSON in a consistent envelope:

```json
{
  "ok": true,
  "data": { ... }
}
```

On error:

```json
{
  "ok": false,
  "error": "Human-readable error message",
  "code": "MACHINE_READABLE_CODE"
}
```

### 28.3 Console PWA (Tier 1)

- **URL:** `console.koda.systems`
- **Stack:** Alpine.js + Tailwind CSS (CDN, no build step)
- **Deployment:** Cloudflare Pages (`wrangler pages deploy`)
- **Authentication:** Protected by Cloudflare Access; requires valid CF Access session before any content loads
- **PWA:** Installable on iPhone via `manifest.json` + service worker for offline shell caching

#### Screens

| Tab | Contents |
|-----|----------|
| Dashboard | System status, active sessions, recent messages, Claude usage summary |
| Sessions | List of active sessions by group, reset session, view session history |
| Settings | Model selection, route selection, memory management, .env config (owner only) |
| More | User management (owner only), app list, deployment history, audit log |

The Console PWA provides the same operations as Signal slash commands. It is a different transport to the same Koda Core functions. Signal remains the primary conversational interface.

### 28.4 Standalone PWA Framework (Tier 2)

#### Directory Convention

```
apps/<app-name>/
  manifest.koda.json     App manifest (registered with Koda Core on deploy)
  wrangler.toml          Cloudflare Workers config with D1 binding
  src/
    index.js             Worker entry point (request router)
    middleware.js         Auth middleware (dual: CF Access JWT + service token)
  public/
    index.html           SPA shell (Alpine.js + Tailwind via CDN)
    manifest.json         PWA manifest
    sw.js                 Service worker
  migrations/
    0001_initial.sql     D1 schema migrations
```

#### manifest.koda.json Schema

```json
{
  "name": "app-name",
  "display_name": "Human-Readable App Name",
  "version": "1.0.0",
  "tier": "standalone",
  "url": "https://appname.koda.systems",
  "api_base": "https://appname.koda.systems/api",
  "description": "One-line description of what the app does",
  "intents": [
    {
      "action": "action_name",
      "method": "POST",
      "path": "/api/resource",
      "params": ["param1", "param2"],
      "required_params": ["param1"],
      "description": "What this action does"
    }
  ],
  "entities": ["entity1", "entity2"]
}
```

#### wrangler.toml Template

```toml
name = "app-name"
main = "src/index.js"
compatibility_date = "2024-01-01"

[[d1_databases]]
binding = "DB"
database_name = "app-name-db"
database_id = "<generated-on-create>"
```

#### Worker Template

- `src/index.js` — request router handling `/api/*` routes and serving static files
- `src/middleware.js` — dual authentication: validates either a CF Access JWT (browser users) or a service token header (Koda Core calling as privileged client)

#### D1 Conventions

- SQLite types only (TEXT, INTEGER, REAL, BLOB)
- Standard tables every app should include:
  - `app_users` — app-specific user records (email, display_name, role, created_at)
  - `audit_log` — all mutations logged (timestamp, actor, action, detail)
- Migrations stored in `migrations/` directory, numbered sequentially (0001_, 0002_, etc.)
- Applied via `wrangler d1 migrations apply` on deploy

#### Scaffold and Deploy Scripts

- **`scripts/scaffold-app.sh <app-name>`** — generates the full app skeleton (directory structure, wrangler.toml, manifest.koda.json, Worker template, initial migration, PWA shell) from templates
- **`scripts/deploy-app.sh <app-name>`** — runs D1 migrations, deploys Worker via `wrangler deploy`, deploys static assets via `wrangler pages deploy`, registers/updates the app in Koda Core's `koda_apps` table

### 28.5 Intent Pipeline

#### How Claude Receives App Context

On every Claude invocation, `lib/apps.js` (`buildAppContext`) reads registered apps from the `koda_apps` table and injects a summary into the system prompt: what apps exist, what actions they support, and what parameters each action accepts. This gives Claude the context to recognize when a user message implies an app action.

#### Intent Block Format

When Claude detects an actionable intent, it emits a fenced code block in its response:

````
```koda-intent
{
  "app": "app-name",
  "action": "action_name",
  "params": {
    "param1": "value1",
    "param2": "value2"
  },
  "confidence": 0.95,
  "clarification_needed": null
}
```
````

If `clarification_needed` is non-null, Claude asks the user for more information instead of triggering confirmation.

#### Validation and Confirmation

1. Koda parses the `koda-intent` block from Claude's response
2. Validates against the app's manifest: app exists, action exists, required params present
3. Sends a confirmation message to the user via Signal (or Console PWA): "I'll [action description] with [params]. Confirm? (yes/no)"
4. User replies yes → execute; no → cancel
5. Confirmation timeout: 5 minutes. If no response, the intent is cancelled and the user is notified.

#### Execution

- **Tier 2 (standalone apps):** Koda Core sends an HTTP request to the Worker API endpoint specified in the manifest, authenticated with a service token that has elevated permissions
- All actions are audit logged to the `intent_log` table with: app, action, params, actor, outcome, timestamp

### 28.6 Database Additions (v2.0)

#### koda_apps Table

```sql
koda_apps
  id              SERIAL PRIMARY KEY
  name            TEXT UNIQUE NOT NULL      -- matches manifest.koda.json "name"
  display_name    TEXT
  tier            TEXT NOT NULL             -- 'console' or 'standalone'
  version         TEXT
  url             TEXT
  api_base        TEXT
  description     TEXT
  manifest        JSONB                     -- full manifest.koda.json contents
  status          TEXT DEFAULT 'active'     -- 'active', 'disabled', 'error'
  registered_at   TIMESTAMPTZ
  updated_at      TIMESTAMPTZ
```

#### intent_log Table

```sql
intent_log
  id              SERIAL PRIMARY KEY
  app_name        TEXT NOT NULL
  action          TEXT NOT NULL
  params          JSONB
  actor_uuid      TEXT                      -- Signal UUID or API user
  source          TEXT                      -- 'signal' or 'console'
  confidence      REAL
  status          TEXT                      -- 'pending', 'confirmed', 'executed', 'cancelled', 'timeout', 'error'
  result          JSONB
  created_at      TIMESTAMPTZ
  confirmed_at    TIMESTAMPTZ
  executed_at     TIMESTAMPTZ
```

#### users.email Column

```sql
ALTER TABLE users ADD COLUMN email TEXT;
```

The `email` column maps Signal users to Cloudflare Access identities. When a CF Access JWT arrives, Koda looks up the user by email to resolve their UUID, role, and capabilities.

#### D1 Conventions for Standalone Apps

Each standalone app has its own D1 database instance (Cloudflare supports multiple D1 databases per account). Tables are app-scoped naturally since each D1 is isolated. Standard tables (`app_users`, `audit_log`) are included in every app's initial migration. Schema migrations are stored in `apps/<app-name>/migrations/` and applied on deploy.

### 28.7 Deployment Additions (v2.0)

#### Cloudflare Tunnel

- Tunnel name: `koda-api-tunnel`
- Route: `api.koda.systems` → `localhost:3033`
- Managed by launchd with KeepAlive (auto-restart on failure)
- Configuration: `~/.cloudflared/config.yml`
- `cloudflared` installed via Homebrew

#### Console PWA Deployment

- Deployed via `wrangler pages deploy public/ --project-name koda-console`
- Static files served from Cloudflare Pages CDN
- Protected by Cloudflare Access application policy (email allowlist)
- Updates deployed by re-running the wrangler pages command

#### Standalone App Deployment

- `scripts/scaffold-app.sh <app-name>` generates the full app skeleton
- `scripts/deploy-app.sh <app-name>` handles the complete deployment:
  1. Creates D1 database if it does not exist
  2. Applies D1 migrations (`wrangler d1 migrations apply`)
  3. Deploys Worker (`wrangler deploy`)
  4. Deploys static assets (`wrangler pages deploy`)
  5. Registers/updates app in Koda Core's `koda_apps` table
  6. Creates Cloudflare Access application policy if needed

### 28.8 Future Migration Paths

**Cloudflare Access → organic per-app authentication (ADR-003).**
If CF Access is ever replaced, the migration is clean: add a login endpoint and session management per app. App logic, API endpoints, and database schema remain unchanged. Koda Core already has JWT infrastructure to extend. The main complexity would be building shared SSO across apps — which is exactly what CF Access provides for free today.

**Alpine.js → heavier framework (ADR-004).**
If a specific app requires complex interactive UI beyond what Alpine.js handles comfortably, a heavier framework (e.g., Vue, Svelte) can be introduced for that app only. The revisit trigger is: if Alpine.js state management becomes the bottleneck for a specific feature, evaluate alternatives for that app.

**Additional app tiers.**
The current two-tier model covers all current needs. If a future app requires direct access to Koda's PostgreSQL (e.g., for cross-app data queries), a Tier 3 could be defined that runs as a local service with direct DB access. This is not currently planned.

---

*End of Specification v2.0*
