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

# Realtime events

> The channel, authorization, discovery, envelope, and full catalogue of the events the Gateway broadcasts through Reverb, and the per-Node channels that Node agents publish on.

# Realtime events

The Gateway broadcasts an event for every record it changes, so a client such as the [web app](/docs/reference/web-app) stays current without polling. HTTP stays the only way to act. An event only tells a client that something it fetched over HTTP has changed.

## Transport

The Gateway's HTTPS broadcasts to Reverb trust only `ORBIT_HOME/ca/root.pem`, with the system CA directory disabled. They use the resolved Node address while retaining the Reverb hostname for TLS verification. The old Node during a role move keeps its shorter client timeouts.

Reverb broadcasts share one empty private CA directory per PHP process. The Gateway creates it when that process first configures broadcasting and removes it at normal process shutdown. Reconfiguring a connection does not create another directory.

The Gateway sends events to Laravel Reverb at `wss://reverb.orbit`. Reverb runs on the Node that holds the `websocket` role. [Realtime events with Reverb](/docs/solutions/realtime-reverb) sets it up. Without an active `websocket` role, the Gateway broadcasts nothing.

A client connects to Reverb directly, not to the Gateway. The Gateway only publishes, as a Pusher-protocol client.

The Gateway broadcasts inside the request that changed the record. It uses no queue. A broadcast waits at most 2 seconds to connect and 5 seconds in total. A failed broadcast never fails the request: the Gateway logs the warning `Failed to broadcast a record event.` and continues. So an API response proves that the change happened, not that its event was sent.

The Gateway looks up the connection at most once per request, and only when the request broadcasts or asks for the realtime connection. It connects to the `websocket` Node's WireGuard address and verifies the name `reverb.orbit` against the Orbit root CA.

During a `websocket` move, clients that connected before the move still listen on the old Node's Reverb. The Gateway then sends each event to both servers. The old server gets 0.3 seconds to connect and 0.5 seconds in total. After a failed send, the Gateway skips the old server for 30 seconds.

## Channel and authorization

Every Gateway event goes out on one private channel, `orbit`. A client signs its subscription through `POST /api/v1/broadcasting/auth`. The endpoint needs Gateway access: the caller is the Gateway Node or holds an access grant to it. It identifies the caller by WireGuard address, as the rest of the API does.

| Code | HTTP | Meaning |
| - | - | - |
| `peer.identity_unknown` | 403 | The caller's address does not belong to an active Node. |
| `node_access.required` | 403 | The caller has no access grant to the Gateway. |
| `realtime.not_configured` | 404 | No `websocket` role is active. |
| `broadcast.channel_forbidden` | 403 | The channel is a `presence-*` channel other than `presence-node.{id}`, a malformed `presence-node.*` name, or a `private-log-stream.*` channel. |

