How claude-scuba works

How claude-scuba turns the interactive Claude Code CLI into a long-running, multi-participant agent daemon on top of Bubble.

Connecting agentsdocs/agents/20-claude-code-architecture.mdx
On this page

The Claude Code CLI is built for one human sitting at a terminal: you type, it streams, you wait for the prompt, you type again. claude-scuba takes the same underlying engine — the Claude Agent SDK — and runs it as a headless daemon that participates in a shared Bubble thread alongside humans and other bots. The adapter never reimplements tool execution, planning, or sub-agent orchestration; it bridges the SDK's stdio-shaped world into Bubble's event-sourced, multi-principal world.

This document explains the shape of that bridge and the design decisions that fall out of the difference.

The two worlds, side by side

AspectInteractive Claude Codeclaude-scuba daemon
LifetimeOne process per user session, exits on EOFLong-lived process, survives many turns and reconnects
Input sourceStdin / TTY keystrokes from a single userWebSocket event stream from a shared Bubble thread
Output sinkRendered to the terminalmessage_part Bubble events, appended to the thread
ParticipantsOne human, one agentN humans + N bots + the scuba bot, all on the same transcript
Turn boundaryThe user hits EnterA drain rule decides when accumulated chatter is a "user turn"
Tool approvalsInteractive y/n at the terminaltool_call_approval / tool_call_result Bubble events
InterruptCtrl-C in the TTYA run_interrupt_requested event from any participant
Sub-agentsRendered inline with indentationRouted onto Bubble subthreads with their own transcripts
Failure recoveryCrash, user restartsWebSocket reconnect with exponential backoff, session persists

The rest of the doc walks through how each row of that table is implemented.

High-level topology

text
                          ┌──────────────────────────┐
                          │     Bubble server        │
                          │  (threads + events)      │
                          └──────────┬───────────────┘
                                     │  WS (live events)
                                     │  HTTP (append, grants, threads)
                                     ▼
   ┌─────────────────────────────────────────────────────────────────────┐
   │                        claude-scuba process                         │
   │                                                                     │
   │   BubbleThreadSubscription ──► ClaudeSdkSession                     │
   │            ▲                       │                                │
   │            │                       ▼                                │
   │            │              BubbleToSdkDrain ──► PromptStream         │
   │            │                                             │          │
   │            │                                             ▼          │
   │            │                                  Claude Agent SDK      │
   │            │                                      (query)           │
   │            │                                             │          │
   │            │                                             ▼          │
   │   BubbleHttpClient ◄──────────── sdk-to-bubble translator           │
   │     (appendEvent)                                                   │
   └─────────────────────────────────────────────────────────────────────┘

One process, one bot principal, many joined threads. The boot sequence in bot.ts is:

  1. Authenticate against /v1/auth/whoami and confirm the resolved principal matches bubble.principalId in config.
  2. Subscribe to the principal's grants via subscribeGrantsAsIterable — the bot discovers joined threads live from the grants stream, and stops a thread runtime when its grant is revoked. There is no bootstrap thread self-join in v1.
  3. For each joined thread, let the SDK's amphibian driver select a replay window from the local settlement checkpoint and configured backlog policy (see Launching a Bot). By default, a missing checkpoint replays bounded history since the principal's latest join, while an existing checkpoint replays bounded history after its last settlement. After that snapshot, open a WebSocket subscription for newer events and feed them into the per-thread session.
  4. Construct a ClaudeSdkSession per joined thread and hand each inbound event stream to the matching session. Each SDK query() is started lazily on the first drainable user message in that thread.

Joining Bubble as a principal

The bot is not magically trusted. From Bubble's point of view it is just another caller holding a bearer token — the same auth model described in the Bubble Authentication and Principals docs. The scuba-specific question is: which bearer, owned by whom, and how does a human end up with one to drop into config?

The bot's credential

claude-scuba always presents a bearer token in the Authorization header (HTTP) or on the first client.auth WebSocket frame. In practice that bearer is a Bubble-issued local bot token — bubble_agent_<base64url> — which the server's local-token dispatcher routes to the matching bot principal. The scuba config captures it under bubble.principalId (the id the bot claims to be) and bubble.token (the secret that proves it):

json
{
  "bubble": {
    "url": "https://bubble.example.com",
    "principalId": "bot-research",
    "token": { "env": "BOT_RESEARCH_TOKEN" }
  }
}

On boot, scuba calls /v1/auth/whoami and refuses to start if the resolved principal doesn't match bubble.principalId. That mismatch check turns a wrong-token deployment into a fast, loud failure rather than a silent identity confusion.

