Broadcasting
Two transports, chosen for two different shapes of data.
| Transport | Carries | |
|---|---|---|
| State changes | Reverb (WebSocket) | Issues moving, runs changing status |
| Run output | Server-sent events | The line-by-line firehose from one agent run |
Run output is per-run and high-volume; putting it on a workspace-wide channel would send every subscriber every keystroke of every agent. State changes are low-volume and everyone needs them.
Channels
One private channel per workspace:
Broadcast::channel('workspace.{workspace}', fn (User $user, Workspace $workspace) =>
$user->belongsToWorkspace($workspace));
Everything the interface reacts to is workspace-scoped, and so is the authorisation.
One presence channel per whiteboard:
Broadcast::channel('whiteboard.{ulid}', Whiteboard::presenceFor(...));
Presence rather than private, because a surface needs to know who else is on it to draw their
cursors — the membership list comes with the subscription instead of costing a heartbeat. The
callback returns an identity rather than true, and that identity is what everybody else sees on
your cursor.
It is also the last gate on a private board. A colleague who cannot open one must not be able to subscribe to every change on it either: hearing a surface is reading it.
Events
IssueChanged
{ "issue_id": 42, "action": "created|updated|deleted", "status": "in_progress" }
Dispatched from IssueObserver, not from each caller. An issue changes from the board, from the
detail page, from a comment mention and from the daemon — hooking the model means none of those
can forget.
Only fields the interface can see trigger a broadcast: status, priority, type, title,
assignee, project_id, parent_id, position. Changing spent_minutes does not.
RunChanged
{ "task": "01kyt…", "issue_id": 42, "status": "running", "agent": "Freya" }
Dispatched when a task is created and whenever its status changes. Renewing a lease does not count as a change.
Both events are deliberately thin: they say what happened and to which record, and the listening component re-reads what it needs. Broadcasting whole models would freeze their shape forever.
WhiteboardChanged
{ "action": "created", "ulid": "01k…", "element": { "type": "note", "x": 320.5, "…": "…" } }
Broadcast as element, carrying the whole element rather than a signal to go and fetch it: a
client that has to ask what changed after being told something changed is doing two round trips
for one edit. Sent with ->toOthers() — the browser that made the change already drew it, and
echoing it back would overwrite whatever that hand has done since.
Delivery
Events implement ShouldBroadcast, so they are queued rather than sent inside the request that
caused them, and they are queued on broadcasts rather than default — a live update is
worthless three minutes behind a batch import.
Except WhiteboardChanged, which is ShouldBroadcastNow. Queueing is right for news; an inbox
item can arrive a second late. A whiteboard is not news, it is a shared object two people are
touching. A stopped worker would also produce the worst possible state there: cursors keep moving,
because those never reach the server at all, while nothing anybody does ever arrives. Half alive
is harder to diagnose than dead.
What is never broadcast
Whiteboard cursors. Sixty positions a second per person is not information worth a request, a queue job or a row — it is worth exactly as much as the moment it describes. They are client events, whispered between members of the presence channel, and the application never sees one.
That needs accept_client_events_from on the Reverb app, which is members by default.
Two consequences worth knowing:
A failed broadcast must not fail the write. With an inline driver an unreachable websocket
server threw straight through the observer and rolled back the transaction, so a stopped Reverb
meant no issue could be created at all. App\Support\Announce reports the failure and lets the
write stand: failing to tell someone is not the same as failing to do the thing.
No worker means no updates, silently. The dashboard reads the backlog and warns when nothing is draining it.
Listening
Livewire components subscribe with a placeholder resolved from a property — not a computed property, which Livewire does not interpolate:
public int $workspaceId = 0;
#[On('echo-private:workspace.{workspaceId},IssueChanged')]
public function issueChanged(): void
{
unset($this->board, $this->issues, $this->statusCounts);
}
Dropping the computed caches and re-reading is cheaper to reason about than patching local state and hoping it matches the database.
Run output
RunOutput carries a batch of lines on a private channel per run,
task.{ulid}:
{ "taskUlid": "01kyt…", "messages": [ { "seq": 12, "type": "tool_use", "tool": "Bash", "content": "…" } ] }
RunStatusChanged follows it on the same channel with the status, summary and
error — the detail only somebody looking at the pane needs. RunChanged still
goes to the workspace for boards and counters.
One channel per run, not the workspace's. A run is a firehose watched by whoever opened it. On a workspace channel every board would receive every line of every agent.
One broadcast per batch, not per line. The daemon already posts output in batches, so the coalescing is free. Re-splitting it would multiply the traffic for nothing.
Closing the gap a dropped socket leaves
Pusher's protocol has no replay, so anything sent while a client was
disconnected is gone. GET /app/tasks/{ulid}/output?after=N returns what a
viewer has not seen, and the pane calls it exactly twice: when it opens, and on
each reconnect. It is bounded at 500 messages, because a chatty agent should not
be able to make one request return a megabyte.
What this replaced
The pane used to be a server-sent event stream that polled the database every seven-tenths of a second and held a php-fpm worker for up to five minutes per viewer. Ten people watching runs was ten workers gone, and on a silent disconnect no bytes were written, so the worker stayed until its deadline.
The lesson generalises: a stream that polls is a queue you are paying for twice. The database was already being told when output arrived — by the daemon posting it.
Configuration
BROADCAST_CONNECTION=reverb
REVERB_APP_ID=…
REVERB_APP_KEY=…
REVERB_APP_SECRET=…
REVERB_HOST="localhost"
REVERB_PORT=8080
REVERB_SCHEME=http
VITE_REVERB_APP_KEY="${REVERB_APP_KEY}"
VITE_REVERB_HOST="${REVERB_HOST}"
VITE_REVERB_PORT="${REVERB_PORT}"
VITE_REVERB_SCHEME="${REVERB_SCHEME}"
Only the public key reaches the browser bundle; the secret never does.
The websocket is a different origin. connect-src 'self' in the content security policy blocks
it outright — the port differs, so the origin differs. SecurityHeaders derives the allowed
ws:// and wss:// origins from the Reverb configuration, and a test locks that behaviour.
Testing broadcasts
phpunit.xml sets BROADCAST_CONNECTION=null, so tests never reach a real server. Assert with
Event::fake.
A channel authorisation test against the null driver proves nothing. NullBroadcaster::auth()
is a no-op that answers 200 to anything. Worse, Broadcast::channel() registers against
whichever connection is current — so swapping the driver inside a test leaves the new broadcaster
with no channels at all. Tests that exercise authorisation swap the driver and re-require
routes/channels.php.