Running a Claude Code bot

Run claude-scuba with the bundled CLI, or embed it programmatically.

Connecting agentsdocs/agents/30-claude-code-launch.mdx
On this page

@octostaff/claude-scuba ships a claude-scuba CLI that reads JSON or YAML configuration and starts bot sessions for every Bubble thread the bot has joined. With no --config, it reads ~/.bubble/claude-scuba/config.yaml; $BUBBLE_HOME relocates the .bubble root.

The CLI

bash
export CLAUDE_SCUBA_TOKEN=<bot bearer token>
mkdir -p ~/.bubble/claude-scuba
cp ./config.example.yaml ~/.bubble/claude-scuba/config.yaml
claude-scuba
text
Usage: claude-scuba [options]

Options:
  -c, --config <path>   Path to a JSON or YAML config file.
                        Defaults to ~/.bubble/claude-scuba/config.yaml
                        ($BUBBLE_HOME overrides the root).
      --cwd <path>      Working directory for the Claude SDK tools
                        (file/shell tools resolve paths against this).
                        Overrides claude.cwd in the config file.
                        Defaults to the bot process working directory.
      --help            Show this message

An explicit --config overrides the default. Relative store and run-log paths resolve from the selected config file's directory. The CLI also loads a .env file from its process working directory when present, applies --cwd, and handles SIGINT/SIGTERM with a graceful runtime stop.

Prerequisites

  • A running OctoStaff Bubble server.
  • A Bubble bot principal and bearer token for that principal.
  • Claude Agent SDK credentials in the process environment.
  • A working directory that Claude may read or modify, depending on your permission mode.

Quick Start

Create ~/.bubble/claude-scuba/config.yaml. The package ships config.example.yaml as a starting point:

sh
mkdir -p ~/.bubble/claude-scuba
cp config.example.yaml ~/.bubble/claude-scuba/config.yaml

Run the bot:

sh
CLAUDE_SCUBA_TOKEN=... claude-scuba

Set BUBBLE_HOME to relocate the .bubble root. An explicit --config <path> overrides the default and accepts JSON or YAML; relative store and run-log paths resolve from that config file's directory.

An existing ~/.octostaff tree is still honored: when ~/.bubble does not exist and ~/.octostaff does, bots keep reading the older root, and OCTOSTAFF_HOME is still accepted behind BUBBLE_HOME.

Override the configured Claude working directory at launch time:

sh
claude-scuba --config ./claude-scuba.config.json --cwd /path/to/project

claude-scuba also loads a .env file from the current working directory when one is present.

Config

The package ships config.example.yaml as the user-level starting point; claude-scuba.config.dev.json remains the repository's minimal development config:

json
{
  "bubble": {
    "url": "http://localhost:3000",
    "principalId": "agent-octo",
    "token": { "env": "CLAUDE_SCUBA_TOKEN" }
  },
  "backlog": {
    "onFirstJoin": "since_joined",
    "onResume": "catch_up",
    "maxReplay": 50
  },
  "logger": { "level": "debug", "pretty": true }
}
FieldDrives
bubble.urlBase URL of the Bubble server (HTTP). The WebSocket URL is derived.
bubble.principalIdThe bot's Bubble principal id. Must already exist as kind: 'bot'.
bubble.tokenLocal-token bearer issued by Bubble. String or { env: NAME }.
backlogFirst-join and resume replay policy. See Backlog and resume policy.
broker.claude.modelOptional model override; omission leaves model selection to the SDK/CLI.
broker.claude.permissionModeOptional SDK permission mode; the SDK injects default when omitted.
broker.claude.effortOptional reasoning-effort override; loaded effortLevel can apply when omitted.
broker.claude.settingSourcesSettings sources; defaults to user → project → local merge order. Set [] to isolate.
broker.claude.systemPromptNative custom-prompt or preset configuration; defaults to the claude_code preset.
broker.claude.cwdWorking directory passed to the SDK. Defaults to process.cwd().
broker.instantOptional { model, effort } for the smart-drain judge; required by drain.mode: 'smart'.
loggerfalse to disable, true for default, or { level, pretty }.

Scuba leaves model, permissionMode, and effort unset unless configured. Loaded settings can therefore supply model and effortLevel. The installed Agent SDK (0.3.273) is an exception for permission mode: it injects default when its option is omitted, so permissions.defaultMode from settings does not replace it. Other loaded permission rules still apply. Scuba separately defaults systemPrompt to the full Claude Code preset and settingSources to all three filesystem sources.

{ env: NAME } keeps the secret out of the file:

json
"token": { "env": "CLAUDE_SCUBA_TOKEN" }

