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

2746 lines
83 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# LetsBe Biz — Component Breakdown
**Date:** February 27, 2026
**Team:** Claude Opus 4.6 Architecture Team
**Document:** 02 of 09
**Status:** Proposal — Competing with independent team
**Companion:** References 01-SYSTEM-ARCHITECTURE.md for high-level context
---
## Table of Contents
1. [Safety Wrapper](#1-safety-wrapper)
2. [Secrets Proxy](#2-secrets-proxy)
3. [Hub Updates](#3-hub-updates)
4. [Provisioner Updates](#4-provisioner-updates)
5. [Mobile App](#5-mobile-app)
6. [Website & Onboarding](#6-website--onboarding)
7. [Tool Registry & Cheat Sheets](#7-tool-registry--cheat-sheets)
8. [Agent Architecture](#8-agent-architecture)
9. [Interactive Demo](#9-interactive-demo)
10. [Shared Packages](#10-shared-packages)
---
## 1. Safety Wrapper
**Process:** `letsbe-safety-wrapper` — Node.js 22+ standalone HTTP server
**Port:** `localhost:8200`
**RAM Budget:** ~128MB (separate from OpenClaw's ~384MB + Chromium)
**Language:** TypeScript (strict mode, ESM)
**Repository:** `packages/safety-wrapper/` in monorepo
### 1.1 Responsibility Summary
The Safety Wrapper is the enforcement layer for all five core responsibilities:
| # | Responsibility | Input | Output |
|---|---------------|-------|--------|
| 1 | Command Classification | Tool call from OpenClaw | Tier assignment (GREEN/YELLOW/YELLOW_EXTERNAL/RED/CRITICAL_RED) |
| 2 | Autonomy Gating | Classified command + agent config | EXECUTE or GATE_FOR_APPROVAL |
| 3 | Credential Injection | `SECRET_REF(name)` placeholders | Real values injected locally |
| 4 | Hub Communication | Heartbeat, approval, token usage, config sync | Hub API responses |
| 5 | Token Metering | LLM response headers from OpenRouter | Hourly aggregated buckets |
### 1.2 HTTP API Contract
The Safety Wrapper exposes an HTTP API that OpenClaw calls instead of executing tools directly.
#### 1.2.1 Tool Execution Endpoint
```
POST /api/v1/tools/execute
Authorization: Bearer {SAFETY_WRAPPER_TOKEN}
Content-Type: application/json
Request:
{
"session_id": "string", // OpenClaw session ID
"agent_id": "string", // e.g., "it-admin", "marketing"
"tool_name": "string", // e.g., "shell", "docker", "file_read"
"tool_args": { // Tool-specific arguments
"command": "string",
"timeout": "number?"
},
"context": { // Optional context from OpenClaw
"conversation_id": "string?",
"parent_tool_call_id": "string?",
"is_subagent": "boolean?"
}
}
Response (200 — executed):
{
"status": "executed",
"result": "string | object", // Tool output (secrets redacted)
"tool_call_id": "string", // Unique ID for audit trail
"classification": "green", // Classification tier
"duration_ms": 142,
"tokens_input": 0, // Only for LLM-proxied calls
"tokens_output": 0
}
Response (202 — gated for approval):
{
"status": "awaiting_approval",
"approval_id": "string", // UUID for tracking
"classification": "red",
"human_readable": "IT Agent wants to delete /nextcloud/data/tmp/* (47 files, 2.3GB)",
"expires_at": "2026-02-28T12:00:00Z",
"poll_endpoint": "/api/v1/approvals/{approval_id}"
}
Response (403 — denied):
{
"status": "denied",
"reason": "Tool 'docker' not in agent 'marketing' allow list",
"classification": null
}
Response (429 — rate limited):
{
"status": "rate_limited",
"retry_after_ms": 5000
}
```
#### 1.2.2 Approval Polling Endpoint
```
GET /api/v1/approvals/{approval_id}
Authorization: Bearer {SAFETY_WRAPPER_TOKEN}
Response (200 — still pending):
{
"status": "pending",
"requested_at": "2026-02-27T11:00:00Z",
"expires_at": "2026-02-28T11:00:00Z"
}
Response (200 — resolved):
{
"status": "approved", // or "denied" or "expired"
"responded_at": "2026-02-27T11:05:00Z",
"responded_by": "customer", // or "staff"
"result": "string | object" // Tool execution result if approved
}
```
#### 1.2.3 Health & Registration
```
GET /api/v1/health
→ { "status": "ok", "version": "1.0.0", "hub_connected": true, "uptime_seconds": 86400 }
POST /api/v1/register
→ Called once on first boot. Registers with Hub, receives API key.
Body: { "registration_token": "string", "version": "string" }
Response: { "hub_api_key": "string", "config": { ... } }
```
#### 1.2.4 Config Sync (Hub → Safety Wrapper)
```
POST /api/v1/config/sync
Authorization: Bearer {HUB_WEBHOOK_TOKEN}
Content-Type: application/json
Request:
{
"config_version": 42,
"agents": {
"it-admin": {
"autonomy_level": 3,
"tools_allowed": ["shell", "docker", "file_read", ...],
"tools_denied": [],
"external_comms_unlocks": {}
},
"marketing": {
"autonomy_level": null, // uses tenant default
"tools_allowed": ["ghost_api", "listmonk_api", ...],
"tools_denied": ["shell", "docker"],
"external_comms_unlocks": {
"ghost_publish": "autonomous",
"listmonk_send": "gated"
}
}
},
"tenant": {
"default_autonomy_level": 2,
"token_pool_remaining": 12450000,
"founding_member_multiplier": 2
}
}
Response: { "accepted": true, "config_version": 42 }
```
### 1.3 Command Classification Engine
The classifier is a deterministic rule engine — no ML, no heuristics. Every tool call maps to exactly one tier.
#### 1.3.1 Classification Rules
```typescript
// packages/safety-wrapper/src/classifier/rules.ts
interface ClassificationRule {
tool_name: string;
operation?: string; // Sub-operation (e.g., "delete", "restart")
arg_patterns?: Record<string, RegExp>; // Argument pattern matchers
tier: CommandTier;
}
type CommandTier = 'green' | 'yellow' | 'yellow_external' | 'red' | 'critical_red';
const CLASSIFICATION_RULES: ClassificationRule[] = [
// GREEN — Non-destructive reads
{ tool_name: 'file_read', tier: 'green' },
{ tool_name: 'env_read', tier: 'green' },
{ tool_name: 'container_stats', tier: 'green' },
{ tool_name: 'container_logs', tier: 'green' },
{ tool_name: 'check_status', tier: 'green' },
{ tool_name: 'dns_lookup', tier: 'green' },
{ tool_name: 'cert_check', tier: 'green' },
{ tool_name: 'umami_read', tier: 'green' },
{ tool_name: 'uptime_check', tier: 'green' },
{ tool_name: 'query_select', tier: 'green' },
// YELLOW — Modifying operations
{ tool_name: 'container_restart', tier: 'yellow' },
{ tool_name: 'file_write', tier: 'yellow' },
{ tool_name: 'env_update', tier: 'yellow' },
{ tool_name: 'nginx_reload', tier: 'yellow' },
{ tool_name: 'chatwoot_assign', tier: 'yellow' },
{ tool_name: 'calcom_create', tier: 'yellow' },
// YELLOW_EXTERNAL — External-facing (independent gate)
{ tool_name: 'ghost_publish', tier: 'yellow_external' },
{ tool_name: 'listmonk_send', tier: 'yellow_external' },
{ tool_name: 'poste_send', tier: 'yellow_external' },
{ tool_name: 'chatwoot_reply_external', tier: 'yellow_external' },
{ tool_name: 'social_post', tier: 'yellow_external' },
{ tool_name: 'documenso_send', tier: 'yellow_external' },
// RED — Destructive operations
{ tool_name: 'file_delete', tier: 'red' },
{ tool_name: 'container_remove', tier: 'red' },
{ tool_name: 'volume_delete', tier: 'red' },
{ tool_name: 'user_revoke', tier: 'red' },
{ tool_name: 'db_drop_table', tier: 'red' },
{ tool_name: 'backup_delete', tier: 'red' },
{ tool_name: 'tool_install', tier: 'red' },
{ tool_name: 'tool_remove', tier: 'red' },
// CRITICAL_RED — Irreversible
{ tool_name: 'db_drop_database', tier: 'critical_red' },
{ tool_name: 'firewall_modify', tier: 'critical_red' },
{ tool_name: 'ssh_config_modify', tier: 'critical_red' },
{ tool_name: 'backup_wipe_all', tier: 'critical_red' },
{ tool_name: 'user_delete_account', tier: 'critical_red' },
{ tool_name: 'ssl_revoke', tier: 'critical_red' },
];
```
#### 1.3.2 Shell Command Classifier
Shell commands (via the `shell` tool) require deeper analysis because the agent sends arbitrary commands:
```typescript
// packages/safety-wrapper/src/classifier/shell-classifier.ts
interface ShellClassification {
tier: CommandTier;
binary: string;
args: string[];
is_allowed: boolean;
reason?: string;
}
// ALLOWLISTED BINARIES — everything else is DENIED
const SHELL_ALLOWLIST: Record<string, CommandTier> = {
// Green — read-only
'cat': 'green', 'head': 'green', 'tail': 'green', 'less': 'green',
'ls': 'green', 'find': 'green', 'grep': 'green', 'wc': 'green',
'df': 'green', 'du': 'green', 'free': 'green', 'uptime': 'green',
'whoami': 'green', 'hostname': 'green', 'date': 'green',
'curl': 'green', // Elevated by arg analysis below
'dig': 'green', 'nslookup': 'green', 'ping': 'green',
'docker': 'green', // Elevated by subcommand analysis
'systemctl': 'green', // Elevated by subcommand analysis
// Yellow — modifying
'tee': 'yellow', 'cp': 'yellow', 'mv': 'yellow', 'mkdir': 'yellow',
'chmod': 'yellow', 'chown': 'yellow', 'sed': 'yellow',
'certbot': 'yellow',
'nginx': 'yellow', // Only "-s reload" allowed
// Red — destructive
'rm': 'red', 'rmdir': 'red',
};
// SHELL METACHARACTER BLOCKING — no pipes, redirects, or command chaining
const BLOCKED_METACHARACTERS = /[|;&`$(){}\\<>]/;
// PATH TRAVERSAL PREVENTION — all paths must resolve under /opt/letsbe
const ALLOWED_ROOT = '/opt/letsbe';
function classifyShellCommand(raw_command: string): ShellClassification {
// 1. Block metacharacters
if (BLOCKED_METACHARACTERS.test(raw_command)) {
return { tier: 'critical_red', binary: '', args: [], is_allowed: false,
reason: 'Shell metacharacters blocked' };
}
// 2. Parse binary + args
const parts = raw_command.trim().split(/\s+/);
const binary = parts[0];
const args = parts.slice(1);
// 3. Check allowlist
if (!(binary in SHELL_ALLOWLIST)) {
return { tier: 'critical_red', binary, args, is_allowed: false,
reason: `Binary '${binary}' not in allowlist` };
}
let tier = SHELL_ALLOWLIST[binary];
// 4. Subcommand analysis for docker/systemctl/curl
if (binary === 'docker') {
tier = classifyDockerSubcommand(args);
} else if (binary === 'systemctl') {
tier = classifySystemctlSubcommand(args);
} else if (binary === 'curl') {
tier = classifyCurlCommand(args);
}
// 5. Path validation
for (const arg of args) {
if (arg.startsWith('/') && !arg.startsWith(ALLOWED_ROOT)) {
if (!isExemptPath(arg)) {
return { tier: 'critical_red', binary, args, is_allowed: false,
reason: `Path '${arg}' outside allowed root ${ALLOWED_ROOT}` };
}
}
}
return { tier, binary, args, is_allowed: true };
}
```
#### 1.3.3 Docker Subcommand Classifier
```typescript
function classifyDockerSubcommand(args: string[]): CommandTier {
const subcommand = args[0];
switch (subcommand) {
// Green — read-only
case 'ps': case 'images': case 'inspect': case 'logs':
case 'stats': case 'top': case 'port': case 'version':
case 'info': case 'network': case 'volume':
if (args[1] === 'ls' || args[1] === 'inspect') return 'green';
return 'green';
// Yellow — service operations
case 'compose':
if (['ps', 'logs', 'config'].includes(args[1])) return 'green';
if (['up', 'restart', 'pull'].includes(args[1])) return 'yellow';
if (['down', 'rm'].includes(args[1])) return 'red';
return 'red';
case 'restart': case 'start': case 'stop':
return 'yellow';
case 'exec':
return 'yellow';
// Red — destructive
case 'rm': case 'rmi':
return 'red';
case 'system':
if (args[1] === 'prune') return 'red';
return 'green';
// Critical Red — infrastructure
case 'network':
if (['create', 'rm', 'disconnect'].includes(args[1])) return 'critical_red';
return 'green';
default:
return 'red'; // Unknown subcommands default to red
}
}
```
### 1.4 Autonomy Resolution Engine
```typescript
// packages/safety-wrapper/src/autonomy/resolver.ts
interface AutonomyDecision {
action: 'execute' | 'gate';
effective_level: 1 | 2 | 3;
reason: string;
}
interface AgentAutonomyConfig {
autonomy_level: number | null; // null = use tenant default
external_comms_unlocks: Record<string, 'autonomous' | 'gated'>;
}
function resolveAutonomy(
agent_id: string,
tier: CommandTier,
agent_config: AgentAutonomyConfig,
tenant_default_level: number,
): AutonomyDecision {
const effective_level = agent_config.autonomy_level ?? tenant_default_level;
// Yellow+External has its own independent gate
if (tier === 'yellow_external') {
const unlock = agent_config.external_comms_unlocks[/* tool_name */];
if (unlock === 'autonomous') {
// Fall through to normal yellow gating
return resolveStandardGating('yellow', effective_level, agent_id);
}
// Default: gated regardless of autonomy level
return {
action: 'gate',
effective_level,
reason: `External comms gated for agent '${agent_id}' — user must explicitly unlock`,
};
}
return resolveStandardGating(tier, effective_level, agent_id);
}
function resolveStandardGating(
tier: CommandTier,
level: number,
agent_id: string,
): AutonomyDecision {
const GATING_MATRIX: Record<CommandTier, Record<number, 'execute' | 'gate'>> = {
green: { 1: 'execute', 2: 'execute', 3: 'execute' },
yellow: { 1: 'gate', 2: 'execute', 3: 'execute' },
red: { 1: 'gate', 2: 'gate', 3: 'execute' },
critical_red: { 1: 'gate', 2: 'gate', 3: 'gate' },
yellow_external: { 1: 'gate', 2: 'gate', 3: 'gate' }, // Handled above
};
const action = GATING_MATRIX[tier]?.[level] ?? 'gate';
return {
action,
effective_level: level as 1 | 2 | 3,
reason: `${tier} at level ${level}${action}`,
};
}
```
### 1.5 Tool Executors
Ported from the deprecated sysadmin agent with security patterns preserved.
```typescript
// packages/safety-wrapper/src/executors/types.ts
interface ToolExecutor {
name: string;
execute(args: Record<string, unknown>): Promise<ToolResult>;
validate(args: Record<string, unknown>): ValidationResult;
}
interface ToolResult {
success: boolean;
output: string;
duration_ms: number;
error?: string;
}
interface ValidationResult {
valid: boolean;
errors: string[];
}
```
#### 1.5.1 Shell Executor
```typescript
// packages/safety-wrapper/src/executors/shell.ts
import { execFile } from 'node:child_process';
const DEFAULT_TIMEOUT_MS = 60_000;
const MAX_OUTPUT_BYTES = 1_048_576; // 1MB
async function executeShell(args: {
command: string;
timeout?: number;
working_dir?: string;
}): Promise<ToolResult> {
const classification = classifyShellCommand(args.command);
if (!classification.is_allowed) {
return { success: false, output: '', duration_ms: 0,
error: classification.reason };
}
const timeout = Math.min(args.timeout ?? DEFAULT_TIMEOUT_MS, 120_000);
const cwd = resolveSafePath(args.working_dir ?? '/opt/letsbe');
return new Promise((resolve) => {
const start = Date.now();
// Use execFile (not exec) to prevent shell injection
const child = execFile(
classification.binary,
classification.args,
{ timeout, cwd, maxBuffer: MAX_OUTPUT_BYTES, env: getSanitizedEnv() },
(error, stdout, stderr) => {
const duration_ms = Date.now() - start;
if (error) {
resolve({
success: false,
output: truncateOutput(stderr || error.message),
duration_ms,
error: error.code === 'ERR_CHILD_PROCESS_TIMEOUT'
? `Command timed out after ${timeout}ms`
: error.message,
});
} else {
resolve({
success: true,
output: truncateOutput(stdout),
duration_ms,
});
}
},
);
});
}
```
#### 1.5.2 Docker Executor
```typescript
// packages/safety-wrapper/src/executors/docker.ts
const DOCKER_OPERATIONS = {
stats: { args: (name: string) => ['stats', name, '--no-stream', '--format', 'json'] },
logs: { args: (name: string, lines: number) => ['logs', name, '--tail', String(lines)] },
restart: { args: (name: string) => ['restart', name] },
start: { args: (name: string) => ['start', name] },
stop: { args: (name: string) => ['stop', name] },
inspect: { args: (name: string) => ['inspect', name] },
ps: { args: () => ['ps', '--format', 'json'] },
} as const;
async function executeDocker(args: {
operation: keyof typeof DOCKER_OPERATIONS;
container_name?: string;
lines?: number;
}): Promise<ToolResult> {
const op = DOCKER_OPERATIONS[args.operation];
if (!op) {
return { success: false, output: '', duration_ms: 0,
error: `Unknown docker operation: ${args.operation}` };
}
// Container name validation: alphanumeric, dash, underscore only
if (args.container_name && !/^[a-zA-Z0-9_-]+$/.test(args.container_name)) {
return { success: false, output: '', duration_ms: 0,
error: 'Invalid container name' };
}
const dockerArgs = op.args(args.container_name!, args.lines ?? 100);
return executeShell({ command: `docker ${dockerArgs.join(' ')}` });
}
```
#### 1.5.3 File Executor
```typescript
// packages/safety-wrapper/src/executors/file.ts
import { readFile, writeFile, stat, unlink } from 'node:fs/promises';
import { resolve, normalize } from 'node:path';
const ALLOWED_ROOT = '/opt/letsbe';
const MAX_READ_BYTES = 10_485_760; // 10MB
const MAX_WRITE_BYTES = 10_485_760; // 10MB
function resolveSafePath(requested_path: string): string {
const resolved = resolve(ALLOWED_ROOT, requested_path);
const normalized = normalize(resolved);
if (!normalized.startsWith(ALLOWED_ROOT)) {
throw new Error(`Path traversal blocked: ${requested_path} resolves to ${normalized}`);
}
return normalized;
}
async function executeFileRead(args: {
path: string;
encoding?: string;
offset?: number;
limit?: number;
}): Promise<ToolResult> {
const safePath = resolveSafePath(args.path);
const start = Date.now();
const fileInfo = await stat(safePath);
if (fileInfo.size > MAX_READ_BYTES) {
return { success: false, output: '', duration_ms: Date.now() - start,
error: `File too large: ${fileInfo.size} bytes (max ${MAX_READ_BYTES})` };
}
const content = await readFile(safePath, args.encoding ?? 'utf-8');
const lines = content.split('\n');
const offset = args.offset ?? 0;
const limit = args.limit ?? lines.length;
const slice = lines.slice(offset, offset + limit).join('\n');
return { success: true, output: slice, duration_ms: Date.now() - start };
}
async function executeFileWrite(args: {
path: string;
content: string;
mode?: 'overwrite' | 'append';
}): Promise<ToolResult> {
const safePath = resolveSafePath(args.path);
const start = Date.now();
if (Buffer.byteLength(args.content) > MAX_WRITE_BYTES) {
return { success: false, output: '', duration_ms: Date.now() - start,
error: `Content too large (max ${MAX_WRITE_BYTES} bytes)` };
}
if (args.mode === 'append') {
const existing = await readFile(safePath, 'utf-8').catch(() => '');
await writeFile(safePath, existing + args.content, 'utf-8');
} else {
// Atomic write: write to temp, then rename
const tmpPath = safePath + '.tmp';
await writeFile(tmpPath, args.content, 'utf-8');
const { rename } = await import('node:fs/promises');
await rename(tmpPath, safePath);
}
return { success: true, output: `Written to ${args.path}`, duration_ms: Date.now() - start };
}
```
#### 1.5.4 Env Executor
```typescript
// packages/safety-wrapper/src/executors/env.ts
import { readFile, writeFile, rename } from 'node:fs/promises';
async function executeEnvRead(args: {
path: string;
keys?: string[];
}): Promise<ToolResult> {
const safePath = resolveSafePath(args.path);
const start = Date.now();
const content = await readFile(safePath, 'utf-8');
const parsed = parseEnvFile(content);
if (args.keys && args.keys.length > 0) {
const filtered: Record<string, string> = {};
for (const key of args.keys) {
if (key in parsed) filtered[key] = parsed[key];
}
return { success: true, output: JSON.stringify(filtered, null, 2),
duration_ms: Date.now() - start };
}
return { success: true, output: JSON.stringify(parsed, null, 2),
duration_ms: Date.now() - start };
}
async function executeEnvUpdate(args: {
path: string;
updates: Record<string, string>;
}): Promise<ToolResult> {
const safePath = resolveSafePath(args.path);
const start = Date.now();
const content = await readFile(safePath, 'utf-8');
let updated = content;
for (const [key, value] of Object.entries(args.updates)) {
const regex = new RegExp(`^${escapeRegex(key)}=.*$`, 'm');
if (regex.test(updated)) {
updated = updated.replace(regex, `${key}=${value}`);
} else {
updated += `\n${key}=${value}`;
}
}
// Atomic write: temp → chmod 600 → rename
const tmpPath = safePath + '.tmp';
await writeFile(tmpPath, updated, { mode: 0o600 });
await rename(tmpPath, safePath);
return { success: true, output: `Updated ${Object.keys(args.updates).length} keys`,
duration_ms: Date.now() - start };
}
```
### 1.6 Hub Communication Client
```typescript
// packages/safety-wrapper/src/hub/client.ts
interface HubClient {
register(token: string): Promise<{ hub_api_key: string; config: TenantConfig }>;
heartbeat(metrics: HeartbeatPayload): Promise<HeartbeatResponse>;
reportUsage(buckets: TokenUsageBucket[]): Promise<void>;
requestApproval(request: ApprovalRequest): Promise<{ approval_id: string }>;
pollApproval(approval_id: string): Promise<ApprovalResponse>;
reportBackupStatus(status: BackupStatus): Promise<void>;
}
interface HeartbeatPayload {
version: string;
openclaw_version: string;
uptime_seconds: number;
agents: AgentStatus[];
active_sessions: number;
pending_approvals: number;
last_token_usage_sync: string; // ISO timestamp
last_backup_check: string; // ISO timestamp
disk_usage_percent: number;
memory_usage_mb: number;
}
interface HeartbeatResponse {
config_version: number;
config?: TenantConfig; // Only if config_version > local
pending_commands: RemoteCommand[]; // Commands queued by admin
pool_remaining: number; // Token pool remaining
}
interface TokenUsageBucket {
agent_id: string;
model: string;
bucket_hour: string; // ISO hour (e.g., "2026-02-27T14:00:00Z")
tokens_input: number;
tokens_output: number;
tokens_cache_read: number;
tokens_cache_write: number;
web_search_count: number;
web_fetch_count: number;
estimated_cost_cents: number;
}
interface ApprovalRequest {
agent_id: string;
command_class: CommandTier;
tool_name: string;
tool_args: Record<string, unknown>;
human_readable: string; // Natural language description
}
interface ApprovalResponse {
status: 'pending' | 'approved' | 'denied' | 'expired';
responded_at?: string;
responded_by?: string;
}
```
#### 1.6.1 Heartbeat Loop
```typescript
// packages/safety-wrapper/src/hub/heartbeat.ts
const HEARTBEAT_INTERVAL_MS = 60_000; // 1 minute
const HEARTBEAT_BACKOFF_MAX_MS = 300_000; // 5 minutes
const USAGE_REPORT_INTERVAL_MS = 300_000; // 5 minutes
class HeartbeatService {
private interval: NodeJS.Timeout | null = null;
private consecutiveFailures = 0;
start(): void {
this.interval = setInterval(() => this.tick(), HEARTBEAT_INTERVAL_MS);
// Also start usage reporting on a separate cadence
setInterval(() => this.reportUsage(), USAGE_REPORT_INTERVAL_MS);
}
private async tick(): Promise<void> {
try {
const metrics = await this.collectMetrics();
const response = await this.hubClient.heartbeat(metrics);
this.consecutiveFailures = 0;
// Apply config update if newer version available
if (response.config && response.config_version > this.localConfigVersion) {
await this.applyConfig(response.config);
this.localConfigVersion = response.config_version;
}
// Execute any pending remote commands
for (const cmd of response.pending_commands) {
await this.executeRemoteCommand(cmd);
}
// Track pool remaining for local rate limiting
this.poolRemaining = response.pool_remaining;
} catch (error) {
this.consecutiveFailures++;
const backoff = Math.min(
HEARTBEAT_INTERVAL_MS * Math.pow(2, this.consecutiveFailures),
HEARTBEAT_BACKOFF_MAX_MS,
);
// Apply jitter (±15%)
const jitter = backoff * (0.85 + Math.random() * 0.3);
await sleep(jitter);
}
}
}
```
### 1.7 Token Metering
```typescript
// packages/safety-wrapper/src/metering/token-meter.ts
class TokenMeter {
private buckets: Map<string, TokenUsageBucket> = new Map();
/**
* Called after every LLM response. Captures token counts from
* OpenRouter response headers.
*/
record(event: {
agent_id: string;
model: string;
tokens_input: number;
tokens_output: number;
tokens_cache_read: number;
tokens_cache_write: number;
is_web_search: boolean;
is_web_fetch: boolean;
}): void {
const bucketHour = getCurrentHour(); // "2026-02-27T14:00:00Z"
const key = `${event.agent_id}:${event.model}:${bucketHour}`;
const bucket = this.buckets.get(key) ?? {
agent_id: event.agent_id,
model: event.model,
bucket_hour: bucketHour,
tokens_input: 0,
tokens_output: 0,
tokens_cache_read: 0,
tokens_cache_write: 0,
web_search_count: 0,
web_fetch_count: 0,
estimated_cost_cents: 0,
};
bucket.tokens_input += event.tokens_input;
bucket.tokens_output += event.tokens_output;
bucket.tokens_cache_read += event.tokens_cache_read;
bucket.tokens_cache_write += event.tokens_cache_write;
if (event.is_web_search) bucket.web_search_count++;
if (event.is_web_fetch) bucket.web_fetch_count++;
bucket.estimated_cost_cents += this.calculateCost(event);
this.buckets.set(key, bucket);
}
/**
* Flush completed hourly buckets to Hub.
* Called every 5 minutes by HeartbeatService.
*/
flush(): TokenUsageBucket[] {
const currentHour = getCurrentHour();
const completed: TokenUsageBucket[] = [];
for (const [key, bucket] of this.buckets.entries()) {
if (bucket.bucket_hour !== currentHour) {
completed.push(bucket);
this.buckets.delete(key);
}
}
return completed;
}
private calculateCost(event: {
model: string;
tokens_input: number;
tokens_output: number;
tokens_cache_read: number;
tokens_cache_write: number;
}): number {
const pricing = MODEL_PRICING[event.model];
if (!pricing) return 0;
return Math.ceil(
(event.tokens_input * pricing.input_per_m / 1_000_000) +
(event.tokens_output * pricing.output_per_m / 1_000_000) +
(event.tokens_cache_read * pricing.cache_read_per_m / 1_000_000) +
(event.tokens_cache_write * pricing.cache_write_per_m / 1_000_000)
);
}
}
```
### 1.8 Audit Log
```typescript
// packages/safety-wrapper/src/audit/logger.ts
interface AuditEntry {
id: string; // UUID
timestamp: string; // ISO 8601
agent_id: string;
session_id: string;
tool_name: string;
tool_args_redacted: Record<string, unknown>; // Args with secrets stripped
classification: CommandTier;
autonomy_decision: 'executed' | 'gated' | 'denied';
effective_level: number;
result_summary: string; // First 500 chars, secrets redacted
duration_ms: number;
approval_id?: string; // If gated
}
// SQLite table: audit_log
// Append-only. Retained for 90 days. Queryable by agent, tier, date range.
// Reported to Hub via heartbeat for admin dashboard.
```
### 1.9 SQLite State Schema
```sql
-- packages/safety-wrapper/src/db/schema.sql
-- Secrets registry (encrypted values via ChaCha20-Poly1305)
CREATE TABLE secrets (
id TEXT PRIMARY KEY,
name TEXT NOT NULL UNIQUE, -- e.g., "nextcloud_admin_password"
encrypted_value BLOB NOT NULL, -- ChaCha20-Poly1305 encrypted
service TEXT NOT NULL, -- e.g., "nextcloud"
config_path TEXT, -- e.g., "/opt/letsbe/env/nextcloud.env"
pattern TEXT, -- Regex pattern for safety net matching
created_at TEXT NOT NULL DEFAULT (datetime('now')),
rotated_at TEXT,
rotation_count INTEGER DEFAULT 0
);
-- Pending approval requests
CREATE TABLE approvals (
id TEXT PRIMARY KEY,
agent_id TEXT NOT NULL,
tool_name TEXT NOT NULL,
tool_args TEXT NOT NULL, -- JSON
human_readable TEXT NOT NULL,
classification TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending', -- pending, approved, denied, expired
requested_at TEXT NOT NULL DEFAULT (datetime('now')),
expires_at TEXT NOT NULL,
responded_at TEXT,
responded_by TEXT
);
CREATE INDEX idx_approvals_status ON approvals(status);
-- Audit log (append-only)
CREATE TABLE audit_log (
id TEXT PRIMARY KEY,
timestamp TEXT NOT NULL DEFAULT (datetime('now')),
agent_id TEXT NOT NULL,
session_id TEXT NOT NULL,
tool_name TEXT NOT NULL,
tool_args_redacted TEXT NOT NULL, -- JSON with secrets stripped
classification TEXT NOT NULL,
decision TEXT NOT NULL, -- executed, gated, denied
effective_level INTEGER NOT NULL,
result_summary TEXT,
duration_ms INTEGER NOT NULL,
approval_id TEXT
);
CREATE INDEX idx_audit_timestamp ON audit_log(timestamp);
CREATE INDEX idx_audit_agent ON audit_log(agent_id);
-- Token usage buckets (aggregated hourly)
CREATE TABLE token_usage (
id TEXT PRIMARY KEY,
agent_id TEXT NOT NULL,
model TEXT NOT NULL,
bucket_hour TEXT NOT NULL,
tokens_input INTEGER DEFAULT 0,
tokens_output INTEGER DEFAULT 0,
tokens_cache_read INTEGER DEFAULT 0,
tokens_cache_write INTEGER DEFAULT 0,
web_search_count INTEGER DEFAULT 0,
web_fetch_count INTEGER DEFAULT 0,
estimated_cost_cents INTEGER DEFAULT 0,
synced_to_hub INTEGER DEFAULT 0, -- Boolean: has this been reported?
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE UNIQUE INDEX idx_usage_bucket ON token_usage(agent_id, model, bucket_hour);
-- Hub sync state
CREATE TABLE hub_state (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
-- Keys: 'config_version', 'hub_api_key', 'last_heartbeat', 'last_usage_sync'
```
### 1.10 Configuration File
```json5
// /opt/letsbe/config/safety-wrapper.json
{
"server": {
"port": 8200,
"host": "127.0.0.1",
"auth_token": "${SAFETY_WRAPPER_TOKEN}"
},
"hub": {
"url": "${HUB_URL}",
"registration_token": "${REGISTRATION_TOKEN}",
"heartbeat_interval_ms": 60000,
"usage_report_interval_ms": 300000
},
"secrets_proxy": {
"url": "http://127.0.0.1:8100"
},
"openclaw": {
"url": "http://127.0.0.1:18789",
"token": "${OPENCLAW_TOKEN}"
},
"tenant": {
"id": "${TENANT_ID}",
"default_autonomy_level": 2
},
"security": {
"shell_timeout_ms": 60000,
"max_file_size_bytes": 10485760,
"allowed_root": "/opt/letsbe",
"approval_expiry_hours": 24,
"max_pending_approvals": 50,
"rate_limit_per_agent_per_minute": 30
},
"database": {
"path": "/opt/letsbe/data/safety-wrapper.db"
}
}
```
---
## 2. Secrets Proxy
**Process:** `letsbe-secrets-proxy` — Lightweight Node.js HTTP proxy
**Port:** `localhost:8100`
**RAM Budget:** ~64MB
**Language:** TypeScript (strict mode, ESM)
**Repository:** `packages/secrets-proxy/` in monorepo
### 2.1 Responsibility
The Secrets Proxy has ONE job: intercept all outbound LLM traffic and strip secrets before they reach OpenRouter. It sits between OpenClaw and the internet at the transport layer.
```
OpenClaw → (port 8100) Secrets Proxy → (HTTPS) OpenRouter/Anthropic/etc.
```
OpenClaw is configured with `modelProvider.url = "http://127.0.0.1:8100"` so all LLM API calls route through this proxy.
### 2.2 4-Layer Redaction Pipeline
```typescript
// packages/secrets-proxy/src/pipeline/redact.ts
interface RedactionResult {
text: string;
redactions: RedactionEntry[];
duration_ms: number;
}
interface RedactionEntry {
original_length: number; // Length of redacted value (not the value itself)
placeholder: string; // e.g., "[REDACTED:nextcloud_admin_password]"
layer: 1 | 2 | 3 | 4; // Which layer caught it
}
async function redact(text: string, registry: SecretsRegistry): Promise<RedactionResult> {
const start = Date.now();
const redactions: RedactionEntry[] = [];
let result = text;
// Layer 1 — Aho-Corasick registry lookup (O(n) in text length)
result = registryRedact(result, registry, redactions);
// Layer 2 — Regex safety net for unregistered secrets
result = patternRedact(result, redactions);
// Layer 3 — Shannon entropy filter for high-entropy blobs
result = entropyRedact(result, redactions);
// Layer 4 — JSON key scanning for sensitive field names
result = jsonKeyRedact(result, redactions);
return { text: result, redactions, duration_ms: Date.now() - start };
}
```
#### 2.2.1 Layer 1 — Aho-Corasick Registry Lookup
```typescript
// packages/secrets-proxy/src/pipeline/layer1-registry.ts
import { AhoCorasick } from './aho-corasick';
/**
* Builds an Aho-Corasick automaton from all known secret values.
* Matches all secrets in a single pass through the text (O(n)).
* Rebuilds automaton when secrets rotate.
*/
class RegistryMatcher {
private automaton: AhoCorasick;
private secretMap: Map<string, string>; // value → placeholder name
constructor(secrets: Array<{ name: string; value: string }>) {
this.secretMap = new Map();
const patterns: string[] = [];
for (const secret of secrets) {
this.secretMap.set(secret.value, `[REDACTED:${secret.name}]`);
patterns.push(secret.value);
}
this.automaton = new AhoCorasick(patterns);
}
redact(text: string): { text: string; matches: number } {
let result = text;
let matches = 0;
// Find all matches in one pass
const found = this.automaton.search(text);
// Replace longest matches first (greedy)
const sorted = found.sort((a, b) => b.length - a.length);
for (const match of sorted) {
const placeholder = this.secretMap.get(match.pattern);
if (placeholder) {
result = result.replaceAll(match.pattern, placeholder);
matches++;
}
}
return { text: result, matches };
}
}
```
#### 2.2.2 Layer 2 — Regex Safety Net
```typescript
// packages/secrets-proxy/src/pipeline/layer2-patterns.ts
const SECRET_PATTERNS: Array<{ name: string; pattern: RegExp; placeholder: string }> = [
{
name: 'private_key',
pattern: /-----BEGIN\s+[\w\s]*PRIVATE KEY-----[\s\S]*?-----END\s+[\w\s]*PRIVATE KEY-----/g,
placeholder: '[REDACTED:private_key]',
},
{
name: 'jwt_token',
pattern: /eyJ[A-Za-z0-9_-]{10,}\.eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}/g,
placeholder: '[REDACTED:jwt_token]',
},
{
name: 'bcrypt_hash',
pattern: /\$2[aby]?\$\d{1,2}\$[./A-Za-z0-9]{53}/g,
placeholder: '[REDACTED:bcrypt_hash]',
},
{
name: 'connection_string',
pattern: /:\/\/[^:\s]+:[^@\s]+@[^/\s]+/g,
placeholder: '[REDACTED:connection_string]',
},
{
name: 'env_secret',
pattern: /(PASSWORD|SECRET|KEY|TOKEN|CREDENTIAL|AUTH)[\s]*[=:]\s*[^\s]{8,}/gi,
placeholder: '[REDACTED:env_secret]',
},
{
name: 'aws_key',
pattern: /AKIA[0-9A-Z]{16}/g,
placeholder: '[REDACTED:aws_access_key]',
},
{
name: 'api_key_generic',
pattern: /(?:api[_-]?key|apikey|access[_-]?token)[\s]*[=:]\s*['"]?[a-zA-Z0-9_-]{20,}['"]?/gi,
placeholder: '[REDACTED:api_key]',
},
];
```
#### 2.2.3 Layer 3 — Shannon Entropy Filter
```typescript
// packages/secrets-proxy/src/pipeline/layer3-entropy.ts
const ENTROPY_THRESHOLD = 4.5; // Shannon bits
const MIN_TOKEN_LENGTH = 32; // Only check strings >= 32 chars
const MAX_TOKEN_LENGTH = 256; // Don't check huge blobs
function shannonEntropy(str: string): number {
const freq = new Map<string, number>();
for (const char of str) {
freq.set(char, (freq.get(char) ?? 0) + 1);
}
let entropy = 0;
for (const count of freq.values()) {
const p = count / str.length;
entropy -= p * Math.log2(p);
}
return entropy;
}
function entropyRedact(text: string, redactions: RedactionEntry[]): string {
// Split on whitespace and common delimiters
const tokens = text.split(/[\s"',;:=\[\]{}()]+/);
let result = text;
for (const token of tokens) {
if (token.length >= MIN_TOKEN_LENGTH && token.length <= MAX_TOKEN_LENGTH) {
const entropy = shannonEntropy(token);
if (entropy >= ENTROPY_THRESHOLD) {
// High entropy — likely a secret
const placeholder = `[REDACTED:high_entropy_${token.length}chars]`;
result = result.replace(token, placeholder);
redactions.push({
original_length: token.length,
placeholder,
layer: 3,
});
}
}
}
return result;
}
```
#### 2.2.4 Layer 4 — JSON Key Scanning
```typescript
// packages/secrets-proxy/src/pipeline/layer4-json.ts
const SENSITIVE_KEYS = new Set([
'password', 'passwd', 'secret', 'token', 'api_key', 'apikey',
'access_token', 'refresh_token', 'private_key', 'auth',
'credential', 'credentials', 'authorization', 'bearer',
'connection_string', 'database_url', 'db_password',
]);
function jsonKeyRedact(text: string, redactions: RedactionEntry[]): string {
// Find JSON-like key-value pairs
const jsonPattern = /"([\w_]+)"\s*:\s*"([^"]{4,})"/g;
let result = text;
for (const match of text.matchAll(jsonPattern)) {
const key = match[1].toLowerCase();
const value = match[2];
if (SENSITIVE_KEYS.has(key)) {
const placeholder = `[REDACTED:${key}]`;
result = result.replace(`"${match[2]}"`, `"${placeholder}"`);
redactions.push({
original_length: value.length,
placeholder,
layer: 4,
});
}
}
return result;
}
```
### 2.3 Proxy HTTP Server
```typescript
// packages/secrets-proxy/src/server.ts
import { createServer, IncomingMessage, ServerResponse } from 'node:http';
const UPSTREAM_PROVIDERS: Record<string, string> = {
openrouter: 'https://openrouter.ai',
anthropic: 'https://api.anthropic.com',
openai: 'https://api.openai.com',
};
async function handleRequest(req: IncomingMessage, res: ServerResponse): Promise<void> {
// Only proxy POST requests to /v1/chat/completions and similar
if (req.method !== 'POST') {
res.writeHead(405);
res.end('Method not allowed');
return;
}
const body = await readBody(req);
const parsed = JSON.parse(body);
// Redact ALL message content before forwarding
for (const message of parsed.messages ?? []) {
if (typeof message.content === 'string') {
const { text } = await redact(message.content, secretsRegistry);
message.content = text;
}
// Handle tool_calls and tool results
if (message.tool_calls) {
for (const tc of message.tool_calls) {
if (tc.function?.arguments) {
const { text } = await redact(
JSON.stringify(tc.function.arguments), secretsRegistry,
);
tc.function.arguments = text;
}
}
}
}
// Forward to upstream provider
const upstream = determineUpstream(req.url!, req.headers);
const upstreamResponse = await fetch(upstream.url + req.url, {
method: 'POST',
headers: { ...upstream.headers, 'Content-Type': 'application/json' },
body: JSON.stringify(parsed),
});
// Capture token usage from response headers
captureTokenUsage(upstreamResponse.headers, parsed);
// Stream response back to OpenClaw
res.writeHead(upstreamResponse.status, Object.fromEntries(upstreamResponse.headers));
const responseBody = await upstreamResponse.text();
res.end(responseBody);
}
```
### 2.4 Secrets API (for App ↔ Secrets Side Channel)
```typescript
// packages/secrets-proxy/src/api/secrets.ts
// These endpoints are called by the mobile app (via Hub relay)
// NOT by the AI — the AI never reaches these endpoints
interface SecretsAPI {
// User provides a new credential value
'POST /secrets/provide': {
body: { secret_id: string; value: string };
response: { success: boolean };
auth: 'hub_session_token';
};
// User requests to view a credential (tap-to-reveal)
'GET /secrets/reveal': {
query: { secret_id: string };
response: { value: string; expires_in_seconds: 30 };
auth: 'hub_session_token';
};
// Generate a cryptographically secure credential
'POST /secrets/generate': {
body: {
service: string;
key: string;
constraints?: { min_length?: number; require_special?: boolean };
};
response: { secret_id: string; status: 'generated' };
auth: 'hub_session_token';
};
// List credentials (names + metadata, never values)
'GET /secrets/list': {
response: Array<{
id: string;
name: string;
service: string;
created_at: string;
rotated_at: string | null;
}>;
auth: 'hub_session_token';
};
// Rotate a credential
'POST /secrets/rotate': {
body: { secret_id: string };
response: { success: boolean; new_secret_id: string };
auth: 'hub_session_token';
};
// View access/rotation audit trail
'GET /secrets/audit': {
query: { secret_id?: string; limit?: number };
response: Array<{
action: 'provide' | 'reveal' | 'generate' | 'rotate' | 'inject';
secret_name: string;
timestamp: string;
actor: string; // 'customer', 'agent:it-admin', 'system'
}>;
auth: 'hub_session_token';
};
}
```
---
## 3. Hub Updates
**Existing codebase:** ~15,000 LOC, 244 files, 80+ endpoints, 22+ Prisma models
**Strategy:** Retool, not rewrite — add new capabilities alongside existing working systems
**Stack:** Next.js 16.1.1 + Prisma 7.0.0 + PostgreSQL 16
### 3.1 New Prisma Models
#### 3.1.1 Token Usage Bucket
```prisma
model TokenUsageBucket {
id String @id @default(uuid())
userId String
orderId String
agentId String
model String
bucketHour DateTime
tokensInput Int @default(0)
tokensOutput Int @default(0)
tokensCacheRead Int @default(0)
tokensCacheWrite Int @default(0)
webSearchCount Int @default(0)
webFetchCount Int @default(0)
costCents Int @default(0)
isPremiumModel Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id])
order Order @relation(fields: [orderId], references: [id])
@@unique([orderId, agentId, model, bucketHour])
@@index([userId, bucketHour])
@@index([orderId, bucketHour])
@@map("token_usage_buckets")
}
```
#### 3.1.2 Billing Period
```prisma
model BillingPeriod {
id String @id @default(uuid())
userId String
subscriptionId String
periodStart DateTime
periodEnd DateTime
tokenAllotment Int // base × founding member multiplier
tokensUsed Int @default(0)
overageTokens Int @default(0)
overageCostCents Int @default(0)
premiumTokensUsed Int @default(0)
premiumCostCents Int @default(0)
stripeInvoiceId String?
status String @default("ACTIVE") // ACTIVE, CLOSED, BILLED
overageOptedIn Boolean @default(false)
poolExhaustedAt DateTime?
createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id])
subscription Subscription @relation(fields: [subscriptionId], references: [id])
@@index([userId, periodStart])
@@index([status])
@@map("billing_periods")
}
```
#### 3.1.3 Founding Member
```prisma
model FoundingMember {
id String @id @default(uuid())
userId String @unique
tokenMultiplier Int @default(2)
startDate DateTime @default(now())
expiresAt DateTime // 12 months from start
isActive Boolean @default(true)
createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id])
@@map("founding_members")
}
```
#### 3.1.4 Agent Config
```prisma
model AgentConfig {
id String @id @default(uuid())
orderId String
agentId String
name String
role String // dispatcher, it-admin, marketing, secretary, sales, custom
soulMd String @db.Text
toolsAllowed String[]
toolsDenied String[]
toolProfile String @default("minimal")
modelPreset String @default("balanced") // basic, balanced, complex
premiumModel String? // e.g., "anthropic/claude-opus-4-6"
autonomyLevel Int? // null = tenant default
externalCommsUnlocks Json? // { "ghost_publish": "autonomous", ... }
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
order Order @relation(fields: [orderId], references: [id])
@@unique([orderId, agentId])
@@map("agent_configs")
}
```
#### 3.1.5 Command Approval
```prisma
model CommandApproval {
id String @id @default(uuid())
orderId String
agentId String
commandClass String // yellow, yellow_external, red, critical_red
toolName String
toolArgs Json
humanReadable String
status String @default("PENDING") // PENDING, APPROVED, DENIED, EXPIRED
requestedAt DateTime @default(now())
respondedAt DateTime?
respondedBy String?
respondedByType String? // 'staff' or 'customer'
expiresAt DateTime
createdAt DateTime @default(now())
order Order @relation(fields: [orderId], references: [id])
@@index([orderId, status])
@@index([status, expiresAt])
@@map("command_approvals")
}
```
#### 3.1.6 Updated ServerConnection
```prisma
model ServerConnection {
id String @id @default(uuid())
orderId String @unique
registrationToken String @unique
hubApiKey String?
hubApiKeyHash String?
safetyWrapperUrl String? // was orchestratorUrl
openclawVersion String?
safetyWrapperVersion String?
lastTokenUsageSync DateTime?
lastBackupReport DateTime?
configVersion Int @default(0)
status String @default("PENDING")
lastHeartbeat DateTime?
order Order @relation(fields: [orderId], references: [id])
@@map("server_connections")
}
```
### 3.2 New API Endpoints
#### 3.2.1 Safety Wrapper Communication (replaces `/api/v1/orchestrator/*`)
```
POST /api/v1/tenant/register
Auth: Registration token in body
Body: { registration_token, version, openclaw_version }
Returns: { hub_api_key, config: TenantConfig }
POST /api/v1/tenant/heartbeat
Auth: Bearer {hubApiKey}
Body: HeartbeatPayload (see Safety Wrapper section)
Returns: HeartbeatResponse (config updates, pending commands, pool status)
POST /api/v1/tenant/usage
Auth: Bearer {hubApiKey}
Body: { buckets: TokenUsageBucket[] }
Returns: { accepted: true, pool_remaining: number }
POST /api/v1/tenant/approval-request
Auth: Bearer {hubApiKey}
Body: ApprovalRequest
Returns: { approval_id: string }
Side effect: Push notification to customer + staff
GET /api/v1/tenant/approval-response/{id}
Auth: Bearer {hubApiKey}
Returns: ApprovalResponse
GET /api/v1/tenant/config
Auth: Bearer {hubApiKey}
Returns: Full TenantConfig (agents, autonomy, tools, classification rules)
POST /api/v1/tenant/backup-status
Auth: Bearer {hubApiKey}
Body: { backup_date, databases: [...], status, errors: [...] }
Returns: { accepted: true }
```
#### 3.2.2 Customer Portal API
```
GET /api/v1/customer/dashboard
Auth: Customer JWT
Returns: { server_status, agents: AgentSummary[], recent_activity, usage_summary }
GET /api/v1/customer/agents
Auth: Customer JWT
Returns: AgentConfig[] with status
GET /api/v1/customer/agents/{id}
Auth: Customer JWT
Returns: Full AgentConfig with activity feed
PATCH /api/v1/customer/agents/{id}
Auth: Customer JWT
Body: { autonomy_level?, soul_md?, model_preset?, premium_model? }
Returns: Updated AgentConfig
Side effect: Pushes config update to Safety Wrapper
PATCH /api/v1/customer/agents/{id}/external-comms
Auth: Customer JWT
Body: { unlocks: Record<string, 'autonomous' | 'gated'> }
Returns: Updated AgentConfig
POST /api/v1/customer/agents
Auth: Customer JWT (custom agent creation)
Body: { name, role: 'custom', soul_md, tools_allowed, tools_denied }
Returns: New AgentConfig
DELETE /api/v1/customer/agents/{id}
Auth: Customer JWT (custom agents only — cannot delete defaults)
Returns: { deleted: true }
GET /api/v1/customer/approvals
Auth: Customer JWT
Returns: CommandApproval[] (pending only by default)
POST /api/v1/customer/approvals/{id}
Auth: Customer JWT
Body: { action: 'approve' | 'deny' }
Returns: Updated CommandApproval
Side effect: Notifies Safety Wrapper of decision
GET /api/v1/customer/usage
Auth: Customer JWT
Returns: {
current_period: BillingPeriod,
by_agent: Record<string, { tokens_used, cost_cents }>,
by_model: Record<string, { tokens_used, cost_cents }>,
daily_trend: Array<{ date, tokens }>,
}
GET /api/v1/customer/usage/breakdown
Auth: Customer JWT
Query: { period_id?, agent_id?, model? }
Returns: Detailed TokenUsageBucket[] with cost calculations
GET /api/v1/customer/tools
Auth: Customer JWT
Returns: Array<{ name, status, category, has_api, internal_url }>
GET /api/v1/customer/billing
Auth: Customer JWT
Returns: {
current_period: BillingPeriod,
subscription: Subscription,
founding_member?: { multiplier, expires_at },
overage_opted_in: boolean,
stripe_portal_url: string,
}
GET /api/v1/customer/backups
Auth: Customer JWT
Returns: { last_backup, status, databases, snapshots }
GET /api/v1/customer/server
Auth: Customer JWT
Returns: { ip, tier, disk_usage, memory_usage, uptime, containers_running }
POST /api/v1/customer/chat
Auth: Customer JWT
Body: { agent_id?, message }
Returns: SSE stream of agent response
Note: Relays through Hub to tenant Safety Wrapper → OpenClaw
```
#### 3.2.3 Admin Billing & Analytics
```
POST /api/v1/admin/billing/usage
Auth: Bearer {hubApiKey} (called by Safety Wrapper)
Body: { buckets: TokenUsageBucket[] }
Logic: Upsert buckets, update BillingPeriod totals, check pool threshold
GET /api/v1/admin/billing/{customerId}
Auth: Staff JWT
Returns: Customer billing summary with current period, usage, overages
GET /api/v1/admin/billing/{customerId}/history
Auth: Staff JWT
Returns: Historical BillingPeriod[] with totals
POST /api/v1/admin/billing/overages
Auth: Staff JWT or automated cron
Logic: Find customers where tokensUsed > tokenAllotment, create Stripe invoice items
GET /api/v1/admin/analytics/tokens
Auth: Staff JWT
Returns: Global token usage by model, by tier, daily trends
GET /api/v1/admin/analytics/tokens/{orderId}
Auth: Staff JWT
Returns: Per-tenant token breakdown
GET /api/v1/admin/analytics/costs
Auth: Staff JWT
Returns: Cost breakdown by customer, model, agent with margin analysis
```
#### 3.2.4 Agent Management (Admin)
```
GET /api/v1/admin/agents/templates
Auth: Staff JWT
Returns: Array of agent role templates (dispatcher, it-admin, etc.)
POST /api/v1/admin/orders/{id}/agents
Auth: Staff JWT
Body: AgentConfig creation payload
Returns: New AgentConfig
GET /api/v1/admin/orders/{id}/agents
Auth: Staff JWT
Returns: All AgentConfig[] for this tenant
PATCH /api/v1/admin/orders/{id}/agents/{agentId}
Auth: Staff JWT
Body: Partial AgentConfig update
Returns: Updated AgentConfig
DELETE /api/v1/admin/orders/{id}/agents/{agentId}
Auth: Staff JWT
Returns: { deleted: true }
```
#### 3.2.5 DNS Automation
```
POST /api/v1/admin/orders/{id}/dns/create
Auth: Staff JWT
Body: { provider: 'cloudflare' | 'entri', zone_id?: string }
Logic: Auto-create A records for all required subdomains
Returns: { records_created: number, records: DnsRecord[] }
```
### 3.3 Stripe Billing Integration Updates
```typescript
// src/lib/services/billing-service.ts
interface BillingService {
/**
* Create/update billing period on subscription renewal.
* Applies founding member multiplier if active.
*/
createBillingPeriod(userId: string, subscriptionId: string): Promise<BillingPeriod>;
/**
* Ingest token usage from Safety Wrapper.
* Updates BillingPeriod totals.
* Triggers alerts at 80%, 90%, 100% pool usage.
*/
ingestUsage(orderId: string, buckets: TokenUsageBucket[]): Promise<{
pool_remaining: number;
alert?: 'approaching_limit' | 'limit_reached';
}>;
/**
* Process overages via Stripe Billing Meters.
* Creates invoice items for included model overages and premium usage.
*/
processOverages(billingPeriodId: string): Promise<{
overage_amount_cents: number;
premium_amount_cents: number;
stripe_invoice_item_id?: string;
}>;
/**
* Generate Stripe customer portal URL for billing management.
*/
getPortalUrl(userId: string): Promise<string>;
}
// Stripe Billing Meter for overage tracking
// Meter: "letsbe_token_overage" — reports token count when pool exhausted
// Meter: "letsbe_premium_usage" — reports premium model token count
// Price: $X per 1M tokens (included model overage, with markup)
// Price: $Y per 1M tokens (premium model, with sliding markup)
```
### 3.4 Push Notification Service
```typescript
// src/lib/services/notification-service.ts
interface NotificationService {
/**
* Send push notification for command approval request.
* Supports: Expo Push (mobile), Web Push, Email fallback.
*/
sendApprovalNotification(params: {
user_id: string;
approval_id: string;
agent_name: string;
human_readable: string;
classification: string;
}): Promise<void>;
/**
* Send usage alert notification.
*/
sendUsageAlert(params: {
user_id: string;
percent_used: number;
pool_remaining: number;
}): Promise<void>;
/**
* Send server health alert.
*/
sendHealthAlert(params: {
user_id: string;
severity: 'soft' | 'medium' | 'hard';
message: string;
}): Promise<void>;
}
```
### 3.5 Chat Relay Service
```typescript
// src/lib/services/chat-relay-service.ts
/**
* Relays chat messages between the mobile app/customer portal
* and the tenant's OpenClaw instance via the Safety Wrapper.
*
* Flow: App → Hub WebSocket → Hub → Safety Wrapper HTTP → OpenClaw → response
* Return: OpenClaw → Safety Wrapper → Hub → Hub WebSocket → App
*/
interface ChatRelayService {
/**
* Open a streaming connection to a tenant's agent.
* Returns an SSE stream of agent responses.
*/
relayMessage(params: {
user_id: string;
order_id: string;
agent_id: string; // 'dispatcher' for team chat, specific agent for direct
message: string;
conversation_id?: string; // Resume existing conversation
}): AsyncGenerator<ChatEvent>;
}
type ChatEvent =
| { type: 'text_delta'; content: string }
| { type: 'tool_call_start'; tool_name: string; agent_id: string }
| { type: 'tool_call_result'; tool_name: string; success: boolean }
| { type: 'approval_required'; approval_id: string; human_readable: string }
| { type: 'secret_input_required'; secret_id: string; prompt: string }
| { type: 'secret_card'; secret_id: string; label: string }
| { type: 'done'; conversation_id: string };
```
---
## 4. Provisioner Updates
**Existing codebase:** ~4,477 LOC Bash, zero tests
**Strategy:** Update deployment targets, add Safety Wrapper config generation
**Location:** `packages/provisioner/` in monorepo (formerly `letsbe-ansible-runner`)
### 4.1 Updated Provisioning Pipeline
The 10-step pipeline stays. Step 10 changes completely:
```
Step 1: System packages (apt-get) [UNCHANGED]
Step 2: Docker CE installation [UNCHANGED]
Step 3: Disable conflicting services [UNCHANGED]
Step 4: nginx + fallback config [UNCHANGED]
Step 5: UFW firewall (80, 443, 22022) [UNCHANGED]
Step 6: Optional admin user + SSH key [UNCHANGED]
Step 7: SSH hardening (port 22022, key-only) [UNCHANGED]
Step 8: Unattended security updates [UNCHANGED]
Step 9: Deploy tool stacks via docker-compose [UPDATED: remove n8n references]
Step 10: Deploy LetsBe AI stack + bootstrap [REWRITTEN: OpenClaw + Safety Wrapper]
```
### 4.2 Step 10 — Deploy LetsBe AI Stack
```bash
#!/bin/bash
# provisioner/scripts/deploy_ai_stack.sh
deploy_ai_stack() {
local DOMAIN="$1"
local TENANT_ID="$2"
local HUB_URL="$3"
local REGISTRATION_TOKEN="$4"
local BUSINESS_TYPE="$5"
log_step "Deploying LetsBe AI Stack"
# 1. Generate Safety Wrapper configuration
generate_safety_wrapper_config "$DOMAIN" "$TENANT_ID" "$HUB_URL" "$REGISTRATION_TOKEN"
# 2. Generate OpenClaw configuration
generate_openclaw_config "$DOMAIN" "$BUSINESS_TYPE"
# 3. Seed secrets registry from env_setup.sh outputs
seed_secrets_registry
# 4. Generate agent SOUL.md files from business type template
generate_agent_configs "$BUSINESS_TYPE"
# 5. Generate tool registry from installed tools
generate_tool_registry "$DOMAIN"
# 6. Deploy containers via docker-compose
deploy_letsbe_containers
# 7. Wait for Safety Wrapper to register with Hub
wait_for_registration "$HUB_URL" "$REGISTRATION_TOKEN"
# 8. Run initial-setup Playwright scenarios via OpenClaw
run_initial_setup_scenarios
# 9. Clean up config.json (CRITICAL: remove plaintext passwords)
cleanup_config_json
log_step "LetsBe AI Stack deployed successfully"
}
```
### 4.3 Generated Configuration Files
#### 4.3.1 Safety Wrapper Config
```bash
generate_safety_wrapper_config() {
cat > /opt/letsbe/config/safety-wrapper.json <<EOF
{
"server": {
"port": 8200,
"host": "127.0.0.1",
"auth_token": "${SAFETY_WRAPPER_TOKEN}"
},
"hub": {
"url": "${HUB_URL}",
"registration_token": "${REGISTRATION_TOKEN}"
},
"secrets_proxy": { "url": "http://127.0.0.1:8100" },
"openclaw": { "url": "http://127.0.0.1:18789", "token": "${OPENCLAW_TOKEN}" },
"tenant": { "id": "${TENANT_ID}", "default_autonomy_level": 2 },
"database": { "path": "/opt/letsbe/data/safety-wrapper.db" }
}
EOF
}
```
#### 4.3.2 OpenClaw Config
```bash
generate_openclaw_config() {
cat > /opt/letsbe/config/openclaw.json <<EOF
{
"model": {
"primary": "openrouter/deepseek/deepseek-chat-v3-0324",
"fallbacks": [
"openrouter/google/gemini-2.0-flash-001",
"openrouter/minimax/minimax-m1-80k"
]
},
"provider": {
"url": "http://127.0.0.1:8100",
"key": "${OPENROUTER_API_KEY}"
},
"security": {
"elevated": { "enable": false },
"token": "${OPENCLAW_TOKEN}"
},
"tools": {
"loopDetection": { "enabled": true, "maxCalls": 30 },
"server": {
"url": "http://127.0.0.1:8200",
"token": "${SAFETY_WRAPPER_TOKEN}"
}
},
"cacheRetention": "long",
"heartbeat": { "every": "55m" },
"logging": { "redactSensitive": "tools" },
"browser": {
"profile": "openclaw-managed",
"ssrf": { "allowlist": ["127.0.0.1", "localhost"] }
},
"agents": {
"list": [/* Generated per business type template */]
}
}
EOF
}
```
### 4.4 Docker Compose — LetsBe Stack
```yaml
# provisioner/stacks/letsbe/docker-compose.yml
version: '3.8'
services:
openclaw:
image: code.letsbe.solutions/letsbe/openclaw:latest
container_name: letsbe-openclaw
restart: unless-stopped
network_mode: host
volumes:
- /opt/letsbe/config/openclaw.json:/home/openclaw/.openclaw/openclaw.json:ro
- /opt/letsbe/agents:/home/openclaw/.openclaw/agents:rw
- /opt/letsbe/skills:/home/openclaw/.openclaw/skills:ro
- /opt/letsbe/references:/home/openclaw/.openclaw/references:ro
- /opt/letsbe/data/openclaw:/home/openclaw/.openclaw/data:rw
- /opt/letsbe/shared-memory:/home/openclaw/.openclaw/shared-memory:rw
environment:
- NODE_ENV=production
mem_limit: 512m
cpus: 2.0
safety-wrapper:
image: code.letsbe.solutions/letsbe/safety-wrapper:latest
container_name: letsbe-safety-wrapper
restart: unless-stopped
network_mode: host
volumes:
- /opt/letsbe/config/safety-wrapper.json:/app/config.json:ro
- /opt/letsbe/data/safety-wrapper.db:/app/data/safety-wrapper.db:rw
environment:
- NODE_ENV=production
mem_limit: 128m
cpus: 0.5
secrets-proxy:
image: code.letsbe.solutions/letsbe/secrets-proxy:latest
container_name: letsbe-secrets-proxy
restart: unless-stopped
network_mode: host
volumes:
- /opt/letsbe/data/safety-wrapper.db:/app/data/secrets.db:ro
environment:
- PORT=8100
- UPSTREAM_URL=https://openrouter.ai
- NODE_ENV=production
mem_limit: 64m
cpus: 0.25
```
### 4.5 n8n Cleanup Manifest
Files requiring n8n reference removal:
```
provisioner/stacks/automation/docker-compose.yml -- Remove n8n service
provisioner/stacks/automation/nginx/n8n.conf -- Delete file
provisioner/scripts/env_setup.sh -- Remove n8n variables
provisioner/scripts/setup.sh -- Remove n8n deploy step
provisioner/playwright/n8n-initial-setup.js -- Delete file
provisioner/scripts/backups.sh -- Remove n8n DB backup
provisioner/scripts/restore.sh -- Remove n8n restore
```
Replace n8n with Activepieces in all relevant locations.
### 4.6 Secrets Registry Seeding
```bash
seed_secrets_registry() {
# Read all generated credentials from env_setup.sh outputs
# Insert into Safety Wrapper's SQLite secrets registry
local DB_PATH="/opt/letsbe/data/safety-wrapper.db"
# Create secrets table if not exists (Safety Wrapper will also do this)
sqlite3 "$DB_PATH" "CREATE TABLE IF NOT EXISTS secrets (
id TEXT PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
encrypted_value BLOB NOT NULL,
service TEXT NOT NULL,
config_path TEXT,
pattern TEXT,
created_at TEXT DEFAULT (datetime('now')),
rotated_at TEXT,
rotation_count INTEGER DEFAULT 0
);"
# For each tool's .env file, extract credentials and insert
for env_file in /opt/letsbe/env/*.env; do
local service=$(basename "$env_file" .env)
while IFS='=' read -r key value; do
if is_secret_key "$key"; then
local encrypted=$(encrypt_value "$value" "$SECRETS_ENCRYPTION_KEY")
sqlite3 "$DB_PATH" "INSERT OR REPLACE INTO secrets (id, name, encrypted_value, service, config_path)
VALUES ('$(uuidgen)', '${service}_${key}', X'${encrypted}', '${service}', '${env_file}');"
fi
done < "$env_file"
done
}
is_secret_key() {
local key="$1"
[[ "$key" =~ (PASSWORD|SECRET|KEY|TOKEN|CREDENTIAL|AUTH|API_KEY) ]]
}
```
---
## 5. Mobile App
**Framework:** Expo SDK 52+ (Bare Workflow with EAS Build + EAS Update)
**Language:** TypeScript (strict mode)
**State:** Zustand + TanStack Query v5
**Navigation:** Expo Router (file-based)
**Push Notifications:** Expo Push + APNs/FCM
**Repository:** `apps/mobile/` in monorepo
### 5.1 Screen Architecture
```
App
├── (auth)
│ ├── login.tsx — Email/password login
│ ├── register.tsx — Account creation (from website flow)
│ └── forgot-password.tsx — Password reset
├── (tabs)
│ ├── index.tsx — Dashboard (morning briefing, quick actions)
│ ├── chat.tsx — Team chat (Dispatcher) or direct agent chat
│ ├── activity.tsx — Agent activity feed
│ ├── approvals.tsx — Pending command approvals
│ └── settings.tsx — Account, agents, billing, server
├── chat/
│ ├── [agent_id].tsx — Direct chat with specific agent
│ └── team.tsx — Team chat via Dispatcher
├── agents/
│ ├── index.tsx — Agent roster
│ ├── [id].tsx — Agent detail (config, stats, conversations)
│ └── [id]/edit.tsx — Edit agent (SOUL.md, autonomy, model, external comms)
├── approvals/
│ └── [id].tsx — Approval detail with context
├── usage/
│ ├── index.tsx — Usage dashboard (per-agent, per-model)
│ └── breakdown.tsx — Detailed breakdown
├── server/
│ ├── index.tsx — Server health overview
│ ├── tools.tsx — Installed tools list
│ └── backups.tsx — Backup status
├── billing/
│ ├── index.tsx — Current period, plan, overages
│ └── portal.tsx — Stripe portal WebView
└── secrets/
├── provide.tsx — Secure credential input modal
└── reveal.tsx — Tap-to-reveal credential card
```
### 5.2 Core Components
```typescript
// apps/mobile/src/components/chat/ChatView.tsx
interface ChatViewProps {
agentId: string; // 'dispatcher' for team, specific agent for direct
conversationId?: string; // Resume existing conversation
}
// Renders streaming agent responses via SSE from Hub
// Handles special events:
// - approval_required → navigates to approval screen
// - secret_input_required → opens secure modal
// - secret_card → renders tap-to-reveal card
// - tool_call_start/result → shows tool activity indicator
```
```typescript
// apps/mobile/src/components/approvals/ApprovalCard.tsx
interface ApprovalCardProps {
approval: CommandApproval;
onApprove: () => void;
onDeny: () => void;
}
// Rendered in both:
// 1. Push notification action buttons (one-tap approve/deny)
// 2. In-app approval queue
// Shows: agent name, human-readable description, classification badge, time remaining
```
```typescript
// apps/mobile/src/components/secrets/SecretCard.tsx
interface SecretCardProps {
secretId: string;
label: string; // e.g., "Nextcloud Admin Password"
}
// Tap-to-reveal card:
// 1. Shows masked "••••••••" by default
// 2. On tap: fetches from Secrets Proxy via Hub relay (authenticated)
// 3. Displays for 30 seconds, then auto-clears
// 4. Copy button writes to clipboard, clears after 60 seconds
```
### 5.3 Push Notification Handlers
```typescript
// apps/mobile/src/notifications/handlers.ts
import * as Notifications from 'expo-notifications';
// Register notification categories with action buttons
Notifications.setNotificationCategoryAsync('approval_request', [
{ identifier: 'approve', buttonTitle: 'Approve', options: { opensAppToForeground: false } },
{ identifier: 'deny', buttonTitle: 'Deny', options: { opensAppToForeground: false } },
{ identifier: 'view', buttonTitle: 'View Details', options: { opensAppToForeground: true } },
]);
Notifications.setNotificationCategoryAsync('usage_alert', [
{ identifier: 'view_usage', buttonTitle: 'View Usage', options: { opensAppToForeground: true } },
]);
// Handle background notification actions
Notifications.addNotificationResponseReceivedListener(async (response) => {
const { actionIdentifier, notification } = response;
const data = notification.request.content.data;
switch (data.type) {
case 'approval_request':
if (actionIdentifier === 'approve') {
await api.post(`/customer/approvals/${data.approval_id}`, { action: 'approve' });
} else if (actionIdentifier === 'deny') {
await api.post(`/customer/approvals/${data.approval_id}`, { action: 'deny' });
}
break;
}
});
```
### 5.4 State Management
```typescript
// apps/mobile/src/stores/auth-store.ts
interface AuthStore {
token: string | null;
user: User | null;
login: (email: string, password: string) => Promise<void>;
logout: () => void;
}
// apps/mobile/src/stores/agent-store.ts
interface AgentStore {
agents: AgentConfig[];
activeChat: string | null; // Currently chatting with
setActiveChat: (agentId: string) => void;
}
// apps/mobile/src/hooks/use-chat.ts — TanStack Query + SSE
// apps/mobile/src/hooks/use-approvals.ts — TanStack Query with polling
// apps/mobile/src/hooks/use-usage.ts — TanStack Query
```
---
## 6. Website & Onboarding
**Framework:** Next.js (separate app in monorepo, NOT part of Hub)
**Location:** `apps/website/` in monorepo
**Hosting:** Vercel or self-hosted (static export + API routes)
**Domain:** letsbe.biz
### 6.1 Architecture Decision: Separate App
The website is a separate Next.js application, not part of the Hub. Reasons:
1. **Different deployment cadence** — Marketing site changes weekly; Hub changes are coordinated releases
2. **Different scaling needs** — Website can be on Vercel CDN; Hub must be near the database
3. **Different audience** — Public visitors vs. authenticated users
4. **SEO optimization** — Full static generation for landing pages, no auth overhead
The website communicates with the Hub via the Public API (`/api/v1/public/*`).
### 6.2 Onboarding Flow
```
Page 1: Landing
├── Hero: "Your AI team. Your server. Your rules."
├── Chat input: "Tell us about your business"
└── CTA: "Get Started"
Page 2: AI Conversation (1-2 messages)
├── AI classifies business type
├── Returns: { business_type, confidence, suggested_tools, suggested_tier }
└── AI model: Gemini 2.0 Flash (cheap, fast, accurate for classification)
Page 3: Tool Recommendation
├── Pre-selected tool bundle based on business type
├── Toggle tools on/off
├── Live resource calculator (RAM, disk, CPU)
└── "X tools selected — requires Build tier or higher"
Page 4: Server Selection
├── Only tiers meeting minimum requirements shown
├── Region selection (EU: Nuremberg, US: Manassas)
├── Monthly price display
└── Founding member badge if available
Page 5: Domain Setup
├── Option A: "I have a domain" → enter domain, show DNS instructions
├── Option B: "I need a domain" → Netcup domain reselling (future)
└── Subdomain preview: files.yourdomain.com, chat.yourdomain.com, etc.
Page 6: Agent Configuration (Optional)
├── Business-type template pre-loaded
├── Quick personality tweaks
├── Model preset selection (Basic/Balanced/Complex)
└── "Customize later in the app"
Page 7: Payment
├── Stripe Checkout
├── Founding member discount display
└── Recurring billing disclosure
Page 8: Provisioning Status
├── Real-time SSE progress (10 steps)
├── Estimated time: 15-20 minutes
├── Email notification on completion
└── App download links (iOS/Android)
```
### 6.3 Business Type Classification
```typescript
// apps/website/src/lib/classify.ts
interface ClassificationResult {
business_type: string; // 'freelancer', 'agency', 'ecommerce', 'consulting', ...
confidence: number; // 0.0 - 1.0
suggested_tools: string[]; // Tool IDs from catalog
suggested_tier: string; // 'lite', 'build', 'scale', 'enterprise'
agent_template: string; // Template ID for agent configs
}
// Uses Gemini 2.0 Flash via OpenRouter
// Single system prompt + user description → structured JSON output
// Cost: ~$0.001 per classification (negligible)
// Latency: <2 seconds
```
### 6.4 Resource Calculator
```typescript
// apps/website/src/lib/resource-calculator.ts
interface ResourceEstimate {
ram_mb: number;
disk_gb: number;
min_tier: 'lite' | 'build' | 'scale' | 'enterprise';
headroom_percent: number;
}
const TOOL_RESOURCES: Record<string, { ram_mb: number; disk_gb: number }> = {
nextcloud: { ram_mb: 512, disk_gb: 10 },
chatwoot: { ram_mb: 768, disk_gb: 5 },
ghost: { ram_mb: 256, disk_gb: 3 },
odoo: { ram_mb: 1024, disk_gb: 10 },
calcom: { ram_mb: 256, disk_gb: 2 },
// ... all 28+ tools
};
const LETSBE_OVERHEAD_MB = 640; // OpenClaw + Safety Wrapper + Secrets Proxy + nginx
function calculateResources(selectedTools: string[]): ResourceEstimate {
const toolRam = selectedTools.reduce((sum, t) => sum + (TOOL_RESOURCES[t]?.ram_mb ?? 256), 0);
const toolDisk = selectedTools.reduce((sum, t) => sum + (TOOL_RESOURCES[t]?.disk_gb ?? 2), 0);
const totalRam = toolRam + LETSBE_OVERHEAD_MB;
const totalDisk = toolDisk + 20; // OS + Docker images overhead
const min_tier = totalRam <= 7300 ? 'lite'
: totalRam <= 15000 ? 'build'
: totalRam <= 31000 ? 'scale'
: 'enterprise';
return { ram_mb: totalRam, disk_gb: totalDisk, min_tier, headroom_percent: /*...*/ };
}
```
---
## 7. Tool Registry & Cheat Sheets
### 7.1 Registry Schema
```typescript
// packages/shared/src/types/tool-registry.ts
interface ToolRegistry {
version: string; // Registry schema version
generated_at: string; // ISO timestamp
tools: Record<string, ToolEntry>;
external_services: Record<string, ExternalServiceEntry>;
}
interface ToolEntry {
name: string; // Human-readable name
category: string; // file-storage, communication, project-management, etc.
internal_url: string; // http://127.0.0.1:30XX
external_url: string; // https://files.{{domain}}
api_base?: string; // e.g., "/ocs/v2.php/apps"
api_auth_type: 'basic' | 'header' | 'bearer' | 'none';
api_auth_header?: string; // e.g., "xc-token"
api_auth_ref?: string; // e.g., "SECRET_REF(nocodb_api_token)"
has_api: boolean;
has_webui: boolean;
cheat_sheet?: string; // Path to cheat sheet, e.g., "references/nextcloud.md"
description: string; // One-line description
status?: 'active' | 'stopped' | 'error';
}
interface ExternalServiceEntry {
name: string;
cli: string; // Binary name, e.g., "gog"
skill: string; // Skill name, e.g., "gog"
services: string[]; // Sub-services, e.g., ["gmail", "calendar"]
configured: boolean;
}
```
### 7.2 Cheat Sheet Standard
Each cheat sheet follows this template:
```markdown
# {Tool Name} API Cheat Sheet
Base: http://127.0.0.1:{port}/{api_base}
Auth: {auth_type} via SECRET_REF({credential_name})
## {Resource 1}
GET /endpoint → Description
POST /endpoint → Description (JSON body: { field: "type" })
...
## {Resource 2}
...
## Common Patterns
- Pattern description with example curl command
- Error handling notes
- Pagination format
```
### 7.3 Priority Delivery Schedule
| Priority | Tools | Delivery |
|----------|-------|----------|
| P0 (launch-critical) | Portainer, Nextcloud, Chatwoot, Ghost, Cal.com, Stalwart Mail | Phase 2 |
| P1 (core experience) | Odoo, Listmonk, NocoDB, Umami, Keycloak, Activepieces | Phase 3 |
| P2 (full coverage) | Gitea, Uptime Kuma, MinIO, Documenso, VaultWarden, WordPress | Phase 4 |
| P3 (extended) | Windmill, Redash, Penpot, Squidex, Typebot | Post-launch |
---
## 8. Agent Architecture
### 8.1 Default Agent Roster
| Agent | ID | Tool Profile | Primary Tools | Model Preset |
|-------|----|-------------|---------------|-------------|
| Dispatcher | `dispatcher` | messaging | agentToAgent only | balanced |
| IT Admin | `it-admin` | coding | shell, docker, file_*, env_*, browser, portainer | complex |
| Marketing | `marketing` | minimal | ghost, listmonk, umami, file_read, browser, nextcloud | balanced |
| Secretary | `secretary` | messaging | calcom, chatwoot, poste, file_read, nextcloud | basic |
| Sales | `sales` | minimal | chatwoot, odoo, calcom, file_read, nextcloud, documenso | balanced |
### 8.2 SOUL.md Template Structure
```markdown
# {Agent Name}
## Identity
You are the {role} for {business_name}. {1-2 sentences about personality}.
## Expertise
- {Domain knowledge point 1}
- {Domain knowledge point 2}
- ...
## Rules
1. Always check tool-registry.json before accessing any tool
2. Read the cheat sheet for a tool before your first API call
3. Prefer API over browser — faster and fewer tokens
4. All credentials use SECRET_REF() — never hardcode values
5. {Role-specific rules}
## Communication Style
{Brand voice guidelines from user customization}
```
Estimated size: ~600-800 tokens per SOUL.md. Cached via `cacheRetention: "long"` for 80-99% cost reduction on subsequent calls.
### 8.3 Dispatcher Routing Logic
The Dispatcher uses a simple keyword + context classifier to route messages:
```
"Send newsletter" → Marketing
"Why is X slow?" → IT Admin
"Schedule meeting" → Secretary
"Follow up with client" → Sales → Secretary (multi-agent)
"Morning update" → Dispatcher (self-handles, aggregates from all agents)
Ambiguous → Dispatcher asks: "Should I have your {A} or {B} handle this?"
```
This is implemented as SOUL.md instructions, not code. The Dispatcher's SOUL.md contains a routing table mapping common intents to agent IDs.
### 8.4 Business Type Agent Templates
```typescript
// packages/shared/src/templates/agent-templates.ts
const TEMPLATES: Record<string, AgentTemplate> = {
freelancer: {
agents: ['dispatcher', 'it-admin', 'secretary'],
// No marketing or sales — freelancers handle their own
soul_tweaks: {
secretary: 'Focus on calendar management and client communication.',
},
},
agency: {
agents: ['dispatcher', 'it-admin', 'marketing', 'secretary', 'sales'],
soul_tweaks: {
marketing: 'Multi-client brand voice management.',
sales: 'Pipeline tracking and proposal automation.',
},
},
ecommerce: {
agents: ['dispatcher', 'it-admin', 'marketing', 'sales'],
soul_tweaks: {
marketing: 'Focus on product listings, SEO, and campaign automation.',
sales: 'Order tracking and customer support integration.',
},
},
consulting: {
agents: ['dispatcher', 'it-admin', 'secretary', 'sales'],
soul_tweaks: {
secretary: 'Document management and scheduling focus.',
sales: 'Proposal generation and contract management.',
},
},
};
```
---
## 9. Interactive Demo
### 9.1 Architecture: Per-Session Ephemeral Containers
Instead of a shared "Bella's Bakery" VPS (which presents data freshness and multi-user conflict issues), we propose per-session ephemeral demo containers:
```
Prospect visits letsbe.biz/demo
→ Spins up lightweight demo container (pre-built Docker image)
→ 15-minute TTL, auto-destroyed
→ Pre-loaded with fake bakery data
→ Connects to shared demo OpenClaw instance with demo-tier model
→ Prospect chats with the AI team in real-time
→ After 15 min: "Liked what you saw? Sign up to get your own."
```
### 9.2 Demo Container Spec
```yaml
# provisioner/stacks/demo/docker-compose.yml
services:
demo-gateway:
image: code.letsbe.solutions/letsbe/demo:latest
container_name: demo-${SESSION_ID}
environment:
- DEMO_SESSION_ID=${SESSION_ID}
- DEMO_TTL_MINUTES=15
- MODEL=openrouter/google/gemini-2.0-flash-001 # Cheapest model
mem_limit: 256m
labels:
- "letsbe.demo=true"
- "letsbe.demo.expires=${EXPIRY_TIMESTAMP}"
```
### 9.3 Demo Limitations
| Capability | Demo | Full Product |
|-----------|------|-------------|
| Chat with AI agents | Yes (Gemini Flash) | Yes (user's model choice) |
| View tool dashboards | Screenshots only | Live tool access |
| Execute tool commands | Simulated responses | Real execution |
| Data persistence | None (15-min TTL) | Permanent |
| Custom agents | No | Yes |
| Multiple sessions | No | Yes |
### 9.4 Cost per Demo Session
- Model cost: ~$0.01-0.05 per session (Gemini Flash, 10-20 messages)
- Container cost: ~$0.001 per session (256MB × 15 min)
- Total: <$0.06 per prospect demo — highly scalable
---
## 10. Shared Packages
### 10.1 `packages/shared-types`
```typescript
// Shared TypeScript types used across all packages
export type CommandTier = 'green' | 'yellow' | 'yellow_external' | 'red' | 'critical_red';
export type AutonomyLevel = 1 | 2 | 3;
export type AgentRole = 'dispatcher' | 'it-admin' | 'marketing' | 'secretary' | 'sales' | 'custom';
export type ApprovalStatus = 'pending' | 'approved' | 'denied' | 'expired';
export type ServerTier = 'lite' | 'build' | 'scale' | 'enterprise';
export interface TenantConfig {
id: string;
default_autonomy_level: AutonomyLevel;
agents: Record<string, AgentConfig>;
command_classification: Record<CommandTier, string[]>;
external_comms_gate: ExternalCommsGate;
token_pool_remaining: number;
founding_member_multiplier: number;
config_version: number;
}
export interface AgentConfig {
agent_id: string;
name: string;
role: AgentRole;
autonomy_level: AutonomyLevel | null;
tools_allowed: string[];
tools_denied: string[];
external_comms_unlocks: Record<string, 'autonomous' | 'gated'>;
model_preset: 'basic' | 'balanced' | 'complex';
premium_model: string | null;
is_active: boolean;
}
export interface ExternalCommsGate {
default: 'gated';
unlocks: Record<string, Record<string, 'autonomous' | 'gated'>>;
}
export interface ToolRegistryEntry {
name: string;
category: string;
internal_url: string;
external_url: string;
api_base?: string;
api_auth_type: 'basic' | 'header' | 'bearer' | 'none';
api_auth_ref?: string;
has_api: boolean;
has_webui: boolean;
cheat_sheet?: string;
description: string;
}
```
### 10.2 `packages/shared-prisma`
Shared Prisma client generated from the Hub's schema. Used by the Hub app and any services that need to query the central database.
### 10.3 `packages/shared-utils`
```typescript
// Shared utilities
export { shannonEntropy } from './crypto/entropy';
export { encryptChaCha20, decryptChaCha20 } from './crypto/chacha20';
export { generateSecurePassword } from './crypto/password';
export { parseEnvFile, serializeEnvFile } from './env/parser';
export { resolveSafePath, isPathSafe } from './fs/safe-path';
export { createLogger } from './logging/logger';
export { sleep, retryWithBackoff } from './async/helpers';
```
---
## Appendix A: Full API Surface Summary
| Component | Endpoints | Auth Method |
|-----------|-----------|-------------|
| Safety Wrapper | 6 | Bearer token |
| Secrets Proxy | 7 | Hub session token |
| Hub — Tenant API | 7 | Hub API key |
| Hub — Customer API | 17 | Customer JWT |
| Hub — Admin Billing | 6 | Staff JWT |
| Hub — Admin Agents | 5 | Staff JWT |
| Hub — Admin DNS | 1 | Staff JWT |
| Hub — Existing | 80+ | Various (see Repo Analysis) |
| **Total new endpoints** | **~49** | |
## Appendix B: Data Model Summary
| Model | Database | New/Existing |
|-------|----------|-------------|
| TokenUsageBucket | Hub PostgreSQL | NEW |
| BillingPeriod | Hub PostgreSQL | NEW |
| FoundingMember | Hub PostgreSQL | NEW |
| AgentConfig | Hub PostgreSQL | NEW |
| CommandApproval | Hub PostgreSQL | NEW |
| ServerConnection | Hub PostgreSQL | UPDATED |
| secrets | Safety Wrapper SQLite | NEW |
| approvals | Safety Wrapper SQLite | NEW |
| audit_log | Safety Wrapper SQLite | NEW |
| token_usage | Safety Wrapper SQLite | NEW |
| hub_state | Safety Wrapper SQLite | NEW |
---
*End of Document — 02 Component Breakdown*