Running a Claude Code bot

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

On this page

@octostaff/claude-scuba ships a claude-scuba CLI that reads JSON or YAML configuration and starts bot sessions for every Octostaff 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 server.
  • A 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 Octostaff server (HTTP). The WebSocket URL is derived.
bubble.principalIdThe bot's Octostaff principal id. Must already exist as kind: 'bot'.
bubble.tokenLocal-token bearer issued by Octostaff. 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; when omitted, loaded permissions.defaultMode applies.
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. Permission mode needs one extra step: the Agent SDK sends an omitted mode to Claude Code as an explicit default, which would outrank the settings. So Scuba resolves the mode before each query and always passes it, highest first: the thread's /approval command, broker.claude.permissionMode, permissions.defaultMode from the loaded settings sources, then default. As in Claude Code, a project's committed settings cannot raise the mode to acceptEdits, auto, or bypassPermissions. A bare /approval reports the effective mode and where it came from. 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 Octostaff 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 Octostaff artifacts are delivered as native Claude content blocks in message order. Disabled or failed deliveries remain visible as references to the Octostaff 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, permissions.defaultMode from the loaded settings sources applies, and default when none sets it; a thread's /approval overrides both. 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, effortLevel, and permissions.defaultMode.

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. Octostaff'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 Octostaff 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: Octostaff 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 Octostaff 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 Octostaff 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 Octostaff 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. A link that dies without a close is caught by Octostaff's heartbeat: after three missed beats (90 seconds at the default cadence) the bot terminates the socket and reconnects the same way. The log says which happened — bubble connection lost; reconnecting with the reason, and bubble connection error: ... for the underlying transport failure.

A run's run_started, run_finished and error parts are sent with an acknowledgement and retried, so a dead link does not leave the run open in the thread; content sent while the link is dead is lost. A run the bot left open anyway — it crashed mid-run — is closed with an error the next time the bot starts, before its next run on that thread, or when someone presses Stop on it. 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.257 (the range floor) and :0.3.0-sdk-0.3.296 (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 Octostaff token in the environment:

  • ANTHROPIC_AUTH_TOKEN + ANTHROPIC_BASE_URL (or ANTHROPIC_API_KEY)
  • CLAUDE_SCUBA_TOKEN — the bot's Octostaff 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 Octostaff 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 Octostaff approval events.
  • The bot runs Claude with your own environment and ~/.claude, so a normal run loads your global CLAUDE.md, skills, plugins and MCP servers, plus the project CLAUDE.md and project memory of the thread's working directory. The default directory has none, so only the global files apply until /cd.
  • /clear starts a fresh Claude session on a thread; /session prints the session id and the claude --resume command for it. Claude Code's own /resume picker does not list sessions started through the Agent SDK.
  • 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 Octostaff SDK code it uses. Public declarations may reference @octostaff/sdk where public APIs expose shared Octostaff protocol types.