webstation-broker
REST API

Users and invites

Adding people to a running session with POST /api/session/join, and minting invite links.

Add a user to the session

curl -X POST http://localhost:3000/streaming/api/session/join \
  -H 'X-Broker-Secret: <shared secret>' \
  -H 'Content-Type: application/json' \
  -d '{
    "user": { "id": 2, "username": "player2", "display_name": "Player Two" },
    "permission": "participant"
  }'

Returns {"status": "joined", "url": "/streaming/?token=<personal token>", ...}, 409 when no session is up, 422 for a permission other than participant or readonly, and 429 when the room already holds BROKER_MAX_ROOM_VIEWERS people. permission: "readonly" for spectators. Re-joining the same user (by id, else username) replaces their old token.

Every user gets a personal token. The broker pushes the full token map, {token: {role, slot, mk_control}}, to selkies, which enforces input routing per streaming connection: a participant can be handed a gamepad slot or the mouse and keyboard from the room UI by dragging the icons onto them; a spectator never gets input. Removing a user from the room revokes the token and selkies drops that connection live.

curl -X POST 'http://localhost:3000/streaming/api/session/invite?token=<controller token>' \
  -H 'Content-Type: application/json' \
  -d '{ "permission": "participant" }'

This route is gated by the controller token instead of the broker secret, since the room UI (which never holds the broker secret) is what actually calls it. It answers 403 without a valid controller token, and 422 for a permission other than participant or readonly.

Returns the session's shareable link for that permission (?invite=... rather than ?token=...). It is the same link on every call, seats nobody by itself, and can go to any number of people: each person who opens it is given their own seat by GET /api/session/context and joins under the name they pick in the room. The invite route ignores the session's multiplayer flag: a link always works. That flag only governs whether RomM advertises the session for joining and whether the room shows its comms surface (chat, webcam) while the host is alone.

Seats live for the whole session. Disconnecting (closing the tab, reloading, a network blip) releases any gamepad slot or mouse/keyboard the seat was holding but keeps the seat, and the room remembers its seat per tab, so a reload lands back in the same seat rather than taking another. Both kinds of link stop working when the session ends.

The room

The URL activate and join return is the collab room, a vanilla JS page that iframes the selkies stream and connects to the broker's websocket at <SUBFOLDER>/ws/room. It bootstraps from GET /api/session/context?token=..., which tells it who the token belongs to and what it may do. The roster of who's in the room isn't part of that response. It arrives later, over the websocket's state_update broadcast. Over the websocket the room carries chat, webcam and microphone fanout, resolution changes, and gamepad and mouse/keyboard reassignment. The wire format is documented on the room module in the Python reference.

Emulator menu

curl -k -X POST 'https://localhost:3001/streaming/api/session/menu?token=<controller token>'

Opens or closes the running emulator's own menu (RetroArch's Quick Menu), for players with no keyboard or gamepad combo to do it themselves. Like the exit route it takes the controller token, or the broker secret in X-Broker-Secret. A participant or read-only token answers 403. The context response's emulator names the running emulator; the room only shows its button to the controller when it's retroarch.

Returns {"status": "ok"}. You get 400 if the emulator has no menu, 409 if there's no active session, the emulator isn't running, or another session action is in progress, and 502 if the emulator refused the toggle. A toggle is also refused while a save, a resume load or a disc swap is running, so it can't land in the middle of one.

On this page