# LetsBe Biz — Component Breakdown

**Date:** February 27, 2026
**Team:** Claude Opus 4.6 Architecture Team
**Document:** 02 of 09
**Status:** Proposal — Competing with independent team
**Companion:** References 01-SYSTEM-ARCHITECTURE.md for high-level context

---

## Table of Contents

1. [Safety Wrapper](#1-safety-wrapper)
2. [Secrets Proxy](#2-secrets-proxy)
3. [Hub Updates](#3-hub-updates)
4. [Provisioner Updates](#4-provisioner-updates)
5. [Mobile App](#5-mobile-app)
6. [Website & Onboarding](#6-website--onboarding)
7. [Tool Registry & Cheat Sheets](#7-tool-registry--cheat-sheets)
8. [Agent Architecture](#8-agent-architecture)
9. [Interactive Demo](#9-interactive-demo)
10. [Shared Packages](#10-shared-packages)

---

## 1. Safety Wrapper

**Process:** `letsbe-safety-wrapper` — Node.js 22+ standalone HTTP server
**Port:** `localhost:8200`
**RAM Budget:** ~128MB (separate from OpenClaw's ~384MB + Chromium)
**Language:** TypeScript (strict mode, ESM)
**Repository:** `packages/safety-wrapper/` in monorepo

### 1.1 Responsibility Summary

The Safety Wrapper is the enforcement layer for all five core responsibilities:

| # | Responsibility | Input | Output |
|---|---------------|-------|--------|
| 1 | Command Classification | Tool call from OpenClaw | Tier assignment (GREEN/YELLOW/YELLOW_EXTERNAL/RED/CRITICAL_RED) |
| 2 | Autonomy Gating | Classified command + agent config | EXECUTE or GATE_FOR_APPROVAL |
| 3 | Credential Injection | `SECRET_REF(name)` placeholders | Real values injected locally |
| 4 | Hub Communication | Heartbeat, approval, token usage, config sync | Hub API responses |
| 5 | Token Metering | LLM response headers from OpenRouter | Hourly aggregated buckets |

### 1.2 HTTP API Contract

The Safety Wrapper exposes an HTTP API that OpenClaw calls instead of executing tools directly.

#### 1.2.1 Tool Execution Endpoint

```
POST /api/v1/tools/execute
Authorization: Bearer {SAFETY_WRAPPER_TOKEN}
Content-Type: application/json

Request:
{
  "session_id": "string",           // OpenClaw session ID
  "agent_id": "string",             // e.g., "it-admin", "marketing"
  "tool_name": "string",            // e.g., "shell", "docker", "file_read"
  "tool_args": {                    // Tool-specific arguments
    "command": "string",
    "timeout": "number?"
  },
  "context": {                      // Optional context from OpenClaw
    "conversation_id": "string?",
    "parent_tool_call_id": "string?",
    "is_subagent": "boolean?"
  }
}

Response (200 — executed):
{
  "status": "executed",
  "result": "string | object",       // Tool output (secrets redacted)
  "tool_call_id": "string",          // Unique ID for audit trail
  "classification": "green",         // Classification tier
  "duration_ms": 142,
  "tokens_input": 0,                 // Only for LLM-proxied calls
  "tokens_output": 0
}

Response (202 — gated for approval):
{
  "status": "awaiting_approval",
  "approval_id": "string",           // UUID for tracking
  "classification": "red",
  "human_readable": "IT Agent wants to delete /nextcloud/data/tmp/* (47 files, 2.3GB)",
  "expires_at": "2026-02-28T12:00:00Z",
  "poll_endpoint": "/api/v1/approvals/{approval_id}"
}

Response (403 — denied):
{
  "status": "denied",
  "reason": "Tool 'docker' not in agent 'marketing' allow list",
  "classification": null
}

Response (429 — rate limited):
{
  "status": "rate_limited",
  "retry_after_ms": 5000
}
```

#### 1.2.2 Approval Polling Endpoint

```
GET /api/v1/approvals/{approval_id}
Authorization: Bearer {SAFETY_WRAPPER_TOKEN}

Response (200 — still pending):
{
  "status": "pending",
  "requested_at": "2026-02-27T11:00:00Z",
  "expires_at": "2026-02-28T11:00:00Z"
}

Response (200 — resolved):
{
  "status": "approved",             // or "denied" or "expired"
  "responded_at": "2026-02-27T11:05:00Z",
  "responded_by": "customer",       // or "staff"
  "result": "string | object"       // Tool execution result if approved
}
```

#### 1.2.3 Health & Registration

```
GET /api/v1/health
→ { "status": "ok", "version": "1.0.0", "hub_connected": true, "uptime_seconds": 86400 }

POST /api/v1/register
→ Called once on first boot. Registers with Hub, receives API key.
   Body: { "registration_token": "string", "version": "string" }
   Response: { "hub_api_key": "string", "config": { ... } }
```

#### 1.2.4 Config Sync (Hub → Safety Wrapper)

```
POST /api/v1/config/sync
Authorization: Bearer {HUB_WEBHOOK_TOKEN}
Content-Type: application/json

Request:
{
  "config_version": 42,
  "agents": {
    "it-admin": {
      "autonomy_level": 3,
      "tools_allowed": ["shell", "docker", "file_read", ...],
      "tools_denied": [],
      "external_comms_unlocks": {}
    },
    "marketing": {
      "autonomy_level": null,       // uses tenant default
      "tools_allowed": ["ghost_api", "listmonk_api", ...],
      "tools_denied": ["shell", "docker"],
      "external_comms_unlocks": {
        "ghost_publish": "autonomous",
        "listmonk_send": "gated"
      }
    }
  },
  "tenant": {
    "default_autonomy_level": 2,
    "token_pool_remaining": 12450000,
    "founding_member_multiplier": 2
  }
}

Response: { "accepted": true, "config_version": 42 }
```

### 1.3 Command Classification Engine

The classifier is a deterministic rule engine — no ML, no heuristics. Every tool call maps to exactly one tier.

#### 1.3.1 Classification Rules

```typescript
// packages/safety-wrapper/src/classifier/rules.ts

interface ClassificationRule {
  tool_name: string;
  operation?: string;            // Sub-operation (e.g., "delete", "restart")
  arg_patterns?: Record<string, RegExp>;  // Argument pattern matchers
  tier: CommandTier;
}

type CommandTier = 'green' | 'yellow' | 'yellow_external' | 'red' | 'critical_red';

const CLASSIFICATION_RULES: ClassificationRule[] = [
  // GREEN — Non-destructive reads
  { tool_name: 'file_read', tier: 'green' },
  { tool_name: 'env_read', tier: 'green' },
  { tool_name: 'container_stats', tier: 'green' },
  { tool_name: 'container_logs', tier: 'green' },
  { tool_name: 'check_status', tier: 'green' },
  { tool_name: 'dns_lookup', tier: 'green' },
  { tool_name: 'cert_check', tier: 'green' },
  { tool_name: 'umami_read', tier: 'green' },
  { tool_name: 'uptime_check', tier: 'green' },
  { tool_name: 'query_select', tier: 'green' },

  // YELLOW — Modifying operations
  { tool_name: 'container_restart', tier: 'yellow' },
  { tool_name: 'file_write', tier: 'yellow' },
  { tool_name: 'env_update', tier: 'yellow' },
  { tool_name: 'nginx_reload', tier: 'yellow' },
  { tool_name: 'chatwoot_assign', tier: 'yellow' },
  { tool_name: 'calcom_create', tier: 'yellow' },

  // YELLOW_EXTERNAL — External-facing (independent gate)
  { tool_name: 'ghost_publish', tier: 'yellow_external' },
  { tool_name: 'listmonk_send', tier: 'yellow_external' },
  { tool_name: 'poste_send', tier: 'yellow_external' },
  { tool_name: 'chatwoot_reply_external', tier: 'yellow_external' },
  { tool_name: 'social_post', tier: 'yellow_external' },
  { tool_name: 'documenso_send', tier: 'yellow_external' },

  // RED — Destructive operations
  { tool_name: 'file_delete', tier: 'red' },
  { tool_name: 'container_remove', tier: 'red' },
  { tool_name: 'volume_delete', tier: 'red' },
  { tool_name: 'user_revoke', tier: 'red' },
  { tool_name: 'db_drop_table', tier: 'red' },
  { tool_name: 'backup_delete', tier: 'red' },
  { tool_name: 'tool_install', tier: 'red' },
  { tool_name: 'tool_remove', tier: 'red' },

  // CRITICAL_RED — Irreversible
  { tool_name: 'db_drop_database', tier: 'critical_red' },
  { tool_name: 'firewall_modify', tier: 'critical_red' },
  { tool_name: 'ssh_config_modify', tier: 'critical_red' },
  { tool_name: 'backup_wipe_all', tier: 'critical_red' },
  { tool_name: 'user_delete_account', tier: 'critical_red' },
  { tool_name: 'ssl_revoke', tier: 'critical_red' },
];
```

#### 1.3.2 Shell Command Classifier

Shell commands (via the `shell` tool) require deeper analysis because the agent sends arbitrary commands:

```typescript
// packages/safety-wrapper/src/classifier/shell-classifier.ts

interface ShellClassification {
  tier: CommandTier;
  binary: string;
  args: string[];
  is_allowed: boolean;
  reason?: string;
}

// ALLOWLISTED BINARIES — everything else is DENIED
const SHELL_ALLOWLIST: Record<string, CommandTier> = {
  // Green — read-only
  'cat': 'green', 'head': 'green', 'tail': 'green', 'less': 'green',
  'ls': 'green', 'find': 'green', 'grep': 'green', 'wc': 'green',
  'df': 'green', 'du': 'green', 'free': 'green', 'uptime': 'green',
  'whoami': 'green', 'hostname': 'green', 'date': 'green',
  'curl': 'green',    // Elevated by arg analysis below
  'dig': 'green', 'nslookup': 'green', 'ping': 'green',
  'docker': 'green',  // Elevated by subcommand analysis
  'systemctl': 'green', // Elevated by subcommand analysis

  // Yellow — modifying
  'tee': 'yellow', 'cp': 'yellow', 'mv': 'yellow', 'mkdir': 'yellow',
  'chmod': 'yellow', 'chown': 'yellow', 'sed': 'yellow',
  'certbot': 'yellow',
  'nginx': 'yellow',  // Only "-s reload" allowed

  // Red — destructive
  'rm': 'red', 'rmdir': 'red',
};

// SHELL METACHARACTER BLOCKING — no pipes, redirects, or command chaining
const BLOCKED_METACHARACTERS = /[|;&`$(){}\\<>]/;

// PATH TRAVERSAL PREVENTION — all paths must resolve under /opt/letsbe
const ALLOWED_ROOT = '/opt/letsbe';

function classifyShellCommand(raw_command: string): ShellClassification {
  // 1. Block metacharacters
  if (BLOCKED_METACHARACTERS.test(raw_command)) {
    return { tier: 'critical_red', binary: '', args: [], is_allowed: false,
             reason: 'Shell metacharacters blocked' };
  }

  // 2. Parse binary + args
  const parts = raw_command.trim().split(/\s+/);
  const binary = parts[0];
  const args = parts.slice(1);

  // 3. Check allowlist
  if (!(binary in SHELL_ALLOWLIST)) {
    return { tier: 'critical_red', binary, args, is_allowed: false,
             reason: `Binary '${binary}' not in allowlist` };
  }

  let tier = SHELL_ALLOWLIST[binary];

  // 4. Subcommand analysis for docker/systemctl/curl
  if (binary === 'docker') {
    tier = classifyDockerSubcommand(args);
  } else if (binary === 'systemctl') {
    tier = classifySystemctlSubcommand(args);
  } else if (binary === 'curl') {
    tier = classifyCurlCommand(args);
  }

  // 5. Path validation
  for (const arg of args) {
    if (arg.startsWith('/') && !arg.startsWith(ALLOWED_ROOT)) {
      if (!isExemptPath(arg)) {
        return { tier: 'critical_red', binary, args, is_allowed: false,
                 reason: `Path '${arg}' outside allowed root ${ALLOWED_ROOT}` };
      }
    }
  }

  return { tier, binary, args, is_allowed: true };
}
```

#### 1.3.3 Docker Subcommand Classifier

```typescript
function classifyDockerSubcommand(args: string[]): CommandTier {
  const subcommand = args[0];
  switch (subcommand) {
    // Green — read-only
    case 'ps': case 'images': case 'inspect': case 'logs':
    case 'stats': case 'top': case 'port': case 'version':
    case 'info': case 'network': case 'volume':
      if (args[1] === 'ls' || args[1] === 'inspect') return 'green';
      return 'green';

    // Yellow — service operations
    case 'compose':
      if (['ps', 'logs', 'config'].includes(args[1])) return 'green';
      if (['up', 'restart', 'pull'].includes(args[1])) return 'yellow';
      if (['down', 'rm'].includes(args[1])) return 'red';
      return 'red';
    case 'restart': case 'start': case 'stop':
      return 'yellow';
    case 'exec':
      return 'yellow';

    // Red — destructive
    case 'rm': case 'rmi':
      return 'red';
    case 'system':
      if (args[1] === 'prune') return 'red';
      return 'green';

    // Critical Red — infrastructure
    case 'network':
      if (['create', 'rm', 'disconnect'].includes(args[1])) return 'critical_red';
      return 'green';

    default:
      return 'red'; // Unknown subcommands default to red
  }
}
```

### 1.4 Autonomy Resolution Engine

```typescript
// packages/safety-wrapper/src/autonomy/resolver.ts

interface AutonomyDecision {
  action: 'execute' | 'gate';
  effective_level: 1 | 2 | 3;
  reason: string;
}

interface AgentAutonomyConfig {
  autonomy_level: number | null;    // null = use tenant default
  external_comms_unlocks: Record<string, 'autonomous' | 'gated'>;
}

function resolveAutonomy(
  agent_id: string,
  tier: CommandTier,
  agent_config: AgentAutonomyConfig,
  tenant_default_level: number,
): AutonomyDecision {
  const effective_level = agent_config.autonomy_level ?? tenant_default_level;

  // Yellow+External has its own independent gate
  if (tier === 'yellow_external') {
    const unlock = agent_config.external_comms_unlocks[/* tool_name */];
    if (unlock === 'autonomous') {
      // Fall through to normal yellow gating
      return resolveStandardGating('yellow', effective_level, agent_id);
    }
    // Default: gated regardless of autonomy level
    return {
      action: 'gate',
      effective_level,
      reason: `External comms gated for agent '${agent_id}' — user must explicitly unlock`,
    };
  }

  return resolveStandardGating(tier, effective_level, agent_id);
}

function resolveStandardGating(
  tier: CommandTier,
  level: number,
  agent_id: string,
): AutonomyDecision {
  const GATING_MATRIX: Record<CommandTier, Record<number, 'execute' | 'gate'>> = {
    green:        { 1: 'execute', 2: 'execute', 3: 'execute' },
    yellow:       { 1: 'gate',    2: 'execute', 3: 'execute' },
    red:          { 1: 'gate',    2: 'gate',    3: 'execute' },
    critical_red: { 1: 'gate',    2: 'gate',    3: 'gate'    },
    yellow_external: { 1: 'gate', 2: 'gate',    3: 'gate'    }, // Handled above
  };

  const action = GATING_MATRIX[tier]?.[level] ?? 'gate';

  return {
    action,
    effective_level: level as 1 | 2 | 3,
    reason: `${tier} at level ${level} → ${action}`,
  };
}
```

### 1.5 Tool Executors

Ported from the deprecated sysadmin agent with security patterns preserved.

```typescript
// packages/safety-wrapper/src/executors/types.ts

interface ToolExecutor {
  name: string;
  execute(args: Record<string, unknown>): Promise<ToolResult>;
  validate(args: Record<string, unknown>): ValidationResult;
}

interface ToolResult {
  success: boolean;
  output: string;
  duration_ms: number;
  error?: string;
}

interface ValidationResult {
  valid: boolean;
  errors: string[];
}
```

#### 1.5.1 Shell Executor

```typescript
// packages/safety-wrapper/src/executors/shell.ts

import { execFile } from 'node:child_process';

const DEFAULT_TIMEOUT_MS = 60_000;
const MAX_OUTPUT_BYTES = 1_048_576;  // 1MB

async function executeShell(args: {
  command: string;
  timeout?: number;
  working_dir?: string;
}): Promise<ToolResult> {
  const classification = classifyShellCommand(args.command);
  if (!classification.is_allowed) {
    return { success: false, output: '', duration_ms: 0,
             error: classification.reason };
  }

  const timeout = Math.min(args.timeout ?? DEFAULT_TIMEOUT_MS, 120_000);
  const cwd = resolveSafePath(args.working_dir ?? '/opt/letsbe');

  return new Promise((resolve) => {
    const start = Date.now();
    // Use execFile (not exec) to prevent shell injection
    const child = execFile(
      classification.binary,
      classification.args,
      { timeout, cwd, maxBuffer: MAX_OUTPUT_BYTES, env: getSanitizedEnv() },
      (error, stdout, stderr) => {
        const duration_ms = Date.now() - start;
        if (error) {
          resolve({
            success: false,
            output: truncateOutput(stderr || error.message),
            duration_ms,
            error: error.code === 'ERR_CHILD_PROCESS_TIMEOUT'
              ? `Command timed out after ${timeout}ms`
              : error.message,
          });
        } else {
          resolve({
            success: true,
            output: truncateOutput(stdout),
            duration_ms,
          });
        }
      },
    );
  });
}
```

#### 1.5.2 Docker Executor

```typescript
// packages/safety-wrapper/src/executors/docker.ts

const DOCKER_OPERATIONS = {
  stats:    { args: (name: string) => ['stats', name, '--no-stream', '--format', 'json'] },
  logs:     { args: (name: string, lines: number) => ['logs', name, '--tail', String(lines)] },
  restart:  { args: (name: string) => ['restart', name] },
  start:    { args: (name: string) => ['start', name] },
  stop:     { args: (name: string) => ['stop', name] },
  inspect:  { args: (name: string) => ['inspect', name] },
  ps:       { args: () => ['ps', '--format', 'json'] },
} as const;

async function executeDocker(args: {
  operation: keyof typeof DOCKER_OPERATIONS;
  container_name?: string;
  lines?: number;
}): Promise<ToolResult> {
  const op = DOCKER_OPERATIONS[args.operation];
  if (!op) {
    return { success: false, output: '', duration_ms: 0,
             error: `Unknown docker operation: ${args.operation}` };
  }

  // Container name validation: alphanumeric, dash, underscore only
  if (args.container_name && !/^[a-zA-Z0-9_-]+$/.test(args.container_name)) {
    return { success: false, output: '', duration_ms: 0,
             error: 'Invalid container name' };
  }

  const dockerArgs = op.args(args.container_name!, args.lines ?? 100);
  return executeShell({ command: `docker ${dockerArgs.join(' ')}` });
}
```

#### 1.5.3 File Executor

```typescript
// packages/safety-wrapper/src/executors/file.ts

import { readFile, writeFile, stat, unlink } from 'node:fs/promises';
import { resolve, normalize } from 'node:path';

const ALLOWED_ROOT = '/opt/letsbe';
const MAX_READ_BYTES = 10_485_760;   // 10MB
const MAX_WRITE_BYTES = 10_485_760;  // 10MB

function resolveSafePath(requested_path: string): string {
  const resolved = resolve(ALLOWED_ROOT, requested_path);
  const normalized = normalize(resolved);

  if (!normalized.startsWith(ALLOWED_ROOT)) {
    throw new Error(`Path traversal blocked: ${requested_path} resolves to ${normalized}`);
  }

  return normalized;
}

async function executeFileRead(args: {
  path: string;
  encoding?: string;
  offset?: number;
  limit?: number;
}): Promise<ToolResult> {
  const safePath = resolveSafePath(args.path);
  const start = Date.now();

  const fileInfo = await stat(safePath);
  if (fileInfo.size > MAX_READ_BYTES) {
    return { success: false, output: '', duration_ms: Date.now() - start,
             error: `File too large: ${fileInfo.size} bytes (max ${MAX_READ_BYTES})` };
  }

  const content = await readFile(safePath, args.encoding ?? 'utf-8');
  const lines = content.split('\n');
  const offset = args.offset ?? 0;
  const limit = args.limit ?? lines.length;
  const slice = lines.slice(offset, offset + limit).join('\n');

  return { success: true, output: slice, duration_ms: Date.now() - start };
}

async function executeFileWrite(args: {
  path: string;
  content: string;
  mode?: 'overwrite' | 'append';
}): Promise<ToolResult> {
  const safePath = resolveSafePath(args.path);
  const start = Date.now();

  if (Buffer.byteLength(args.content) > MAX_WRITE_BYTES) {
    return { success: false, output: '', duration_ms: Date.now() - start,
             error: `Content too large (max ${MAX_WRITE_BYTES} bytes)` };
  }

  if (args.mode === 'append') {
    const existing = await readFile(safePath, 'utf-8').catch(() => '');
    await writeFile(safePath, existing + args.content, 'utf-8');
  } else {
    // Atomic write: write to temp, then rename
    const tmpPath = safePath + '.tmp';
    await writeFile(tmpPath, args.content, 'utf-8');
    const { rename } = await import('node:fs/promises');
    await rename(tmpPath, safePath);
  }

  return { success: true, output: `Written to ${args.path}`, duration_ms: Date.now() - start };
}
```

#### 1.5.4 Env Executor

```typescript
// packages/safety-wrapper/src/executors/env.ts

import { readFile, writeFile, rename } from 'node:fs/promises';

async function executeEnvRead(args: {
  path: string;
  keys?: string[];
}): Promise<ToolResult> {
  const safePath = resolveSafePath(args.path);
  const start = Date.now();
  const content = await readFile(safePath, 'utf-8');

  const parsed = parseEnvFile(content);

  if (args.keys && args.keys.length > 0) {
    const filtered: Record<string, string> = {};
    for (const key of args.keys) {
      if (key in parsed) filtered[key] = parsed[key];
    }
    return { success: true, output: JSON.stringify(filtered, null, 2),
             duration_ms: Date.now() - start };
  }

  return { success: true, output: JSON.stringify(parsed, null, 2),
           duration_ms: Date.now() - start };
}

async function executeEnvUpdate(args: {
  path: string;
  updates: Record<string, string>;
}): Promise<ToolResult> {
  const safePath = resolveSafePath(args.path);
  const start = Date.now();

  const content = await readFile(safePath, 'utf-8');
  let updated = content;

  for (const [key, value] of Object.entries(args.updates)) {
    const regex = new RegExp(`^${escapeRegex(key)}=.*$`, 'm');
    if (regex.test(updated)) {
      updated = updated.replace(regex, `${key}=${value}`);
    } else {
      updated += `\n${key}=${value}`;
    }
  }

  // Atomic write: temp → chmod 600 → rename
  const tmpPath = safePath + '.tmp';
  await writeFile(tmpPath, updated, { mode: 0o600 });
  await rename(tmpPath, safePath);

  return { success: true, output: `Updated ${Object.keys(args.updates).length} keys`,
           duration_ms: Date.now() - start };
}
```

### 1.6 Hub Communication Client

```typescript
// packages/safety-wrapper/src/hub/client.ts

interface HubClient {
  register(token: string): Promise<{ hub_api_key: string; config: TenantConfig }>;
  heartbeat(metrics: HeartbeatPayload): Promise<HeartbeatResponse>;
  reportUsage(buckets: TokenUsageBucket[]): Promise<void>;
  requestApproval(request: ApprovalRequest): Promise<{ approval_id: string }>;
  pollApproval(approval_id: string): Promise<ApprovalResponse>;
  reportBackupStatus(status: BackupStatus): Promise<void>;
}

interface HeartbeatPayload {
  version: string;
  openclaw_version: string;
  uptime_seconds: number;
  agents: AgentStatus[];
  active_sessions: number;
  pending_approvals: number;
  last_token_usage_sync: string;     // ISO timestamp
  last_backup_check: string;         // ISO timestamp
  disk_usage_percent: number;
  memory_usage_mb: number;
}

interface HeartbeatResponse {
  config_version: number;
  config?: TenantConfig;             // Only if config_version > local
  pending_commands: RemoteCommand[];  // Commands queued by admin
  pool_remaining: number;            // Token pool remaining
}

interface TokenUsageBucket {
  agent_id: string;
  model: string;
  bucket_hour: string;               // ISO hour (e.g., "2026-02-27T14:00:00Z")
  tokens_input: number;
  tokens_output: number;
  tokens_cache_read: number;
  tokens_cache_write: number;
  web_search_count: number;
  web_fetch_count: number;
  estimated_cost_cents: number;
}

interface ApprovalRequest {
  agent_id: string;
  command_class: CommandTier;
  tool_name: string;
  tool_args: Record<string, unknown>;
  human_readable: string;            // Natural language description
}

interface ApprovalResponse {
  status: 'pending' | 'approved' | 'denied' | 'expired';
  responded_at?: string;
  responded_by?: string;
}
```

#### 1.6.1 Heartbeat Loop

```typescript
// packages/safety-wrapper/src/hub/heartbeat.ts

const HEARTBEAT_INTERVAL_MS = 60_000;           // 1 minute
const HEARTBEAT_BACKOFF_MAX_MS = 300_000;        // 5 minutes
const USAGE_REPORT_INTERVAL_MS = 300_000;        // 5 minutes

class HeartbeatService {
  private interval: NodeJS.Timeout | null = null;
  private consecutiveFailures = 0;

  start(): void {
    this.interval = setInterval(() => this.tick(), HEARTBEAT_INTERVAL_MS);
    // Also start usage reporting on a separate cadence
    setInterval(() => this.reportUsage(), USAGE_REPORT_INTERVAL_MS);
  }

  private async tick(): Promise<void> {
    try {
      const metrics = await this.collectMetrics();
      const response = await this.hubClient.heartbeat(metrics);

      this.consecutiveFailures = 0;

      // Apply config update if newer version available
      if (response.config && response.config_version > this.localConfigVersion) {
        await this.applyConfig(response.config);
        this.localConfigVersion = response.config_version;
      }

      // Execute any pending remote commands
      for (const cmd of response.pending_commands) {
        await this.executeRemoteCommand(cmd);
      }

      // Track pool remaining for local rate limiting
      this.poolRemaining = response.pool_remaining;

    } catch (error) {
      this.consecutiveFailures++;
      const backoff = Math.min(
        HEARTBEAT_INTERVAL_MS * Math.pow(2, this.consecutiveFailures),
        HEARTBEAT_BACKOFF_MAX_MS,
      );
      // Apply jitter (±15%)
      const jitter = backoff * (0.85 + Math.random() * 0.3);
      await sleep(jitter);
    }
  }
}
```

### 1.7 Token Metering

```typescript
// packages/safety-wrapper/src/metering/token-meter.ts

class TokenMeter {
  private buckets: Map<string, TokenUsageBucket> = new Map();

  /**
   * Called after every LLM response. Captures token counts from
   * OpenRouter response headers.
   */
  record(event: {
    agent_id: string;
    model: string;
    tokens_input: number;
    tokens_output: number;
    tokens_cache_read: number;
    tokens_cache_write: number;
    is_web_search: boolean;
    is_web_fetch: boolean;
  }): void {
    const bucketHour = getCurrentHour();  // "2026-02-27T14:00:00Z"
    const key = `${event.agent_id}:${event.model}:${bucketHour}`;

    const bucket = this.buckets.get(key) ?? {
      agent_id: event.agent_id,
      model: event.model,
      bucket_hour: bucketHour,
      tokens_input: 0,
      tokens_output: 0,
      tokens_cache_read: 0,
      tokens_cache_write: 0,
      web_search_count: 0,
      web_fetch_count: 0,
      estimated_cost_cents: 0,
    };

    bucket.tokens_input += event.tokens_input;
    bucket.tokens_output += event.tokens_output;
    bucket.tokens_cache_read += event.tokens_cache_read;
    bucket.tokens_cache_write += event.tokens_cache_write;
    if (event.is_web_search) bucket.web_search_count++;
    if (event.is_web_fetch) bucket.web_fetch_count++;
    bucket.estimated_cost_cents += this.calculateCost(event);

    this.buckets.set(key, bucket);
  }

  /**
   * Flush completed hourly buckets to Hub.
   * Called every 5 minutes by HeartbeatService.
   */
  flush(): TokenUsageBucket[] {
    const currentHour = getCurrentHour();
    const completed: TokenUsageBucket[] = [];

    for (const [key, bucket] of this.buckets.entries()) {
      if (bucket.bucket_hour !== currentHour) {
        completed.push(bucket);
        this.buckets.delete(key);
      }
    }

    return completed;
  }

  private calculateCost(event: {
    model: string;
    tokens_input: number;
    tokens_output: number;
    tokens_cache_read: number;
    tokens_cache_write: number;
  }): number {
    const pricing = MODEL_PRICING[event.model];
    if (!pricing) return 0;

    return Math.ceil(
      (event.tokens_input * pricing.input_per_m / 1_000_000) +
      (event.tokens_output * pricing.output_per_m / 1_000_000) +
      (event.tokens_cache_read * pricing.cache_read_per_m / 1_000_000) +
      (event.tokens_cache_write * pricing.cache_write_per_m / 1_000_000)
    );
  }
}
```

### 1.8 Audit Log

```typescript
// packages/safety-wrapper/src/audit/logger.ts

interface AuditEntry {
  id: string;                         // UUID
  timestamp: string;                  // ISO 8601
  agent_id: string;
  session_id: string;
  tool_name: string;
  tool_args_redacted: Record<string, unknown>;  // Args with secrets stripped
  classification: CommandTier;
  autonomy_decision: 'executed' | 'gated' | 'denied';
  effective_level: number;
  result_summary: string;             // First 500 chars, secrets redacted
  duration_ms: number;
  approval_id?: string;               // If gated
}

// SQLite table: audit_log
// Append-only. Retained for 90 days. Queryable by agent, tier, date range.
// Reported to Hub via heartbeat for admin dashboard.
```

### 1.9 SQLite State Schema

```sql
-- packages/safety-wrapper/src/db/schema.sql

-- Secrets registry (encrypted values via ChaCha20-Poly1305)
CREATE TABLE secrets (
  id TEXT PRIMARY KEY,
  name TEXT NOT NULL UNIQUE,          -- e.g., "nextcloud_admin_password"
  encrypted_value BLOB NOT NULL,      -- ChaCha20-Poly1305 encrypted
  service TEXT NOT NULL,              -- e.g., "nextcloud"
  config_path TEXT,                   -- e.g., "/opt/letsbe/env/nextcloud.env"
  pattern TEXT,                       -- Regex pattern for safety net matching
  created_at TEXT NOT NULL DEFAULT (datetime('now')),
  rotated_at TEXT,
  rotation_count INTEGER DEFAULT 0
);

-- Pending approval requests
CREATE TABLE approvals (
  id TEXT PRIMARY KEY,
  agent_id TEXT NOT NULL,
  tool_name TEXT NOT NULL,
  tool_args TEXT NOT NULL,            -- JSON
  human_readable TEXT NOT NULL,
  classification TEXT NOT NULL,
  status TEXT NOT NULL DEFAULT 'pending',  -- pending, approved, denied, expired
  requested_at TEXT NOT NULL DEFAULT (datetime('now')),
  expires_at TEXT NOT NULL,
  responded_at TEXT,
  responded_by TEXT
);
CREATE INDEX idx_approvals_status ON approvals(status);

-- Audit log (append-only)
CREATE TABLE audit_log (
  id TEXT PRIMARY KEY,
  timestamp TEXT NOT NULL DEFAULT (datetime('now')),
  agent_id TEXT NOT NULL,
  session_id TEXT NOT NULL,
  tool_name TEXT NOT NULL,
  tool_args_redacted TEXT NOT NULL,   -- JSON with secrets stripped
  classification TEXT NOT NULL,
  decision TEXT NOT NULL,             -- executed, gated, denied
  effective_level INTEGER NOT NULL,
  result_summary TEXT,
  duration_ms INTEGER NOT NULL,
  approval_id TEXT
);
CREATE INDEX idx_audit_timestamp ON audit_log(timestamp);
CREATE INDEX idx_audit_agent ON audit_log(agent_id);

-- Token usage buckets (aggregated hourly)
CREATE TABLE token_usage (
  id TEXT PRIMARY KEY,
  agent_id TEXT NOT NULL,
  model TEXT NOT NULL,
  bucket_hour TEXT NOT NULL,
  tokens_input INTEGER DEFAULT 0,
  tokens_output INTEGER DEFAULT 0,
  tokens_cache_read INTEGER DEFAULT 0,
  tokens_cache_write INTEGER DEFAULT 0,
  web_search_count INTEGER DEFAULT 0,
  web_fetch_count INTEGER DEFAULT 0,
  estimated_cost_cents INTEGER DEFAULT 0,
  synced_to_hub INTEGER DEFAULT 0,    -- Boolean: has this been reported?
  created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE UNIQUE INDEX idx_usage_bucket ON token_usage(agent_id, model, bucket_hour);

-- Hub sync state
CREATE TABLE hub_state (
  key TEXT PRIMARY KEY,
  value TEXT NOT NULL,
  updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
-- Keys: 'config_version', 'hub_api_key', 'last_heartbeat', 'last_usage_sync'
```

### 1.10 Configuration File

```json5
// /opt/letsbe/config/safety-wrapper.json
{
  "server": {
    "port": 8200,
    "host": "127.0.0.1",
    "auth_token": "${SAFETY_WRAPPER_TOKEN}"
  },
  "hub": {
    "url": "${HUB_URL}",
    "registration_token": "${REGISTRATION_TOKEN}",
    "heartbeat_interval_ms": 60000,
    "usage_report_interval_ms": 300000
  },
  "secrets_proxy": {
    "url": "http://127.0.0.1:8100"
  },
  "openclaw": {
    "url": "http://127.0.0.1:18789",
    "token": "${OPENCLAW_TOKEN}"
  },
  "tenant": {
    "id": "${TENANT_ID}",
    "default_autonomy_level": 2
  },
  "security": {
    "shell_timeout_ms": 60000,
    "max_file_size_bytes": 10485760,
    "allowed_root": "/opt/letsbe",
    "approval_expiry_hours": 24,
    "max_pending_approvals": 50,
    "rate_limit_per_agent_per_minute": 30
  },
  "database": {
    "path": "/opt/letsbe/data/safety-wrapper.db"
  }
}
```

---

## 2. Secrets Proxy

**Process:** `letsbe-secrets-proxy` — Lightweight Node.js HTTP proxy
**Port:** `localhost:8100`
**RAM Budget:** ~64MB
**Language:** TypeScript (strict mode, ESM)
**Repository:** `packages/secrets-proxy/` in monorepo

### 2.1 Responsibility

The Secrets Proxy has ONE job: intercept all outbound LLM traffic and strip secrets before they reach OpenRouter. It sits between OpenClaw and the internet at the transport layer.

```
OpenClaw → (port 8100) Secrets Proxy → (HTTPS) OpenRouter/Anthropic/etc.
```

OpenClaw is configured with `modelProvider.url = "http://127.0.0.1:8100"` so all LLM API calls route through this proxy.

### 2.2 4-Layer Redaction Pipeline

```typescript
// packages/secrets-proxy/src/pipeline/redact.ts

interface RedactionResult {
  text: string;
  redactions: RedactionEntry[];
  duration_ms: number;
}

interface RedactionEntry {
  original_length: number;           // Length of redacted value (not the value itself)
  placeholder: string;               // e.g., "[REDACTED:nextcloud_admin_password]"
  layer: 1 | 2 | 3 | 4;            // Which layer caught it
}

async function redact(text: string, registry: SecretsRegistry): Promise<RedactionResult> {
  const start = Date.now();
  const redactions: RedactionEntry[] = [];
  let result = text;

  // Layer 1 — Aho-Corasick registry lookup (O(n) in text length)
  result = registryRedact(result, registry, redactions);

  // Layer 2 — Regex safety net for unregistered secrets
  result = patternRedact(result, redactions);

  // Layer 3 — Shannon entropy filter for high-entropy blobs
  result = entropyRedact(result, redactions);

  // Layer 4 — JSON key scanning for sensitive field names
  result = jsonKeyRedact(result, redactions);

  return { text: result, redactions, duration_ms: Date.now() - start };
}
```

#### 2.2.1 Layer 1 — Aho-Corasick Registry Lookup

```typescript
// packages/secrets-proxy/src/pipeline/layer1-registry.ts

import { AhoCorasick } from './aho-corasick';

/**
 * Builds an Aho-Corasick automaton from all known secret values.
 * Matches all secrets in a single pass through the text (O(n)).
 * Rebuilds automaton when secrets rotate.
 */
class RegistryMatcher {
  private automaton: AhoCorasick;
  private secretMap: Map<string, string>;  // value → placeholder name

  constructor(secrets: Array<{ name: string; value: string }>) {
    this.secretMap = new Map();
    const patterns: string[] = [];

    for (const secret of secrets) {
      this.secretMap.set(secret.value, `[REDACTED:${secret.name}]`);
      patterns.push(secret.value);
    }

    this.automaton = new AhoCorasick(patterns);
  }

  redact(text: string): { text: string; matches: number } {
    let result = text;
    let matches = 0;

    // Find all matches in one pass
    const found = this.automaton.search(text);

    // Replace longest matches first (greedy)
    const sorted = found.sort((a, b) => b.length - a.length);
    for (const match of sorted) {
      const placeholder = this.secretMap.get(match.pattern);
      if (placeholder) {
        result = result.replaceAll(match.pattern, placeholder);
        matches++;
      }
    }

    return { text: result, matches };
  }
}
```

#### 2.2.2 Layer 2 — Regex Safety Net

```typescript
// packages/secrets-proxy/src/pipeline/layer2-patterns.ts

const SECRET_PATTERNS: Array<{ name: string; pattern: RegExp; placeholder: string }> = [
  {
    name: 'private_key',
    pattern: /-----BEGIN\s+[\w\s]*PRIVATE KEY-----[\s\S]*?-----END\s+[\w\s]*PRIVATE KEY-----/g,
    placeholder: '[REDACTED:private_key]',
  },
  {
    name: 'jwt_token',
    pattern: /eyJ[A-Za-z0-9_-]{10,}\.eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}/g,
    placeholder: '[REDACTED:jwt_token]',
  },
  {
    name: 'bcrypt_hash',
    pattern: /\$2[aby]?\$\d{1,2}\$[./A-Za-z0-9]{53}/g,
    placeholder: '[REDACTED:bcrypt_hash]',
  },
  {
    name: 'connection_string',
    pattern: /:\/\/[^:\s]+:[^@\s]+@[^/\s]+/g,
    placeholder: '[REDACTED:connection_string]',
  },
  {
    name: 'env_secret',
    pattern: /(PASSWORD|SECRET|KEY|TOKEN|CREDENTIAL|AUTH)[\s]*[=:]\s*[^\s]{8,}/gi,
    placeholder: '[REDACTED:env_secret]',
  },
  {
    name: 'aws_key',
    pattern: /AKIA[0-9A-Z]{16}/g,
    placeholder: '[REDACTED:aws_access_key]',
  },
  {
    name: 'api_key_generic',
    pattern: /(?:api[_-]?key|apikey|access[_-]?token)[\s]*[=:]\s*['"]?[a-zA-Z0-9_-]{20,}['"]?/gi,
    placeholder: '[REDACTED:api_key]',
  },
];
```

#### 2.2.3 Layer 3 — Shannon Entropy Filter

```typescript
// packages/secrets-proxy/src/pipeline/layer3-entropy.ts

const ENTROPY_THRESHOLD = 4.5;       // Shannon bits
const MIN_TOKEN_LENGTH = 32;         // Only check strings >= 32 chars
const MAX_TOKEN_LENGTH = 256;        // Don't check huge blobs

function shannonEntropy(str: string): number {
  const freq = new Map<string, number>();
  for (const char of str) {
    freq.set(char, (freq.get(char) ?? 0) + 1);
  }
  let entropy = 0;
  for (const count of freq.values()) {
    const p = count / str.length;
    entropy -= p * Math.log2(p);
  }
  return entropy;
}

function entropyRedact(text: string, redactions: RedactionEntry[]): string {
  // Split on whitespace and common delimiters
  const tokens = text.split(/[\s"',;:=\[\]{}()]+/);
  let result = text;

  for (const token of tokens) {
    if (token.length >= MIN_TOKEN_LENGTH && token.length <= MAX_TOKEN_LENGTH) {
      const entropy = shannonEntropy(token);
      if (entropy >= ENTROPY_THRESHOLD) {
        // High entropy — likely a secret
        const placeholder = `[REDACTED:high_entropy_${token.length}chars]`;
        result = result.replace(token, placeholder);
        redactions.push({
          original_length: token.length,
          placeholder,
          layer: 3,
        });
      }
    }
  }

  return result;
}
```

#### 2.2.4 Layer 4 — JSON Key Scanning

```typescript
// packages/secrets-proxy/src/pipeline/layer4-json.ts

const SENSITIVE_KEYS = new Set([
  'password', 'passwd', 'secret', 'token', 'api_key', 'apikey',
  'access_token', 'refresh_token', 'private_key', 'auth',
  'credential', 'credentials', 'authorization', 'bearer',
  'connection_string', 'database_url', 'db_password',
]);

function jsonKeyRedact(text: string, redactions: RedactionEntry[]): string {
  // Find JSON-like key-value pairs
  const jsonPattern = /"([\w_]+)"\s*:\s*"([^"]{4,})"/g;
  let result = text;

  for (const match of text.matchAll(jsonPattern)) {
    const key = match[1].toLowerCase();
    const value = match[2];

    if (SENSITIVE_KEYS.has(key)) {
      const placeholder = `[REDACTED:${key}]`;
      result = result.replace(`"${match[2]}"`, `"${placeholder}"`);
      redactions.push({
        original_length: value.length,
        placeholder,
        layer: 4,
      });
    }
  }

  return result;
}
```

### 2.3 Proxy HTTP Server

```typescript
// packages/secrets-proxy/src/server.ts

import { createServer, IncomingMessage, ServerResponse } from 'node:http';

const UPSTREAM_PROVIDERS: Record<string, string> = {
  openrouter: 'https://openrouter.ai',
  anthropic: 'https://api.anthropic.com',
  openai: 'https://api.openai.com',
};

async function handleRequest(req: IncomingMessage, res: ServerResponse): Promise<void> {
  // Only proxy POST requests to /v1/chat/completions and similar
  if (req.method !== 'POST') {
    res.writeHead(405);
    res.end('Method not allowed');
    return;
  }

  const body = await readBody(req);
  const parsed = JSON.parse(body);

  // Redact ALL message content before forwarding
  for (const message of parsed.messages ?? []) {
    if (typeof message.content === 'string') {
      const { text } = await redact(message.content, secretsRegistry);
      message.content = text;
    }
    // Handle tool_calls and tool results
    if (message.tool_calls) {
      for (const tc of message.tool_calls) {
        if (tc.function?.arguments) {
          const { text } = await redact(
            JSON.stringify(tc.function.arguments), secretsRegistry,
          );
          tc.function.arguments = text;
        }
      }
    }
  }

  // Forward to upstream provider
  const upstream = determineUpstream(req.url!, req.headers);
  const upstreamResponse = await fetch(upstream.url + req.url, {
    method: 'POST',
    headers: { ...upstream.headers, 'Content-Type': 'application/json' },
    body: JSON.stringify(parsed),
  });

  // Capture token usage from response headers
  captureTokenUsage(upstreamResponse.headers, parsed);

  // Stream response back to OpenClaw
  res.writeHead(upstreamResponse.status, Object.fromEntries(upstreamResponse.headers));
  const responseBody = await upstreamResponse.text();
  res.end(responseBody);
}
```

### 2.4 Secrets API (for App ↔ Secrets Side Channel)

```typescript
// packages/secrets-proxy/src/api/secrets.ts

// These endpoints are called by the mobile app (via Hub relay)
// NOT by the AI — the AI never reaches these endpoints

interface SecretsAPI {
  // User provides a new credential value
  'POST /secrets/provide': {
    body: { secret_id: string; value: string };
    response: { success: boolean };
    auth: 'hub_session_token';
  };

  // User requests to view a credential (tap-to-reveal)
  'GET /secrets/reveal': {
    query: { secret_id: string };
    response: { value: string; expires_in_seconds: 30 };
    auth: 'hub_session_token';
  };

  // Generate a cryptographically secure credential
  'POST /secrets/generate': {
    body: {
      service: string;
      key: string;
      constraints?: { min_length?: number; require_special?: boolean };
    };
    response: { secret_id: string; status: 'generated' };
    auth: 'hub_session_token';
  };

  // List credentials (names + metadata, never values)
  'GET /secrets/list': {
    response: Array<{
      id: string;
      name: string;
      service: string;
      created_at: string;
      rotated_at: string | null;
    }>;
    auth: 'hub_session_token';
  };

  // Rotate a credential
  'POST /secrets/rotate': {
    body: { secret_id: string };
    response: { success: boolean; new_secret_id: string };
    auth: 'hub_session_token';
  };

  // View access/rotation audit trail
  'GET /secrets/audit': {
    query: { secret_id?: string; limit?: number };
    response: Array<{
      action: 'provide' | 'reveal' | 'generate' | 'rotate' | 'inject';
      secret_name: string;
      timestamp: string;
      actor: string;  // 'customer', 'agent:it-admin', 'system'
    }>;
    auth: 'hub_session_token';
  };
}
```

---

## 3. Hub Updates

**Existing codebase:** ~15,000 LOC, 244 files, 80+ endpoints, 22+ Prisma models
**Strategy:** Retool, not rewrite — add new capabilities alongside existing working systems
**Stack:** Next.js 16.1.1 + Prisma 7.0.0 + PostgreSQL 16

### 3.1 New Prisma Models

#### 3.1.1 Token Usage Bucket

```prisma
model TokenUsageBucket {
  id                String   @id @default(uuid())
  userId            String
  orderId           String
  agentId           String
  model             String
  bucketHour        DateTime
  tokensInput       Int      @default(0)
  tokensOutput      Int      @default(0)
  tokensCacheRead   Int      @default(0)
  tokensCacheWrite  Int      @default(0)
  webSearchCount    Int      @default(0)
  webFetchCount     Int      @default(0)
  costCents         Int      @default(0)
  isPremiumModel    Boolean  @default(false)
  createdAt         DateTime @default(now())
  updatedAt         DateTime @updatedAt

  user  User  @relation(fields: [userId], references: [id])
  order Order @relation(fields: [orderId], references: [id])

  @@unique([orderId, agentId, model, bucketHour])
  @@index([userId, bucketHour])
  @@index([orderId, bucketHour])
  @@map("token_usage_buckets")
}
```

#### 3.1.2 Billing Period

```prisma
model BillingPeriod {
  id                  String   @id @default(uuid())
  userId              String
  subscriptionId      String
  periodStart         DateTime
  periodEnd           DateTime
  tokenAllotment      Int      // base × founding member multiplier
  tokensUsed          Int      @default(0)
  overageTokens       Int      @default(0)
  overageCostCents    Int      @default(0)
  premiumTokensUsed   Int      @default(0)
  premiumCostCents    Int      @default(0)
  stripeInvoiceId     String?
  status              String   @default("ACTIVE")  // ACTIVE, CLOSED, BILLED
  overageOptedIn      Boolean  @default(false)
  poolExhaustedAt     DateTime?
  createdAt           DateTime @default(now())

  user         User         @relation(fields: [userId], references: [id])
  subscription Subscription @relation(fields: [subscriptionId], references: [id])

  @@index([userId, periodStart])
  @@index([status])
  @@map("billing_periods")
}
```

#### 3.1.3 Founding Member

```prisma
model FoundingMember {
  id                String   @id @default(uuid())
  userId            String   @unique
  tokenMultiplier   Int      @default(2)
  startDate         DateTime @default(now())
  expiresAt         DateTime      // 12 months from start
  isActive          Boolean  @default(true)
  createdAt         DateTime @default(now())

  user User @relation(fields: [userId], references: [id])

  @@map("founding_members")
}
```

#### 3.1.4 Agent Config

```prisma
model AgentConfig {
  id                    String   @id @default(uuid())
  orderId               String
  agentId               String
  name                  String
  role                  String   // dispatcher, it-admin, marketing, secretary, sales, custom
  soulMd                String   @db.Text
  toolsAllowed          String[]
  toolsDenied           String[]
  toolProfile           String   @default("minimal")
  modelPreset           String   @default("balanced")  // basic, balanced, complex
  premiumModel          String?                        // e.g., "anthropic/claude-opus-4-6"
  autonomyLevel         Int?     // null = tenant default
  externalCommsUnlocks  Json?    // { "ghost_publish": "autonomous", ... }
  isActive              Boolean  @default(true)
  createdAt             DateTime @default(now())
  updatedAt             DateTime @updatedAt

  order Order @relation(fields: [orderId], references: [id])

  @@unique([orderId, agentId])
  @@map("agent_configs")
}
```

#### 3.1.5 Command Approval

```prisma
model CommandApproval {
  id              String    @id @default(uuid())
  orderId         String
  agentId         String
  commandClass    String    // yellow, yellow_external, red, critical_red
  toolName        String
  toolArgs        Json
  humanReadable   String
  status          String    @default("PENDING")  // PENDING, APPROVED, DENIED, EXPIRED
  requestedAt     DateTime  @default(now())
  respondedAt     DateTime?
  respondedBy     String?
  respondedByType String?   // 'staff' or 'customer'
  expiresAt       DateTime
  createdAt       DateTime  @default(now())

  order Order @relation(fields: [orderId], references: [id])

  @@index([orderId, status])
  @@index([status, expiresAt])
  @@map("command_approvals")
}
```

#### 3.1.6 Updated ServerConnection

```prisma
model ServerConnection {
  id                     String    @id @default(uuid())
  orderId                String    @unique
  registrationToken      String    @unique
  hubApiKey              String?
  hubApiKeyHash          String?
  safetyWrapperUrl       String?   // was orchestratorUrl
  openclawVersion        String?
  safetyWrapperVersion   String?
  lastTokenUsageSync     DateTime?
  lastBackupReport       DateTime?
  configVersion          Int       @default(0)
  status                 String    @default("PENDING")
  lastHeartbeat          DateTime?

  order Order @relation(fields: [orderId], references: [id])

  @@map("server_connections")
}
```

### 3.2 New API Endpoints

#### 3.2.1 Safety Wrapper Communication (replaces `/api/v1/orchestrator/*`)

```
POST /api/v1/tenant/register
  Auth: Registration token in body
  Body: { registration_token, version, openclaw_version }
  Returns: { hub_api_key, config: TenantConfig }

POST /api/v1/tenant/heartbeat
  Auth: Bearer {hubApiKey}
  Body: HeartbeatPayload (see Safety Wrapper section)
  Returns: HeartbeatResponse (config updates, pending commands, pool status)

POST /api/v1/tenant/usage
  Auth: Bearer {hubApiKey}
  Body: { buckets: TokenUsageBucket[] }
  Returns: { accepted: true, pool_remaining: number }

POST /api/v1/tenant/approval-request
  Auth: Bearer {hubApiKey}
  Body: ApprovalRequest
  Returns: { approval_id: string }
  Side effect: Push notification to customer + staff

GET /api/v1/tenant/approval-response/{id}
  Auth: Bearer {hubApiKey}
  Returns: ApprovalResponse

GET /api/v1/tenant/config
  Auth: Bearer {hubApiKey}
  Returns: Full TenantConfig (agents, autonomy, tools, classification rules)

POST /api/v1/tenant/backup-status
  Auth: Bearer {hubApiKey}
  Body: { backup_date, databases: [...], status, errors: [...] }
  Returns: { accepted: true }
```

#### 3.2.2 Customer Portal API

```
GET /api/v1/customer/dashboard
  Auth: Customer JWT
  Returns: { server_status, agents: AgentSummary[], recent_activity, usage_summary }

GET /api/v1/customer/agents
  Auth: Customer JWT
  Returns: AgentConfig[] with status

GET /api/v1/customer/agents/{id}
  Auth: Customer JWT
  Returns: Full AgentConfig with activity feed

PATCH /api/v1/customer/agents/{id}
  Auth: Customer JWT
  Body: { autonomy_level?, soul_md?, model_preset?, premium_model? }
  Returns: Updated AgentConfig
  Side effect: Pushes config update to Safety Wrapper

PATCH /api/v1/customer/agents/{id}/external-comms
  Auth: Customer JWT
  Body: { unlocks: Record<string, 'autonomous' | 'gated'> }
  Returns: Updated AgentConfig

POST /api/v1/customer/agents
  Auth: Customer JWT (custom agent creation)
  Body: { name, role: 'custom', soul_md, tools_allowed, tools_denied }
  Returns: New AgentConfig

DELETE /api/v1/customer/agents/{id}
  Auth: Customer JWT (custom agents only — cannot delete defaults)
  Returns: { deleted: true }

GET /api/v1/customer/approvals
  Auth: Customer JWT
  Returns: CommandApproval[] (pending only by default)

POST /api/v1/customer/approvals/{id}
  Auth: Customer JWT
  Body: { action: 'approve' | 'deny' }
  Returns: Updated CommandApproval
  Side effect: Notifies Safety Wrapper of decision

GET /api/v1/customer/usage
  Auth: Customer JWT
  Returns: {
    current_period: BillingPeriod,
    by_agent: Record<string, { tokens_used, cost_cents }>,
    by_model: Record<string, { tokens_used, cost_cents }>,
    daily_trend: Array<{ date, tokens }>,
  }

GET /api/v1/customer/usage/breakdown
  Auth: Customer JWT
  Query: { period_id?, agent_id?, model? }
  Returns: Detailed TokenUsageBucket[] with cost calculations

GET /api/v1/customer/tools
  Auth: Customer JWT
  Returns: Array<{ name, status, category, has_api, internal_url }>

GET /api/v1/customer/billing
  Auth: Customer JWT
  Returns: {
    current_period: BillingPeriod,
    subscription: Subscription,
    founding_member?: { multiplier, expires_at },
    overage_opted_in: boolean,
    stripe_portal_url: string,
  }

GET /api/v1/customer/backups
  Auth: Customer JWT
  Returns: { last_backup, status, databases, snapshots }

GET /api/v1/customer/server
  Auth: Customer JWT
  Returns: { ip, tier, disk_usage, memory_usage, uptime, containers_running }

POST /api/v1/customer/chat
  Auth: Customer JWT
  Body: { agent_id?, message }
  Returns: SSE stream of agent response
  Note: Relays through Hub to tenant Safety Wrapper → OpenClaw
```

#### 3.2.3 Admin Billing & Analytics

```
POST /api/v1/admin/billing/usage
  Auth: Bearer {hubApiKey} (called by Safety Wrapper)
  Body: { buckets: TokenUsageBucket[] }
  Logic: Upsert buckets, update BillingPeriod totals, check pool threshold

GET /api/v1/admin/billing/{customerId}
  Auth: Staff JWT
  Returns: Customer billing summary with current period, usage, overages

GET /api/v1/admin/billing/{customerId}/history
  Auth: Staff JWT
  Returns: Historical BillingPeriod[] with totals

POST /api/v1/admin/billing/overages
  Auth: Staff JWT or automated cron
  Logic: Find customers where tokensUsed > tokenAllotment, create Stripe invoice items

GET /api/v1/admin/analytics/tokens
  Auth: Staff JWT
  Returns: Global token usage by model, by tier, daily trends

GET /api/v1/admin/analytics/tokens/{orderId}
  Auth: Staff JWT
  Returns: Per-tenant token breakdown

GET /api/v1/admin/analytics/costs
  Auth: Staff JWT
  Returns: Cost breakdown by customer, model, agent with margin analysis
```

#### 3.2.4 Agent Management (Admin)

```
GET /api/v1/admin/agents/templates
  Auth: Staff JWT
  Returns: Array of agent role templates (dispatcher, it-admin, etc.)

POST /api/v1/admin/orders/{id}/agents
  Auth: Staff JWT
  Body: AgentConfig creation payload
  Returns: New AgentConfig

GET /api/v1/admin/orders/{id}/agents
  Auth: Staff JWT
  Returns: All AgentConfig[] for this tenant

PATCH /api/v1/admin/orders/{id}/agents/{agentId}
  Auth: Staff JWT
  Body: Partial AgentConfig update
  Returns: Updated AgentConfig

DELETE /api/v1/admin/orders/{id}/agents/{agentId}
  Auth: Staff JWT
  Returns: { deleted: true }
```

#### 3.2.5 DNS Automation

```
POST /api/v1/admin/orders/{id}/dns/create
  Auth: Staff JWT
  Body: { provider: 'cloudflare' | 'entri', zone_id?: string }
  Logic: Auto-create A records for all required subdomains
  Returns: { records_created: number, records: DnsRecord[] }
```

### 3.3 Stripe Billing Integration Updates

```typescript
// src/lib/services/billing-service.ts

interface BillingService {
  /**
   * Create/update billing period on subscription renewal.
   * Applies founding member multiplier if active.
   */
  createBillingPeriod(userId: string, subscriptionId: string): Promise<BillingPeriod>;

  /**
   * Ingest token usage from Safety Wrapper.
   * Updates BillingPeriod totals.
   * Triggers alerts at 80%, 90%, 100% pool usage.
   */
  ingestUsage(orderId: string, buckets: TokenUsageBucket[]): Promise<{
    pool_remaining: number;
    alert?: 'approaching_limit' | 'limit_reached';
  }>;

  /**
   * Process overages via Stripe Billing Meters.
   * Creates invoice items for included model overages and premium usage.
   */
  processOverages(billingPeriodId: string): Promise<{
    overage_amount_cents: number;
    premium_amount_cents: number;
    stripe_invoice_item_id?: string;
  }>;

  /**
   * Generate Stripe customer portal URL for billing management.
   */
  getPortalUrl(userId: string): Promise<string>;
}

// Stripe Billing Meter for overage tracking
// Meter: "letsbe_token_overage" — reports token count when pool exhausted
// Meter: "letsbe_premium_usage" — reports premium model token count

// Price: $X per 1M tokens (included model overage, with markup)
// Price: $Y per 1M tokens (premium model, with sliding markup)
```

### 3.4 Push Notification Service

```typescript
// src/lib/services/notification-service.ts

interface NotificationService {
  /**
   * Send push notification for command approval request.
   * Supports: Expo Push (mobile), Web Push, Email fallback.
   */
  sendApprovalNotification(params: {
    user_id: string;
    approval_id: string;
    agent_name: string;
    human_readable: string;
    classification: string;
  }): Promise<void>;

  /**
   * Send usage alert notification.
   */
  sendUsageAlert(params: {
    user_id: string;
    percent_used: number;
    pool_remaining: number;
  }): Promise<void>;

  /**
   * Send server health alert.
   */
  sendHealthAlert(params: {
    user_id: string;
    severity: 'soft' | 'medium' | 'hard';
    message: string;
  }): Promise<void>;
}
```

### 3.5 Chat Relay Service

```typescript
// src/lib/services/chat-relay-service.ts

/**
 * Relays chat messages between the mobile app/customer portal
 * and the tenant's OpenClaw instance via the Safety Wrapper.
 *
 * Flow: App → Hub WebSocket → Hub → Safety Wrapper HTTP → OpenClaw → response
 * Return: OpenClaw → Safety Wrapper → Hub → Hub WebSocket → App
 */
interface ChatRelayService {
  /**
   * Open a streaming connection to a tenant's agent.
   * Returns an SSE stream of agent responses.
   */
  relayMessage(params: {
    user_id: string;
    order_id: string;
    agent_id: string;         // 'dispatcher' for team chat, specific agent for direct
    message: string;
    conversation_id?: string; // Resume existing conversation
  }): AsyncGenerator<ChatEvent>;
}

type ChatEvent =
  | { type: 'text_delta'; content: string }
  | { type: 'tool_call_start'; tool_name: string; agent_id: string }
  | { type: 'tool_call_result'; tool_name: string; success: boolean }
  | { type: 'approval_required'; approval_id: string; human_readable: string }
  | { type: 'secret_input_required'; secret_id: string; prompt: string }
  | { type: 'secret_card'; secret_id: string; label: string }
  | { type: 'done'; conversation_id: string };
```

---

## 4. Provisioner Updates

**Existing codebase:** ~4,477 LOC Bash, zero tests
**Strategy:** Update deployment targets, add Safety Wrapper config generation
**Location:** `packages/provisioner/` in monorepo (formerly `letsbe-ansible-runner`)

### 4.1 Updated Provisioning Pipeline

The 10-step pipeline stays. Step 10 changes completely:

```
Step 1:  System packages (apt-get)              [UNCHANGED]
Step 2:  Docker CE installation                  [UNCHANGED]
Step 3:  Disable conflicting services            [UNCHANGED]
Step 4:  nginx + fallback config                 [UNCHANGED]
Step 5:  UFW firewall (80, 443, 22022)           [UNCHANGED]
Step 6:  Optional admin user + SSH key           [UNCHANGED]
Step 7:  SSH hardening (port 22022, key-only)    [UNCHANGED]
Step 8:  Unattended security updates             [UNCHANGED]
Step 9:  Deploy tool stacks via docker-compose   [UPDATED: remove n8n references]
Step 10: Deploy LetsBe AI stack + bootstrap      [REWRITTEN: OpenClaw + Safety Wrapper]
```

### 4.2 Step 10 — Deploy LetsBe AI Stack

```bash
#!/bin/bash
# provisioner/scripts/deploy_ai_stack.sh

deploy_ai_stack() {
  local DOMAIN="$1"
  local TENANT_ID="$2"
  local HUB_URL="$3"
  local REGISTRATION_TOKEN="$4"
  local BUSINESS_TYPE="$5"

  log_step "Deploying LetsBe AI Stack"

  # 1. Generate Safety Wrapper configuration
  generate_safety_wrapper_config "$DOMAIN" "$TENANT_ID" "$HUB_URL" "$REGISTRATION_TOKEN"

  # 2. Generate OpenClaw configuration
  generate_openclaw_config "$DOMAIN" "$BUSINESS_TYPE"

  # 3. Seed secrets registry from env_setup.sh outputs
  seed_secrets_registry

  # 4. Generate agent SOUL.md files from business type template
  generate_agent_configs "$BUSINESS_TYPE"

  # 5. Generate tool registry from installed tools
  generate_tool_registry "$DOMAIN"

  # 6. Deploy containers via docker-compose
  deploy_letsbe_containers

  # 7. Wait for Safety Wrapper to register with Hub
  wait_for_registration "$HUB_URL" "$REGISTRATION_TOKEN"

  # 8. Run initial-setup Playwright scenarios via OpenClaw
  run_initial_setup_scenarios

  # 9. Clean up config.json (CRITICAL: remove plaintext passwords)
  cleanup_config_json

  log_step "LetsBe AI Stack deployed successfully"
}
```

### 4.3 Generated Configuration Files

#### 4.3.1 Safety Wrapper Config

```bash
generate_safety_wrapper_config() {
  cat > /opt/letsbe/config/safety-wrapper.json <<EOF
{
  "server": {
    "port": 8200,
    "host": "127.0.0.1",
    "auth_token": "${SAFETY_WRAPPER_TOKEN}"
  },
  "hub": {
    "url": "${HUB_URL}",
    "registration_token": "${REGISTRATION_TOKEN}"
  },
  "secrets_proxy": { "url": "http://127.0.0.1:8100" },
  "openclaw": { "url": "http://127.0.0.1:18789", "token": "${OPENCLAW_TOKEN}" },
  "tenant": { "id": "${TENANT_ID}", "default_autonomy_level": 2 },
  "database": { "path": "/opt/letsbe/data/safety-wrapper.db" }
}
EOF
}
```

#### 4.3.2 OpenClaw Config

```bash
generate_openclaw_config() {
  cat > /opt/letsbe/config/openclaw.json <<EOF
{
  "model": {
    "primary": "openrouter/deepseek/deepseek-chat-v3-0324",
    "fallbacks": [
      "openrouter/google/gemini-2.0-flash-001",
      "openrouter/minimax/minimax-m1-80k"
    ]
  },
  "provider": {
    "url": "http://127.0.0.1:8100",
    "key": "${OPENROUTER_API_KEY}"
  },
  "security": {
    "elevated": { "enable": false },
    "token": "${OPENCLAW_TOKEN}"
  },
  "tools": {
    "loopDetection": { "enabled": true, "maxCalls": 30 },
    "server": {
      "url": "http://127.0.0.1:8200",
      "token": "${SAFETY_WRAPPER_TOKEN}"
    }
  },
  "cacheRetention": "long",
  "heartbeat": { "every": "55m" },
  "logging": { "redactSensitive": "tools" },
  "browser": {
    "profile": "openclaw-managed",
    "ssrf": { "allowlist": ["127.0.0.1", "localhost"] }
  },
  "agents": {
    "list": [/* Generated per business type template */]
  }
}
EOF
}
```

### 4.4 Docker Compose — LetsBe Stack

```yaml
# provisioner/stacks/letsbe/docker-compose.yml
version: '3.8'
services:
  openclaw:
    image: code.letsbe.solutions/letsbe/openclaw:latest
    container_name: letsbe-openclaw
    restart: unless-stopped
    network_mode: host
    volumes:
      - /opt/letsbe/config/openclaw.json:/home/openclaw/.openclaw/openclaw.json:ro
      - /opt/letsbe/agents:/home/openclaw/.openclaw/agents:rw
      - /opt/letsbe/skills:/home/openclaw/.openclaw/skills:ro
      - /opt/letsbe/references:/home/openclaw/.openclaw/references:ro
      - /opt/letsbe/data/openclaw:/home/openclaw/.openclaw/data:rw
      - /opt/letsbe/shared-memory:/home/openclaw/.openclaw/shared-memory:rw
    environment:
      - NODE_ENV=production
    mem_limit: 512m
    cpus: 2.0

  safety-wrapper:
    image: code.letsbe.solutions/letsbe/safety-wrapper:latest
    container_name: letsbe-safety-wrapper
    restart: unless-stopped
    network_mode: host
    volumes:
      - /opt/letsbe/config/safety-wrapper.json:/app/config.json:ro
      - /opt/letsbe/data/safety-wrapper.db:/app/data/safety-wrapper.db:rw
    environment:
      - NODE_ENV=production
    mem_limit: 128m
    cpus: 0.5

  secrets-proxy:
    image: code.letsbe.solutions/letsbe/secrets-proxy:latest
    container_name: letsbe-secrets-proxy
    restart: unless-stopped
    network_mode: host
    volumes:
      - /opt/letsbe/data/safety-wrapper.db:/app/data/secrets.db:ro
    environment:
      - PORT=8100
      - UPSTREAM_URL=https://openrouter.ai
      - NODE_ENV=production
    mem_limit: 64m
    cpus: 0.25
```

### 4.5 n8n Cleanup Manifest

Files requiring n8n reference removal:

```
provisioner/stacks/automation/docker-compose.yml      -- Remove n8n service
provisioner/stacks/automation/nginx/n8n.conf           -- Delete file
provisioner/scripts/env_setup.sh                       -- Remove n8n variables
provisioner/scripts/setup.sh                           -- Remove n8n deploy step
provisioner/playwright/n8n-initial-setup.js            -- Delete file
provisioner/scripts/backups.sh                         -- Remove n8n DB backup
provisioner/scripts/restore.sh                         -- Remove n8n restore
```

Replace n8n with Activepieces in all relevant locations.

### 4.6 Secrets Registry Seeding

```bash
seed_secrets_registry() {
  # Read all generated credentials from env_setup.sh outputs
  # Insert into Safety Wrapper's SQLite secrets registry

  local DB_PATH="/opt/letsbe/data/safety-wrapper.db"

  # Create secrets table if not exists (Safety Wrapper will also do this)
  sqlite3 "$DB_PATH" "CREATE TABLE IF NOT EXISTS secrets (
    id TEXT PRIMARY KEY,
    name TEXT NOT NULL UNIQUE,
    encrypted_value BLOB NOT NULL,
    service TEXT NOT NULL,
    config_path TEXT,
    pattern TEXT,
    created_at TEXT DEFAULT (datetime('now')),
    rotated_at TEXT,
    rotation_count INTEGER DEFAULT 0
  );"

  # For each tool's .env file, extract credentials and insert
  for env_file in /opt/letsbe/env/*.env; do
    local service=$(basename "$env_file" .env)
    while IFS='=' read -r key value; do
      if is_secret_key "$key"; then
        local encrypted=$(encrypt_value "$value" "$SECRETS_ENCRYPTION_KEY")
        sqlite3 "$DB_PATH" "INSERT OR REPLACE INTO secrets (id, name, encrypted_value, service, config_path)
          VALUES ('$(uuidgen)', '${service}_${key}', X'${encrypted}', '${service}', '${env_file}');"
      fi
    done < "$env_file"
  done
}

is_secret_key() {
  local key="$1"
  [[ "$key" =~ (PASSWORD|SECRET|KEY|TOKEN|CREDENTIAL|AUTH|API_KEY) ]]
}
```

---

## 5. Mobile App

**Framework:** Expo SDK 52+ (Bare Workflow with EAS Build + EAS Update)
**Language:** TypeScript (strict mode)
**State:** Zustand + TanStack Query v5
**Navigation:** Expo Router (file-based)
**Push Notifications:** Expo Push + APNs/FCM
**Repository:** `apps/mobile/` in monorepo

### 5.1 Screen Architecture

```
App
├── (auth)
│   ├── login.tsx              — Email/password login
│   ├── register.tsx           — Account creation (from website flow)
│   └── forgot-password.tsx    — Password reset
├── (tabs)
│   ├── index.tsx              — Dashboard (morning briefing, quick actions)
│   ├── chat.tsx               — Team chat (Dispatcher) or direct agent chat
│   ├── activity.tsx           — Agent activity feed
│   ├── approvals.tsx          — Pending command approvals
│   └── settings.tsx           — Account, agents, billing, server
├── chat/
│   ├── [agent_id].tsx         — Direct chat with specific agent
│   └── team.tsx               — Team chat via Dispatcher
├── agents/
│   ├── index.tsx              — Agent roster
│   ├── [id].tsx               — Agent detail (config, stats, conversations)
│   └── [id]/edit.tsx          — Edit agent (SOUL.md, autonomy, model, external comms)
├── approvals/
│   └── [id].tsx               — Approval detail with context
├── usage/
│   ├── index.tsx              — Usage dashboard (per-agent, per-model)
│   └── breakdown.tsx          — Detailed breakdown
├── server/
│   ├── index.tsx              — Server health overview
│   ├── tools.tsx              — Installed tools list
│   └── backups.tsx            — Backup status
├── billing/
│   ├── index.tsx              — Current period, plan, overages
│   └── portal.tsx             — Stripe portal WebView
└── secrets/
    ├── provide.tsx            — Secure credential input modal
    └── reveal.tsx             — Tap-to-reveal credential card
```

### 5.2 Core Components

```typescript
// apps/mobile/src/components/chat/ChatView.tsx

interface ChatViewProps {
  agentId: string;              // 'dispatcher' for team, specific agent for direct
  conversationId?: string;      // Resume existing conversation
}

// Renders streaming agent responses via SSE from Hub
// Handles special events:
//   - approval_required → navigates to approval screen
//   - secret_input_required → opens secure modal
//   - secret_card → renders tap-to-reveal card
//   - tool_call_start/result → shows tool activity indicator
```

```typescript
// apps/mobile/src/components/approvals/ApprovalCard.tsx

interface ApprovalCardProps {
  approval: CommandApproval;
  onApprove: () => void;
  onDeny: () => void;
}

// Rendered in both:
//   1. Push notification action buttons (one-tap approve/deny)
//   2. In-app approval queue
// Shows: agent name, human-readable description, classification badge, time remaining
```

```typescript
// apps/mobile/src/components/secrets/SecretCard.tsx

interface SecretCardProps {
  secretId: string;
  label: string;              // e.g., "Nextcloud Admin Password"
}

// Tap-to-reveal card:
//   1. Shows masked "••••••••" by default
//   2. On tap: fetches from Secrets Proxy via Hub relay (authenticated)
//   3. Displays for 30 seconds, then auto-clears
//   4. Copy button writes to clipboard, clears after 60 seconds
```

### 5.3 Push Notification Handlers

```typescript
// apps/mobile/src/notifications/handlers.ts

import * as Notifications from 'expo-notifications';

// Register notification categories with action buttons
Notifications.setNotificationCategoryAsync('approval_request', [
  { identifier: 'approve', buttonTitle: 'Approve', options: { opensAppToForeground: false } },
  { identifier: 'deny', buttonTitle: 'Deny', options: { opensAppToForeground: false } },
  { identifier: 'view', buttonTitle: 'View Details', options: { opensAppToForeground: true } },
]);

Notifications.setNotificationCategoryAsync('usage_alert', [
  { identifier: 'view_usage', buttonTitle: 'View Usage', options: { opensAppToForeground: true } },
]);

// Handle background notification actions
Notifications.addNotificationResponseReceivedListener(async (response) => {
  const { actionIdentifier, notification } = response;
  const data = notification.request.content.data;

  switch (data.type) {
    case 'approval_request':
      if (actionIdentifier === 'approve') {
        await api.post(`/customer/approvals/${data.approval_id}`, { action: 'approve' });
      } else if (actionIdentifier === 'deny') {
        await api.post(`/customer/approvals/${data.approval_id}`, { action: 'deny' });
      }
      break;
  }
});
```

### 5.4 State Management

```typescript
// apps/mobile/src/stores/auth-store.ts
interface AuthStore {
  token: string | null;
  user: User | null;
  login: (email: string, password: string) => Promise<void>;
  logout: () => void;
}

// apps/mobile/src/stores/agent-store.ts
interface AgentStore {
  agents: AgentConfig[];
  activeChat: string | null;          // Currently chatting with
  setActiveChat: (agentId: string) => void;
}

// apps/mobile/src/hooks/use-chat.ts — TanStack Query + SSE
// apps/mobile/src/hooks/use-approvals.ts — TanStack Query with polling
// apps/mobile/src/hooks/use-usage.ts — TanStack Query
```

---

## 6. Website & Onboarding

**Framework:** Next.js (separate app in monorepo, NOT part of Hub)
**Location:** `apps/website/` in monorepo
**Hosting:** Vercel or self-hosted (static export + API routes)
**Domain:** letsbe.biz

### 6.1 Architecture Decision: Separate App

The website is a separate Next.js application, not part of the Hub. Reasons:

1. **Different deployment cadence** — Marketing site changes weekly; Hub changes are coordinated releases
2. **Different scaling needs** — Website can be on Vercel CDN; Hub must be near the database
3. **Different audience** — Public visitors vs. authenticated users
4. **SEO optimization** — Full static generation for landing pages, no auth overhead

The website communicates with the Hub via the Public API (`/api/v1/public/*`).

### 6.2 Onboarding Flow

```
Page 1: Landing
  ├── Hero: "Your AI team. Your server. Your rules."
  ├── Chat input: "Tell us about your business"
  └── CTA: "Get Started"

Page 2: AI Conversation (1-2 messages)
  ├── AI classifies business type
  ├── Returns: { business_type, confidence, suggested_tools, suggested_tier }
  └── AI model: Gemini 2.0 Flash (cheap, fast, accurate for classification)

Page 3: Tool Recommendation
  ├── Pre-selected tool bundle based on business type
  ├── Toggle tools on/off
  ├── Live resource calculator (RAM, disk, CPU)
  └── "X tools selected — requires Build tier or higher"

Page 4: Server Selection
  ├── Only tiers meeting minimum requirements shown
  ├── Region selection (EU: Nuremberg, US: Manassas)
  ├── Monthly price display
  └── Founding member badge if available

Page 5: Domain Setup
  ├── Option A: "I have a domain" → enter domain, show DNS instructions
  ├── Option B: "I need a domain" → Netcup domain reselling (future)
  └── Subdomain preview: files.yourdomain.com, chat.yourdomain.com, etc.

Page 6: Agent Configuration (Optional)
  ├── Business-type template pre-loaded
  ├── Quick personality tweaks
  ├── Model preset selection (Basic/Balanced/Complex)
  └── "Customize later in the app"

Page 7: Payment
  ├── Stripe Checkout
  ├── Founding member discount display
  └── Recurring billing disclosure

Page 8: Provisioning Status
  ├── Real-time SSE progress (10 steps)
  ├── Estimated time: 15-20 minutes
  ├── Email notification on completion
  └── App download links (iOS/Android)
```

### 6.3 Business Type Classification

```typescript
// apps/website/src/lib/classify.ts

interface ClassificationResult {
  business_type: string;             // 'freelancer', 'agency', 'ecommerce', 'consulting', ...
  confidence: number;                // 0.0 - 1.0
  suggested_tools: string[];         // Tool IDs from catalog
  suggested_tier: string;            // 'lite', 'build', 'scale', 'enterprise'
  agent_template: string;            // Template ID for agent configs
}

// Uses Gemini 2.0 Flash via OpenRouter
// Single system prompt + user description → structured JSON output
// Cost: ~$0.001 per classification (negligible)
// Latency: <2 seconds
```

### 6.4 Resource Calculator

```typescript
// apps/website/src/lib/resource-calculator.ts

interface ResourceEstimate {
  ram_mb: number;
  disk_gb: number;
  min_tier: 'lite' | 'build' | 'scale' | 'enterprise';
  headroom_percent: number;
}

const TOOL_RESOURCES: Record<string, { ram_mb: number; disk_gb: number }> = {
  nextcloud:   { ram_mb: 512, disk_gb: 10 },
  chatwoot:    { ram_mb: 768, disk_gb: 5 },
  ghost:       { ram_mb: 256, disk_gb: 3 },
  odoo:        { ram_mb: 1024, disk_gb: 10 },
  calcom:      { ram_mb: 256, disk_gb: 2 },
  // ... all 28+ tools
};

const LETSBE_OVERHEAD_MB = 640;  // OpenClaw + Safety Wrapper + Secrets Proxy + nginx

function calculateResources(selectedTools: string[]): ResourceEstimate {
  const toolRam = selectedTools.reduce((sum, t) => sum + (TOOL_RESOURCES[t]?.ram_mb ?? 256), 0);
  const toolDisk = selectedTools.reduce((sum, t) => sum + (TOOL_RESOURCES[t]?.disk_gb ?? 2), 0);

  const totalRam = toolRam + LETSBE_OVERHEAD_MB;
  const totalDisk = toolDisk + 20;  // OS + Docker images overhead

  const min_tier = totalRam <= 7300 ? 'lite'
    : totalRam <= 15000 ? 'build'
    : totalRam <= 31000 ? 'scale'
    : 'enterprise';

  return { ram_mb: totalRam, disk_gb: totalDisk, min_tier, headroom_percent: /*...*/ };
}
```

---

## 7. Tool Registry & Cheat Sheets

### 7.1 Registry Schema

```typescript
// packages/shared/src/types/tool-registry.ts

interface ToolRegistry {
  version: string;                    // Registry schema version
  generated_at: string;               // ISO timestamp
  tools: Record<string, ToolEntry>;
  external_services: Record<string, ExternalServiceEntry>;
}

interface ToolEntry {
  name: string;                       // Human-readable name
  category: string;                   // file-storage, communication, project-management, etc.
  internal_url: string;               // http://127.0.0.1:30XX
  external_url: string;               // https://files.{{domain}}
  api_base?: string;                  // e.g., "/ocs/v2.php/apps"
  api_auth_type: 'basic' | 'header' | 'bearer' | 'none';
  api_auth_header?: string;           // e.g., "xc-token"
  api_auth_ref?: string;              // e.g., "SECRET_REF(nocodb_api_token)"
  has_api: boolean;
  has_webui: boolean;
  cheat_sheet?: string;               // Path to cheat sheet, e.g., "references/nextcloud.md"
  description: string;                // One-line description
  status?: 'active' | 'stopped' | 'error';
}

interface ExternalServiceEntry {
  name: string;
  cli: string;                        // Binary name, e.g., "gog"
  skill: string;                      // Skill name, e.g., "gog"
  services: string[];                 // Sub-services, e.g., ["gmail", "calendar"]
  configured: boolean;
}
```

### 7.2 Cheat Sheet Standard

Each cheat sheet follows this template:

```markdown
# {Tool Name} API Cheat Sheet
Base: http://127.0.0.1:{port}/{api_base}
Auth: {auth_type} via SECRET_REF({credential_name})

## {Resource 1}
GET  /endpoint        → Description
POST /endpoint        → Description (JSON body: { field: "type" })
...

## {Resource 2}
...

## Common Patterns
- Pattern description with example curl command
- Error handling notes
- Pagination format
```

### 7.3 Priority Delivery Schedule

| Priority | Tools | Delivery |
|----------|-------|----------|
| P0 (launch-critical) | Portainer, Nextcloud, Chatwoot, Ghost, Cal.com, Stalwart Mail | Phase 2 |
| P1 (core experience) | Odoo, Listmonk, NocoDB, Umami, Keycloak, Activepieces | Phase 3 |
| P2 (full coverage) | Gitea, Uptime Kuma, MinIO, Documenso, VaultWarden, WordPress | Phase 4 |
| P3 (extended) | Windmill, Redash, Penpot, Squidex, Typebot | Post-launch |

---

## 8. Agent Architecture

### 8.1 Default Agent Roster

| Agent | ID | Tool Profile | Primary Tools | Model Preset |
|-------|----|-------------|---------------|-------------|
| Dispatcher | `dispatcher` | messaging | agentToAgent only | balanced |
| IT Admin | `it-admin` | coding | shell, docker, file_*, env_*, browser, portainer | complex |
| Marketing | `marketing` | minimal | ghost, listmonk, umami, file_read, browser, nextcloud | balanced |
| Secretary | `secretary` | messaging | calcom, chatwoot, poste, file_read, nextcloud | basic |
| Sales | `sales` | minimal | chatwoot, odoo, calcom, file_read, nextcloud, documenso | balanced |

### 8.2 SOUL.md Template Structure

```markdown
# {Agent Name}

## Identity
You are the {role} for {business_name}. {1-2 sentences about personality}.

## Expertise
- {Domain knowledge point 1}
- {Domain knowledge point 2}
- ...

## Rules
1. Always check tool-registry.json before accessing any tool
2. Read the cheat sheet for a tool before your first API call
3. Prefer API over browser — faster and fewer tokens
4. All credentials use SECRET_REF() — never hardcode values
5. {Role-specific rules}

## Communication Style
{Brand voice guidelines from user customization}
```

Estimated size: ~600-800 tokens per SOUL.md. Cached via `cacheRetention: "long"` for 80-99% cost reduction on subsequent calls.

### 8.3 Dispatcher Routing Logic

The Dispatcher uses a simple keyword + context classifier to route messages:

```
"Send newsletter"         → Marketing
"Why is X slow?"          → IT Admin
"Schedule meeting"        → Secretary
"Follow up with client"   → Sales → Secretary (multi-agent)
"Morning update"          → Dispatcher (self-handles, aggregates from all agents)
Ambiguous                 → Dispatcher asks: "Should I have your {A} or {B} handle this?"
```

This is implemented as SOUL.md instructions, not code. The Dispatcher's SOUL.md contains a routing table mapping common intents to agent IDs.

### 8.4 Business Type Agent Templates

```typescript
// packages/shared/src/templates/agent-templates.ts

const TEMPLATES: Record<string, AgentTemplate> = {
  freelancer: {
    agents: ['dispatcher', 'it-admin', 'secretary'],
    // No marketing or sales — freelancers handle their own
    soul_tweaks: {
      secretary: 'Focus on calendar management and client communication.',
    },
  },
  agency: {
    agents: ['dispatcher', 'it-admin', 'marketing', 'secretary', 'sales'],
    soul_tweaks: {
      marketing: 'Multi-client brand voice management.',
      sales: 'Pipeline tracking and proposal automation.',
    },
  },
  ecommerce: {
    agents: ['dispatcher', 'it-admin', 'marketing', 'sales'],
    soul_tweaks: {
      marketing: 'Focus on product listings, SEO, and campaign automation.',
      sales: 'Order tracking and customer support integration.',
    },
  },
  consulting: {
    agents: ['dispatcher', 'it-admin', 'secretary', 'sales'],
    soul_tweaks: {
      secretary: 'Document management and scheduling focus.',
      sales: 'Proposal generation and contract management.',
    },
  },
};
```

---

## 9. Interactive Demo

### 9.1 Architecture: Per-Session Ephemeral Containers

Instead of a shared "Bella's Bakery" VPS (which presents data freshness and multi-user conflict issues), we propose per-session ephemeral demo containers:

```
Prospect visits letsbe.biz/demo
  → Spins up lightweight demo container (pre-built Docker image)
  → 15-minute TTL, auto-destroyed
  → Pre-loaded with fake bakery data
  → Connects to shared demo OpenClaw instance with demo-tier model
  → Prospect chats with the AI team in real-time
  → After 15 min: "Liked what you saw? Sign up to get your own."
```

### 9.2 Demo Container Spec

```yaml
# provisioner/stacks/demo/docker-compose.yml
services:
  demo-gateway:
    image: code.letsbe.solutions/letsbe/demo:latest
    container_name: demo-${SESSION_ID}
    environment:
      - DEMO_SESSION_ID=${SESSION_ID}
      - DEMO_TTL_MINUTES=15
      - MODEL=openrouter/google/gemini-2.0-flash-001  # Cheapest model
    mem_limit: 256m
    labels:
      - "letsbe.demo=true"
      - "letsbe.demo.expires=${EXPIRY_TIMESTAMP}"
```

### 9.3 Demo Limitations

| Capability | Demo | Full Product |
|-----------|------|-------------|
| Chat with AI agents | Yes (Gemini Flash) | Yes (user's model choice) |
| View tool dashboards | Screenshots only | Live tool access |
| Execute tool commands | Simulated responses | Real execution |
| Data persistence | None (15-min TTL) | Permanent |
| Custom agents | No | Yes |
| Multiple sessions | No | Yes |

### 9.4 Cost per Demo Session

- Model cost: ~$0.01-0.05 per session (Gemini Flash, 10-20 messages)
- Container cost: ~$0.001 per session (256MB × 15 min)
- Total: <$0.06 per prospect demo — highly scalable

---

## 10. Shared Packages

### 10.1 `packages/shared-types`

```typescript
// Shared TypeScript types used across all packages

export type CommandTier = 'green' | 'yellow' | 'yellow_external' | 'red' | 'critical_red';
export type AutonomyLevel = 1 | 2 | 3;
export type AgentRole = 'dispatcher' | 'it-admin' | 'marketing' | 'secretary' | 'sales' | 'custom';
export type ApprovalStatus = 'pending' | 'approved' | 'denied' | 'expired';
export type ServerTier = 'lite' | 'build' | 'scale' | 'enterprise';

export interface TenantConfig {
  id: string;
  default_autonomy_level: AutonomyLevel;
  agents: Record<string, AgentConfig>;
  command_classification: Record<CommandTier, string[]>;
  external_comms_gate: ExternalCommsGate;
  token_pool_remaining: number;
  founding_member_multiplier: number;
  config_version: number;
}

export interface AgentConfig {
  agent_id: string;
  name: string;
  role: AgentRole;
  autonomy_level: AutonomyLevel | null;
  tools_allowed: string[];
  tools_denied: string[];
  external_comms_unlocks: Record<string, 'autonomous' | 'gated'>;
  model_preset: 'basic' | 'balanced' | 'complex';
  premium_model: string | null;
  is_active: boolean;
}

export interface ExternalCommsGate {
  default: 'gated';
  unlocks: Record<string, Record<string, 'autonomous' | 'gated'>>;
}

export interface ToolRegistryEntry {
  name: string;
  category: string;
  internal_url: string;
  external_url: string;
  api_base?: string;
  api_auth_type: 'basic' | 'header' | 'bearer' | 'none';
  api_auth_ref?: string;
  has_api: boolean;
  has_webui: boolean;
  cheat_sheet?: string;
  description: string;
}
```

### 10.2 `packages/shared-prisma`

Shared Prisma client generated from the Hub's schema. Used by the Hub app and any services that need to query the central database.

### 10.3 `packages/shared-utils`

```typescript
// Shared utilities

export { shannonEntropy } from './crypto/entropy';
export { encryptChaCha20, decryptChaCha20 } from './crypto/chacha20';
export { generateSecurePassword } from './crypto/password';
export { parseEnvFile, serializeEnvFile } from './env/parser';
export { resolveSafePath, isPathSafe } from './fs/safe-path';
export { createLogger } from './logging/logger';
export { sleep, retryWithBackoff } from './async/helpers';
```

---

## Appendix A: Full API Surface Summary

| Component | Endpoints | Auth Method |
|-----------|-----------|-------------|
| Safety Wrapper | 6 | Bearer token |
| Secrets Proxy | 7 | Hub session token |
| Hub — Tenant API | 7 | Hub API key |
| Hub — Customer API | 17 | Customer JWT |
| Hub — Admin Billing | 6 | Staff JWT |
| Hub — Admin Agents | 5 | Staff JWT |
| Hub — Admin DNS | 1 | Staff JWT |
| Hub — Existing | 80+ | Various (see Repo Analysis) |
| **Total new endpoints** | **~49** | |

## Appendix B: Data Model Summary

| Model | Database | New/Existing |
|-------|----------|-------------|
| TokenUsageBucket | Hub PostgreSQL | NEW |
| BillingPeriod | Hub PostgreSQL | NEW |
| FoundingMember | Hub PostgreSQL | NEW |
| AgentConfig | Hub PostgreSQL | NEW |
| CommandApproval | Hub PostgreSQL | NEW |
| ServerConnection | Hub PostgreSQL | UPDATED |
| secrets | Safety Wrapper SQLite | NEW |
| approvals | Safety Wrapper SQLite | NEW |
| audit_log | Safety Wrapper SQLite | NEW |
| token_usage | Safety Wrapper SQLite | NEW |
| hub_state | Safety Wrapper SQLite | NEW |

---

*End of Document — 02 Component Breakdown*