The CLI calls process.loadEnvFile() for ./.env before resolving env references, so local development can keep CLAUDE_SCUBA_TOKEN=... in that file. Embedded callers can pass an env override to ClaudeBubbleBot for tests.

broker

Settings under broker.claude are passed straight through to the Claude Agent SDK. The smart-drain judge has its separate, deliberately small configuration under broker.instant.

  • prompt.groupChat: Defaults to true. Announces Bubble group chat and keeps each ordered message boundary plus displayName (principalId) attribution.

  • prompt.artifacts.enabled: Defaults to false. When enabled, verified JPEG/PNG/GIF/WebP, PDF, and UTF-8 text-like Bubble artifacts are delivered as native Claude content blocks in message order. Disabled or failed deliveries remain visible as references to the Bubble artifact tools.

  • prompt.artifacts.maxTotalBytes: Raw artifact download budget per forwarded turn. Defaults to 10485760 (10 MiB).

  • claude.model: Claude model alias or full model ID. When omitted, the SDK/CLI chooses the model; there is no fixed model ID in Scuba.

  • claude.cwd: Working directory for Claude file and shell tools. Defaults to the process working directory.

  • claude.permissionMode: Claude Agent SDK permission mode. Supported values are default, plan, acceptEdits, bypassPermissions, dontAsk, and auto. When omitted, the installed SDK currently injects default; consequently a settings-loaded permissions.defaultMode does not replace it. Other loaded permission rules such as allow, deny, and ask still apply.

  • claude.effort: Optional reasoning effort. When omitted, Scuba sends no override, so the loaded effortLevel setting can apply. The SDK documents high as its fallback default.

  • claude.settingSources: Claude Code settings sources: user, project, and local. Scuba defaults to all three; they merge in that order, so project or local settings can override user settings. Set [] for isolation.

  • claude.systemPrompt: A custom string, an array of prompt sections, or the native { type: "preset", preset: "claude_code" } form. Scuba defaults to that preset, giving Claude the full Claude Code prompt. Loading project CLAUDE.md also requires settingSources to include project.

  • claude.forwardSubagentText: Defaults to true. Set it to false to suppress forwarded subagent narration.

  • instant: Optional { model, effort } for the isolated one-turn smart-drain judge. It is required when drain.mode is smart.

Scuba owns the systemPrompt, settingSources, and forwardSubagentText defaults. It leaves model, permissionMode, and effort unset; loaded user settings can select model and effortLevel, while the Agent SDK currently forces its own default permission mode.

Backlog and resume policy

The top-level backlog block governs which existing events a thread driver replays before it switches to live delivery. All three fields are optional:

json
"backlog": {
  "onFirstJoin": "since_joined",
  "onResume": "catch_up",
  "maxReplay": 50
}

onFirstJoin applies whenever no local settlement checkpoint exists, including the first run and a restart with a lost or in-memory store:

OptionMeaning
since_joinedReplay eligible history since this principal's latest join (default).
catch_upReplay eligible thread history, including history from before joining.
skip_to_headSkip all existing history and begin with new events.

onResume applies when a fresh process finds an existing local settlement checkpoint:

OptionMeaning
catch_upReplay eligible events after the last local settlement (default).
skip_to_headDiscard the offline backlog and begin with new events.

maxReplay is a positive integer (default 50). It selects the newest raw-event window whenever onFirstJoin is since_joined or catch_up, or onResume is catch_up. If the lower edge falls inside a run, replay expands backward to avoid splitting that run. skip_to_head ignores maxReplay.

catch_up is therefore bounded catch-up, not an unbounded guarantee: eligible events older than the selected replay window may be skipped. A durable local store preserves the settlement checkpoint used by onResume. Bubble's consumer cursor records receipt rather than local settlement and is not used as the recovery watermark.

That checkpoint also carries the backend session ids and smart-drain state, so a restart resumes the conversation rather than starting a new one. Without it, a lost or in-memory store favors conservative replay inside the selected window and may repeat an already-handled turn.

Logging

config.logger controls the shared OctoStaff logger. The supported levels are trace, debug, info, warn, error (set in the config file — the CLI has no --log-level flag):

json
"logger": { "level": "trace", "pretty": true }

Set logger.runLog.enabled to write one NDJSON audit file per bot run. When no dir is supplied, the files go to ~/.bubble/claude-scuba/logs (under $BUBBLE_HOME when set).

Run logs contain SDK inputs, SDK outputs, and the translated Bubble events for each Claude run. Multimodal input logs retain artifact metadata but redact base64 payloads and source URLs.

What you see at each level:

