VR Deck · for plugin developers
VR Deck live pictures: a guide for plugin developers
VR Deck shows a Stream Deck owner's keys inside a SteamVR headset, lined up on the real deck. Keys of other plugins appear with the picture and title the Stream Deck app has on file for them, because plugins cannot see each other's pictures. If your plugin changes a key's picture or title while it runs (a clock, a counter, a meter, a status light, now-playing art), you can hand VR Deck that picture too, and the headset shows it live. It takes a few lines of code, and it changes nothing for users who do not run VR Deck.
VR Deck is coming to the Elgato Marketplace; it is not listed yet. Questions about this guide: contact.
- The request
- How long a live picture stays
- Example (Stream Deck SDK, Node.js)
- What VR Deck does with it
The request
POST http://127.0.0.1:28788/v1/key
Content-Type: application/json
{"plugin": "com.example.myplugin", "device": "<action.device.id>", "column": 2, "row": 1,
"image": "data:image/png;base64,iVBORw0KGgo...", "title": "12:34"}
| field | what to send |
|---|---|
plugin | your plugin's UUID (the UUID in your manifest). VR Deck draws only on keys the Stream Deck profile records as your plugin's actions |
device | the device id the SDK gives your action: action.device.id |
column, row | the key's coordinates: action.coordinates. Keypad keys only (action.controllerType === "Keypad"): dials and the touch strip have coordinates too but are not drawn, and an action inside a Multi Action has none, so send nothing for them |
image | optional: the picture as a PNG or JPEG data URL, base64 (padded, no line breaks), at most 288 x 288 pixels and 262,144 bytes (256 KiB) decoded; a key picture is 72 to 144 px. null puts the stored picture back. SVG is not drawn yet |
title | optional: up to 64 characters, \n for a line break; "" draws no title (as setTitle("") does; a key with no picture at all keeps its stored look); null puts the stored title back. Not drawn on a key whose title the user switched off |
Send image, title or both. A field you leave out keeps its last live value (also while a request is held: a picture and a title sent as two requests are drawn together).
Every answer is JSON with "api": 1:
| status | meaning |
|---|---|
200 {"ok": true, "shown": true} | drawn in the headset |
202 {"ok": true, "shown": false, "pending": true, "reason"} | not drawn now: the key is not one of your plugin's actions on the page VR Deck shows, the deck is not drawn (yet), or one of VR Deck's own keys sits there. VR Deck holds the request for 2 s and draws it if the page catches up (it sees a page switch a moment after your key appears); after that the request is dropped, and VR Deck's Setup inspector shows why. So a 202 can end in a refusal: a typo in your plugin UUID shows up this way |
| 400 | the body is not valid JSON, a field is missing or malformed, the title is over 64 characters, or the image's base64 is malformed; reason says which |
| 403 | a request from a web page (it carries an Origin header: send from your plugin's own code, not from a property inspector or an HTML-based plugin page), a Host other than 127.0.0.1:28788 or localhost:28788, or VR Deck's own UUID |
| 404 | a path other than /v1/key and /v1 |
| 405 | /v1/key with a method other than POST, or /v1 with one other than GET (Allow says which) |
| 413 | a picture over 288 x 288 pixels or 262,144 bytes, or a request body over 360,000 bytes |
| 415 | not Content-Type: application/json, or a picture VR Deck cannot draw: not a data: URL, SVG, GIF or any type but PNG and JPEG, no ;base64, or bytes that do not start like the type named |
| 429 | more than 120 requests a second from your plugin (all keys together, refused ones included), or more than 600 a second to the endpoint from all senders together (Retry-After: 1) |
no connection, or an answer without "api": 1 | VR Deck is not running or has just started, its user switched live pictures (or the whole-deck mirror) off or moved them with livePicturesPort, or another program holds port 28788 (VR Deck's Setup inspector then says it is not listening) |
GET http://127.0.0.1:28788/v1 tells what the endpoint accepts: {"ok": true, "api": 1, "app": "VR Deck", "version", "key": "POST /v1/key", "formats", "maxImageBytes", "maxImageSide", "maxTitleChars", "maxRequestsPerSecond", "pendingMs"}.
How long a live picture stays
Until you send another one, or null; until the key leaves the page VR Deck shows (a page or profile switch); until another action takes the key's place; until the deck is unplugged; until the user switches live pictures or the whole-deck mirror off; or until VR Deck restarts. So send the current picture whenever your key appears (most plugins already set it in their willAppear handler). For a key whose picture rarely changes, send it again every 10 s or so while it is visible, so a VR Deck started after your plugin catches up; VR Deck skips a picture it already shows. VR Deck redraws a key at most every 200 ms: sending faster only replaces the picture that is waiting. A picture that arrives while the old page is still shown, and lands on your plugin's key at that spot of the old page, moves to your key on the new page when the switch reaches VR Deck (within 2 s).
Example (Stream Deck SDK, Node.js)
// VR Deck live pictures (optional): shows a key's live picture / title in the VR Deck headset overlay.
// Does nothing when VR Deck is not running; after a failed connection it waits 30 s before the next try (an answer
// such as 202 or 429 is not a failure).
const VR_DECK = "http://127.0.0.1:28788/v1/key";
const PLUGIN_UUID = "com.example.myplugin"; // your manifest's UUID
let vrDeckQuietUntil = 0;
export function toVrDeck(
action: { controllerType: string; device: { id: string }; coordinates?: { column: number; row: number } | undefined },
picture: { image?: string | null; title?: string | null }
): void {
const c = action.coordinates;
if (action.controllerType !== "Keypad" || !c || Date.now() < vrDeckQuietUntil) return;
fetch(VR_DECK, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ plugin: PLUGIN_UUID, device: action.device.id, column: c.column, row: c.row, ...picture })
}).catch(() => {
vrDeckQuietUntil = Date.now() + 30_000;
});
}
Next to the calls you already make:
await ev.action.setImage(png);
toVrDeck(ev.action, { image: png }); // png = "data:image/png;base64,..."
await ev.action.setTitle(text);
toVrDeck(ev.action, { title: text });
fetch is built into Node.js 18 and later (the Stream Deck app's Node.js 20 included). Any language works: it is one JSON POST to 127.0.0.1. A quick test from PowerShell, with a device id and key of your own plugin:
Invoke-RestMethod -Method Post -Uri http://127.0.0.1:28788/v1/key -ContentType application/json -Body '{"plugin":"com.example.myplugin","device":"<device id>","column":0,"row":0,"title":"hi"}'
What VR Deck does with it
- It listens on 127.0.0.1 only, so nothing outside the PC can reach it, and it takes pictures and titles, never commands: nothing it receives presses, types or moves anything.
- It draws on a key only when the Stream Deck profile records that key's action as belonging to the plugin named in the request, and only while the key is on the page VR Deck shows. The keys on the deck itself are not touched.
- Requests from web pages are refused (the
OriginandHostchecks), so a website open in a browser cannot draw on your keys. - Pictures and titles are kept in memory only and go to VR Deck's own companion on the same PC, which draws the headset overlay. The plugin log notes which plugins send (plugin UUID, the start of the deck id, the key) and why a request was refused, at most 30 lines a minute, never the pictures or titles.
- Any program on the PC can name any plugin's UUID. The most it can do is change how mirrored keys look in the headset (picture and title only), or crowd out other plugins' live pictures by flooding the endpoint; it cannot press, type or change anything on the deck.
- Your requests go to whatever program listens on 127.0.0.1:28788: normally VR Deck (
GET /v1says so), but a program that took the port first would get them. Send nothing you would not show on the key itself.
Users can switch it off with "livePictures": false in %LOCALAPPDATA%\VRDeck\settings.json, or move it to another port with "livePicturesPort" (1024 to 65535; your plugin then finds nothing at 28788, which costs nothing with the snippet above). Changes take effect within about 10 s.