Scuba itself is bearer-agnostic — the dispatcher chooses the driver. A deployment could in principle hand it any token the server's auth stack accepts (including an Auth0 JWT belonging to a bot-kind principal). Local bot tokens are the intended, supported path because they're rotatable, scoped to one principal, and don't share a credential with the human's interactive session.

Bringing your own scuba as an Auth0 user

If you authenticate to Bubble as a human via Auth0, you do not give scuba your JWT. JWTs are short-lived, tied to your browser session, and authenticate as you — running a bot under your identity would make every action it takes look like yours. Instead, you create a dedicated bot principal that you own, mint a token for it, and hand that token to scuba.

text
   ┌──────────────┐  Auth0 login   ┌───────────────────────┐
   │   You (web)  │ ─────────────► │  Bubble (your JWT)    │
   └──────┬───────┘                └──────────┬────────────┘
          │                                   │
          │  POST /v1/principals              │
          │  { principalId: "bot-yuge-claude",│
          │    kind: "bot", … }               │
          ├──────────────────────────────────►│
          │                                   │
          │  ◄────────────────────────────────┤
          │  { principal, token: { token:     │
          │    "bubble_agent_…" } }           │
          │  (plaintext returned ONCE)        │
          │                                   │
          │  paste bubble_agent_… into        │
          │  claude-scuba.config.json         │
          │                                   │
          ▼                                   │
   ┌──────────────┐    Bearer bubble_agent_…  │
   │ claude-scuba │ ─────────────────────────►│
   │   process    │                           │
   └──────────────┘                           │

The contract:

  • You become the bot's owner. POST /v1/principals automatically adds the calling principal to ownerPrincipalIds. Owners can rotate and revoke the bot's tokens at any time via POST/DELETE /v1/principals/:id/tokens — the same routes documented in the Bubble principals page. If your scuba goes rogue or you lose the token, you revoke it; the bot stops working immediately.
  • The token is one-shot in plaintext. The response from POST /v1/principals is the only time the server returns the plaintext. Lose it and you mint a new one. The store only ever holds the SHA-256 hash.
  • Thread access is a separate grant. Authenticating as the bot doesn't put it on a thread. Scuba listens to direct participant grants via the live subscribeGrantsAsIterable stream — every newly granted thread gets a subscription and its own session, every revoked grant stops its session. There is no bootstrap thread; grant the bot out-of-band before (or after) it starts.
  • One bot per process. One process can cover many joined threads for the same bot principal. Use separate processes for separate bot principals or failure domains.

In short: an Auth0-authenticated human is the owner of one or more bot principals; the bot is the principal that scuba runs as. The two identities are deliberately separate so that revoking the bot, rotating its credentials, or auditing its actions doesn't touch the human.

Where this lives in the code

ConcernFile
Config schema (principalId/token)src/config/schema.ts
Identity check + self-joinstart() and ensureParticipant() in src/bot.ts
HTTP bearer wiringsrc/client/bubble-http-client.ts
WebSocket client.auth handshakesrc/client/bubble-ws-subscription.ts

The long-lived Query

In the CLI, every prompt you type becomes its own short-lived query. In claude-scuba there is at most one Query per session. It is opened lazily on the first drainable user turn and then kept in streaming-input mode: its prompt is an AsyncIterable<SDKUserMessage> that we push into as new user turns arrive.

text
Bubble events ──► drain rule ──► PromptStream.push(msg) ──┐
                                                          │
                                                          ▼
                                       SDK query({ prompt: PromptStream })
                                                          │
                                                          ▼
                                              for await (m of query) { … }

Why one query instead of one-per-turn:

  • The SDK builds up context (system prompt, allowed tools, MCP connections, the subprocess itself) on every query() call. Re-doing that for every Bubble message would be expensive and would discard the in-memory KV cache between turns.
  • query.interrupt() only targets the in-flight run of its query. Keeping one query lets a run_interrupt_requested event map unambiguously to the right call.
  • Sub-agents spawned by the model live inside the same query; routing them to subthreads would be impossible if each turn were a fresh process.

PromptStream (src/session/prompt-stream.ts) is the producer side of that async iterable — a tiny push/pull buffer the session writes to when the drain rule says "this is the next user turn."

Turn boundaries: the drain rule

A terminal CLI has an obvious turn boundary — the Enter key. A shared Bubble thread doesn't. Three humans might be talking to each other while the bot is mid-response; a bot's own message_part chunks are events too. So BubbleToSdkDrain (src/bridge/bubble-to-sdk.ts) implements an explicit rule, delegating the actual finalization oracle to the SDK assembler's shared extractDrainPayload helper (also used by reef):

