Quick start

Let’s build the smallest possible widget — one that shows the current track. It takes about five minutes.

Prefer to have it written for you? These docs are available to AI assistants over MCP, so Claude, Cursor, Codex or OpenCode can read this entire reference and build the widget with you. See Build with an AI assistant — then come back here for step 6, which it cannot do for you.

1. Create the bundle folder

Make a new folder named Hello.nepget. (On macOS, the .nepget extension turns it into an installable bundle.)

2. Add a manifest

Create manifest.json inside the folder:

{
  "manifestVersion": 1,
  "id": "com.example.hello",
  "name": "Hello",
  "version": "1.0.0",
  "entry": "index.html",
  "defaultSize": { "width": 280, "height": 120 },
  "permissions": ["artwork"]
}

Every widget needs manifestVersion, a unique id (reverse-domain style), a name, a version, an entry HTML file, and a defaultSize. See the full manifest reference.

3. Add the HTML

Create index.html:

<!doctype html>
<html>
  <head>
    <meta charset="utf-8" />
    <style>
      html, body {
        height: 100%;
        margin: 0;
        background: transparent;
        font-family: -apple-system, system-ui, sans-serif;
      }
      .w {
        height: 100%;
        display: grid;
        place-items: center;
        color: #fff;
        background: rgba(0, 0, 0, 0.5);
        border-radius: 16px;
      }
    </style>
  </head>
  <body>
    <div class="w"><div id="t">Nothing playing</div></div>
    <script>
      NepTunes.on('statechange', (state) => {
        document.getElementById('t').textContent = state.track
          ? `${state.track.title} — ${state.track.artist}`
          : 'Nothing playing';
      });
    </script>
  </body>
</html>

The statechange event fires whenever the track or playback state changes. state.track is null when nothing is playing — always handle that case.

4. Install it

Double-click Hello.nepget. It installs to ~/Library/Group Containers/group.pl.micropixels.NepTunes/Widgets/. Now start playing something — widgets stay hidden while nothing is playing — and it appears on your desktop with the text updating live.

5. Debug it

A widget running inside NepTunes cannot be inspected, and has no console — console.log and uncaught errors both go nowhere. So develop it as what it is: a web page. Open index.html directly in a browser with a small stub for window.NepTunes, and you get real developer tools for the layout and the logic; install into NepTunes for integration.

For what NepTunes itself will tell you — refused permissions, a bundle that won’t load, a widget that never appears — read the host’s log:

/usr/bin/log stream --process "NepTunes Widget" --level debug

Debugging has the browser stub to copy, the log recipes, and what to check when the widget doesn’t show up at all.

6. Sign it

An unsigned widget installs fine while you’re building it, but the moment you share one it needs a signature — and once a widget id has installed signed anywhere, an unsigned version of it is refused from then on. So it is worth doing now rather than discovering it later:

curl -O https://neptunesmac.app/widget-tools.mjs

# Once, ever. Back up the private half — it is how you ship updates.
node widget-tools.mjs keygen my-widget-author

# Every time the bundle's contents change, including a version bump. Do it LAST:
# the signature covers the files as they are, so any later edit invalidates it.
node widget-tools.mjs embed-sign Hello.nepget --key .keys/my-widget-author.pem
node widget-tools.mjs embed-verify Hello.nepget

widget-tools.mjs is a single Node script with no dependencies. See Updates & signing for what the signature covers.

A note on shadows

Widgets render on a transparent background. If you want a drop shadow around your widget, give the body some padding so the shadow has room, and keep the background transparent:

html, body { height: 100%; width: 100%; background: transparent; overflow: visible; }
body { padding: 20px; }            /* room for the shadow */
.widget {
  height: 100%; width: 100%;
  border-radius: 12px;
  box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
}

← Introductionmanifest.json reference →