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.js

Minimal 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-sdk

Then 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.

Want to see every SDK feature in one place? Install the SDK Demo plugin from the marketplace — it demonstrates every component type and every SDK method with annotated source code. See the full example below.

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 needs
Subdirectories are fully supported — both when loading from folder and when installed via zip from the marketplace.

Architecture Overview

Understanding how MacroPad loads and runs plugins will save you debugging time.

Runtime topology

Button press flow — end to end

When the user taps a button on their phone:

  1. The PWA sends a { type: "press", pageId, compId, hold: false } WebSocket message to the server.
  2. 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.
  3. Inside the worker, the SDK calls your sdk.onAction handler with the params configured on that component in the admin panel.
  4. Your handler runs (up to 10-second timeout). Any calls to sdk.broadcast, sdk.widget.set, or sdk.notify are sent back to the main thread and forwarded to all connected clients.

Plugin lifecycle

  1. Loaded — the plugin worker is spawned. Your factory function is called and receives the SDK instance.
  2. Registered — your handlers are registered via sdk.onAction(), and any sdk.cron() timers start.
  3. Running — the plugin responds to button presses, slider moves, and other user interactions.
  4. 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.

Plugin action timeouts are 10 seconds. If a handler takes longer, a warning is logged and the promise is dropped. Use fire-and-forget patterns (no 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:

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.

Tile (with plugin subscription) vs widget.set — a tile configured with plugin event fields is a pub/sub subscriber: it watches for any broadcast event with the given name and re-renders when one arrives. Use it for live data panels. 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.

EventWhere it goesParams shape
movePWA → OS mouse driver{ dx, dy, pageId, compId }
scrollPWA → OS scroll{ dy, pageId, compId }
clickPWA → OS click{ button: 1|2|3, pageId, compId }
If you want continuous control from the trackpad (e.g. volume from swipe distance), use a slider component instead — it sends a { 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.

GestureDescription
swipeLeftOne-finger swipe left
swipeRightOne-finger swipe right
swipeUpOne-finger swipe up
swipeDownOne-finger swipe down
pinchInTwo-finger pinch inward
pinchOutTwo-finger spread outward
tapSingle finger tap
doubleTapDouble tap
longPressPress and hold
twoFingerTapTwo-finger tap
rotateClockwiseTwo-finger rotation clockwise
rotateCounterClockwiseTwo-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.

ParameterTypeDescription
keystringAction key, e.g. 'my-plugin.doThing'. Must start with your plugin ID.
handlerfunctionCalled with a params object. May be async. 10-second timeout.
sdk.onAction('my-plugin.greet', (params) => {
  sdk.log.info('Hello, ' + (params?.name || 'World'))
})
sdk.on is a deprecated alias for 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

ComponentParams passed to handler
buttonValues 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.

ParameterTypeDescription
eventstringEvent name, e.g. 'statusUpdate'
dataobjectAny 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"
}
Broadcast is rate limited to 30 messages/second per plugin. Calls that exceed this limit are silently dropped and a warning is logged. sdk.widget.set and sdk.widget.flash share this same budget.
broadcast vs widget.set — which to use?
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)

ParameterTypeDescription
actionKeystringMust start with pluginId + '.'. Server enforces this.
opts.labelstringText label displayed on the component
opts.colorstringBackground color (CSS hex)
opts.iconstringIcon name
opts.imagestring | nullImage URL, or null to clear
opts.badgestringSmall overlay badge text
sdk.widget.set('my-plugin.status', {
  label: 'Connected',
  color: '#4ade80',
  badge: 'OK'
})
If the target component is on a page that is not currently displayed, the update is cached and applied automatically when the user navigates to that page.
sdk.widget.set, sdk.widget.flash, and sdk.broadcast all share the same 30 messages/second per-plugin budget.
sdk.tile.set(pageId, tileId, opts) and sdk.tile.flash(pageId, tileId, color, ms?) are legacy APIs that address components by page ID and tile ID. They still work but should be avoided in new code — use 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.')
Notifications are rate limited to 5/second per plugin. Excess calls are dropped with a warning. If you need threshold-based alerts (e.g. CPU > 80% for an extended period), implement your own debounce or cooldown with a variable to avoid notification spam.

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.