text
   Bubble event arrives
           │
           ▼
   observe(event):
     Authored by this bot?                  ──► drop
     run_interrupt_requested for our runId? ──► signal interrupt
     run_started / run_finished / message_part from another?
                                            ──► append to buffer
           │
           ▼ (on idle, or in steer mode mid-bot-run)
   drain() / drainSteer() ──► extractDrainPayload(buffer)
             │                          │
             │                          ▼
             │            assembler identifies which foreign runs
             │            have finalized (run_finished seen, OR
             │            no run_started observed — sender-contract
             │            bare-delta turn)
             │
             ▼
   concat text-delta deltas of those runs ──► one SDKUserMessage

Key properties:

  • Wait-for-idle: the bot never gets a new user message while its previous turn is still streaming. The session's tryStartNextTurn only fires drain() when currentRunId === null; mid-run, drainSteer() is the parallel lane that pushes with shouldQuery: false so the model picks up the text at its next internal turn step without spawning a fresh assistant turn.
  • Per-run gating via the shared helper: foreign runs are drainable iff they've finalized (run_finished seen, or never had a run_started — the bare-delta sender contract). In-flight runs stay buffered.
  • Coalescing: when multiple foreign runs finalize together, they drain as one SDKUserMessage — same anti-spam property reef relies on. The "longest contiguous prefix" semantic of the prior drain has been replaced with "every drainable run, concatenated."
  • Tool chunks are silent: a drain whose finalized runs carry only tool-input-… chunks consumes the events but yields no SDKUserMessage. Tool metadata is noise to Claude; only text-delta content rolls into the prompt. File parts are also collected by the helper but the v1 Claude bridge does not forward them to the SDK (silently dropped).
  • Self-ignore: the bot's own emitted events are filtered out before they hit the drain. Otherwise the bot would prompt itself.

When a turn does start, the session assigns a runId, emits run_started, and pushes the concatenated message into PromptStream.

Outbound translation

sdk-to-bubble.ts turns each standard SDKMessage produced by the query into one or more Bubble events:

  • assistant text blocks → message_part events with text-start, text-delta, and text-end chunks inside a run_started/run_finished envelope.
  • assistant thinking blocks → message_part events with reasoning-start, reasoning-delta, and reasoning-end chunks.
  • Non-sub-agent assistant tool_use blocks → tool-input-available chunks (with the same toolCallId the SDK uses internally). Agent and legacy Task tool uses are intercepted by the sub-agent registry before this standard translation runs.
  • user tool_result blocks → tool-output-available chunks paired with the matching toolCallId.
  • result messages → run_finished on the parent run.

The translator is pure: it takes a message and a { principalId, runId } context and returns events. The session is the one that decides which thread the events land on (parent thread for normal flow, subthread for sub-agent output — see below).

Tool approvals over the network

The CLI prompts at the terminal when a tool needs permission. The daemon can't do that — there's no terminal, and the answer may need to come from a specific principal on the thread. Instead, the session wires the SDK's canUseTool callback to ApprovalRegistry:

text
   SDK wants to run a tool
           │
           ▼
   canUseTool(toolCallId, name, input)
           │
           ▼
   ApprovalRegistry parks the request, keyed by toolCallId
           │
           ▼
   session emits a `message_part` with a `tool-input-available` chunk
           │
   (some Bubble participant — human or bot — appends)
           ▼
   `tool_call_approval` or `tool_call_result` event arrives
           │
           ▼
   ApprovalRegistry.observe() resolves the parked promise
           │
           ▼
   canUseTool returns { behavior: 'allow' | 'deny', … } to the SDK

The SDK is blocked on that callback the whole time, so the SDK's default permission mode (default) and a real participant out there together replace the terminal y/n prompt.

Local Bubble tools

Every Claude query also receives one static in-process MCP server named bubble. Its core catalog comes from @octostaff/sdk/amphibian and covers browse_thread, open_subthread, search_principals, and invite_to_thread. The Claude Scuba composition root separately registers the trusted artifactClientExtension with its BubbleClient and adds artifactToolProvider to the MCP catalog. Artifact code therefore remains extension-owned and loads only after an exact artifacts manifest match from /v1/info.

These tools are local in the transport sense only: after the normal approval event resolves, the handler calls Bubble through the bot's own authenticated BubbleApi. Bubble remains authoritative for every artifact read/write, subthread, principal-visibility, grant-management, and invite-privacy decision.

