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);
}