Realtime channels

Subscribe to live changes and device control channels over Supabase Realtime.

Subscribe to live changes and device-control events over Supabase Realtime at wss://api.busymate.net/realtime/v1 — the same socket the dashboard and the iOS app use to stay in sync without polling.

Realtime is authenticated by the same OAuth access token you use for REST and MCP. The token's role gates which channels you may join, so you only ever receive events you're allowed to see.

Two delivery modes

Busymate uses both of Realtime's mechanisms:

  • Broadcast — named channels carry small, purpose-built control + firehose messages. This is the primary path: the row-level auth check happens once at channel-join time, so it scales to a high insert rate.
  • postgres_changes — table-level change data capture (CDC). Subscribe to INSERT/UPDATE/DELETE on a specific table and receive the changed row. Useful when you want to mirror a table's state.

Connecting

Use @supabase/supabase-js (or any client that speaks the Realtime protocol). Point it at the Busymate host and attach your OAuth token:

js
import { createClient } from "@supabase/supabase-js";
 
const supabase = createClient("https://api.busymate.net", PUBLISHABLE_KEY, {
  global: { headers: { Authorization: `Bearer ${TOKEN}` } },
});

The client opens wss://api.busymate.net/realtime/v1 under the hood; you work with channels.

Broadcast channels