The broker correlates an approved call with the active message context. A top-level run defaults to its parent thread; a Claude subagent defaults to the Bubble subthread carrying that subagent. Tools may name another thread, but the id grants no authority by itself. Child creation derives a stable key from the Bubble thread, run, and tool-call ids so an uncertain retry returns the same child, initially containing only the bot. Invitations are deliberately fixed to member; a model cannot request owner or another role.

Sub-agents become subthreads

When Claude spawns an Agent sub-agent in the CLI, its output is rendered inline (indented). In Bubble that would muddy the parent transcript and confuse the drain rule (the bot would see its own sub-agent output as foreign chatter). So the session intercepts sub-agent lifecycle messages before standard translation:

text
   assistant: tool_use(name='Agent', id=T)   ──► SubagentRegistry.open(T)
                                                  │
                                                  ▼
                              createSubthread(parentThreadId) — child thread S
                                                  │
                                                  ▼
                          emit parent `tool-input-available` with
                          childThreadId, then child `run_started`
                                                  │
                                                  ▼
                          all later messages with parent_tool_use_id === T
                          are routed to thread S, with their own runId

   user: tool_result(tool_use_id=T)         ──► SubagentRegistry.close(T)
                                                  → emit `run_finished` on S
                                                  → emit parent
                                                    `tool-output-available`
                                                  → drop the tool_result block
                                                    from standard translation

The result is one clean conversation on the parent thread plus a full transcript per sub-agent on its own subthread (parent.sub-XXXXX-YYY). The bot also starts a normal subscription/session for the created subthread because it is a joined Bubble thread. That does not give the adapter a way to push user messages into the already-running Claude sub-agent; the SDK does not expose that channel.

Interrupts

text
   any participant appends:  run_interrupt_requested(runId = R)
                                        │
                                        ▼
              drain.observe() reports { interrupt: true } if R === currentRun
                                        │
                                        ▼
                                  query.interrupt()
                                        │
                                        ▼
                       SDK ends the current turn early; the iterator
                       eventually yields the `result` message and the
                       session emits `run_finished`.

This is the daemon analogue of Ctrl-C in the terminal — except anyone on the thread with the right role can trigger it.

Reconnect, not restart

The CLI dying is a user problem; the daemon dying is an operator problem. bot.ts wraps the subscription in a drainWithReconnect loop that reopens the WebSocket on transient failure (and on server-initiated close) with exponential backoff capped at 30s. The in-process ClaudeSdkSession keeps its Query, its drain buffer, and its approval registry intact across reconnects. Joined-thread discovery happens live through subscribeGrantsAsIterable — newly granted threads spin up sessions, revoked grants stop them.

Where a thread starts reading after a process restart is governed by the flat backlog block. A missing local settlement checkpoint uses onFirstJoin (default since_joined); an existing checkpoint uses onResume (default catch_up). Both defaults target the newest maxReplay raw events (default 50), expanding backward when needed to avoid splitting a run. skip_to_head intentionally discards existing history instead. Bubble's consumer cursor is a receipt, not proof of local settlement, so it is not used as the recovery watermark. A durable local store preserves the settlement checkpoint; a lost or in-memory store falls back to onFirstJoin and may replay already-handled events within the selected window. Events older than that window are not covered by the recovery guarantee. See Launching a Bot for the full matrix.

Logging

pino writes structured JSON to stdout by default; optional pretty-printing (logger.pretty: true) is provided for local dev. Every layer (bot, session, drain, registries) takes a BotLogger so the same ids (runId, threadId, principalId, seq) flow through the whole stack.

What is intentionally not here

  • No unbounded backlog replay. Replaying policies select the newest maxReplay raw events and may expand backward to keep a run complete; older eligible events can be skipped.
  • No multi-bot per process. Run multiple processes for multiple bot principals.
  • No cost/usage telemetry surfaced into Bubble in Stage 1.
  • No custom MCP servers, plugins, slash commands, or file checkpointing. The SDK supports them; the adapter does not yet expose them.

Where to look in the code

ConcernFile
Boot, auth, reconnect loopsrc/bot.ts
HTTP client (appendEvent, grants, threads)src/client/bubble-http-client.ts
WS subscription + auth handshakesrc/client/bubble-ws-subscription.ts
Long-lived Query + message routingsrc/session/claude-sdk-session.ts
Producer side of the SDK prompt streamsrc/session/prompt-stream.ts
Drain rule (turn boundary state machine)src/bridge/bubble-to-sdk.ts
SDK message → Bubble event translationsrc/bridge/sdk-to-bubble.ts
Sub-agent → subthread routingsrc/session/subagent-registry.ts
canUseTool ↔ Bubble approval eventssrc/session/approval-registry.ts