---
title: "Events"
description: "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."
canonical: "https://scalebrowser.net/docs/events"
---

> ## Documentation Index
> Fetch the complete documentation index at: https://scalebrowser.net/llms.txt
> Use this file to discover all available pages before exploring further.

# 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.

```bash
$ 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.

<Note>

**`/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](/docs/cdp) is the page for it.

</Note>

## Every frame is numbered
Each message is one JSON object with a `type`, an `at` (Unix seconds) and a `seq`.

```json
{ "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:

```json
{ "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](/docs/proxies) 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".

<Warning>

**A secret event never carries a value.** It carries the row: label, kind, origin, state. That is the same rule the whole [secret layer](/docs/secrets) runs on, and this stream is not an exception to it.

</Warning>

## Writing a reader
Both SDKs wrap the stream, reconnect with backoff and hand you typed events:

<CodeGroup>

```python Python
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)
```

```ts Node
import { ScalebrowserClient } from '@scalebrowser/sdk';

const sb = new ScalebrowserClient({
  baseUrl: 'http://127.0.0.1:8787',
  token: process.env.SCALEBROWSER_TOKEN,
});

for await (const event of sb.events()) {
  if (event.type === 'profile_crashed') console.log(event.profile_id, event.reason);
}
```

</CodeGroup>

Handle `resync` even if you think you will never fall behind: a laptop that sleeps is a slow reader.

## Next
- [REST API](/docs/rest): the calls that produce most of these events.
- [Errors](/docs/errors): the reply side of the refusals above.
- [Activity & runs](/docs/agents/runs): what the run events are built into.
