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