Reusable checklist and rationale for building Elgato Stream Deck plugins. Written from the 2026-07-05 review of the VDO.Ninja plugin; each practice notes why it matters and, where relevant, where this repo applies it. Sizes and format rules below come from the official manifest schema (@elgato/schemas/streamdeck/plugins/manifest.json), not from memory.
| Slot | Manifest field | Size (@1x / @2x) | Format | Style rule |
|---|---|---|---|---|
| Plugin icon (preferences/marketplace) | Icon (root) |
256×256 / 512×512 | PNG only | Full-color allowed |
| Category icon (action list header) | CategoryIcon |
28×28 / 56×56 | PNG or SVG | Monochrome #FFFFFF on transparent |
| Action list icon | Actions[].Icon |
20×20 / 40×40 | PNG or SVG | Monochrome #FFFFFF on transparent |
| Key state image | States[].Image |
72×72 / 144×144 | PNG, SVG, or GIF | Full-color; this is what shows on the key |
| Multi-action image | States[].MultiActionImage |
72×72 / 144×144 | PNG or SVG | Full-color |
| Encoder (dial) icon | Encoder.Icon |
72×72 / 144×144 | PNG or SVG | Shown in circular dial canvas |
| Touchscreen background | Encoder.background |
200×100 / 400×200 | PNG or SVG | Behind the touch layout |
Why it matters: a dark full-color key tile used as an action-list icon renders as a muddy square on the dark action-list background and fails marketplace review. The list wants small white glyphs; the key wants a rich tile. They are different assets with different jobs.
Practices:
Actions[].Icon, States[].Image, and Encoder.Icon at the same extensionless path; you can’t style one role without breaking another. This repo uses imgs/actions/*.svg for list glyphs and keeps imgs/*.png tiles for key states.validate accepts both..sdPlugin folder because the copy script filtered to *.png only. After any asset change, list the built .sdPlugin/imgs/ folder and confirm.Description: mention every controller type you support (“keys and dials”), and the one-line value proposition — this is marketplace copy.URL: link to the product website. SupportURL exists too but only from Stream Deck 6.9 — check your Software.MinimumVersion before using it.Tooltip per action: one sentence, task-oriented; it’s the only inline help users see while browsing actions.UserTitleEnabled: false on any action whose title the plugin renders via setTitle. Otherwise users type a title into the Stream Deck field and your plugin silently overwrites it — a confusing fight the user always loses. Offer a title/template setting in the property inspector instead (this plugin uses {slot}, {label}, {state} tokens).DisableAutomaticStates: true for feedback-driven keys. Without it, Stream Deck flips the state image on every press regardless of what actually happened; you want state to reflect reality reported by the controlled app.Controllers accurate per action (Keypad vs Encoder), with TriggerDescription filled in for encoders — it’s what the UI shows for rotate/push/touch.npx @elgato/cli validate <sdPlugin> and pack --dry-run are fast and catch manifest/layout errors without hardware.The property inspector is the only onboarding surface a Stream Deck plugin has. Two competing needs:
Resolution used here and recommended generally:
“Connected to relay” and “the app you want to control answered” are different states, and users only care about the second. Enumerate explicit states with plain-language messages — e.g. missing key / connecting / waiting for page / connected / timeout / error — and phrase the waiting state as an instruction (“Open your generated link and keep that page open”), not a protocol report.
Use color + text together (a red dot alone is not accessible); keep <label for=...> on every field.
If setup involves the user constructing a URL or config string, build it for them: page-type dropdown, the two or three fields that matter, generated output with Copy / Open / QR buttons. Generate credentials (API keys) with one click using crypto.getRandomValues. Every manual editing step is a support ticket.
Group buttons by what they operate on: key actions (Generate / Copy / Show / Test) next to the key field, URL actions (Copy / Open / QR) next to the generated URL. A misplaced button (Show Key living in the URL row) reads as belonging to the wrong object.
Elgato’s sdpi-components web components give the property inspector the native Stream Deck look (correct fonts, spacing, dark theme) for free and handle the settings plumbing. Hand-rolled CSS is acceptable (this plugin’s is close), but budget for matching future Stream Deck theme changes yourself.
The Stream Deck runtime does not resurrect your plugin gracefully mid-session; an uncaught exception kills every key. Real example from this plugin’s logs: ws.close() on a socket still in CONNECTING state throws, and a global-settings change mid-connect crashed the whole process. Rules:
terminate() a CONNECTING socket, close() an OPEN one, wrap in try/catch, and swallow the error event of a discarded socket after removeAllListeners()..sdPlugin/logs/ directory after every manual test session — crashes you never saw in the UI are recorded there.DisableAutomaticStates: true, set state explicitly from reported app state, never from “I sent the command”.setTitle that isn’t part of the normal render path and ask “what clears this?”.Two-press confirmation (arm on first press, execute within a window) is a good pattern for destructive commands (hangup, transfer-all). Requirements: visible armed feedback, a short expiry (~2s), automatic visual reset on expiry, and a per-action settings toggle so power users can disable it.
TriggerDescription.Send explicit on/off for key-down/key-up rather than toggle twice, and use per-key sequence counters so a stale async completion can’t repaint the key after a newer release event.
Runnable without hardware, in order:
npm test # pure-logic units: payload builders, parsers, state normalization
npm run check # tsc --noEmit
npm run build # emit + copy into .sdPlugin
npx @elgato/cli validate <sdPlugin> --no-update-check
npx @elgato/cli pack <sdPlugin> --dry-run --no-update-check
command-registry / settings / session-store split)..sdPlugin folder: stale build output is a classic source of “fixed it but the bug persists” (line numbers in crash logs here pointed at an older build than src).URL set; SupportURL if MinimumVersion ≥ 6.9.x.y.z.build).no-page vs disconnected vs timeout — most plugins conflate these.