Plugin SDK
Build plugins for MacroPad in two files. Your plugin gets a full Node.js SDK for running shell commands, persisting data, making HTTP requests, pushing live updates to the phone UI, and reacting to buttons, sliders, knobs, switches, voice input, and trackpad gestures.
What is a Plugin?
A MacroPad plugin is a small Node.js script that runs on your computer alongside the MacroPad server. It registers handlers for action keys — when a user taps a button, moves a slider, or speaks a voice command on their phone, MacroPad routes that event to your plugin handler by the action key configured on that component.
From your handler, you can run shell commands, call APIs, persist data, and push display updates back to the phone — live, with no page reload needed. Plugins also support background timers (cron), outbound WebSocket connections, and desktop notifications.
Every plugin is just two files: a manifest.json that declares the plugin's metadata and actions, and an index.js that exports a factory function that receives the SDK instance and registers handlers.
Quick Start
Create a folder with two files:
my-plugin/
manifest.json
index.jsMinimal manifest.json:
{
"id": "my-plugin",
"name": "My Plugin",
"version": "1.0.0",
"actions": [
{ "key": "my-plugin.doThing", "label": "Do thing" }
]
}Minimal index.js — factory pattern (recommended):
// index.js
module.exports = (sdk) => {
sdk.onAction('my-plugin.doThing', async (params) => {
const out = await sdk.shell.execAsync('date')
sdk.log.info('Current date: ' + out)
})
}TypeScript
For full type safety, install the SDK types package as a dev dependency:
npm install --save-dev @macropad/plugin-sdkThen use definePlugin to wrap your factory — the compiler infers the full SDK shape and every method gets inline docs and autocompletion:
// index.ts
import { definePlugin } from '@macropad/plugin-sdk'
export default definePlugin((sdk) => {
sdk.onAction<{ message: string }>('my-plugin.send', async ({ message }) => {
const out = await sdk.shell.execAsync(`echo "${message}"`)
sdk.log.info(out)
})
})You can also import the MacroPadSDK interface directly if you prefer an explicit type annotation:
import type { MacroPadSDK } from '@macropad/plugin-sdk'
module.exports = (sdk: MacroPadSDK) => {
// ...
}At runtime definePlugin is a no-op — it just returns the function you pass in. The package has no other runtime code; it is types-only.
Load it in the app: open the Plugin Marketplace → Developer tab → "Load from folder". Done.
File Structure
my-plugin/
manifest.json ← required: metadata, actions list, pricing
index.js ← required: action handlers
icon.png ← optional: shown in marketplace (128×128)
lib/ ← optional: helper modules (require('./lib/...'))
assets/ ← optional: any other files your plugin needsArchitecture Overview
Understanding how MacroPad loads and runs plugins will save you debugging time.
Runtime topology
- MacroPad runs a local WebSocket server on port 3000 inside the Electron main process. The phone PWA connects to this server over your local network.
- Each plugin runs in its own
worker_threadsWorker — isolated from each other and from the main process. A crash or infinite loop in a plugin cannot take down the server. - Workers are not a true OS sandbox. They have full Node.js API access —
require(), filesystem, network, child processes, etc. This is intentional: plugins are trusted code, like npm packages. - Each plugin gets its own SDK instance. Rate limits (broadcasts, notifications) are per-plugin and do not share quota across plugins.
Button press flow — end to end
When the user taps a button on their phone:
- The PWA sends a
{ type: "press", pageId, compId, hold: false }WebSocket message to the server. - The server looks up which component was pressed, reads its configured action key (e.g.
"my-plugin.doThing"), and routes the event to the plugin worker that owns that key. - Inside the worker, the SDK calls your
sdk.onActionhandler with the params configured on that component in the admin panel. - Your handler runs (up to 10-second timeout). Any calls to
sdk.broadcast,sdk.widget.set, orsdk.notifyare sent back to the main thread and forwarded to all connected clients.
Plugin lifecycle
- Loaded — the plugin worker is spawned. Your factory function is called and receives the SDK instance.
- Registered — your handlers are registered via
sdk.onAction(), and anysdk.cron()timers start. - Running — the plugin responds to button presses, slider moves, and other user interactions.
- Unloaded / hot-reloaded — the worker is terminated. Any cleanup functions registered with
sdk.onReload()are called first. Cron timers are stopped automatically.
Widget write isolation
A plugin can only push display updates to components that belong to it. sdk.widget.set(key, opts) enforces that key must start with pluginId + '.' — the server drops any call that violates this rule.
await) for long-running tasks.Core Component Types
MacroPad has 15 component types. Some fire plugin actions when the user interacts; others can receive live display updates pushed from a plugin.
| Type | What it renders | Fires plugin action? | Accepts widget.set? |
|---|---|---|---|
| button | Pressable tile | yes — on tap | yes |
| switch | Toggle on/off | yes — with { active } |
yes |
| toggle | Two-state toggle; runs separate on/off shell commands per state | built-in only | no |
| slider | Vertical or horizontal drag | yes — with { value: 0–100 } |
yes |
| knob | Rotary control | yes — with { value: 0–100 } |
yes |
| voice | Speech-to-text button | yes — with { transcript } |
yes |
| trackpad | Multi-touch surface | yes — per-gesture action bindings in admin panel | yes |
| tile | Display tile. Runs a shell command on a poll interval, OR subscribes to a named sdk.broadcast() event when plugin event fields are configured. |
no | yes — via action key or pluginDisplayKey |
| folder | Navigates to a sub-page | built-in | no |
| counter | Built-in counter with min/max/step | built-in | no |
| clock | Live clock display | built-in | no |
| stopwatch | Start/stop/reset timer | built-in | no |
| countdown | Countdown timer with completion action | built-in | no |
Plugin-actionable types fire your handler when the user interacts. Built-in types are handled entirely by MacroPad — no plugin API is needed or exposed.
If you need a plugin-controlled on/off state, use switch — it tracks toggle state and passes { active: boolean } to your handler. The toggle type is a built-in shell-command toggle with no plugin involvement.
Wiring a Tile to Plugin Events
A tile can subscribe to a named broadcast event and automatically update its display when that event fires. Here is the full wiring, step by step:
Step 1 — broadcast an event from your plugin
// Every 5s, push the current CPU load as a broadcast event named "cpuLoad"
sdk.cron(5000, async () => {
const raw = await sdk.shell.execAsync("top -bn1 | grep 'Cpu(s)'")
const usage = parseFloat(raw.match(/(\d+\.\d+)\s*us/)?.[1] ?? '0')
sdk.broadcast('cpuLoad', { value: `${usage}%`, color: usage > 80 ? '#f87171' : '#4ade80' })
})Step 2 — add a tile in the admin panel
In the admin panel, add a tile component to your layout, then configure its Plugin event subscription fields (Plugin, Event, Field) in the settings drawer. Set:
- Event:
cpuLoad— this must match the event name you pass tosdk.broadcast - Field:
value— the tile renders this field from the broadcast payload as its label
What the tile displays
The tile renders the configured Field from the broadcast payload as its text. The optional color field in the payload sets the tile's background color. When no plugin event subscription is configured, the tile behaves as a static display tile updated via sdk.widget.set() or a shell poll command.
sdk.widget.set targets a specific component by its action key and is better suited for updating a button's appearance in response to a user action.Trackpad Gestures
The trackpad component delivers two categories of events.
Raw pointer events — PWA only
Raw events (move, scroll, click) are delivered to the PWA client to drive system-level pointer behavior (mouse movement, scrolling, clicks). They are not routed to plugin handlers — there is no SDK API to receive them.
| Event | Where it goes | Params shape |
|---|---|---|
| move | PWA → OS mouse driver | { dx, dy, pageId, compId } |
| scroll | PWA → OS scroll | { dy, pageId, compId } |
| click | PWA → OS click | { button: 1|2|3, pageId, compId } |
{ value: 0–100 } param to your handler directly. Gesture events are discrete; they carry no magnitude.Gesture events — routed to your plugin
Gesture events are routed to your sdk.onAction handler based on per-gesture bindings configured in the admin panel. Each gesture (swipe, pinch, tap, etc.) can be mapped independently to any plugin action. All gesture params include { gesture: GestureType, pageId: string, compId: string }.
How to configure gesture bindings
In the admin panel, when editing a trackpad component, scroll to the Gesture Actions section. For each gesture you want to handle, select a plugin action from the dropdown. Each gesture is independent — you can bind swipe-left to one action and pinch-in to another.
In your manifest, set "componentType": "trackpad" on the action to hint to the admin panel which component type to suggest.
| Gesture | Description |
|---|---|
| swipeLeft | One-finger swipe left |
| swipeRight | One-finger swipe right |
| swipeUp | One-finger swipe up |
| swipeDown | One-finger swipe down |
| pinchIn | Two-finger pinch inward |
| pinchOut | Two-finger spread outward |
| tap | Single finger tap |
| doubleTap | Double tap |
| longPress | Press and hold |
| twoFingerTap | Two-finger tap |
| rotateClockwise | Two-finger rotation clockwise |
| rotateCounterClockwise | Two-finger rotation counter-clockwise |
sdk.onAction('my-plugin.trackpadControl', (params) => {
if (params.gesture === 'swipeLeft') { /* previous track */ }
if (params.gesture === 'swipeRight') { /* next track */ }
if (params.gesture === 'longPress') { /* toggle play/pause */ }
if (params.gesture === 'pinchIn') { /* zoom out */ }
})The pluginDisplayKey Field
Any tile component can declare a pluginDisplayKey string. This lets your plugin push display updates to that tile by key — even if the tile has no action key, or a different action key than what you want to address.
This is useful for pure display tiles: informational panels, status badges, counters, or any tile that the user never taps but that your plugin needs to update dynamically.
Example config (admin panel sets this field on the tile component):
{
"componentType": "tile",
"pluginDisplayKey": "my-spotify.nowPlaying"
}Your plugin pushes updates to that tile by its display key:
sdk.widget.set('my-spotify.nowPlaying', {
label: 'Playing: Bohemian Rhapsody',
icon: 'music_note'
})The server resolves the key against the widget index (which tracks both action keys and pluginDisplayKey values). If the tile is on a page that is not currently displayed, the update is cached and applied when the user navigates to that page.
sdk.pluginId
A string property containing your plugin's own ID, exactly as it appears in manifest.json. Use this instead of hardcoding your plugin ID.
sdk.log.info(`Loaded as plugin: ${sdk.pluginId}`)
// "Loaded as plugin: my-plugin"sdk.onAction(key, handler)
Register a handler function for an action key. The key must match an action declared in your manifest.json. The server dispatches to this handler when the user interacts with a component configured to that action.
| Parameter | Type | Description |
|---|---|---|
| key | string | Action key, e.g. 'my-plugin.doThing'. Must start with your plugin ID. |
| handler | function | Called with a params object. May be async. 10-second timeout. |
sdk.onAction('my-plugin.greet', (params) => {
sdk.log.info('Hello, ' + (params?.name || 'World'))
})sdk.onAction. It registers the handler identically but does not emit the key-prefix warning. Prefer sdk.onAction in all new code.Params shape by component type
| Component | Params passed to handler |
|---|---|
| button | Values from manifest params fields (configured per button in admin) |
| switch | { active: boolean } plus any manifest params |
| slider / knob | { value: number } (0–100) plus any manifest params |
| voice | { transcript: string } |
| trackpad gesture | { gesture: GestureType, pageId: string, compId: string } |
sdk.broadcast(event, data)
Broadcast a named event to all connected PWA clients over WebSocket. Any tile component with matching plugin event fields configured will update automatically.
| Parameter | Type | Description |
|---|---|---|
| event | string | Event name, e.g. 'statusUpdate' |
| data | object | Any JSON-serializable payload. Keys land at the top level of the wire message. |
sdk.broadcast('statusUpdate', { value: 'online', color: '#4ade80' })The WebSocket message shape received by the PWA:
{
"type": "pluginEvent",
"pluginId": "my-plugin",
"event": "statusUpdate",
"value": "online", // data keys spread to top level
"color": "#4ade80"
}sdk.widget.set and sdk.widget.flash share this same budget.Use
sdk.broadcast when you want a tile (with plugin event subscription configured) to react to an event — the tile subscribes by event name and displays the configured field from the payload. Think of it as a pub/sub bus for the phone UI.Use
sdk.widget.set when you want to push a specific visual update (label, color, badge) directly to a named component — button, tile, slider, etc. It targets one component by its action key. Think of it as a remote setter.sdk.widget
Push display updates to a specific component by its action key (or pluginDisplayKey). Unlike sdk.broadcast, which fans out to all clients, sdk.widget.set targets a single component in the layout.
sdk.widget.set(actionKey, opts)
| Parameter | Type | Description |
|---|---|---|
| actionKey | string | Must start with pluginId + '.'. Server enforces this. |
| opts.label | string | Text label displayed on the component |
| opts.color | string | Background color (CSS hex) |
| opts.icon | string | Icon name |
| opts.image | string | null | Image URL, or null to clear |
| opts.badge | string | Small overlay badge text |
sdk.widget.set('my-plugin.status', {
label: 'Connected',
color: '#4ade80',
badge: 'OK'
})sdk.widget.set, sdk.widget.flash, and sdk.broadcast all share the same 30 messages/second per-plugin budget.sdk.widget.set and sdk.widget.flash instead.sdk.widget.flash(actionKey, color, ms?)
Briefly flash a component's background color, then restore it. Default duration is 500ms.
sdk.widget.flash('my-plugin.ping', '#7c3aed', 300)sdk.notify(title, body?)
Show a native desktop notification via the Electron renderer.
sdk.notify('Build complete', 'Your project compiled successfully.')sdk.cron(intervalMs, fn)
Run a function on a repeating timer. All cron timers are stopped automatically when the plugin is unloaded or hot-reloaded — you do not need to clean them up manually.
| Parameter | Description |
|---|---|
| intervalMs | Interval in milliseconds |
| fn | Function to call. May be async. Errors are logged (not swallowed) — exceptions will appear in the console with [plugin:id] cron error: prefix. |
Returns a stop function — call it to cancel the timer early:
const stop = sdk.cron(10000, async () => {
const data = await sdk.http.get('https://api.example.com/status')
sdk.broadcast('status', data)
})
// Cancel early if needed:
stop()sdk.cron but has no user interactions, you may set "actions": [] in your manifest. The plugin will load and run its cron jobs without requiring any button configuration.sdk.storage
Per-plugin persistent key/value storage. Data is serialized to JSON and saved to userData/plugins-data/<pluginId>.json. Survives plugin hot-reload and app restart. Uninstalling the plugin does not delete the data file.
Storage is loaded once into memory the first time any storage method is called. All subsequent reads and writes use the in-memory copy; mutations are written through to disk immediately.
| Method | Description |
|---|---|
| storage.get(key) | Returns the stored value, or undefined if not set. |
| storage.getAll() | Returns a plain object of all key-value pairs. |
| storage.set(key, value) | Stores any JSON-serializable value. |
| storage.delete(key) | Removes the key. |
| storage.clear() | Wipes all storage for this plugin. |
// Counter that persists across button presses
sdk.onAction('my-plugin.count', () => {
const n = (Number(sdk.storage.get('count')) || 0) + 1
sdk.storage.set('count', n)
sdk.log.info(`Pressed ${n} times`)
})sdk.http
HTTP helpers with SSRF protection built in. All three methods block requests to local network targets: localhost, 127.0.0.1, ::1, RFC1918 ranges (10.x, 172.16–31.x, 192.168.x), and link-local addresses (169.254.x.x). The default timeout is 8 seconds.
sdk.http.get(url, opts?)
GET request. Returns parsed JSON by default. Pass { raw: true } to get the Response object instead (useful to read status codes or non-JSON bodies). TypeScript users: the return type narrows to Promise<Response> when raw: true is passed.
sdk.http.post(url, body, opts?)
body is serialized to JSON. Returns parsed JSON by default, or the Response object when raw: true.
sdk.http.request(url, init?)
Raw fetch with full RequestInit options. Returns the Response object. No default timeout — unlike get/post, you must set signal: AbortSignal.timeout(ms) manually.
| Option | Description |
|---|---|
| opts.timeout | Timeout in ms (default 8000). Rejection on timeout is an AbortError (err.name === 'AbortError'). |
| opts.headers | Additional request headers |
| opts.raw | When true, return the raw Response instead of parsed JSON |
// GET JSON
const data = await sdk.http.get('https://api.example.com/status')
// GET with raw: true — read status code
const res = await sdk.http.get('https://example.com/health', { raw: true })
sdk.log.info('Status: ' + res.status)
// POST JSON
await sdk.http.post('https://webhook.site/my-hook', { event: 'button_pressed' })
// Distinguish timeout from other errors
try {
const data = await sdk.http.get('https://slow.example.com/', { timeout: 3000 })
} catch (err) {
if (err.name === 'AbortError') sdk.log.warn('Request timed out')
else sdk.log.error('Network error:', err.message)
}192.168.x.x), LAN services, and localhost. This prevents marketplace plugins from scanning your network. If your plugin legitimately needs to reach a local service (e.g. a home server), bypass sdk.http and use Node's built-in fetch or require('http') directly — full Node API access is available to plugins.sdk.shell
Run shell commands via Node's child_process. Commands are validated against a shell injection pattern before execution (backticks and $( are blocked).
sdk.shell.execSync(command, opts?)
Synchronous. Blocks the worker thread until the command completes. Returns stdout as a trimmed string. Throws on non-zero exit. Default timeout: 5 seconds. Only use for fast commands (<50ms) such as reading /proc files.
const load = sdk.shell.execSync('cat /proc/loadavg')
sdk.log.info(load) // "0.52 0.58 0.59 2/847 12345"sdk.shell.execAsync(command, opts?)
Async. Returns a Promise<string> that resolves with stdout, rejects on non-zero exit. Use this for anything that takes more than ~50ms.
const ip = await sdk.shell.execAsync("hostname -I | awk '{print $1}'")
sdk.log.info('Local IP: ' + ip)execSync. Use execSync or execAsync in new code. For commands that accept user-supplied arguments, prefer execFileSync/execFile from Node's child_process directly to avoid shell injection.sdk.log
Prefixed structured logging. Output appears in the Electron devtools console and server logs. All messages are tagged with the plugin ID.
| Method | Description |
|---|---|
| log.info(...args) | console.log with [plugin:id] prefix |
| log.warn(...args) | console.warn with [plugin:id] prefix |
| log.error(...args) | console.error with [plugin:id] prefix |
sdk.log.info('Plugin started')
sdk.log.warn('Retrying...')
sdk.log.error('Failed:', err.message)sdk.onReload(fn)
Register a cleanup function called before the plugin is hot-reloaded or unloaded. You can call sdk.onReload multiple times — all registered functions are called in registration order.
sdk.onReload(() => {
conn.close() // close outbound connections
sdk.log.info('cleanup done')
})onReload.sdk.ws
The WebSocket constructor from the ws package, exposed on the SDK so plugins can open outbound WebSocket connections without bundling the ws package themselves.
const conn = new sdk.ws('wss://example.com/live')
conn.on('message', (data) => {
sdk.broadcast('liveData', JSON.parse(data))
})
sdk.onReload(() => conn.close())sdk.onReload — otherwise hot-reload will leave stale connections open.manifest.json Reference
Full reference of every supported manifest field:
| Field | Type | Description |
|---|---|---|
| id | string | required Unique slug. Must match the folder name. Use kebab-case. |
| name | string | required Human-readable name shown in the marketplace. |
| version | string | required Semver string, e.g. "1.2.0". Used for update checks. |
| actions | array | required List of action definitions. Use [] for background-only plugins with no user interactions. |
| description | string | optional Short description shown in the marketplace. |
| author | string | optional Your name or handle. |
| authorUrl | string | optional Link to your website or GitHub profile. |
| homepage | string | optional Docs or repo URL. Shown as "Docs" link in marketplace. |
| icon | string | optional Path to icon image relative to plugin folder, emoji, or URL. |
| price | number | optional Price in cents. 0 = free. Set to e.g. 800 for $8. |
| purchaseUrl | string | optional Your payment link (Stripe, Gumroad, LemonSqueezy). Required when price > 0. |
| downloadUrl | string | optional Direct URL to a .zip of your plugin. Required for marketplace install. |
| license | string | optional e.g. "MIT" |
| tags | string[] | optional Used for filtering in the marketplace browse tab. |
| minAppVersion | string | optional Minimum MacroPad version required. |
Action definition
Each item in the actions array:
| Field | Type | Description |
|---|---|---|
| key | string | required Unique action identifier. Convention: <plugin-id>.<verb> |
| label | string | required Shown in the admin panel action dropdown. |
| componentType | string | optional Hint for the admin panel. One of: button, switch, slider, knob, tile, voice, trackpad |
| description | string | optional Longer description shown in the admin panel. |
| params | array | optional Configurable inputs rendered in the admin panel (see Plugin params section). |
Plugin Params (Per-Button Config)
Declare a params array on any action to let users configure values per button in the admin panel. When the button is pressed, the configured values arrive in your handler as the params object. Each button can have different param values for the same action.
Param definition
| Field | Description |
|---|---|
| key | Used as the key in the params object passed to your handler |
| label | Label shown above the input in the admin panel |
| type | text, number, or textarea |
| default | Default value pre-filled in the input |
| placeholder | Placeholder text for the input |
// manifest.json
"actions": [
{
"key": "my-plugin.greet",
"label": "Show greeting",
"params": [
{
"key": "name",
"label": "Person to greet",
"type": "text",
"placeholder": "Alice"
},
{
"key": "delay",
"label": "Delay (ms)",
"type": "number",
"default": 500
}
]
}
]// index.js — params arrive in the handler
sdk.onAction('my-plugin.greet', async (params) => {
const name = params?.name || 'World'
const delay = params?.delay || 500
await new Promise(r => setTimeout(r, delay))
sdk.log.info(`Hello, ${name}!`)
})Security & Trust Model
MacroPad plugins are trusted code, analogous to npm packages. The plugin host enforces a minimal set of guardrails:
| Guardrail | Detail |
|---|---|
| Broadcast rate limit | 30 messages/second per plugin — excess calls are dropped with a warning |
| Notify rate limit | 5 desktop notifications/second per plugin |
| SSRF protection | sdk.http blocks localhost, 127.0.0.1, ::1, RFC1918 ranges, and 169.254.x.x link-local addresses |
| Shell injection check | Commands passed to execSync/execAsync are validated — backticks and $( are blocked |
| Widget write isolation | sdk.widget.set/flash only accept keys that start with the plugin's own ID |
| Worker isolation | Each plugin runs in its own worker_threads Worker — a crash cannot affect other plugins or the server |
What is NOT restricted:
- Filesystem access — plugins can read and write arbitrary files
- Network access beyond the SSRF block list
require()of Node built-ins or any package installed alongside the plugin- Spawning child processes with arbitrary arguments
Troubleshooting
My handler never fires
- Check the action key in
sdk.onActionmatches the key inmanifest.jsonexactly, including the plugin ID prefix. - Open the admin panel and confirm the component is configured with that action key (not a different action or "none").
- Check the devtools console for a
[plugin:id] sdk.onAction: key "..." should start with "pluginId."warning — this means a key mismatch.
sdk.widget.set does nothing
- The key must start with your plugin's ID (e.g.
"my-plugin.actionKey"). A warning is logged and the call is dropped if it doesn't. - If the target component is on a different page, the update is cached — switch to that page to see it applied.
- Make sure the component is configured with that action key in the admin panel, not a
pluginDisplayKeyon a separate tile.
sdk.http throws "Plugin ... blocked: requests to private IP ranges"
The SSRF guard is blocking a request to a local address. If you legitimately need to reach a local service, bypass sdk.http and use Node's native APIs directly:
// Bypass sdk.http SSRF guard — only use for intentional local-network requests
const res = await fetch('http://192.168.1.100:8080/api/status')
const data = await res.json()Tile with plugin subscription shows nothing or stale data
- Confirm the tile's Event field (in the Plugin event subscription section) matches the event string in your
sdk.broadcast('eventName', ...)call exactly. - Confirm the tile's Field matches a key in your broadcast payload (default:
value). - Fire the broadcast manually (press a button that triggers it) and check the devtools console for any rate limit warnings.
My cron fires too fast / rate limit warnings
Broadcasts (including sdk.broadcast, sdk.widget.set, and sdk.widget.flash) share a 30/second per-plugin budget. If a cron fires every 100ms and calls sdk.broadcast, you will hit the limit. Increase the cron interval or batch multiple updates into a single broadcast.
Hot-reload loses state
In-memory variables are cleared on hot-reload. Persist any state you need to survive reload using sdk.storage.set. Read it back at plugin startup with sdk.storage.get.
Quick Start Example — Demo Plugin
A representative example showing the most common SDK patterns. See the full source at plugins/demo/ for all component types and every SDK method demonstrated in one place. The demo is also available in the marketplace — search SDK Demo.
/**
* SDK Demo — condensed. Demonstrates:
* onAction, broadcast, storage, notify, cron, widget.set, widget.flash
*/
module.exports = (sdk) => {
// ── Persistent state ──────────────────────────────────────
let count = Number(sdk.storage.get('count') ?? 0)
let volume = Number(sdk.storage.get('volume') ?? 50)
// ── Cron: broadcast a heartbeat every 10 s ────────────────
sdk.cron(10000, () => {
sdk.broadcast('status', { count, volume })
sdk.log.info(`Heartbeat — count=${count} volume=${volume}`)
})
// ── Button: increment counter — updates button label live ─
sdk.onAction('demo.increment', () => {
count++
sdk.storage.set('count', count)
sdk.broadcast('status', { count, volume })
sdk.widget.set('demo.increment', { label: String(count), badge: `×${count}` })
sdk.log.info(`demo.increment → count=${count}`)
})
// ── Button: flash with random color ──────────────────────
sdk.onAction('demo.colorFlash', (params) => {
const color = params?.color || '#7c3aed'
sdk.broadcast('flash', { color })
sdk.widget.flash('demo.colorFlash', color) // flash the button itself
})
// ── Slider: volume control ────────────────────────────────
sdk.onAction('demo.volume', (params) => {
volume = Math.max(0, Math.min(100, Math.round(Number(params?.value ?? 50))))
sdk.storage.set('volume', volume)
sdk.broadcast('volume', { value: volume })
})
// ── HTTP fetch demo ───────────────────────────────────────
sdk.onAction('demo.httpFetch', async () => {
try {
const data = await sdk.http.get('https://worldtimeapi.org/api/timezone/UTC')
sdk.broadcast('httpResult', { ok: true, time: data.datetime })
} catch (err) {
sdk.broadcast('httpResult', { ok: false, error: err.message })
}
})
// ── Cleanup on hot-reload ─────────────────────────────────
sdk.onReload(() => {
sdk.log.info('SDK Demo plugin reloading — cleanup done')
})
sdk.log.info(`SDK Demo loaded — pluginId=${sdk.pluginId} count=${count} volume=${volume}`)
}Local Development
You don't need to publish to test your plugin.
- Go to the Plugin Marketplace → Developer tab.
- Click "Load from folder" and select your plugin directory.
- The plugin is copied to
userData/plugins/and loaded immediately. - Edit your
index.js, then click "Reload plugins" in the admin panel — no app restart needed.
_local: true and skipped during update checks.Publish to Marketplace
- Create a GitHub release (or any public host) and upload a .zip of your plugin folder.
- Set
downloadUrlin your manifest to the direct download URL of the zip. - Fork chunkies/macropad and add your plugin to
registry/registry.json. - Open a Pull Request. Once merged, your plugin appears in the marketplace for all users.
registry.json entry format
{
"id": "my-plugin",
"name": "My Plugin",
"version": "1.0.0",
"description": "What it does",
"author": "Your Name",
"authorUrl": "https://github.com/you",
"homepage": "https://github.com/you/my-plugin",
"icon": "",
"price": 0,
"purchaseUrl": "",
"downloadUrl": "https://github.com/you/my-plugin/releases/download/v1.0.0/my-plugin.zip",
"license": "MIT",
"tags": ["productivity"],
"downloads": 0,
"minAppVersion": "1.0.0"
}Paid Plugins
Set price (in cents) and purchaseUrl in your manifest. The app shows a "Buy $X" button that opens the user's browser to your payment URL. You keep 100% of revenue — use any payment provider (Stripe, Gumroad, LemonSqueezy, Paddle).
{
"price": 800,
"purchaseUrl": "https://buy.stripe.com/your-link"
}After purchase, direct the user to download the zip and load it via the Developer tab, or provide a private downloadUrl in your delivery email.