The same endpoint signs a viewer membership on `presence-node.{id}`, as [Node agent channels](#node-agent-channels) describes.

## Discovery

A client asks the Gateway where Reverb is. `GET /api/v1/realtime` needs Gateway access and returns:

```json theme={"dark"}
{
  "data": { "url": "wss://reverb.orbit", "key": "<reverb-app-key>", "channel": "orbit" },
  "meta": { "request_id": "..." }
}
```

`url` and `key` are null when no `websocket` role is active. `channel` is always `orbit`. The key is public. The Reverb app secret never leaves the Gateway.

## Envelope

Every event has the same shape. The broadcast name equals `type`.

| Field | Contract |
| - | - |
| `type` | `<family>.<verb>`, such as `node.created`. |
| `id` | The record's id. An annotation uses its string ID, and a deploy step uses its name. |
| `at` | ISO 8601 time of the broadcast, in the Gateway's timezone. |
| `data` | The record as its list or show endpoint returns it, unless the catalogue names other fields. |

```json theme={"dark"}
{
  "type": "process.status",
  "id": 42,
  "at": "2026-09-18T10:15:00+00:00",
  "data": { "id": 42, "name": "web", "status": "active", "runtime_status": "running", "...": "..." }
}
```

A `deleted` event carries a short snapshot, because the record is already gone. Task, Activity, annotation, and Process usage events are notices: they carry only identifiers, status, and counts. A client fetches the record over HTTP when it needs more.

## Event catalogue

### Node

| Type | Fires when | Data |
| - | - | - |
| `node.created` | A Node is provisioned for the first time. | `NodeData` |
| `node.updated` | A Node is provisioned again, renamed, retargeted, or its settings or roles change, including a role relocate. It also fires when the Node's [`updating`](/docs/reference/gateway-recovery#nodes-being-updated) value changes: a fleet rollout visit starts or ends on it, or a Gateway release starts running or ends on the Gateway Node. | `NodeData` |
| `node.deleted` | A Node is removed. | `{ id, name }` |

### Project

| Type | Fires when | Data |
| - | - | - |
| `project.created` | A Project is created. | `ProjectData` |
| `project.updated` | A Project changes. | `ProjectData` |
| `project.deleted` | A Project is removed. | `{ id, name, slug }` |

### Instance

| Type | Fires when | Data |
| - | - | - |
| `instance.created` | An Instance is created or registered. | `InstanceData` |
| `instance.updated` | An Instance's deployment branch changes, or its removal returns while the record still exists. | `InstanceData` |
| `instance.deleted` | An Instance is removed. | `{ id, name }` |

### Process

| Type | Fires when | Data |
| - | - | - |
| `process.created` | A Process is added. | `ProcessData` |
| `process.status` | A Process is started, stopped, or restarted. | `ProcessData` |
| `process.deleted` | A Process is removed. | `{ id, name }` |

`process.status` carries the Process's stored status and desired state, and the `runtime_status` that the Gateway read when the action ended. Later runtime changes come from the [Node agent channels](#node-agent-channels).

Other changes to an Instance, such as a transfer, a clone, an environment change, or a deployment, send no `instance.*` event. A deployment sends [`deployment.*`](#deployment).

### Process usage

| Type | Fires when | Data |
| - | - | - |
| `process.usage` | Every 15 seconds while a browser watches. | `{ part, parts, processes }` |

The agent view subscriber sends `process.usage` while at least one `viewer.*` member is on a Node agent channel. [Process usage](/docs/reference/node-agent#process-usage) describes when. `id` is the sample time in Unix seconds. `processes` lists `[id, cpu, memory_bytes]` for at most 200 Processes per part, with the units of the Process list. A Process without a sample has null values.

### Schedule

| Type | Fires when | Data |
| - | - | - |
| `schedule.created` | A Schedule is added. | `ScheduleData` |
| `schedule.updated` | A Schedule is activated, records a run, or its removal starts. | `ScheduleData` |
| `schedule.deleted` | A Schedule is removed. | `{ id, name }` |

### Database connection

| Type | Fires when | Data |
| - | - | - |
| `database.created` | A database connection is registered. | `DatabaseConnectionData` |
| `database.updated` | A database connection changes. | `DatabaseConnectionData` |
| `database.deleted` | A database connection is removed. | `{ id, slug }` |

### Firewall rule

| Type | Fires when | Data |
| - | - | - |
| `firewall.created` | A firewall rule is stored. | `FirewallRuleData` |
| `firewall.deleted` | A firewall rule is removed. | `{ id, name }` |

A stored rule never changes, so there is no `firewall.updated`.

### Route

| Type | Fires when | Data |
| - | - | - |
| `route.created` | A Route is created through the routes API, or analytics creates a tracking Route. | `RouteData` |
| `route.updated` | A Route's domain, publication, or target changes. | `RouteData` |
| `route.deleted` | A Route is removed. | `{ id, domain }` |

A Route that Orbit creates or replaces as part of another action sends no `route.created`. That covers the Route of a new or cloned Instance and the replacement Routes of a transfer, a target change, or a domain change.

### Deploy step

| Type | Fires when | Data |
| - | - | - |
| `deploy_step.created` | A deploy step is added. | `DeploymentStepData` |
| `deploy_step.updated` | A deploy step changes. | `DeploymentStepData` |
| `deploy_step.deleted` | A deploy step is removed. | `DeploymentStepData` of the removed step |

`id` is the step's name. `data` matches the deploy step endpoints, so it has no Instance id.

### Deployment

| Type | Fires when | Data |
| - | - | - |
| `deployment.created` | A deploy or rollback starts. | `InstanceDeploymentData` |
| `deployment.updated` | That run ends, with success or failure. | `InstanceDeploymentData` |

`data` matches one row of `GET /api/v1/instances/{instance}/deployments`. It never carries the run's output. The Gateway keeps the 50 newest rows per Instance and removes older rows without an event.

### Annotation

| Type | Fires when | Data |
| - | - | - |
| `annotation.updated` | An annotation change commits. | `{ id, instanceId, revision }` |

A client fetches the Instance's annotation list for the current state.

### Tasks

| Type | Fires when | Data |
| - | - | - |
| `task_group.created` | A task is created. | `{ id, status }` |
| `task_group.updated` | A task, one of its subtasks, or one of its checks changes, or the workspace has a new diff. | `{ id, status, lines_added, lines_deleted, line_diff }` |
| `task_comment.created` | A comment or turn receipt is stored. | `{ id, task_group_id, task_id }` |
| `agent_thread.updated` | An agent thread is created, or its state, error, or observation error changes. | `{ id, task_group_id, task_id, state }` |
| `tasks.updated` | The Tasks extension is enabled or disabled. | `{ enabled }` |

`id` is the task, comment, or thread id, and `0` for `tasks.updated`. `task_group_id` is the top-level task id. A thread of the top-level task has a null `task_id`. A subtask's implementer and reviewer threads carry that subtask's id in `task_id`. A thread row that is still reserved, with an external id that starts with `pending:`, sends no event.

In a request or a scheduler tick, a change only to tokens, line counts, or duration sends no event, because showing a task refreshes those values. A publish run for a new workspace report always sends `task_group.updated` for the tasks of that workspace. The Gateway collects the task changes of one request, one scheduler tick, or one publish run, and sends each record once when that work ends.

### Activity

| Type | Fires when | Data |
| - | - | - |
| `activity.created` | An Activity row is stored. | `{ id, request_id, command, status, caller_node_id, target_node_id, error_code, duration_ms, occurred_at }` |
| `activity.updated` | A `running` row records its outcome. | The same fields |

A change request fires `activity.created` when its `running` row is stored and `activity.updated` when the outcome is stored. A read is stored once, when it ends, so it fires only `activity.created`. A read that stores no row fires nothing. [What the Gateway records](/docs/cli/activity#what-the-gateway-records) lists which requests store a row. The interrupted-Activity sweep and the shutdown finalizer fire `activity.updated` after they save the row.

A row whose command starts with `activity:` fires nothing, so reading the log never feeds it. A write that changes only `properties` fires nothing. `occurred_at` is the row's `created_at`: the time the request started, also for a read that is written when it ends. Null columns stay null.

The Gateway never deletes an Activity row, so there is no `activity.deleted`. Removing a Node sets its id in `caller_node_id` and `target_node_id` to null.

## When no event fires

An event fires only after its action succeeds. Two writes also fire after a failure: a deployment records its outcome, and a failed request stores its Activity. Failure details that an action writes before it throws, such as `failed_step`, fire nothing. The HTTP response reports that failure.

A create call that finds the same record already stored, such as an identical Process, Schedule, or firewall rule, changes nothing and fires nothing.

No event reports a change of a Node's or an Instance's runtime state. A Node's status changes only through actions that already fire `node.*`. A Node's `updating` value is the one exception, as the `node.updated` row says. Hibernation keeps an Instance's awake state outside the Instance and Process records.

Creating, replacing, or deleting a task definition fires nothing. The [web app](/docs/reference/web-app#polling) loads definition lists and the definition view when those pages open.

## Node agent channels

Each Node has a presence channel, `presence-node.{id}`. Its [Node agent](/docs/reference/node-agent) publishes presence and Process state there. The Gateway broadcasts nothing on these channels. Its agent view subscriber joins every channel and keeps the latest state as the [Gateway view](/docs/reference/node-agent#gateway-view).

### Membership

| Member ID | Who | Signed by |
| - | - | - |
| `agent.{id}` | The agent of Node `{id}` | `POST /api/v1/agent/broadcasting/auth`, only for Node `{id}`'s own address and [agent secret](/docs/reference/node-agent#agent-secret) |
| `gateway.{socket id}` | The Gateway's agent view subscriber | The subscriber itself, with the Reverb app secret |
| `viewer.{socket id}` | Any other subscriber, such as a browser | `POST /api/v1/broadcasting/auth` |

`user_info` is `{ "kind": "agent", "node_id": 12, "version": "0.3.0" }` for the agent, `{ "kind": "gateway" }` for the subscriber, and `{ "kind": "viewer", "node_id": 7 }` for a viewer. A viewer's `node_id` is the Node it connects from. `POST /api/v1/broadcasting/auth` never signs an `agent.*` or `gateway.*` member.

Reverb announces a member when its first connection joins (`pusher:member_added`) and when its last connection leaves (`pusher:member_removed`). A stopped or crashed agent closes its connection, so it leaves at once.

### Events

The agent publishes Pusher client events. The Reverb app accepts client events from channel members (`accept_client_events_from` is `members`). Reverb adds the sender's `user_id` to each one. A subscriber ignores an event on `presence-node.{id}` unless its `user_id` is `agent.{id}`.

| Event | When | Data |
| - | - | - |
| `client-heartbeat` | Every 5 seconds. | `{ sequence, at }` |
| `client-snapshot` | After the agent joins, when a member joins, after the agent reconnects to Docker, and at least every 60 seconds. | `{ sequence, at, docker, part, parts, units }` |
| `client-process` | When a unit or container changes, after 250 milliseconds of merging. | `{ sequence, at, unit }` |
| `client-workspaces` | After each snapshot, when the watch list changes, and when a checkout it read before fails to open. | `{ sequence, at, part, parts, workspaces }` |
| `client-workspace` | When the agent reads a new state for one task checkout. | `{ sequence, at, workspace }` |

| Field | Contract |
| - | - |
| `sequence` | A counter that grows with every event of one agent run and restarts at 1 when the agent restarts. When it goes back without a member event, a subscriber drops its state and waits for a new snapshot. |
| `at` | ISO 8601 UTC time at which the agent sent the event. |
| `docker` | `available` or `absent`. |
| `part`, `parts` | The part number, from 1, and the number of parts. Each message stays under Reverb's 10,000-byte limit. Parts arrive in order, and a subscriber applies the list once it has every part. |
| `units` | Every `orbit-process-*` unit and container the agent sees. |
| `unit` | `{ name, runtime, runtime_status }`, such as `{ "name": "orbit-process-42-web", "runtime": "docker", "runtime_status": "exited" }`. `name` has no `.service` suffix. `runtime` is `systemd` or `docker`. |
| `workspaces` | Every watched task checkout the agent could read. |
| `workspace` | `{ instance_id, base, start, branch, head, dirty, commits, diff }`. [Task workspaces](/docs/reference/node-agent#task-workspaces) defines each field. |

A complete snapshot replaces what a subscriber knows about the Node's units. A Process whose unit is missing reads as `inactive` for systemd and `exited` for Docker. A complete `client-workspaces` list replaces the Node's workspaces in the same way.

A subscriber applies a `unit` to a Process only when the Process runs on Node `{id}`, uses the same runtime, and `name` equals `orbit-process-{process id}-{process name}`.

### Presence

A subscriber treats the agent as online while `agent.{id}` is a member and an event arrived in the last 15 seconds. It treats the agent as lost when the member leaves or 15 seconds pass without an event. Before it sees the agent, a subscriber knows nothing about the Node from this channel and uses another source, such as Prometheus.

## Log channels

[Live logs](/docs/reference/live-logs) use two more kinds of channel. No log line goes out on `orbit` or `presence-node.{id}`.

| Channel | Members | Signed by |
| - | - | - |
| `presence-node-logs.{id}` | `agent.{id}` and the subscriber's `gateway.{socket id}` | `POST /api/v1/agent/broadcasting/auth` for the agent; the subscriber signs itself |
| `private-log-stream.{stream}` | The one client that opened the stream | The response that opens the stream, for that client's `socket_id` only |

The agent publishes `client-log` and `client-log-end` on `presence-node-logs.{id}`. The Gateway publishes `log-streams.changed`, with empty data, when a stream of the Node opens or closes. The agent then fetches its [stream list](/docs/reference/node-agent#stream-list).

The Gateway publishes `log.lines` and `log.ended` on `private-log-stream.{stream}`, in the envelope above, with the stream ID as `id`. [Live logs](/docs/reference/live-logs#receive-events) defines their data.

## Why it works this way

These reasons explain the design. Check them before you propose a change.

### Reverb as a role, not inside the Gateway

A WebSocket server holds long-lived connections. The Gateway process serves every API request, so it must not hold them too. Reverb is a purpose-built server that fans events out to every client. It runs as an unmodified Laravel app, so it carries no Orbit code. The `websocket` role installs it with one command. Running Reverb as an Instance behind a custom proxy Route takes six manual steps, and that Instance cannot share the Gateway's Node, because `gateway` conflicts with `app-prod`.

### Broadcast inside the request, without a queue

The Gateway runs every action inside its own request and has no queue worker. A queued event could also arrive after a second change to the same record and look newer than it is.

### Events do not replace HTTP

A client acts only through the API, which checks identity, access grants, and validation. An event only invalidates what a client fetched. So a lost event costs a refetch, never a wrong action.

### Notices instead of full records

Reverb refuses a message over 10,000 bytes. A task with its subtasks can pass that limit. Activity `properties` hold operation input, which should not reach every subscriber. So these events carry identifiers, status, and counts, and a client refetches the record. A change only to tokens or duration sends nothing, because every show would then send an event and make every tab show the task again.

### One channel for all Gateway events

Every subscriber of `orbit` has Gateway access and can already read every record over HTTP. A channel per family or per Node would add authorization rules that protect nothing.

### A presence channel per Node for agents

Whoever holds the Reverb app secret can sign any membership and publish any event, so the secret stays on the Gateway. The Gateway signs `agent.{id}` only for Node `{id}`, and Reverb stamps each client event with the sender's member ID. So a Node can report false state only about itself. A client event on a private channel carries no sender, so any member could forge another Node's state. A Reverb app per Node would force a browser to open one connection per Node and restart Reverb on every `node:add`. Agent messages never pass through the Gateway, so a heartbeat costs no Gateway request.

### A heartbeat timeout detects lost agents

Reverb checks for dead connections on a fixed timer, and a vanished connection stays a member for 120 to 180 seconds. Changing that timer would make Reverb Orbit-specific. A 15-second heartbeat timeout detects a lost Node without a change to Reverb.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.