Stream Deck Plugin — System & Devices
Prerequisite:
streamdeck-general(runtime architecture and thesrc/plugin.tsentry point). Most handlers here register at the plugin scope, not inside an action class.
Purpose
The streamDeck.system and streamDeck.devices namespaces are the
plugin's window into the host OS and connected hardware. This skill
covers every event and command they expose:
streamDeck.system.openUrl(url)— open in user's default browserstreamDeck.system.onSystemDidWakeUp(...)— restore connections after sleepstreamDeck.system.onDidReceiveDeepLink(...)— receivestreamdeck://messagesstreamDeck.system.onApplicationDidLaunch/Terminate(...)— react to monitored appsstreamDeck.devices.onDeviceDidConnect/Change/Disconnect(...)— device lifecycle
For OAuth — which composes openUrl + onDidReceiveDeepLink + global
settings into a sign-in flow — load streamdeck-oauth instead, which
goes end-to-end.
1. streamDeck.system.openUrl
Opens a URL in the user's default browser.
import streamDeck from "@elgato/streamdeck";
await streamDeck.system.openUrl("https://elgato.com");
| Rule | Notes |
|---|---|
https:// and http:// only |
Custom schemes (e.g. my-app://) are NOT supported through this API. To deep-link into another app, the OS handles it — just don't expect openUrl("my-app://...") to work everywhere. |
| Opens in the default browser | Not configurable. |
| Async, fire-and-forget | The promise resolves when Stream Deck dispatched the open, not when the browser finished loading. |
Common uses:
- Send the user to your support page (
SupportURLalready does this on the actions list, but you may want a runtime "Help" button). - Launch an OAuth authorize URL — see
streamdeck-oauth. - Open the user's settings page on a SaaS dashboard.
2. streamDeck.system.onSystemDidWakeUp
Fires once when the host machine resumes from sleep/hibernate. Also,
onWillAppear fires for every visible action shortly after. Use
onSystemDidWakeUp for plugin-global restoration (re-open
WebSockets, re-acquire OAuth tokens) and onWillAppear for per-action
re-rendering.
streamDeck.system.onSystemDidWakeUp(() => {
streamDeck.logger.info("System woke up — reconnecting upstream");
reconnectWebSocket();
refreshAllVisible();
});
⚠️ This event fires only in the plugin runtime, not in the property inspector. Don't try to listen from a PI script.
3. Deep linking — streamdeck://plugins/message/<UUID>
Stream Deck registers a streamdeck:// URL scheme with the OS. Anyone
on the user's machine (browser, OS shortcut, another app) can fire a URL
of this shape and your plugin will receive it:
streamdeck://plugins/message/<PLUGIN_UUID>[/path][?query][#fragment]
Receiving
streamDeck.system.onDidReceiveDeepLink((ev) => {
const { path, fragment, searchParams } = ev.url; // URL instance
streamDeck.logger.info("deep-link", { path, fragment, search: ev.url.search });
});
The hostname/prefix (streamdeck://plugins/message/<UUID>) is stripped.
You see only the path/query/fragment.
Active vs passive
| Trait | Active (default) | Passive |
|---|---|---|
| URL form | streamdeck://plugins/message/<UUID>/anything |
…?streamdeck=hidden |
| Brings Stream Deck window to front | ✓ | ✗ |
| Stream Deck app version required | 6.5+ | 7.0+ |
| Recommended for | OAuth callbacks; any flow where the user needs to see Stream Deck next | Silent setup messages from a companion app (e.g. "here is the port my IPC server is listening on") |
streamdeck://plugins/message/com.x.y/oauth/callback?code=... ← active
streamdeck://plugins/message/com.x.y/ipc?port=1234&streamdeck=hidden ← passive
Limits and warnings
| Limit | Value |
|---|---|
| Max URL length | ~2,000 characters total. For larger payloads use a local WebSocket between the companion app and the plugin. |
| Remote access | Local-only. You can't deep-link a plugin from a remote machine. |
| Provider compatibility | Many OAuth providers (Google, Microsoft, Spotify) reject custom URI schemes as callback URLs — for those, route through the Elgato proxy. See streamdeck-oauth. |
⚠️ Treat deep-link input as untrusted. Any process on the machine can fire URLs at your plugin. Validate
path, validate any tokens (e.g. CSRFstate), and never blindly execute commands from a deep-link payload.
Testing deep links
# Windows: Win+R, paste, Enter
streamdeck://plugins/message/com.yourhandle.hello-world/test?x=1
# Or, on any OS: paste into a browser address bar
4. Application monitoring
Tells your plugin when a specific app launches or quits — useful to toggle action icons or auto-connect when, say, OBS opens.
Manifest (declare what to watch)
"ApplicationsToMonitor": {
"mac": ["com.obsproject.obs-studio", "com.spotify.client"],
"windows": ["obs64.exe", "Spotify.exe"]
}
How to find the identifier:
| OS | Identifier | How to find |
|---|---|---|
| Windows | exe name | Task Manager → Details tab → "Name" column |
| macOS | bundle id | mdls -name kMDItemCFBundleIdentifier /Applications/<App>.app |
Plugin handlers
streamDeck.system.onApplicationDidLaunch((ev) => {
streamDeck.logger.info("launched", ev.application);
if (ev.application === "Spotify.exe" || ev.application === "com.spotify.client") {
void streamDeck.actions.forEach((a) => void a.setTitle("Spotify ✓"));
}
});
streamDeck.system.onApplicationDidTerminate((ev) => {
streamDeck.logger.info("terminated", ev.application);
});
Only apps you've declared in ApplicationsToMonitor are reported. The
event fires once per launch / quit, not for already-running
processes when Stream Deck starts.
5. Devices
A "device" is one piece of Stream Deck hardware (or Mobile, or Virtual). A single user can have several connected at once; your plugin sees all of them.
Events
streamDeck.devices.onDeviceDidConnect((ev) => {
const { id, isConnected, name, size, type } = ev.device;
streamDeck.logger.info("connect", { id, name, type, size });
});
streamDeck.devices.onDeviceDidChange((ev) => { // Stream Deck app 7.0+
// E.g. user renamed the device, or the size changed (Mobile)
});
streamDeck.devices.onDeviceDidDisconnect((ev) => {
streamDeck.logger.info("disconnect", ev.device.id);
});
⚠️ A disconnected device's actions are still registered with Stream Deck (they re-appear when the user plugs it back in). Don't tear down per-action state on
disconnect— wait foronWillDisappearon the individual actions if you need per-instance cleanup.
ev.device.type integer table
| Value | Device |
|---|---|
| 0 | Stream Deck (original / MK.2) |
| 1 | Stream Deck Mini |
| 2 | Stream Deck XL |
| 3 | Stream Deck Mobile |
| 4 | Corsair GKeys |
| 5 | Stream Deck Pedal |
| 6 | Voyager |
| 7 | Stream Deck + |
| 8 | SCUF Stream Deck |
| 9 | Stream Deck Neo |
| 10 | Stream Deck Studio |
| 11 | Virtual Stream Deck |
| 12 | Galleon |
| 13 | Stream Deck Plus XL |
(Same table as streamdeck-manifest → Profile.DeviceType — the
manifest profile field uses the same integers.)
Inspecting all known devices
for (const device of streamDeck.devices) {
streamDeck.logger.debug(device.name, device.type, device.size);
}
Inside an action — access the owning device
override onKeyDown(ev: KeyDownEvent) {
const device = ev.action.device;
streamDeck.logger.info(`pressed on ${device.name} (type=${device.type})`);
}
6. Restoring after sleep / device unplug — the resilience pattern
A robust plugin survives the user closing the lid, switching docks, and hot-plugging hardware. Wire it like this:
import streamDeck from "@elgato/streamdeck";
let upstreamSocket: WebSocket | null = null;
function connectUpstream() {
upstreamSocket?.close();
upstreamSocket = new WebSocket("ws://localhost:8080");
upstreamSocket.addEventListener("open", () => refreshAllVisible());
upstreamSocket.addEventListener("close", () => setTimeout(connectUpstream, 1000));
}
streamDeck.system.onSystemDidWakeUp(() => connectUpstream());
streamDeck.devices.onDeviceDidConnect(() => refreshAllVisible());
function refreshAllVisible() {
streamDeck.actions.forEach((a) => void a.setTitle("ok"));
}
connectUpstream();
streamDeck.connect();
Rules:
- Reconnect from
onSystemDidWakeUp(sleep) AND on socketclose(transient drops). - Re-render on
onDeviceDidConnect(the device'sonWillAppearwill also fire, but a plugin-wide refresh is cheap insurance). - Don't tear down per-action state on disconnect — the device may come back.
7. Common pitfalls
| Symptom | Cause | Fix |
|---|---|---|
openUrl("my-app://...") does nothing |
Custom schemes not supported by openUrl |
Use OS-specific APIs or child_process.exec(...); or trigger the other app via OAuth-style flow |
| Deep-link fires Stream Deck but plugin handler doesn't run | Handler not registered before streamDeck.connect(), or wrong UUID in URL |
Register at plugin scope before connect; verify the <UUID> in the URL matches manifest.UUID |
state mismatch in OAuth on plugin restart |
pending map cleared at restart; user finished sign-in in the browser after |
Expected — re-prompt sign in (or persist pending in global settings) |
onApplicationDidLaunch never fires |
App's bundle id / exe name typoed in manifest | macOS: mdls -name kMDItemCFBundleIdentifier. Windows: Task Manager → Details → "Name" |
onDeviceDidChange doesn't fire |
User has Stream Deck app < 7.0 | Bump manifest.Software.MinimumVersion >= "7.0" if you rely on it |
| Plugin loses connection after sleep | Not handling onSystemDidWakeUp |
Reconnect in the handler |
| Deep-link payload corrupts when it contains special characters | Not URL-encoded by the sender | encodeURIComponent everything; decode via URL API on receipt (already done by ev.url) |
streamDeck.system.onDidReceiveDeepLink is undefined |
Older SDK (@elgato/streamdeck v1) — deep-link API was added in v2 |
Upgrade the npm package |
See Also
- System guide: https://docs.elgato.com/streamdeck/sdk/guides/system
- Deep-Linking guide (incl. URL Builder): https://docs.elgato.com/streamdeck/sdk/guides/deep-linking
- App Monitoring guide: https://docs.elgato.com/streamdeck/sdk/guides/app-monitoring
- Devices guide: https://docs.elgato.com/streamdeck/sdk/guides/devices
- Sibling skills:
streamdeck-oauth(composes openUrl + deep link),streamdeck-actions(per-action lifecycle on connect/disconnect),streamdeck-manifest(declaringApplicationsToMonitor)