Published on

Inside Clawdbot: CLI Boot Sequence, Command Architecture, and the Terminal UI

Authors

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 GuardassertSupportedRuntime()         └─────────────┬──────────────┘
         ┌─────────────▼──────────────┐
    3.Fast Route                 │──── hit ──→ execute & exit
tryRouteCli()         └─────────────┬──────────────┘
                       │ miss
         ┌─────────────▼──────────────┐
    4.Console CaptureRedirect to structured log │
         └─────────────┬──────────────┘
         ┌─────────────▼──────────────┐
    5.Build Commander ProgrambuildProgram()         └─────────────┬──────────────┘
         ┌─────────────▼──────────────┐
    6.Lazy Subcommand LoadingregisterSubCliByName()         └─────────────┬──────────────┘
         ┌─────────────▼──────────────┐
    7.Plugin CLI RegistrationParse & 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:

CommandWhat it does
clawdbot healthDirect gateway health check
clawdbot statusChannel health + session summary
clawdbot sessionsList stored sessions
clawdbot agents listList configured agents
clawdbot memory statusMemory 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:

  1. Process title — Sets process.title to clawdbot-<command> for visibility in ps and top
  2. Banner — Prints the lobster banner (suppressed for --json, CLAWDBOT_HIDE_BANNER, non-TTY, pipes, and the update command)
  3. Verbose mode — Enables/disables verbose logging globally
  4. Config guard — Runs ensureConfigReady() which handles state migrations
  5. Plugin loading — Loads channel plugins for message, channels, and directory commands

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

CommandDescription
setupInitialize ~/.clawdbot/clawdbot.json and agent workspace
onboardInteractive wizard for gateway, workspace, and skills
configureInteractive credential and device configuration
doctorHealth checks + quick fixes for gateway and channels
dashboardOpen the Control UI with current token
resetReset local config/state (keeps CLI installed)
uninstallUninstall gateway service + local data
statusShow channel health and recent session recipients
healthFetch health from the running gateway
sessionsList stored conversation sessions
agentRun an agent turn via the Gateway (or --local)
agentsManage isolated agents (workspaces, auth, routing)
messageSend messages and channel actions

Lazy-Loaded Sub-CLIs

Sub-CLIDescription
gatewayGateway control (run, install, start, stop, restart, call, probe, discover, usage-cost)
tuiTerminal UI connected to the Gateway
configConfig get/set/delete/list/path
channelsChannel management (add, list, remove, login, status, logs)
modelsModel configuration (list, set, scan)
cronCron scheduler (add, edit, list, delete)
nodesNode commands (status, camera, canvas, screen, notify, pairing)
browserBrowser control (profiles, tabs, navigate, screenshot)
memoryMemory system (status, search)
pluginsPlugin management
skillsSkills management
sandboxDocker-based execution environments
securitySecurity helpers (audit)
logsGateway logs
approvalsExec approvals management
hooksHooks tooling
webhooksWebhook helpers
dnsDNS helpers
docsDocumentation helpers
updateCLI update helpers
systemSystem events, heartbeat, and presence
devicesDevice pairing + token management
directoryDirectory commands

Gateway Subcommands (Detail)

The gateway sub-CLI is one of the most complex, with 12 subcommands:

CommandDescription
gateway runRun the WebSocket Gateway in foreground
gateway statusShow gateway service status + probe
gateway installInstall as launchd/systemd/schtasks service
gateway uninstallRemove the installed service
gateway startStart the installed service
gateway stopStop the installed service
gateway restartRestart the installed service
gateway call <method>Call a Gateway RPC method directly
gateway healthFetch Gateway health endpoint
gateway probeFull reachability + discovery + health + status
gateway discoverDiscover gateways via Bonjour/mDNS
gateway usage-costFetch usage cost summary from session logs

Message Subcommands (Detail)

The message sub-CLI covers every channel action:

CommandDescription
message sendSend a text/media message
message broadcastBroadcast to multiple targets
message pollCreate a poll
message react / unreactAdd/remove reactions
message readRead messages from a channel
message editEdit a message
message deleteDelete a message
message pin / unpinPin/unpin messages
message searchSearch messages
message threadThread 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:

  • --dev is sugar for --profile dev
  • --dev and --profile cannot be combined
  • Profile names are validated: [a-zA-Z0-9_-] only
  • Each profile gets its own state directory: ~/.clawdbot-<name>/
  • --dev automatically 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:

  1. Chat events (chat.send responses) — delta for streaming chunks, final for the complete response, aborted/error for failures
  2. Agent events (tool execution) — tool.start, tool.update, tool.result, and lifecycle.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.urlCLAWDBOT_GATEWAY_TOKEN env var → config gateway.auth.token → fallback to ws://127.0.0.1:<port>.

Keyboard Shortcuts

KeyAction
EnterSubmit input
EscapeAbort active agent run
Ctrl+CClear input (first press), exit (double-tap)
Ctrl+DExit immediately
Ctrl+OToggle tool output expansion
Ctrl+LOpen model picker overlay
Ctrl+GOpen agent picker overlay
Ctrl+POpen session picker overlay
Ctrl+TToggle 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:

ImplementationBackendUse case
ClackPrompter@clack/prompts (terminal)clawdbot onboard from the command line
WizardSessionPrompterAsync step protocolControl 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 AColumn BStatus├──────────┼──────────┼────────┤
│ 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

  1. Core Architecture & Gateway
  2. Memory System
  3. Agent System & AI Providers
  4. Channel & Messaging
  5. Sessions & Multi-Agent
  6. CLI, Commands & TUI (this post)
  7. Browser, Media & Canvas
  8. Infrastructure & Security

Resources