LevelAdds on top of the level above
errorunrecoverable failures only.
warnrecoverable issues (reconnect retries, dropped SDK messages without a run context, query.interrupt() failures).
infolifecycle events: Bubble authentication, thread subscribe/reconnect, subthread creation, grant changes, and one completed Claude run summary per turn ({ runId, threadId, userMessages, outbound: { run_started, message_part, tool_approval, run_finished } }).
debugevery inbound Bubble event (thread event), the starting Claude SDK query line with model + allowedTools count, and one-line summaries for each message sent to and received from Claude (sending message to Claude / received message from Claude with type and block count).
tracefull payloads of the messages sent to and received from Claude (sending message to Claude (full) and received message from Claude (full)). Verbose — assistant deltas, tool inputs, and tool results are all logged in full.

trace is the right level when you are debugging the bridge between Bubble and the Claude Agent SDK: it lets you see exactly what user message was streamed into the SDK and the full assistant/tool messages that came back, alongside the Bubble events those translate into. Expect large, JSON-shaped lines; pair with "pretty": true for local reading and prefer routing stdout and stderr to a file (pnpm dev > scuba.log 2>&1) when recording a long session.

Embedding

ts
import { ClaudeBubbleBot, loadClaudeScubaConfig, createPinoLogger } from '@octostaff/claude-scuba';

const config = loadClaudeScubaConfig({
  bubble: {
    url: 'http://localhost:3000',
    principalId: 'bot-claude',
    token: process.env.CLAUDE_SCUBA_TOKEN!,
  },
  claude: { allowedTools: ['Read', 'Grep', 'Glob'] },
});

const bot = new ClaudeBubbleBot({
  config,
  logger: createPinoLogger({ level: 'debug', pretty: true }),
});

await bot.start();
process.on('SIGINT', () => void bot.stop());
await bot.wait();

Constructor overrides useful for tests:

  • fetch — replace the global fetch used by the HTTP client.
  • webSocketFactory — replace the WebSocket implementation.
  • queryFactory — replace the SDK query() so the test never spawns the real Claude Code subprocess.

Reconnect Behaviour

The WebSocket subscription is wrapped in a reconnect loop with exponential backoff (250 ms → 30 s). On a transient drop or a server-initiated thread.subscription_closed, the bot reopens with fromSeq = lastSeenSeq + 1 so no events are dropped. Independently, joined-thread discovery refreshes every 5 seconds so new grants are picked up and removed grants stop their sessions. bot.stop() interrupts in-flight backoff so shutdown stays prompt.

Watch Mode for Local Development

bash
export CLAUDE_SCUBA_TOKEN=<bot bearer token>
pnpm dev

Runs tsup --watch over src/ and re-launches the CLI against claude-scuba.config.dev.json on each successful build.

Docker

Prebuilt images are published to GHCR on each release:

sh
docker pull ghcr.io/octostaff/claude-scuba:latest

Tags:

TagInner SDK
:latest, :<version>Latest supported inner SDK (current 0.3.x).
:<version>-sdk-<sdkversion>Coupled to an exact inner SDK — one per supported line, e.g. :0.3.0-sdk-0.3.201 (the range floor) and :0.3.0-sdk-0.3.273 (the resolved ceiling). Use these to pin the inner SDK for reproducible deployments.

<version> is the claude-scuba release version; <sdkversion> is the bundled @anthropic-ai/claude-agent-sdk version (see Supported versions).

The image runs the claude-scuba daemon (ENTRYPOINT) and reads its config from --config /etc/claude-scuba/config.json (CMD). It needs Claude Agent SDK credentials and the bot's Bubble token in the environment:

  • ANTHROPIC_AUTH_TOKEN + ANTHROPIC_BASE_URL (or ANTHROPIC_API_KEY)
  • CLAUDE_SCUBA_TOKEN — the bot's Bubble bearer token (referenced by the config)
  • CLAUDE_SCUBA_PROJECT_DIR — the host project mounted as the container working dir

See docker-compose.yml for a complete example that inlines the config and wires these variables.

Operations

  • The bot runs on Bubble threads where its principal has a participant role.
  • Adding the bot principal to a thread starts a runtime for that thread.
  • Revoking the bot principal's thread grant stops that thread runtime.
  • In default permission mode, Claude tool approvals are routed through Bubble approval events.
  • Send SIGINT or SIGTERM for graceful shutdown.

Package Contents

The npm package ships compiled, minified JavaScript, the claude-scuba executable, and TypeScript declarations. The runtime bundles the Bubble SDK code it uses. Public declarations may reference @octostaff/sdk where public APIs expose shared Bubble protocol types.