skills/streamdeck-react/SKILL.md
Build Stream Deck plugins with React using @fcannizzaro/streamdeck-react. Use for: creating action components, wiring keys/dials/touch input to React, Vite bundling for Stream Deck, settings sync, shared state, and Takumi image rendering.
npx skillsauth add fcannizzaro/streamdeck-react streamdeck-reactInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
A custom React renderer that turns JSX into rendered images for Elgato Stream Deck hardware. Each action instance gets its own isolated React root with full hooks, state, and lifecycle support.
Use when the user is:
@fcannizzaro/streamdeck-react@elgato/streamdeck SDK in a React-based pluginReact Tree --> Reconciler --> VNode Tree --> Takumi --> Adapter --> setImage/setFeedback
(JSX+Hooks) (host config) (plain JS) (JSX->PNG) (bridge) (hardware/simulator)
react-reconciler manages the fiber tree.{ type, props, children } with back-pointers for dirty propagation.action.setImage() or action.setFeedback(). The default physicalDevice() adapter wraps the Elgato SDK; custom adapters handle it differently.Every render passes through a multi-tier skip hierarchy to avoid redundant work:
Phase 1: Dirty-flag check (O(1)) → skip if no VNode mutated
Phase 2: Merkle hash → Image cache lookup → skip if hash matches cached render
Phase 3: Takumi render (main thread or worker) → rasterize
Phase 4: xxHash output dedup → skip hardware push if identical to last frame
Two entry points: renderToDataUri (keys/dials → base64 data URI) and renderToRaw (TouchStrip → raw RGBA Buffer).
When multiple roots request flushes in the same tick, the FlushCoordinator batches them via microtask and processes in priority order:
When actions disappear (profile switch, page navigation), the root is suspended rather than destroyed — the fiber tree stays alive. When the same action type reappears, the root is resumed with new context data, avoiding expensive fiber root creation.
${actionUUID}:${canvasType} — ensures component and surface compatibility.Every action root uses 4 context providers (stable outermost, volatile innermost):
RootContext.Provider ← merged: action + device + canvas + streamDeck (immutable)
EventBusContext.Provider ← per-root EventBus (new instance on resume)
GlobalSettingsContext.Provider ← plugin-wide settings
SettingsContext.Provider ← per-action settings
PluginWrapper / ActionWrapper / <UserComponent />
RootContext merges ActionInfo, DeviceInfo, CanvasInfo, and StreamDeckAccess into a single provider, eliminating 3 fiber nodes per root. For 32 active roots, this saves 96 provider fiber nodes.
The Vite bundler plugin auto-generates manifest.json from code:
defineAction({ info: { name, icon, ... } }) — the bundler plugin auto-extracts it from the module graph at build time via AST analysis.manifest option in the bundler plugin config.info.disabled: true are excluded from the manifest but still work at runtime..sdPlugin directory during writeBundle.No hand-written manifest.json is needed.
Each visible action instance on the hardware gets its own isolated React root. No shared state between roots unless you use an external store (Zustand, Jotai) or the wrapper API.
Native .node binaries (Takumi and any user-registered nativeModules) are lazy-loaded by default — downloaded from npm on first plugin startup and cached on disk.
streamDeckReact() Vite plugin resolves the installed version of each native module from its package.json.resolveId hook replaces all imports of the native module with the virtual loader. No user code changes needed.Version resolution uses three strategies in order: createRequire from project root, from library location (hoisted packages), and direct node_modules walk (for packages with restricted exports maps). If all fail, that module falls back to copy mode with a build-time warning.
Read .native-versions.json manifest
↓
existsSync(nodePath) && cachedVersion === VERSION?
├── YES → require() cached .node file (fast path, ~1ms)
└── NO → fetch npm tarball → gunzipSync → inline tar parse
→ writeFileSync .node to disk
→ update .native-versions.json
→ require()
The .node file is written next to the bundle output (import.meta.url-relative) and persists across restarts.
.native-versions.json)Tracks cached binary versions for cache invalidation on dependency upgrades:
{ "core.darwin-arm64.node": "0.73.1" }
When the baked-in VERSION (from build time) differs from the manifest entry, the binary is re-downloaded. Each native module only reads/writes its own key — concurrent module evaluation is safe.
The loader includes a minimal inline tar parser (no external dependency). npm tarballs use 512-byte headers (filename at offset 0, size at offset 124 in octal). The parser scans sequentially until it finds the target .node file.
Set nativeBindings: "copy" for air-gapped/offline environments. Requires platform packages installed and targets specified. .node files are copied from node_modules during writeBundle. Missing bindings are warnings in dev, errors in production.
Register additional NAPI-RS packages via nativeModules on streamDeckReact(). Each entry gets the same lazy/copy treatment. Validation at build time catches empty exports/bindings, duplicate specifiers, filename collisions, and Takumi conflicts. See references/bundling.md for configuration details.
The adapter layer abstracts the @elgato/streamdeck SDK behind a pluggable StreamDeckAdapter interface. This makes the SDK an optional peer dependency and enables alternative backends (web simulator, test harness).
physicalDevice() is the default adapter wrapping the real Elgato SDK. It is the only module that value-imports from @elgato/streamdeck.createPlugin({ adapter: myAdapter() }).useOpenUrl, useSwitchProfile, useSendToPI, useShowAlert, useShowOk, useTitle, useDialHint) route through the adapter.AdapterActionHandle is a flat interface unifying Key/Dial/Action. Inapplicable methods (e.g., setImage on dial) no-op.For greenfield projects, prefer the scaffolder first:
npm create streamdeck-react@latest
It asks for the plugin UUID, author, platforms, native targets, starter example, and whether to use React Compiler, then generates a working project.
To use React Compiler via CLI flag:
npm create streamdeck-react@latest --react-compiler true
React Compiler automatically memoizes components at build time, preventing unnecessary re-renders. This is especially beneficial in this environment because every re-render triggers an expensive rasterization pipeline (VNode tree -> Takumi layout -> Rust PNG render -> hardware).
If the user wants to build it manually, use this structure:
A minimal plugin project needs:
my-plugin/
src/
plugin.ts # Entry point
actions/
my-action.tsx # Action component + defineAction with info
com.example.my-plugin.sdPlugin/
bin/ # Vite output goes here
imgs/ # Action and plugin icons
vite.config.ts
package.json
tsconfig.json
# Runtime
npm install @fcannizzaro/streamdeck-react react
# Runtime support used by the Stream Deck SDK
npm install ws
# Build tooling
npm install -D [email protected] @vitejs/[email protected]
# Build tooling (with React Compiler -- add on top of base)
# npm install -D @rolldown/plugin-babel @babel/core babel-plugin-react-compiler
# Types (if using TypeScript)
npm install -D @types/react
Native Takumi bindings are lazy-loaded by default — they are downloaded from npm on first plugin startup and cached on disk. No platform-specific @takumi-rs/core-* packages need to be installed. Only the main @takumi-rs/core package is required (included as a dependency of @fcannizzaro/streamdeck-react).
To opt out of lazy loading and copy binaries from node_modules at build time instead, set nativeBindings: "copy" on streamDeckReact() and install the matching @takumi-rs/core-* packages. See references/bundling.md for the full platform matrix.
Must use "type": "module". Example:
{
"type": "module",
"scripts": {
"build": "vite build",
"dev": "vite build --watch"
}
}
{
"compilerOptions": {
"lib": ["ESNext"],
"target": "ESNext",
"module": "Preserve",
"jsx": "react-jsx",
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"noEmit": true,
"strict": true,
"skipLibCheck": true
},
"include": ["src"]
}
// src/actions/counter.tsx
import { useState } from "react";
import { defineAction, useKeyDown, useKeyUp, tw } from "@fcannizzaro/streamdeck-react";
function CounterKey() {
const [count, setCount] = useState(0);
const [pressed, setPressed] = useState(false);
useKeyDown(() => {
setCount((c) => c + 1);
setPressed(true);
});
useKeyUp(() => setPressed(false));
return (
<div
className={tw(
"flex flex-col items-center justify-center w-full h-full gap-1",
pressed ? "bg-[#2563eb]" : "bg-[#0f172a]",
)}
>
<span className="text-white/70 text-[12px] font-medium">COUNT</span>
<span className="text-white text-[36px] font-bold">{count}</span>
</div>
);
}
export const counterAction = defineAction({
uuid: "com.example.my-plugin.counter",
key: CounterKey,
info: {
name: "Counter",
icon: "imgs/actions/counter",
},
});
// src/plugin.ts
import { createPlugin, googleFont } from "@fcannizzaro/streamdeck-react";
import { counterAction } from "./actions/counter.tsx";
const inter = await googleFont("Inter");
const plugin = createPlugin({
fonts: [inter],
actions: [counterAction],
});
await plugin.connect();
Default setup (Oxc transforms):
// vite.config.ts
import { builtinModules } from "node:module";
import { resolve } from "node:path";
import { defineConfig, esmExternalRequirePlugin } from "vite";
import react from "@vitejs/plugin-react";
import { streamDeckReact } from "@fcannizzaro/streamdeck-react/vite";
const PLUGIN_DIR = "com.example.my-plugin.sdPlugin";
const builtins = builtinModules.flatMap((m) => [m, `node:${m}`]);
export default defineConfig({
resolve: {
conditions: ["node"],
},
plugins: [
esmExternalRequirePlugin({ external: builtins }),
react(),
streamDeckReact({
uuid: "com.example.my-plugin",
manifest: {
uuid: "com.example.my-plugin",
name: "My Plugin",
author: "Your Name",
description: "A Stream Deck plugin built with React.",
icon: "imgs/plugin-icon",
version: "0.0.0.1",
},
}),
],
build: {
target: "node20",
outDir: resolve(PLUGIN_DIR, "bin"),
emptyOutDir: false,
sourcemap: true,
minify: false,
lib: {
entry: resolve("src/plugin.ts"),
formats: ["es"],
fileName: () => "plugin.mjs",
},
rolldownOptions: {
output: {
codeSplitting: false,
},
},
},
});
With React Compiler (add Babel on top):
// vite.config.ts
import { builtinModules } from "node:module";
import { resolve } from "node:path";
import { defineConfig, esmExternalRequirePlugin } from "vite";
import react, { reactCompilerPreset } from "@vitejs/plugin-react";
import babel from "@rolldown/plugin-babel";
import { streamDeckReact } from "@fcannizzaro/streamdeck-react/vite";
const PLUGIN_DIR = "com.example.my-plugin.sdPlugin";
const builtins = builtinModules.flatMap((m) => [m, `node:${m}`]);
export default defineConfig({
resolve: {
conditions: ["node"],
},
plugins: [
esmExternalRequirePlugin({ external: builtins }),
react(),
// @ts-expect-error — @rolldown/plugin-babel types incorrectly mark inherited babel fields as required
await babel({
presets: [reactCompilerPreset()],
}),
streamDeckReact({
uuid: "com.example.my-plugin",
manifest: {
uuid: "com.example.my-plugin",
name: "My Plugin",
author: "Your Name",
description: "A Stream Deck plugin built with React.",
icon: "imgs/plugin-icon",
version: "0.0.0.1",
},
}),
],
build: {
target: "node20",
outDir: resolve(PLUGIN_DIR, "bin"),
emptyOutDir: false,
sourcemap: true,
minify: false,
lib: {
entry: resolve("src/plugin.ts"),
formats: ["es"],
fileName: () => "plugin.mjs",
},
rolldownOptions: {
output: {
codeSplitting: false,
},
},
},
});
Native bindings are lazy-loaded by default — they are downloaded from npm on first plugin startup and cached on disk. No targets option is needed.
manifest.json is auto-generated. You do not need to write or maintain it by hand. Action metadata is extracted from
defineAction({ info })calls at build time.
The manifest.json is auto-generated during the build. Action metadata comes from defineAction({ info }) calls, and plugin metadata from the bundler plugin's manifest option.
Critical: The
uuidin eachdefineAction()must start with the plugin UUID prefix (e.g.,"com.example.my-plugin.").
npx vite build --watch
Install the .sdPlugin folder in the Stream Deck app.
If your package.json has a dev script configured, you can also just run bun dev (or npm run dev / pnpm dev).
| Category | Hooks | Purpose |
| --------- | ------------------------------------------- | --------------------------------------------------------- |
| Events | useKeyDown, useKeyUp | Key press/release |
| Events | useDialRotate, useDialDown, useDialUp | Encoder rotation/press |
| Events | useTouchTap | Touch strip tap |
| Events | useDialHint | Set encoder trigger descriptions |
| Gestures | useTap | Single tap (auto-delayed when useDoubleTap is active) |
| Gestures | useLongPress | Key held for configurable duration (default 500ms) |
| Gestures | useDoubleTap | Two rapid taps within configurable window (default 250ms) |
| Settings | useSettings, useGlobalSettings | Bidirectional settings sync |
| Lifecycle | useWillAppear, useWillDisappear | Action mount/unmount |
| Context | useDevice, useAction, useCanvas | Device/action/canvas metadata |
| Context | useStreamDeck | Adapter and action handle |
| SDK | useOpenUrl, useSwitchProfile | System actions |
| SDK | useSendToPI, usePropertyInspector | PI communication |
| SDK | useShowAlert, useShowOk, useTitle | Key overlays |
| Utility | useInterval, useTimeout, usePrevious | Timers and helpers |
| Utility | useTick | Animation frame loop |
| Animation | useSpring, useTween | Physics and easing-based value animation |
| Animation | SpringPresets, Easings | Built-in spring presets and easing functions |
See references/hooks.md for full signatures and usage.
| Component | Element | Purpose |
| --------------- | ------- | ----------------------------------------------------------------------------- |
| Box | div | Flex container with shorthand props (center, padding, gap, direction) |
| Text | span | Text with shorthand props (size, color, weight, align, font) |
| Image | img | Image with required width/height, optional fit |
| Icon | svg | Single SVG path icon with path, size, color |
| ProgressBar | div | Horizontal progress bar with value/max |
| CircularGauge | svg | Ring/arc gauge with value/max/size/strokeWidth |
| ErrorBoundary | -- | Catches errors, renders fallback |
All components are optional convenience wrappers. Raw div, span, img, svg elements work directly.
See references/components.md for full props tables.
Three approaches, all valid:
Tailwind classes via className -- resolved by Takumi at render time (no CSS build step):
<div className="flex items-center justify-center w-full h-full bg-[#1a1a1a]">
tw() utility for conditional classes (like clsx):
<div className={tw('w-full h-full', pressed && 'bg-green-500')}>
Inline style for exact control:
<div style={{ width: '100%', height: '100%', background: '#1a1a1a' }}>
| Need | Solution |
| ------------------------------------------------ | --------------------------------------------------------------------- |
| Simple per-action state | useState / useReducer |
| Persist per-action settings across reloads | useSettings<T>() |
| Plugin-wide shared config | useGlobalSettings<T>() |
| Shared state across actions (no provider needed) | Zustand store in module scope |
| Shared state with provider pattern | Jotai/React Context via wrapper on createPlugin or defineAction |
For Stream Deck+ encoders, provide a dial component in defineAction. If omitted, the key component is used as fallback on encoder slots.
export const volumeAction = defineAction({
uuid: "com.example.my-plugin.volume",
key: VolumeKey,
dial: VolumeDial,
info: {
name: "Volume",
icon: "imgs/actions/volume",
encoder: {
layout: "$A0",
triggerDescription: {
rotate: "Adjust volume",
push: "Mute / Unmute",
},
},
},
});
The info.encoder block tells the Stream Deck UI about dial interactions. Controllers are auto-derived: if dial or touchStrip is present, ["Encoder"] is used; if key is also present, ["Keypad", "Encoder"].
For touch interaction on Stream Deck+, use useTouchTap() inside the mounted action root. Treat touch as input handling, not as a separate primary rendering surface.
googleFont("Inter") to download TTF fonts from Google Fonts (cached to .google-fonts/ on disk). Alternatively, load font files manually via readFile. Supported formats depend on the backend: native-binding supports .ttf, .otf, .woff, .woff2; WASM mode only supports .ttf and .otf.plugin.connect() must be called last -- after createPlugin() and all setup.uuid in defineAction() must start with the plugin UUID prefix (e.g., "com.example.my-plugin."). The manifest is auto-generated from these..node binary from npm on first startup and caches it on disk. No targets option or @takumi-rs/core-* platform packages are needed. Set nativeBindings: "copy" to revert to the old behavior of copying from node_modules. Additional native modules can be registered via the nativeModules option on streamDeckReact() — each entry gets the same lazy/copy treatment as the built-in Takumi binding. See references/bundling.md for configuration details.ws -- required by the Stream Deck SDK runtime. When using the WASM backend (takumi: "wasm"), install @takumi-rs/wasm instead and native binding packages are not needed.setImage call is a static frame. Use useTick for manual animation loops, or the higher-level useSpring and useTween hooks for physics-based and easing-based animation.takumi: "wasm" is available for environments where native addons can't load (WebContainers, browsers). It force-disables worker threads and does not support WOFF/WOFF2 fonts (use TTF/OTF only). Pass takumi: "wasm" to both createPlugin() and streamDeckReact() to skip native binary copying at build time.useCanvas() to adapt to larger devices.div, span, img, svg, p).useTick, useSpring, and useTween hooks default to 30fps (clamped). Design animations accordingly.When scaffolding or modifying a @fcannizzaro/streamdeck-react plugin, verify:
@fcannizzaro/streamdeck-react and react are in dependenciesws is installed for the Stream Deck SDK runtimepackage.json has "type": "module"tsconfig.json has "jsx": "react-jsx"googleFont() or manual readFile and passed to createPlugin()defineAction() has info: { name, icon } for manifest generationdefineAction() UUID starts with the plugin UUID prefixvite.config.ts includes streamDeckReact({ manifest: { uuid, name, author, ... } })info.encoder with layout and triggerDescriptionplugin.connect() is called after createPlugin()npx vite buildmanifest.json is auto-generated in the .sdPlugin directory after buildreact.memo_cache_sentinel (proof compiler is active)A browser-based inspector for debugging plugins during development. When enabled, the plugin starts an HTTP + SSE (Server-Sent Events) server on localhost (port range 39400-39499) and the browser UI auto-discovers running plugins by scanning that range.
const plugin = createPlugin({
devtools: true, // starts the devtools server (port derived from plugin UUID)
fonts: [
// ...your fonts
],
actions: [
/* ... */
],
});
npx @fcannizzaro/streamdeck-react-devtools (npm package @fcannizzaro/streamdeck-react-devtools)| Panel | Description |
| ----------- | ----------------------------------------------------------------------------- |
| Console | Intercepted console.log/warn/error/info/debug output |
| Network | Intercepted fetch requests and responses |
| Elements | VNode tree inspector with element highlighting on the physical device |
| Preview | Live rendered images for every active action and touch bar |
| Events | EventBus emissions (keyDown, dialRotate, touchTap, etc.) |
| Performance | Render pipeline metrics: flush counts, skip rates, cache stats, render timing |
ws dependency, and instrumentation hooks are removed from the bundle when NODE_ENV=production (non-watch builds). Zero overhead in release builds.createPlugin and defineAction detailstools
Use when work should span one or more detached tasks but still behave like one job with a single owner context. TaskFlow is the durable flow substrate under authoring layers like Lobster, ACPX, plugins, or plain code. Keep conditional logic in the caller; use TaskFlow for flow identity, child-task linkage, waiting state, revision-checked mutations, and user-facing emergence.
tools
# Lobster Lobster executes multi-step workflows with approval checkpoints. Use it when: - User wants a repeatable automation (triage, monitor, sync) - Actions need human approval before executing (send, post, delete) - Multiple tool calls should run as one deterministic operation ## When to use Lobster | User intent | Use Lobster? | | ------------------------------------------------------ | --------------------------
tools
# Lobster Lobster executes multi-step workflows with approval checkpoints. Use it when: - User wants a repeatable automation (triage, monitor, sync) - Actions need human approval before executing (send, post, delete) - Multiple tool calls should run as one deterministic operation ## When to use Lobster | User intent | Use Lobster? | | ------------------------------------------------------ | --------------------------
tools
A CLI tool for making authenticated requests to the X (Twitter) API. Use this skill when you need to post tweets, reply, quote, search, read posts, manage followers, send DMs, upload media, or interact with any X API v2 endpoint.