Events
The daemon's live stream over SSE or WebSocket: the 23 event types, why every frame is numbered, and how a reader notices it fell behind.
Two transports, the same events, both behind the bearer token. GET /v1/events is Server-Sent Events with a 15 second keep-alive; GET /v1/ws is a WebSocket. Both are server to client only.
$ curl -N http://127.0.0.1:8787/v1/events \
-H "Authorization: Bearer $SCALEBROWSER_BEARER_TOKEN"Each connection subscribes before its response is produced, so nothing published between your request and the first frame is lost. A slow client skips events rather than being disconnected.
/v1/ws is this stream and nothing else. It is not a CDP connection. The CDP address comes back from a profile start, and Direct CDP is the page for it.
Every frame is numbered
Each message is one JSON object with a type, an at (Unix seconds) and a seq.
{ "type": "profile_started", "seq": 12, "profile_id": "pr_9c21e4",
"cdp_ws": "ws://127.0.0.1:41337/devtools/browser/2f1c…",
"headless": false, "at": 1754300112 }seq counts the frames of this connection, starting at 0, not the frames of the daemon. A global number would only earn its keep with a server-side replay buffer, and there is none.
The number exists so a reader can notice a loss nobody reported: a truncated frame, a parser that dropped one. Compare it with what you saw last.
When you fall behind
The bus is a bounded ring. A reader that cannot keep up has its oldest events overwritten, and that is announced rather than swallowed:
{ "type": "resync", "seq": 41, "dropped": 17, "at": 1754300140 }After a resync, whatever you are showing may disagree with the daemon. Re-read the lists you care about. This is the only event that is injected at the edge rather than published on the bus, because falling behind is a property of one subscriber and not of the system.
The events
There are 23 of them. Every table below is generated from the daemon's own event enum, fields included.
Profile lifecycle
type | Fields besides at and seq |
|---|---|
profile_state_changed | profile_id, from, to |
profile_started | profile_id, cdp_ws, headless |
profile_stopped | profile_id |
profile_crashed | profile_id, reason |
Refused starts. Each of these is the stream side of a REST error, so a dashboard learns about a refusal it did not itself ask for: preflight_failed is 4005, capacity_rejected is 4003 (this machine is full), concurrency_rejected is 4008 (the subscription is full).
type | Fields besides at and seq |
|---|---|
preflight_failed | profile_id, detail |
capacity_rejected | profile_id |
concurrency_rejected | profile_id |
Proxies and exits
type | Fields besides at and seq |
|---|---|
proxy_checked | proxy_id, healthy, country |
exit_guard_changed | profile_id, state, addr, class, scope, owned, detail |
exit_guard_gone | profile_id |
state is ok, degraded, unverified or severed. class is the address kind: mobile, residential, datacenter or unknown. scope says how far the claim reaches: machine while this machine holds no account key, account once it counts everywhere. Proxies & exits explains what each state means for the browser that is running.
Agent activity
type | Fields besides at and seq |
|---|---|
run_started | run_id, profile_id, actor, goal |
run_step | run_id, profile_id, step |
run_finished | run_id, profile_id, outcome |
profile_activity_changed | activity |
profile_activity_gone | profile_id |
Secrets, recordings, videos and tasks
type | Fields besides at and seq |
|---|---|
secret_changed | profile_id, secret, count |
secret_gone | profile_id, secret_id, label, count |
recording_changed | recording |
video_changed | video |
video_gone | video_id |
task_changed | task |
task_gone | task_id, profile_id |
A render's progress rides in video_changed, published every 2 percent or 2 seconds while rendering. A separate job event would be a second truth about the same thing.
Falling behind
type | Fields besides at and seq |
|---|---|
resync | dropped |
Two rules these events follow
Both are worth knowing before you write a reader, because they decide how much bookkeeping you need.
An event carries the whole state of its subject. run_step carries the entire stored step, video_changed the entire video row, task_changed the entire task. Not a selection of changed fields. A display that rebuilds a row from parts is confidently wrong the first time one event goes missing, and you cannot tell from the outside which time that was.
Every list gets arrival, change and departure. That is why exit_guard_gone, secret_gone, video_gone, task_gone and profile_activity_gone exist. Without the departure half, a row that a retention sweep removed at three in the morning stays on an open dashboard until somebody reloads.
secret_changed and secret_gone also carry count, the profile's running total afterwards, so a reader can tell "I missed one" from "there is one".
A secret event never carries a value. It carries the row: label, kind, origin, state. That is the same rule the whole secret layer runs on, and this stream is not an exception to it.
Writing a reader
Both SDKs wrap the stream, reconnect with backoff and hand you typed events:
from scalebrowser import ScalebrowserClient
sb = ScalebrowserClient(base_url="http://127.0.0.1:8787", token="…")
for event in sb.iter_events():
if event.type == "profile_crashed":
print(event.profile_id, event.reason)Handle resync even if you think you will never fall behind: a laptop that sleeps is a slow reader.
Next
- REST API: the calls that produce most of these events.
- Errors: the reply side of the refusals above.
- Activity & runs: what the run events are built into.