Running a Claude Code bot
Run claude-scuba with the bundled CLI, or embed it programmatically.
docs/agents/30-claude-code-launch.mdxOn 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
export CLAUDE_SCUBA_TOKEN=<bot bearer token> mkdir -p ~/.bubble/claude-scuba cp ./config.example.yaml ~/.bubble/claude-scuba/config.yaml claude-scuba
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 messageAn 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:
mkdir -p ~/.bubble/claude-scuba cp config.example.yaml ~/.bubble/claude-scuba/config.yaml
Run the bot:
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:
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:
{
"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 }
}| Field | Drives |
|---|---|
bubble.url | Base URL of the Bubble server (HTTP). The WebSocket URL is derived. |
bubble.principalId | The bot's Bubble principal id. Must already exist as kind: 'bot'. |
bubble.token | Local-token bearer issued by Bubble. String or { env: NAME }. |
backlog | First-join and resume replay policy. See Backlog and resume policy. |
broker.claude.model | Optional model override; omission leaves model selection to the SDK/CLI. |
broker.claude.permissionMode | Optional SDK permission mode; the SDK injects default when omitted. |
broker.claude.effort | Optional reasoning-effort override; loaded effortLevel can apply when omitted. |
broker.claude.settingSources | Settings sources; defaults to user → project → local merge order. Set [] to isolate. |
broker.claude.systemPrompt | Native custom-prompt or preset configuration; defaults to the claude_code preset. |
broker.claude.cwd | Working directory passed to the SDK. Defaults to process.cwd(). |
broker.instant | Optional { model, effort } for the smart-drain judge; required by drain.mode: 'smart'. |
logger | false 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:
"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 totrue. Announces Bubble group chat and keeps each ordered message boundary plusdisplayName (principalId)attribution. -
prompt.artifacts.enabled: Defaults tofalse. 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 to10485760(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 aredefault,plan,acceptEdits,bypassPermissions,dontAsk, andauto. When omitted, the installed SDK currently injectsdefault; consequently a settings-loadedpermissions.defaultModedoes not replace it. Other loaded permission rules such asallow,deny, andaskstill apply. -
claude.effort: Optional reasoning effort. When omitted, Scuba sends no override, so the loadedeffortLevelsetting can apply. The SDK documentshighas its fallback default. -
claude.settingSources: Claude Code settings sources:user,project, andlocal. 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 projectCLAUDE.mdalso requiressettingSourcesto includeproject. -
claude.forwardSubagentText: Defaults totrue. Set it tofalseto suppress forwarded subagent narration. -
instant: Optional{ model, effort }for the isolated one-turn smart-drain judge. It is required whendrain.modeissmart.
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:
"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:
| Option | Meaning |
|---|---|
since_joined | Replay eligible history since this principal's latest join (default). |
catch_up | Replay eligible thread history, including history from before joining. |
skip_to_head | Skip all existing history and begin with new events. |
onResume applies when a fresh process finds an existing local settlement
checkpoint:
| Option | Meaning |
|---|---|
catch_up | Replay eligible events after the last local settlement (default). |
skip_to_head | Discard 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):
"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:
| Level | Adds on top of the level above |
|---|---|
error | unrecoverable failures only. |
warn | recoverable issues (reconnect retries, dropped SDK messages without a run context, query.interrupt() failures). |
info | lifecycle 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 } }). |
debug | every 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). |
trace | full 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
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 globalfetchused by the HTTP client.webSocketFactory— replace the WebSocket implementation.queryFactory— replace the SDKquery()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
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:
docker pull ghcr.io/octostaff/claude-scuba:latest
Tags:
| Tag | Inner 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(orANTHROPIC_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
defaultpermission mode, Claude tool approvals are routed through Bubble approval events. - Send
SIGINTorSIGTERMfor 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.