ParameterDescription
intervalMsInterval in milliseconds
fnFunction 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()
For a background-data-only plugin that uses 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.

MethodDescription
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.

OptionDescription
opts.timeoutTimeout in ms (default 8000). Rejection on timeout is an AbortError (err.name === 'AbortError').
opts.headersAdditional request headers
opts.rawWhen 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)
}
SSRF protection blocks all RFC1918 addresses, including your router (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)
sdk.shell.exec is a deprecated alias for 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.

MethodDescription
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')
})
Cron timers are stopped automatically on unload — you do not need to cancel them in 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())
Always close outbound WebSocket connections in sdk.onReload — otherwise hot-reload will leave stale connections open.

manifest.json Reference

Full reference of every supported manifest field:

FieldTypeDescription
idstringrequired Unique slug. Must match the folder name. Use kebab-case.
namestringrequired Human-readable name shown in the marketplace.
versionstringrequired Semver string, e.g. "1.2.0". Used for update checks.
actionsarrayrequired List of action definitions. Use [] for background-only plugins with no user interactions.
descriptionstringoptional Short description shown in the marketplace.
authorstringoptional Your name or handle.
authorUrlstringoptional Link to your website or GitHub profile.
homepagestringoptional Docs or repo URL. Shown as "Docs" link in marketplace.
iconstringoptional Path to icon image relative to plugin folder, emoji, or URL.
pricenumberoptional Price in cents. 0 = free. Set to e.g. 800 for $8.
purchaseUrlstringoptional Your payment link (Stripe, Gumroad, LemonSqueezy). Required when price > 0.
downloadUrlstringoptional Direct URL to a .zip of your plugin. Required for marketplace install.
licensestringoptional e.g. "MIT"
tagsstring[]optional Used for filtering in the marketplace browse tab.
minAppVersionstringoptional Minimum MacroPad version required.

Action definition

Each item in the actions array:

FieldTypeDescription
keystringrequired Unique action identifier. Convention: <plugin-id>.<verb>
labelstringrequired Shown in the admin panel action dropdown.
componentTypestringoptional Hint for the admin panel. One of: button, switch, slider, knob, tile, voice, trackpad
descriptionstringoptional Longer description shown in the admin panel.
paramsarrayoptional 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

FieldDescription
keyUsed as the key in the params object passed to your handler
labelLabel shown above the input in the admin panel
typetext, number, or textarea
defaultDefault value pre-filled in the input
placeholderPlaceholder 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:

GuardrailDetail
Broadcast rate limit30 messages/second per plugin — excess calls are dropped with a warning
Notify rate limit5 desktop notifications/second per plugin
SSRF protectionsdk.http blocks localhost, 127.0.0.1, ::1, RFC1918 ranges, and 169.254.x.x link-local addresses
Shell injection checkCommands passed to execSync/execAsync are validated — backticks and $( are blocked
Widget write isolationsdk.widget.set/flash only accept keys that start with the plugin's own ID
Worker isolationEach plugin runs in its own worker_threads Worker — a crash cannot affect other plugins or the server

What is NOT restricted:

Only install plugins you trust or that you wrote yourself. A malicious plugin has full access to your machine.

Troubleshooting

My handler never fires

sdk.widget.set does nothing

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

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.

  1. Go to the Plugin Marketplace → Developer tab.
  2. Click "Load from folder" and select your plugin directory.
  3. The plugin is copied to userData/plugins/ and loaded immediately.
  4. Edit your index.js, then click "Reload plugins" in the admin panel — no app restart needed.
Plugins loaded this way are marked _local: true and skipped during update checks.

Publish to Marketplace

  1. Create a GitHub release (or any public host) and upload a .zip of your plugin folder.
  2. Set downloadUrl in your manifest to the direct download URL of the zip.
  3. Fork chunkies/macropad and add your plugin to registry/registry.json.
  4. 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"
}
Your zip can use GitHub archive format — a single top-level folder wrapping your plugin files. The installer automatically flattens it.

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.