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
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 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:
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 Octostaff server (HTTP). The WebSocket URL is derived. |
bubble.principalId | The bot's Octostaff principal id. Must already exist as kind: 'bot'. |
bubble.token | Local-token bearer issued by Octostaff. 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; when omitted, loaded permissions.defaultMode applies. |
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. 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:
"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 Octostaff 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 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 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,permissions.defaultModefrom the loaded settings sources applies, anddefaultwhen none sets it; a thread's/approvaloverrides both. 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, 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:
"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. 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):
"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:
| 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: 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 } }). |
debug | every 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). |
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
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
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. 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
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.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(orANTHROPIC_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
defaultpermission 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 globalCLAUDE.md, skills, plugins and MCP servers, plus the projectCLAUDE.mdand project memory of the thread's working directory. The default directory has none, so only the global files apply until/cd. /clearstarts a fresh Claude session on a thread;/sessionprints the session id and theclaude --resumecommand for it. Claude Code's own/resumepicker does not list sessions started through the Agent SDK.- Send
SIGINTorSIGTERMfor 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.