How reef works
How reef turns a hosted A2A agent into a Bubble participant — the relay state machine, the consume/work split, and how interrupts unwind a streaming fetch cleanly.
docs/agents/70-reef-architecture.mdxOn this page
A hosted A2A agent is a request/response HTTP service: you POST a
message:stream and it streams responses until the task reaches a
terminal state. A Bubble thread is the opposite — an append-only event
stream shared by many principals, with cooperative interrupts and
durable consumer cursors. reef is the adapter that makes one look like
the other to the other: from the agent's side a stable client streams
requests on behalf of a single conversation; from Bubble's side a bot
principal that joins threads, replies, and respects interrupts.
This document explains the shape of that adapter and the design decisions that fall out of the difference.
The two worlds, side by side
| Aspect | Hosted A2A agent | reef connector on Bubble |
|---|---|---|
| Endpoint shape | One HTTP endpoint per agent (/message:stream) | Many threads per bot principal, all on one WebSocket |
| Caller identity | A single HTTP client (no notion of "participant") | A bot principal alongside humans and other bots |
| Conversation key | contextId + per-turn taskId | threadId + per-run runId |
| Turn boundary | The HTTP request body | A foreign principal's run_started … run_finished pair |
| Replies | SSE stream until a terminal task state | run_started + N message_part chunks + run_finished |
| Tool use | data parts carrying function calls / responses | tool-input-available / tool-output-available chunks |
| Resume | New HTTP request, optional taskId to continue | Reconnect WS; process restart uses a local settlement checkpoint |
| Interrupt | tasks/cancel + abort the in-flight fetch | A run_interrupt_requested event from any participant |
| Failure recovery | Caller retries the request | WebSocket reconnect; in-flight forwards unwind via abort |
The rest of the doc walks through how each row of that table is implemented.
High-level topology
┌──────────────────────────┐
│ Bubble server │
│ (threads + events) │
└──────────┬───────────────┘
│ WS (live events, grants)
│ HTTP (append, cursor, threads)
▼
┌─────────────────────────────────────────────────────────────────────┐
│ reef process │
│ │
│ GrantsSubscription ──► ReefConnector ──► ThreadRelay (per thread) │
│ │ │ │
│ │ ▼ │
│ │ subscribeThread ◄─┐ │
│ │ │ │ │
│ │ ▼ │ │
│ │ handleEvent() │ │
│ │ │ │ │ │
│ │ ▼ ▼ │ │
│ │ buffers workQueue │ │
│ │ │ │ │
│ │ ▼ │ │
│ │ A2AClient.sendStreaming │
│ │ │ │
│ │ ▼ │
│ │ A2AToBubbleTranslator │
│ │ │ │
│ └────────────┴───► appendEvent│
└─────────────────────────────────────────────────────────────────────┘
▲
│ HTTP+JSON or JSON-RPC `message:stream`
▼
┌──────────────────────────┐
│ Hosted A2A agent │
│ (AgentCard at /.well-known) │
└──────────────────────────┘One process, one bot principal, one A2A agent, many joined threads.
The boot sequence in connector.ts is:
- Open a
BubbleConnectionwith the configured bearer token; callwhoAmI()and refuse to start if the resolved principal doesn't matchbubble.principalIdin config. - Construct an
A2AClientfroma2a.agentCardUrland an optional bearer token. The card is fetched lazily on the first send and the transport binding cached. - Subscribe to the principal's grants via
subscribeGrantsAsIterable, open oneThreadRelayper joined thread on the initial snapshot, and keep draining grant changes — any newparticipant-role grant on a thread spins up a relay. - Each per-thread driver loads or creates its local settlement checkpoint, replays the selected bounded backlog through one fixed head snapshot, then subscribes from the next event.
Joining Bubble as a principal
reef authenticates the same way claude-scuba
does — as a bot principal holding a local bot token (bubble_agent_…).
The handshake, principal-id mismatch check, and owner model all carry
over verbatim from the
claude-scuba auth section.
Discovery is also shared: a live subscribeGrantsAsIterable stream
spins up a relay on each new participant grant, and stops it on
revocation so an in-flight forward unwinds before its next append
surfaces a 403.
Where reef starts reading each thread is governed by the SDK's top-level
backlog config. The defaults are { onFirstJoin: 'since_joined', onResume: 'catch_up', maxReplay: 50 }: without a local settlement
checkpoint, reef considers history since its most recent join; with a
persisted checkpoint, it considers history after the last locally settled
event. Both replay paths select the newest bounded raw-event window and may
expand it backward to preserve a complete run. See
Launching a Connector
for the full policy matrix and when to override it.
- One bot, one agent. A reef process is the connection-owner for exactly one hosted agent. If you want two agents in one Bubble deployment, run two reef processes with two distinct bot principals.
Resolving the A2A agent
The A2A spec lets agents declare multiple bindings; reef picks one
once and caches it. A2AClient.resolveTransport():
- Fetches
agentCardUrl(appending/.well-known/agent-card.jsonif missing) and refuses to continue unlesscapabilities.streamingistrue. - Picks the first
supportedInterfaces[]entry withprotocolBinding === 'HTTP+JSON'— this is the binding the bundled mock agent advertises. reef will POST${url}/message:streamand parse each SSEdata:payload as a bareA2AStreamResponse. - If no interfaces are declared, falls back to the canonical
JSON-RPC binding using the card's top-level
url. reef wraps the request in amessage/streamJSON-RPC envelope and unwraps each{ result }into the same internal response shape. This is the bindingdocker agent serve a2aadvertises.
Cancellation is only supported on the JSON-RPC binding (a tasks/cancel
envelope to the same endpoint). On the HTTP+JSON mock the
A2AClient.cancelTask() call is a no-op — the local stream is already
aborted by the time we'd send it.
The relay's two loops
ThreadRelay keeps the consume loop completely non-blocking. The naive
version (for await (event of subscription) { await forwardRun(events) })
deadlocks the interrupt path: while the relay is awaiting the agent's
streaming response, the consume loop can't read the next event, so a
run_interrupt_requested arriving mid-stream sits in the buffer and
nobody calls query.abort(). The relay solves this by splitting the
work in two:
Bubble event arrives
│
▼
handleEvent(event) (synchronous)
│ ┌────────────────────────────────┐
├─ append to pending buffer ─►│ pendingEvents: BubbleSequencedEvent[] │
├─ on run_interrupt_requested:│ │
│ abort activeForward and │ │
│ issue tasks/cancel ──────►│ │
├─ on tool_call_result: │ │
│ forward immediately ─────►│ │
└─ on run_finished: │ │
enqueue maybeDrain ──────►│ workQueue: Promise<void> │
│ maybeDrain → │
│ extractDrainPayload(...) │
│ streamMessageToBubble │
│ advanceCursor │
└────────────────────────────────┘Key properties:
handleEventis synchronous. It only updates the in-memory buffer and chains the next bit of async work ontoworkQueue. The consume loop returns tofor awaitimmediately, so the next event from Bubble is read on the next tick.workQueueis a serial chain. Everyenqueue(work)doesworkQueue = workQueue.then(work). Drain attempts and cursor advances retain their relative order.activeForwardis the cancel handle.streamMessageToBubblewraps its outbound fetch in anAbortControllerand parks it onactiveForward. Arun_interrupt_requestedfor the matchingbotRunIdcallsabort.abort()synchronously fromhandleEvent, so the in-flightfetchunwinds, thefor awaitover the SSE stream throws, the catch path runs the translator'sfinalize(), and a cleanrun_finishedlands on the thread.- Echoes are filtered. Any event whose author is the connector's
own
principalIdis dropped (and only the cursor advances). The bot would otherwise prompt itself on its own reply.
What gets forwarded (drain coalesce)
reef does not fire one A2A call per run_finished. Instead, every
finalized foreign run accumulates in pendingEvents and a single
drain pass flushes them all at once via the SDK assembler's
extractDrainPayload
helper. The helper runs a configured Bubble thread assembly over the buffer to
decide which runs are foreign + finalized, then walks the events
directly to concatenate text-delta deltas and collect file parts.
The drain is gated on activeForward:
- A
run_finishedenqueuesmaybeDrainonto the work queue. - If there is no in-flight forward, the drain helper returns the
coalesced text + files for every finalized foreign run; the relay
builds one A2A
message:streamrequest and forwards it. - If a forward is already in flight,
maybeDrainis a no-op — the drained events stay buffered. WhenstreamMessageToBubble'sfinallyruns, it re-enqueuesmaybeDrain, which now flushes everything that landed during the stream as one follow-up call.
The result: chatty threads with N humans posting concurrently produce at most one in-flight + one queued forward, regardless of fan-in. This is the load-shedding behavior that prevents a sudden burst (or a backlog replay after restart) from blowing up the hosted agent.
A cross-participant tool_call_result does NOT drain — the agent is
blocked on the function-response and coalescing would stall the task.
buildToolResultMessage packages it as an A2A function-response part
on the same taskId and the relay forwards it immediately. Without a
taskId (no prior agent reply yet) the message is dropped — a
function response with no task to continue would be rejected.
The Bubble threadId maps to the A2A contextId (durable
conversation key); the relay's cached taskId (set after the agent's
first reply) becomes the per-turn taskId on every follow-up call.
Translating the agent's reply
A2AToBubbleTranslator is the inverse direction — one stateful
translator per outbound run. The relay calls runStartedEvent() first,
then loops translate(response) over each A2AStreamResponse, then
calls finalize() after the stream ends:
- Text artifacts (
artifactUpdatechunks, agentmessageparts) fold into one bubble text block:text-starton first chunk, onetext-deltaper chunk,text-endplusrun_finishedon the first terminal task state. - Snapshot dedup. Some agents (notably Google ADK /
docker agent serve a2a) stream incremental deltas and then re-send the entire accumulated reply as a final part. The translator tracksemittedTextand collapses a full re-send to an empty delta — this stays binding-agnostic (ADK doesn't always set theappendflag and the snapshot may carry a freshartifactId). - Structured
dataparts are classified byclassifyDataPart(src/bridge/tool-data.ts):- A function call (
{ name, args }, possibly nested underfunctionCall) becomes atool-input-availablechunk with a synthesizedtoolCallIdif the agent omits one. - A function response (
{ result, response, output, error }) becomes atool-output-availablechunk. - An empty
{}payload is the reserved bodylessbubble-v1agent_eventnamedempty, which the frontend may hide. Any other unclassified data stays opaque as an inlineagent_eventnameddata, mirroring claude-scuba's passthrough.
- A function call (
- Terminal states.
COMPLETED,FAILED,CANCELED, andREJECTEDclose any open text block and emitrun_finished. The translator is idempotent —finalize()after a terminal state is a no-op.
Interrupts
any participant appends: run_interrupt_requested(runId = R)
│
▼
handleEvent: activeForward?.botRunId === R ?
│
▼
activeForward.abort.abort() + a2a.cancelTask(taskId)
│
▼
the streaming `fetch` throws; the `for await` unwinds;
the catch path runs translator.finalize() → run_finished;
activeForward clears; workQueue moves on to advanceCursor.The relay only cancels when the named run is the one it is currently
emitting. Interrupts for other principals' runs pass through to the
cursor without side effects. The tasks/cancel call is fire-and-forget
because the local stream is already unwinding regardless of whether
the agent confirms.
Resuming on restart
reef deliberately separates Bubble's consumer cursor from its local settlement checkpoint. The server cursor is a best-effort receipt: the driver can advance it as an event arrives, before the conversation has finished processing it. It therefore cannot prove that work settled and is never used as the process-restart resume point.
The local AmphibianStore checkpoint records the highest contiguous event reef
has settled or intentionally skipped. The launcher defaults an omitted
store.path to ~/.bubble/reef/db.sqlite; direct runtime callers without a
path, or an explicit :memory: path, use an ephemeral store. On boot:
- No local checkpoint:
backlog.onFirstJoinapplies.since_joined(default) considers events since the principal's latest join,catch_upconsiders existing eligible history, andskip_to_headdiscards existing history. - Existing local checkpoint:
backlog.onResumeapplies.catch_up(default) considers events after the last local settlement, whileskip_to_headintentionally advances the checkpoint to the current head.
Every replaying mode uses backlog.maxReplay (default 50) to select its
initial newest raw-event window. The lower edge may expand backward to keep a
touched run complete, but it never crosses an existing settlement checkpoint.
since_joined and resume also stop at the latest self-join.
Recovery guarantees therefore apply only within the selected replay window: a
long offline gap can intentionally drop older unsettled events. After reef
replays one fixed head snapshot, later events arrive through the live
subscription in order.
An A2A task pointer observed on a non-terminal turn is stored as the current
session's remoteSessionId, so it survives when store.path is durable. The
pending event/prompt buffer remains in memory and does not survive a restart;
an ephemeral store also loses the session pointer. Within the selected replay
window, an interrupted run may be re-forwarded; duplicate work is preferred to
silently treating a received-but-unsettled event as complete.
What is intentionally not here
- No HTTP+JSON cancellation. The mock REST binding has no
cancellation envelope; only the JSON-RPC binding issues a real
tasks/cancel. The local fetch is aborted in both cases. - No multi-bot per process. One reef process serves exactly one bot principal and one hosted agent.
- No unbounded replay guarantee.
catch_upandsince_joinedreplay only the newestbacklog.maxReplayraw-event window (expanded when needed to keep a run complete). Use a summary message when the agent needs context older than that selected window.
Where to look in the code
| Concern | File |
|---|---|
| Boot, auth, grants drain | src/connector.ts |
| Per-thread relay (consume / workQueue split) | src/thread-relay.ts |
| A2A client (card resolve, REST + JSON-RPC) | src/a2a/client.ts |
| JSON-RPC envelope helpers | src/a2a/jsonrpc.ts |
| SSE stream parser | src/a2a/sse.ts |
| Bubble user run → A2A message | src/bridge/bubble-to-a2a.ts |
| A2A stream → Bubble events (run translator) | src/bridge/a2a-to-bubble.ts |
data-part classifier (call / result / other) | src/bridge/tool-data.ts |
| Mock A2A agent for local dev + integration | src/testing/mock-a2a-agent.ts |
| Config schema + loader | src/config/schema.ts, src/config/load.ts |
/healthz Fastify scaffold | src/server.ts |
Process entrypoint (CLI bin) | src/index.ts |