LetsBeBiz-Redesign/docs/architecture-proposal/claude/02-COMPONENT-BREAKDOWN.md

83 KiB
Raw Blame History

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
  2. Secrets Proxy
  3. Hub Updates
  4. Provisioner Updates
  5. Mobile App
  6. Website & Onboarding
  7. Tool Registry & Cheat Sheets
  8. Agent Architecture
  9. Interactive Demo
  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

// 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:

// 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

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

// 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.

// 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

// 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

// 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

// 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

// 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

// 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

// 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

// 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

// 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

-- 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

// /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

// 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

// 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

// 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

// 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

// 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

// 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)

// 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

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

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

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

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

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

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

// 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

// 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

// 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

#!/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

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

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

# 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

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

// 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
// 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
// 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

// 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

// 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

// 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

// 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

// 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:

# {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

# {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

// 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

# 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

// 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

// 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