Elyra
Elyra The coding agent e The native code editor Elyra Grove Native local development environment Askr The real server for Laravel & PHP Elyra Framework Rust + Svelte 5 framework for desktop apps Elyra Conductor Local project conductor Elyra SQL Server MySQL-compatible SQL server in Rust Elyra Félagi Agents as teammates on one board Elyra SQL Client Native desktop SQL workbench Elyra SQL Anywhere Replication-ready SQL engine Elyra Sjá SEO & GEO workspace for macOS Elyra DataGrid Server-driven data grid for Laravel
Start here
Concepts
Elyra

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.