# 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; // 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 = { // 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; } 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> = { 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): Promise; validate(args: Record): 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 { 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 { 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 { 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 { 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 { 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 = {}; 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; }): Promise { 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; reportUsage(buckets: TokenUsageBucket[]): Promise; requestApproval(request: ApprovalRequest): Promise<{ approval_id: string }>; pollApproval(approval_id: string): Promise; reportBackupStatus(status: BackupStatus): Promise; } 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; 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 { 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 = 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; // 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 { 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; // 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(); 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 = { openrouter: 'https://openrouter.ai', anthropic: 'https://api.anthropic.com', openai: 'https://api.openai.com', }; async function handleRequest(req: IncomingMessage, res: ServerResponse): Promise { // 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 } 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, by_model: Record, 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; /** * 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; } // 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; /** * Send usage alert notification. */ sendUsageAlert(params: { user_id: string; percent_used: number; pool_remaining: number; }): Promise; /** * Send server health alert. */ sendHealthAlert(params: { user_id: string; severity: 'soft' | 'medium' | 'hard'; message: string; }): Promise; } ``` ### 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; } 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 < /opt/letsbe/config/openclaw.json < 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; 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 = { 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; external_services: Record; } 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 = { 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; command_classification: Record; 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; model_preset: 'basic' | 'balanced' | 'complex'; premium_model: string | null; is_active: boolean; } export interface ExternalCommsGate { default: 'gated'; unlocks: Record>; } 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*