- Published on
Inside Clawdbot: CLI Boot Sequence, Command Architecture, and the Terminal UI
- Authors

- Name
- Avasdream
- @avasdream_
This is the sixth post in my Clawdbot deep dive series. Previous posts covered core architecture, memory, the agent system, channels and messaging, and sessions and multi-agent. Here I'm looking at everything between clawdbot hitting your shell and a command executing — the boot sequence, argv processing, lazy loading, the TUI chat interface, and the onboarding wizard.
The CLI is the primary interface to Clawdbot. It manages the gateway daemon, configures channels, sends messages, runs an interactive terminal chat, and extends itself through plugins. The open-source codebase implements all of this across src/cli/, src/tui/, src/wizard/, and src/terminal/.
Boot Sequence
Every clawdbot invocation flows through runCli() in src/cli/run-main.ts. The boot sequence is designed around one principle: load as little as possible for the command being run.
export async function runCli(argv: string[] = process.argv) {
const normalizedArgv = stripWindowsNodeExec(argv);
loadDotEnv({ quiet: true });
normalizeEnv();
ensureClawdbotCliOnPath();
assertSupportedRuntime();
// Fast-path: route known commands without full program build
if (await tryRouteCli(normalizedArgv)) return;
enableConsoleCapture();
const { buildProgram } = await import("./program.js");
const program = buildProgram();
installUnhandledRejectionHandler();
const parseArgv = rewriteUpdateFlagArgv(normalizedArgv);
const primary = getPrimaryCommand(parseArgv);
if (primary) {
const { registerSubCliByName } = await import("./program/register.subclis.js");
await registerSubCliByName(program, primary);
}
if (!shouldSkipPluginRegistration) {
const { registerPluginCliCommands } = await import("../plugins/cli.js");
registerPluginCliCommands(program, loadConfig());
}
await program.parseAsync(parseArgv);
}
The sequence breaks into seven phases:
┌────────────────────────────────────────────────────┐
│ clawdbot <args> │
└──────────────────────┬─────────────────────────────┘
│
┌─────────────▼──────────────┐
1. │ Environment Normalization │
│ .env, normalize, PATH │
└─────────────┬──────────────┘
│
┌─────────────▼──────────────┐
2. │ Runtime Guard │
│ assertSupportedRuntime() │
└─────────────┬──────────────┘
│
┌─────────────▼──────────────┐
3. │ Fast Route │──── hit ──→ execute & exit
│ tryRouteCli() │
└─────────────┬──────────────┘
│ miss
┌─────────────▼──────────────┐
4. │ Console Capture │
│ Redirect to structured log │
└─────────────┬──────────────┘
│
┌─────────────▼──────────────┐
5. │ Build Commander Program │
│ buildProgram() │
└─────────────┬──────────────┘
│
┌─────────────▼──────────────┐
6. │ Lazy Subcommand Loading │
│ registerSubCliByName() │
└─────────────┬──────────────┘
│
┌─────────────▼──────────────┐
7. │ Plugin CLI Registration │
│ Parse & Execute │
└─────────────────────────────┘
Phase 1 handles cross-platform quirks. stripWindowsNodeExec() cleans up Windows-specific argv pollution where node.exe appears multiple times with path prefixes and control characters. ensureClawdbotCliOnPath() adds the CLI to PATH so spawned subprocesses can find it.
Phase 2 gates execution on Node.js version requirements — fail fast rather than produce cryptic errors downstream.
Phase 3 is the critical optimization. The most frequently called commands never reach Commander at all.
Fast-Path Routing
The single biggest performance optimization in the CLI is tryRouteCli() in src/cli/route.ts. It intercepts common commands and executes them directly, bypassing the entire Commander.js program tree.
export async function tryRouteCli(argv: string[]): Promise<boolean> {
if (isTruthyEnvValue(process.env.CLAWDBOT_DISABLE_ROUTE_FIRST)) return false;
if (hasHelpOrVersion(argv)) return false;
const path = getCommandPath(argv, 2);
if (!path[0]) return false;
const route = findRoutedCommand(path);
if (!route) return false;
await prepareRoutedCommand({ argv, commandPath: path, loadPlugins: route.loadPlugins });
return route.run(argv);
}
Five commands are routed this way:
| Command | What it does |
|---|---|
clawdbot health | Direct gateway health check |
clawdbot status | Channel health + session summary |
clawdbot sessions | List stored sessions |
clawdbot agents list | List configured agents |
clawdbot memory status | Memory system status |
These are the commands you run most often — checking if the gateway is alive, listing sessions, quick status checks. Each is defined as a RouteSpec with a run() function that parses argv manually (using the lightweight src/cli/argv.ts utilities) and calls the command function directly.
The effect is measurable: a fast-routed clawdbot health avoids importing Commander, all 30+ subcommand registrations, and the plugin system. The import tree stays shallow.
The --help and --version flags bypass fast routing intentionally — they need Commander's help formatter.
Argv Processing
src/cli/argv.ts provides low-level argv parsing without any Commander dependency. This is what fast-path routing uses, and it's deliberately minimal:
// Extract positional commands: "clawdbot gateway status" → ["gateway", "status"]
export function getCommandPath(argv: string[], depth = 2): string[]
// Get the first command: "clawdbot gateway" → "gateway"
export function getPrimaryCommand(argv: string[]): string | null
// Flag presence and value extraction
export function hasFlag(argv: string[], name: string): boolean
export function getFlagValue(argv: string[], name: string): string | null | undefined
export function getPositiveIntFlagValue(argv: string[], name: string): number | null | undefined
The key design choice is getFlagValue's tristate return: undefined (flag absent), null (flag present but no value), or string (flag with value). This lets callers distinguish between --timeout without a value (error) and no --timeout at all (use default). It's a small API decision that prevents an entire class of flag parsing bugs.
There's also a special-case rewrite: clawdbot --update is transformed into clawdbot update before parsing, allowing --update to act as a subcommand alias.
Program Architecture
When a command isn't fast-routed, buildProgram() in src/cli/program/build-program.ts constructs the Commander.js program tree:
export function buildProgram() {
const program = new Command();
const ctx = createProgramContext();
const argv = process.argv;
configureProgramHelp(program, ctx);
registerPreActionHooks(program, ctx.programVersion);
registerProgramCommands(program, ctx, argv);
return program;
}
The ProgramContext carries runtime metadata:
export type ProgramContext = {
programVersion: string;
channelOptions: string[]; // Available channel names
messageChannelOptions: string; // "whatsapp|telegram|discord|..."
agentChannelOptions: string; // "last|whatsapp|telegram|..."
};
Pre-Action Hooks
Every command passes through pre-action hooks before execution. These run in src/cli/program/preaction.ts:
- Process title — Sets
process.titletoclawdbot-<command>for visibility inpsandtop - Banner — Prints the lobster banner (suppressed for
--json,CLAWDBOT_HIDE_BANNER, non-TTY, pipes, and theupdatecommand) - Verbose mode — Enables/disables verbose logging globally
- Config guard — Runs
ensureConfigReady()which handles state migrations - Plugin loading — Loads channel plugins for
message,channels, anddirectorycommands
The Banner
The CLI banner prints a lobster emoji, version number with git commit hash, and a random tagline. On wide terminals, it renders ASCII art:
░████░█░░░░░█████░█░░░█░███░░████░░████░░▀█▀
█░░░░░█░░░░░█░░░█░█░█░█░█░░█░█░░░█░█░░░█░░█░
█░░░░░█░░░░░█████░█░█░█░█░░█░████░░█░░░█░░█░
█░░░░░█░░░░░█░░░█░█░█░█░█░░█░█░░█░░█░░░█░░█░
░████░█████░█░░░█░░█░█░░███░░████░░░███░░░█░
🦞 FRESH DAILY 🦞
The banner auto-suppresses for JSON output, version checks, non-TTY contexts, and pipe scenarios. These aren't edge cases — they're the difference between a pleasant CLI and one that breaks jq pipelines.
Help System
Commander's help output is customized with the lobster palette theme in src/cli/program/help.ts:
program.configureHelp({
optionTerm: (option) => theme.option(option.flags),
subcommandTerm: (cmd) => theme.command(cmd.name()),
});
program.configureOutput({
writeOut: (str) => {
const colored = str
.replace(/^Usage:/gm, theme.heading("Usage:"))
.replace(/^Options:/gm, theme.heading("Options:"))
.replace(/^Commands:/gm, theme.heading("Commands:"));
process.stdout.write(colored);
},
});
Lazy Subcommand Loading
The CLI has 30+ subcommands. Loading all of them on every invocation would be wasteful. The solution is a placeholder-and-swap pattern in src/cli/program/register.subclis.ts.
How It Works
Each subcommand is defined as a SubCliEntry with a name, description, and async register function:
const entries: SubCliEntry[] = [
{
name: "gateway",
description: "Gateway control",
register: async (program) => {
const mod = await import("../gateway-cli.js");
mod.registerGatewayCli(program);
},
},
// ... 25+ more entries
];
When the CLI boots, it identifies the primary command from argv. If it matches an entry, only that entry's module is loaded:
if (primary && shouldRegisterPrimaryOnly(argv)) {
const entry = entries.find(candidate => candidate.name === primary);
if (entry) {
registerLazyCommand(program, entry);
return;
}
}
For commands not matched (or when listing help), each entry registers a lightweight placeholder:
function registerLazyCommand(program: Command, entry: SubCliEntry) {
const placeholder = program.command(entry.name).description(entry.description);
placeholder.allowUnknownOption(true);
placeholder.allowExcessArguments(true);
placeholder.action(async (...actionArgs) => {
// When invoked: remove placeholder, load real module, re-parse
removeCommand(program, placeholder);
await entry.register(program);
const parseArgv = buildParseArgv({ programName, rawArgs, fallbackArgv });
await program.parseAsync(parseArgv);
});
}
The result: clawdbot gateway status only imports the gateway CLI module. The browser, cron, nodes, memory, and 20+ other modules stay unloaded. The registerSubCliByName() function provides an even faster path for fast-route scenarios, bypassing the placeholder entirely.
┌────────────────────────────────────────────────────────────┐
│ clawdbot gateway status │
│ │
│ 1. getPrimaryCommand(argv) → "gateway" │
│ 2. Find SubCliEntry for "gateway" │
│ 3. import("../gateway-cli.js") ← only this module │
│ 4. registerGatewayCli(program) │
│ 5. program.parseAsync(argv) → gateway status action │
│ │
│ NOT loaded: browser, cron, nodes, memory, models, │
│ sandbox, tui, channels, plugins, security, skills, ... │
└────────────────────────────────────────────────────────────┘
Complete Command Registry
The command registry in src/cli/program/command-registry.ts defines every available command. Commands fall into two categories: directly registered (always available) and lazily loaded (on-demand).
Direct Commands
| Command | Description |
|---|---|
setup | Initialize ~/.clawdbot/clawdbot.json and agent workspace |
onboard | Interactive wizard for gateway, workspace, and skills |
configure | Interactive credential and device configuration |
doctor | Health checks + quick fixes for gateway and channels |
dashboard | Open the Control UI with current token |
reset | Reset local config/state (keeps CLI installed) |
uninstall | Uninstall gateway service + local data |
status | Show channel health and recent session recipients |
health | Fetch health from the running gateway |
sessions | List stored conversation sessions |
agent | Run an agent turn via the Gateway (or --local) |
agents | Manage isolated agents (workspaces, auth, routing) |
message | Send messages and channel actions |
Lazy-Loaded Sub-CLIs
| Sub-CLI | Description |
|---|---|
gateway | Gateway control (run, install, start, stop, restart, call, probe, discover, usage-cost) |
tui | Terminal UI connected to the Gateway |
config | Config get/set/delete/list/path |
channels | Channel management (add, list, remove, login, status, logs) |
models | Model configuration (list, set, scan) |
cron | Cron scheduler (add, edit, list, delete) |
nodes | Node commands (status, camera, canvas, screen, notify, pairing) |
browser | Browser control (profiles, tabs, navigate, screenshot) |
memory | Memory system (status, search) |
plugins | Plugin management |
skills | Skills management |
sandbox | Docker-based execution environments |
security | Security helpers (audit) |
logs | Gateway logs |
approvals | Exec approvals management |
hooks | Hooks tooling |
webhooks | Webhook helpers |
dns | DNS helpers |
docs | Documentation helpers |
update | CLI update helpers |
system | System events, heartbeat, and presence |
devices | Device pairing + token management |
directory | Directory commands |
Gateway Subcommands (Detail)
The gateway sub-CLI is one of the most complex, with 12 subcommands:
| Command | Description |
|---|---|
gateway run | Run the WebSocket Gateway in foreground |
gateway status | Show gateway service status + probe |
gateway install | Install as launchd/systemd/schtasks service |
gateway uninstall | Remove the installed service |
gateway start | Start the installed service |
gateway stop | Stop the installed service |
gateway restart | Restart the installed service |
gateway call <method> | Call a Gateway RPC method directly |
gateway health | Fetch Gateway health endpoint |
gateway probe | Full reachability + discovery + health + status |
gateway discover | Discover gateways via Bonjour/mDNS |
gateway usage-cost | Fetch usage cost summary from session logs |
Message Subcommands (Detail)
The message sub-CLI covers every channel action:
| Command | Description |
|---|---|
message send | Send a text/media message |
message broadcast | Broadcast to multiple targets |
message poll | Create a poll |
message react / unreact | Add/remove reactions |
message read | Read messages from a channel |
message edit | Edit a message |
message delete | Delete a message |
message pin / unpin | Pin/unpin messages |
message search | Search messages |
message thread | Thread operations |
Command Execution Pattern
All command actions follow a consistent wrapper pattern:
.action(async (opts) => {
await runCommandWithRuntime(defaultRuntime, async () => {
await someCommand(opts, defaultRuntime);
});
});
runCommandWithRuntime provides error handling and ensures clean shutdown. The separation between src/cli/ (registration and wiring) and src/commands/ (business logic) is clean — CLI files define Commander options and map them to command functions. Command files contain the actual logic, testable without Commander.
Profile System
Profiles let you run multiple Clawdbot instances with fully isolated state. The implementation is in src/cli/profile.ts.
export function applyCliProfileEnv(params: {
profile: string;
env?: Record<string, string | undefined>;
homedir?: () => string;
}) {
env.CLAWDBOT_PROFILE = profile;
env.CLAWDBOT_STATE_DIR = resolveProfileStateDir(profile, homedir);
env.CLAWDBOT_CONFIG_PATH = path.join(stateDir, "clawdbot.json");
if (profile === "dev") env.CLAWDBOT_GATEWAY_PORT = "19001";
}
Usage:
# Production instance (default)
clawdbot gateway status
# Development instance — separate state dir, port 19001
clawdbot --dev gateway status
# Named profile — ~/.clawdbot-staging/
clawdbot --profile staging gateway status
Key rules:
--devis sugar for--profile dev--devand--profilecannot be combined- Profile names are validated:
[a-zA-Z0-9_-]only - Each profile gets its own state directory:
~/.clawdbot-<name>/ --devautomatically shifts the gateway port to 19001- Profiles only set defaults — explicit environment variables always win
The profile system works by modifying environment variables before any config loading happens. This means everything downstream (config, state, sessions, memory) is automatically isolated with zero code changes in the rest of the codebase.
Slash Commands
Slash commands are in-chat commands processed by the agent or gateway, distinct from CLI subcommands. They're defined in the gateway configuration and available in both the TUI and channel conversations.
In the TUI, slash commands are handled locally before reaching the gateway:
export function getSlashCommands(options): SlashCommand[] {
return [
{ name: "help", description: "Show slash command help" },
{ name: "status", description: "Show gateway status summary" },
{ name: "agent", description: "Switch agent (or open picker)" },
{ name: "agents", description: "Open agent picker" },
{ name: "session", description: "Switch session (or open picker)" },
{ name: "sessions", description: "Open session picker" },
{ name: "model", description: "Set model (or open picker)" },
{ name: "models", description: "Open model picker" },
{ name: "think", description: "Set thinking level" },
{ name: "verbose", description: "Set verbose on/off" },
{ name: "reasoning", description: "Set reasoning on/off" },
{ name: "usage", description: "Toggle per-response usage line" },
{ name: "elevated", description: "Set elevated on/off/ask/full" },
{ name: "activation", description: "Set group activation" },
{ name: "abort", description: "Abort active run" },
{ name: "new", description: "Reset the session" },
{ name: "settings", description: "Open settings" },
{ name: "exit", description: "Exit the TUI" },
];
}
In channel conversations (Telegram, Discord, etc.), slash commands are forwarded to the gateway for processing. The gateway maintains its own set of configurable chat commands that control session behavior, model switching, and agent routing.
TUI Implementation
The TUI is a full terminal user interface in src/tui/, built on @mariozechner/pi-tui. It provides a real-time chat experience connected to the Gateway via WebSocket — think of it as a terminal-native chat client for your AI assistant.
Component Tree
TUI (root)
├── Header (Text)
│ "clawdbot tui - ws://... - agent main - session main"
│
├── ChatLog (Container) ← scrollable message history
│ ├── UserMessageComponent
│ ├── AssistantMessageComponent ← markdown-rendered
│ ├── ToolExecutionComponent ← collapsible tool calls
│ └── Text (system messages)
│
├── StatusContainer
│ ├── Loader (spinning indicator during agent runs)
│ └── Text (idle status)
│
├── Footer (Text)
│ "agent main | session main | model | think off | tokens"
│
└── CustomEditor ← multi-line input with autocomplete
Entry Point
The main runTui() function in src/tui/tui.ts wires everything together:
export async function runTui(opts: TuiOptions) {
const config = loadConfig();
const client = new GatewayChatClient({ url, token, password });
const tui = new TUI(new ProcessTerminal());
const header = new Text("", 1, 0);
const chatLog = new ChatLog();
const editor = new CustomEditor(tui, editorTheme);
// Wire up input handling
editor.onSubmit = createEditorSubmitHandler({
editor, handleCommand, sendMessage, handleBangLine
});
editor.onEscape = () => void abortActive();
editor.onCtrlC = () => { /* double-tap to exit */ };
editor.onCtrlD = () => { process.exit(0) };
editor.onCtrlO = () => { /* toggle tool expansion */ };
editor.onCtrlL = () => { /* open model selector */ };
editor.onCtrlG = () => { /* open agent selector */ };
editor.onCtrlP = () => { /* open session selector */ };
editor.onCtrlT = () => { /* toggle thinking display */ };
// Gateway event handling
client.onEvent = (evt) => {
if (evt.event === "chat") handleChatEvent(evt.payload);
if (evt.event === "agent") handleAgentEvent(evt.payload);
};
tui.start();
client.start();
}
Three Input Modes
The editor submit handler in src/tui/tui-input.ts routes input through three distinct modes:
export function createEditorSubmitHandler(params) {
return (text: string) => {
const value = text.trim();
if (!value) return;
// 1. Bang mode: !command runs locally
if (text.startsWith("!") && text !== "!") {
params.handleBangLine(text);
return;
}
// 2. Slash commands: /help, /model, /think, etc.
if (value.startsWith("/")) {
params.handleCommand(value);
return;
}
// 3. Regular message: send to agent via gateway
params.sendMessage(value);
};
}
Regular messages are sent to the Gateway via WebSocket and processed by the agent. Slash commands are handled locally in the TUI (model switching, session management, etc.). Bang commands (!ls, !git status) run locally on the user's machine — with a one-time safety prompt before first use.
Local Shell Execution
The ! prefix runs commands locally via src/tui/tui-local-shell.ts:
const runLocalShellLine = async (line: string) => {
const cmd = line.slice(1);
const allowed = await ensureLocalExecAllowed();
if (!allowed) return;
chatLog.addSystem(`[local] $ ${cmd}`);
const child = spawn(cmd, { shell: true, cwd: getCwd(), env });
child.on("close", (code) => {
chatLog.addSystem(`[local] exit ${code ?? "?"}...`);
});
};
The safety prompt is deliberate. Local shell execution happens on the user's machine, outside the gateway's security model. The one-time-per-session permission check is a speed bump, not a roadblock.
Stream Assembly
The Gateway streams responses as deltas. The TuiStreamAssembler in src/tui/tui-stream-assembler.ts accumulates these into displayable text:
export class TuiStreamAssembler {
private runs = new Map<string, RunStreamState>();
ingestDelta(runId, message, showThinking): string | null {
// Accumulate thinking + content text separately
// Compose display text based on showThinking flag
// Return null if no visible change
}
finalize(runId, message, showThinking): string {
// Produce final text, clean up run state
}
}
Two event streams flow from the Gateway:
- Chat events (
chat.sendresponses) —deltafor streaming chunks,finalfor the complete response,aborted/errorfor failures - Agent events (tool execution) —
tool.start,tool.update,tool.result, andlifecycle.start/end/error
Gateway Chat Client
The GatewayChatClient in src/tui/gateway-chat.ts wraps a WebSocket connection:
export class GatewayChatClient {
async sendChat(opts: ChatSendOptions): Promise<{ runId: string }>
async abortChat(opts: { sessionKey, runId }): Promise<{ ok, aborted }>
async loadHistory(opts: { sessionKey, limit? }): Promise<...>
async listSessions(opts?): Promise<GatewaySessionList>
async listAgents(): Promise<GatewayAgentsList>
async patchSession(opts): Promise<...>
async resetSession(key): Promise<...>
async getStatus(): Promise<...>
async listModels(): Promise<GatewayModelChoice[]>
}
Connection resolution follows a priority chain: explicit --url/--token options → config gateway.remote.url → CLAWDBOT_GATEWAY_TOKEN env var → config gateway.auth.token → fallback to ws://127.0.0.1:<port>.
Keyboard Shortcuts
| Key | Action |
|---|---|
Enter | Submit input |
Escape | Abort active agent run |
Ctrl+C | Clear input (first press), exit (double-tap) |
Ctrl+D | Exit immediately |
Ctrl+O | Toggle tool output expansion |
Ctrl+L | Open model picker overlay |
Ctrl+G | Open agent picker overlay |
Ctrl+P | Open session picker overlay |
Ctrl+T | Toggle thinking display |
The picker overlays (FilterableSelectList, SearchableSelectList) are rendered as full-screen modal selectors with fuzzy filtering. The SettingsList component provides a settings panel with toggle options.
TUI Theme
The TUI has its own theme, separate from the CLI's lobster palette. Defined in src/tui/theme/theme.ts:
const palette = {
text: "#E8E3D5", // Warm off-white
dim: "#7B7F87", // Gray
accent: "#F6C453", // Gold
accentSoft: "#F2A65A", // Orange-gold
border: "#3C414B", // Dark gray
userBg: "#2B2F36", // Dark blue-gray
systemText: "#9BA3B2", // Light gray
toolTitle: "#F6C453", // Gold
success: "#7DD3A5", // Green
error: "#F97066", // Red
};
The CLI uses lobster red/orange for brief terminal output. The TUI uses gold/warm tones for a sustained reading experience — different contexts, different palettes. The TUI theme includes Markdown rendering with syntax highlighting via cli-highlight.
Setup Wizard
The onboarding wizard in src/wizard/ uses a prompter abstraction to decouple wizard logic from I/O. The same wizard flow works in the terminal, over the web, or in tests.
Prompter Interface
export type WizardPrompter = {
intro: (title: string) => Promise<void>;
outro: (message: string) => Promise<void>;
note: (message: string, title?: string) => Promise<void>;
select: <T>(params: WizardSelectParams<T>) => Promise<T>;
multiselect: <T>(params: WizardMultiSelectParams<T>) => Promise<T[]>;
text: (params: WizardTextParams) => Promise<string>;
confirm: (params: WizardConfirmParams) => Promise<boolean>;
progress: (label: string) => WizardProgress;
};
Two implementations exist:
| Implementation | Backend | Use case |
|---|---|---|
ClackPrompter | @clack/prompts (terminal) | clawdbot onboard from the command line |
WizardSessionPrompter | Async step protocol | Control UI (web) driving the wizard via WebSocket |
The WizardSession class provides an async step-based protocol for remote wizards:
export class WizardSession {
async awaitAnswer(step: WizardStep): Promise<unknown> // Push step, await answer
async next(): Promise<WizardNextResult> // Get next step
async answer(stepId: string, value: unknown): Promise<void> // Provide answer
cancel(): void
}
All prompts pass through guardCancel() which throws WizardCancelledError on user cancellation — ensuring clean exit at any point without partial state.
Onboarding Flow
The main runOnboardingWizard() in src/wizard/onboarding.ts walks through this sequence:
1. Risk Acknowledgement ── Security warning + confirm
2. Flow Selection ── QuickStart (auto) vs Manual (prompted)
3. Existing Config ── Keep / Update / Reset
4. Mode Selection ── Local gateway vs Remote
5. Workspace Configuration ── Set workspace dir (default: ~/clawd)
6. Authentication Setup ── Anthropic, OpenAI, OpenRouter, Gemini, etc.
7. Model Selection ── Choose default from available providers
8. Gateway Configuration ── Port, bind mode, auth mode, Tailscale
9. Channel Setup ── WhatsApp, Telegram, Discord, Slack, Signal
10. Skills Setup ── Install skill packages
11. Internal Hooks ── Session memory on /new
12. Finalization ── Service install, health check, Control UI
Gateway configuration in step 8 handles the combinatorial complexity of networking options:
export async function configureGatewayForOnboarding(opts) {
// Port: keep existing or default 18789 (QuickStart), or prompt (Manual)
// Bind: loopback / LAN / tailnet / auto / custom
// Auth: token / password
// Tailscale: off / serve / funnel
// Constraints:
// Tailscale requires bind=loopback
// Funnel requires password auth
// Token auto-generated if blank
}
The finalization phase is extensive: systemd linger check on Linux, gateway service installation (launchd/systemd/schtasks depending on platform), health check with retry, Control UI asset building, and the "hatch your bot" choice — TUI (recommended), Web UI, or Later. If TUI is chosen, it launches with the initial message "Wake up, my friend!"
Terminal Utilities
Shared terminal utilities live in src/terminal/, used by both the CLI and TUI.
Lobster Palette Theme
export const LOBSTER_PALETTE = {
accent: "#FF5A2D", // Lobster red
accentBright: "#FF7A3D", // Light lobster
accentDim: "#D14A22", // Dark lobster
info: "#FF8A5B", // Warm orange
success: "#2FBF71", // Green
warn: "#FFB020", // Yellow-orange
error: "#E23D2D", // Red
muted: "#8B7F77", // Warm gray
};
The theme respects NO_COLOR and FORCE_COLOR environment variables.
Table Rendering
src/terminal/table.ts implements a full-featured table renderer with Unicode or ASCII box-drawing borders, column alignment, ANSI-aware text wrapping (never splits escape sequences), flex columns that expand/shrink to terminal width, and word-aware line wrapping:
┌──────────┬──────────┬────────┐
│ Column A │ Column B │ Status │
├──────────┼──────────┼────────┤
│ value │ value │ ok │
└──────────┴──────────┴────────┘
Stream Safety
src/terminal/stream-writer.ts provides a safe stream writer that handles broken pipes:
export function createSafeStreamWriter(options?): SafeStreamWriter {
// Catches EPIPE errors (e.g., when piped to `head`)
// Marks stream as closed, notifies callback once
// Subsequent writes silently return false
}
This prevents the classic Node.js crash when output is piped to head or grep -m1 — the downstream process closes the pipe, and without safe writing, the CLI crashes with an unhandled EPIPE.
Plugin CLI Extensibility
Plugins can register their own CLI commands, extending the program tree after core commands. The registration happens in the boot sequence:
if (!shouldSkipPluginRegistration) {
const { registerPluginCliCommands } = await import("../plugins/cli.js");
registerPluginCliCommands(program, loadConfig());
}
This means a plugin can add entirely new top-level commands or subcommands to the CLI. The plugin's register function receives the Commander program instance and can attach commands using the same patterns as core commands.
Plugin CLI registration runs after core command registration but before parsing, so plugin commands have the same priority as built-in commands. The plugin system is covered in depth in the infrastructure post.
Design Patterns
Several patterns recur throughout the CLI architecture:
Lazy loading everywhere. Subcommands use dynamic import(). Only the invoked command's module is loaded. Fast-path routing avoids Commander entirely for the most common commands. The import tree stays shallow by design.
Separation of registration and logic. src/cli/ handles Commander wiring — options, flags, descriptions. src/commands/ contains the business logic. Command functions are testable without Commander, and registration files stay declarative.
Prompter abstraction. The wizard system decouples I/O from flow logic. Terminal prompts, web-driven wizards, and test mocks all implement the same WizardPrompter interface. The onboarding flow doesn't know or care which backend it's talking to.
Dual theme system. The CLI uses the lobster palette (red/orange) for brief terminal output. The TUI uses a separate dark theme (gold/warm tones) for sustained full-screen use. Different contexts warrant different visual treatment.
Component-based TUI. The TUI uses a tree of components (Container, Text, Loader, Editor) rendered by pi-tui — similar to React but for terminals. State changes flow through the component tree, and the renderer handles diffing.
Profile isolation via environment. Profiles modify environment variables before any config loading. Everything downstream — config, state, sessions, memory — is automatically isolated. Zero code changes needed in the rest of the codebase.
Series
- Core Architecture & Gateway
- Memory System
- Agent System & AI Providers
- Channel & Messaging
- Sessions & Multi-Agent
- CLI, Commands & TUI (this post)
- Browser, Media & Canvas
- Infrastructure & Security