The wire protocol
The contract the PHP side and the browser display server share. PHP declares a widget tree, the serialiser turns it into JSON, the display server renders it, interactions travel back up over HTTP, and rebuilt trees come back down over a websocket. This page is the reference for everything that crosses that boundary.
The node envelope
Every node in a serialised tree is the same four keys:
{ "type": "string", "id": "string | null", "props": { }, "children": [ ] }typeis the widget kind, the string both registries pair on. An unknown type doesn't throw; it renders a placeholder element and warns in the console, so a version mismatch degrades instead of blanking the desktop.idis the app-assigned event-addressing handle, ornull. It lands on the element asdata-sx-id.propsis a map and always serialises as an object. No props encodes as{}, never[].childrenis an ordered array. No children is[].
Two fields ride inside props rather than on the envelope. props.key is the reconciliation identity, required on every list child and distinct from id: the morph matches list rows by key, so rows can reorder without being rebuilt. props.events is the per-widget allowlist of which DOM events round-trip.
Events go up over HTTP
A declared event posts to /system-x/event:
{ "widget": "clicker", "event": "click", "value": null, "app": "hello", "window": "hello" }widgetis theidof the node that fired.eventis the event name. The delegated dispatcher round-tripsclick,submitandchange. Overlay widgets (dialogs, menus) emitcloseandselectprogrammatically through the same POST, because those names don't exist as DOM events on the elements involved.valueis the live value an input echoes: a text field's string, a checkbox's boolean, a menu pick's item value. Absent for a click. It is client-controlled, which is why handlers must coerce and validate it rather than trusting it.appis the slug that selects which app class handles it.windowis the state and surface key: a ULID for windows launched at runtime, the slug for the seeded boot windows.
The client echoes no app state, only the event. The server hydrates the app's durable properties from its own store, runs the handler, saves, re-renders and broadcasts. The POST itself returns 204 No Content; the new tree does not come back in the response body.
Trees come down over the websocket
The rebuilt tree broadcasts on the user's private channel, user.{userId}, as a desktop.rendered event:
{ "app": "hello", "window": "hello", "tree": { "type": "window", "...": "..." } }tree is a full serialised tree, the whole thing, every frame. There is no server-side diff. The client reconciles: it morphs its live DOM toward the new tree, creating, updating and removing elements as needed, which is what keeps focus, caret and scroll intact across re-renders. The channel is authorised against the real authenticated user, so one user can never receive another's desktop.
The endpoints
GET /system-x/desktop?window={id}returns a window's current tree. It's both the boot fetch and the reconnect resync, so a dropped websocket recovers by refetching.POST /system-x/eventcarries widget events, as above.POST /system-x/wm/launch {app}andPOST /system-x/wm/close {window}manage the per-user open-window set. Launch is enforced server side: an uninstalled app 403s regardless of what the client asks for.POST /system-x/wm/geometrypersists a window's settled layout: position, size, whether the user has resized it, maximised, minimised, and stacking order.POST /system-x/launcher/layout {layout}saves the launcher arrangement as one document: a list of app entries and folder entries, where a folder has an id, a name and its apps. At boot the stored layout is reconciled against the live app set (unknown slugs dropped, new apps appended) before it reaches the client. The layout covers user apps only; system apps live in the system menu, never the grid.
What stays client-side and what settles to the wire
Window manager interactions are client-owned while they happen. A pointer move during a drag or resize never touches PHP, and the panel's window list is built client-side. But the settled outcome persists: when a drag, resize, maximise or minimise ends, the client fires the geometry endpoint, and a reload restores the layout.
The morph only ever touches content inside a window, never the window's position or size, so a server frame can re-render content mid-drag without fighting the window manager.
Transient overlay state, like an open menu popup, is client-only and never persists. A dialog's open flag, by contrast, is an ordinary durable app property, which is why a dialog that was open comes back open after a reload.