Each channel carries a fixed set of named events. The table below marks who each channel is for — channels meant for integrators vs. ones that exist to drive a specific Busymate client (you can observe some of them, but they're designed for that client).

ChannelCarriesFor
ws:<workspace_id>The fleet capture firehose for a workspace — every device's new entries (INSERT) and their status close-outs (UPDATE), plus breakpoint (paused-request) events. Fanned out once per ingest statement, not per row. See the entries firehose for the payload.Integrators with devices:view building a live feed; this is what the dashboard subscribes to for an operator/admin.
ws:<workspace_id>:<owner_user_id>The owner-scoped firehose — the same workspace feed narrowed to one owner's devices, identical payload. It is emitted only when that owner lacks devices:view: an owner who can read the fleet topic subscribes that instead, so an owner-scoped copy would have no subscriber.A plain owner (no devices:view) — the dashboard subscribes this variant for them; also readable by any devices:view holder.
device:<uuid>Per-device control + results (see the device events below) — the device row's own UPDATE, health, control-lock and breakpoint frames. It carries no captured entries (since #2873): every phone, tunnel extension and connector holds this topic open for control, so the data stream lives on its sibling.The owning device + tools that drive it.
device:<uuid>:entriesThis one device's own entries INSERT / UPDATE, same payload as the workspace firehose — a single-device consumer needs no workspace topic. Same authorization as device:<uuid> (owner, devices:view, admin); a device JWT cannot join it.Single-device feeds such as the /demo live feed and bmc traffic.
devices:allFleet changes — a device added, renamed, or removed (INSERT/UPDATE/DELETE, full row) — plus a slim heartbeat event for a device whose setup changed only in volatile keys (a farm phone's health timestamps): { heartbeat: true, record: { uuid, owner_user_id, tenant_id, platform, parent_device_id, last_seen_at, updated_at, setup_patch } } where setup_patch is an RFC 7386 JSON Merge Patch to apply onto the row's setup. Farm hosts always send the full UPDATE.Dashboard / any fleet view.
service_groups:allA service group (Proxyman-style "App") was created, edited, or deleted.Dashboard / settings views.
settings:globalA coarse "global settings changed" invalidation — re-fetch your effective settings.Every device.
settings_device:allPer-device settings overrides changed.Dashboard / settings views.
device_events:<uuid>Every telemetry event for one device from iOS and its PAC/proxy process, including write-time-redacted detail; detail.producer identifies the process.Device owners and operators building a per-device diagnostic tail.
device_events:alliOS and PAC/proxy telemetry events fleet-wide except level: "debug" (per-request rows — net.forward, tunnel.connect/close, mitm.*, capture.* — stay on the per-device topic and the REST read); payload.record is the complete device_events row.Operators with devices:view; this drives the dashboard Telemetry stream.
proxy-controlBreakpoint-continue and resend-request commands.The proxy-server + iOS (control plane, not for integrators).

Broadcast messages are not durable. For a gap-free viewer, subscribe first, then read the newest window over REST; repeat that bounded read after a stable socket reconnect and deduplicate by event id. The dashboard uses this pattern for device_events:all, so an event emitted during a network handoff appears without a page reload.

PAC/proxy rows use detail.producer: "proxy-server" and carry their process provenance (proxy_build, node_version, process_boot_id, producer_seq) inside detail; iOS-only envelope fields such as ios_version, app_build, boot_id, seq, and device ts are null. The device_health projection remains iOS-only: PAC/proxy and unknown future producers are still durable and live here, but cannot refresh or overwrite phone health.

device:<uuid> events

The per-device channel multiplexes several control flows. The topic name is literally device: followed by the device's uuid (for example device:6f1c…):

EventDirectionPayloadMeaning
unpair→ deviceClear the device's stored credential; it returns to its activation gate.
vpn-on / vpn-off→ deviceAsk the device to connect / disconnect its VPN tunnel.
open-sheet→ device{ sheet, open } (sheetsettings | cert | pac)Remotely open or close one of the iOS app's modal sheets.
cdp-command→ device{ id, method, params?, sessionId? }A remote Chrome DevTools Protocol command for a bmc connector.
cdp-result← device{ id, ok, result?, error? }The correlated reply to a cdp-command (matched by id).
live-activity-message→ device{ message }Update the device's Live Activity lock-screen pill text in realtime.

Two more events ride the workspace topics from dashboard 838 on, both driven by the server-maintained counters ledger described in Wipe history & counters:

EventTopicsPayloadMeaning
COUNTS_DELTAws:<workspace_id> · device:<uuid> · the owner-scoped topic (same rule as entries){ table: "entry_dh_counts", operation: "DELTA", workspace_id, deltas: [{ device_uuid, host, n }] }Per-device / per-host count changes from a delete or wipe, one message per statement; n is signed. Inserts need no delta — add 1 per entries record you receive.
WIPE_JOBws:<workspace_id> (+ the requester's owner-scoped topic when they lack devices:view)broadcast-from-database envelope, record = the wipe job row (status, device_uuids, total, deleted, last_heartbeat, …)Every insert/update of a wipe job — what the dashboard's wipe banner renders; the same rows the list_wipe_jobs MCP tool returns.

Most device:<uuid> events are a private control plane between Busymate's own tools and a device. They're documented here for completeness; integrators typically use the REST writes or the MCP tools that publish them rather than broadcasting directly.

The entries firehose

Three topics carry captured entries, all with the same envelope — only the audience differs:

TopicWho is in itEmitted
ws:<workspace_id>every device in the workspacealways
ws:<workspace_id>:<owner_user_id>one owner's devicesonly when that owner lacks devices:view
device:<uuid>:entriesone devicealways

The fan-out runs once per ingest statement (a client's upload batch), with a single device lookup per batch, so a burst of captures costs one trigger pass rather than one per row. What arrives depends on a server-side switch:

Per-record mode (the default) — one message per captured row:

jsonc
{
  "table": "entries",
  "operation": "INSERT",            // or "UPDATE" — a CONNECT envelope closing from active to its final status
  "record": {
    "id": 5821, "ts": "…", "device_uuid": "…", "workspace_id": "…",
    "kind": "http", "host": "api.example.com", "path": "/v1/orders", "url": "…",
    "payload": { /* headers + bodies stripped */ "bodyStripped": true, "headersStripped": true }
  }
}

record is the light row: request/response bodies and headers are stripped (the bodyStripped / headersStripped markers say so) and the search text is null — read the full entry over REST or the get_entry MCP tool when you need it.

Batch mode (off by default) — an operator can flip the workspace-wide setting realtime.entriesBatchBroadcast on the server (no deploy). INSERTs then arrive as one message per topic per ≤ 50 rows of an ingest statement; UPDATEs stay per-record:

jsonc
{
  "table": "entries",
  "operation": "INSERT",
  "batch": true,
  "count": 37,
  "records": [ /* ≤ 50 light rows, same shape as `record` */ ]
}

A consumer must accept both shapes — payload.record or payload.records[]. Batch mode is on since 2026-09-13 (#2873): one records[] frame per ingest statement (up to 50 rows) per topic. Busymate's own consumers (the dashboard, the /demo feed, the bmc connector) accept both.

Subscribing to a workspace feed

js
const rows = (payload) =>
  Array.isArray(payload.records) ? payload.records : payload.record ? [payload.record] : [];
 
const channel = supabase
  .channel("ws:" + WORKSPACE_ID, { config: { private: true } })
  .on("broadcast", { event: "INSERT" }, ({ payload }) => {
    if (payload.table !== "entries") return;
    for (const entry of rows(payload)) console.log("entry →", entry.host);
  })
  .on("broadcast", { event: "UPDATE" }, ({ payload }) => {
    for (const entry of rows(payload)) console.log("closed →", entry.id);
  })
  .subscribe();

Treat it as a live tail of new captures + breakpoint changes for that workspace. Subscribe as a plain owner to ws:<workspace_id>:<your_user_id> instead; for a single device, subscribe device:<uuid> and filter on payload.table === "entries" (the other events on that topic carry no table).

Where the /demo feed comes from. The public demo page is a real consumer of this contract: it joins the private device:<uuid>:entries topic of the visitor's own sandbox device (the anonymous visitor owns that device, so the private join passes) and renders the INSERT broadcasts — it does not use postgres_changes on the entries table, which is no longer published for CDC (see below).

postgres_changes (table-level CDC)

For tables you want to mirror, subscribe to row changes directly. The same capability gate applies — a token without the relevant view capability silently receives no rows.

js
const channel = supabase
  .channel("device-watch")
  .on(
    "postgres_changes",
    { event: "*", schema: "public", table: "devices" },
    ({ eventType, new: row }) => console.log(eventType, row),
  )
  .subscribe();

Tables published for CDC include devices, breakpoint_events, workspace_tabs, tags, service_groups, device_status, settings_global, settings_device, and the single-row service_stats / service_status snapshot tables plus stats_rollup_state (see Stats & status for the live-monitor pattern).

devices is published with full row identity, so UPDATE events include the previous values — handy for detecting renames.

entries is not published for CDC. Captured entries fan out only as the broadcasts above (ws:<workspace_id>, the owner-scoped variant, device:<uuid>:entries); a postgres_changes subscription on entries subscribes successfully and then never receives a row. Decoding every captured row for the WAL poller was the single largest cost of a capture burst, and no live feed needed it.

Access control

Channel subscription is RLS-gated server-side:

  • You may join shared channels your role can read.
  • device:<uuid> and device-scoped fan-outs are limited to devices you own (or any device, if you're admin).
  • A token lacking the relevant capability never receives the events — it just stays quiet.

This is the same role model as REST and MCP. See Roles & permissions.

Troubleshooting

SymptomCause / fix
Channel subscribe() succeeds but no events arriveCapability/ownership scope — your role can't read that channel, or the device isn't yours. Confirm the equivalent REST read works first.
device:<uuid> join is rejectedYou don't own that device and aren't admin.
Socket connects then dropsVerify the OAuth token is attached as Authorization: Bearer on the client and hasn't expired.
Ask your mate