Make Xenon yours.
Every look and layout in Xenon can be created by you and shared with anyone as a single code. No account, nothing leaves your PC, and you can start without writing a line of code. This guide takes you from zero to your first creation, then as far as you want to go.
Your first win, in 30 seconds
Don't read anything else yet. Copy the code below, open Xenon, and go to Settings → Widgets & sharing → Share & Import → Import. Paste it, press Apply, and watch your whole dashboard turn violet. That's a theme someone made and shared — and by the end of this page, that someone is you.
eyJ4ZW5vblByZXNldCI6MSwiZXhwb3J0ZWRBdCI6IiIsImFwcFZlcnNpb24iOiI0LjUuMSIsImdyaWRDb2xzIjoyNCwia2luZCI6InRoZW1lIiwibmFtZSI6Ik5lYnVsYSIsImRhdGEiOnsiYXBwZWFyYW5jZSI6ImRhcmsiLCJzdHlsZU1vZGUiOiJnbGFzcyIsImFjY2VudCI6IiNiNTdiZmYiLCJiYWNrZ3JvdW5kIjoiIzBhMDcxMyIsInRleHQiOiIjZjFlY2ZhIiwibXV0ZWRUZXh0IjoiIzlhOGZiYiIsImR5bmFtaWNBbGJ1bVRoZW1lIjpmYWxzZSwicGFuZWxBbHBoYSI6MC45LCJ1aVJvdW5kbmVzcyI6MS4zLCJnbGFzc0JsdXIiOjI2LCJnbGFzc1NhdHVyYXRlIjoxNzUsImJnRGltIjowLjUsImJnQXVyb3JhIjp7ImVuYWJsZWQiOnRydWUsImludGVuc2l0eSI6NjIsInNwZWVkIjo0NX0sImJnR3JpZCI6eyJlbmFibGVkIjpmYWxzZX0sImJnU3RhdGljIjp7InN0eWxlIjoibm9uZSIsImludGVuc2l0eSI6NzB9fX0
Every import shows you a preview first and never runs any code, so it is always safe to try one out.
Three ways to create — pick the one that fits you
Click in the app
Themes, backgrounds, pages, Deck keys and simple widgets all have a built-in editor. Point, tap, done. No code at all.
Ask Xenon AI
Tell the assistant "make me a synthwave theme" or "build me a streaming page" and it creates it live. Opt-in, in Settings → AI.
Write a widget
Want something truly custom? A widget is just a folder with an HTML file. The SDK section shows you how, step by step.
What you can make
Xenon has nine kinds of creation. Each one exports as a small, self-contained code — a copyable link or a .json file — that anyone can import to get your exact result. Here's the whole map.
| I want to… | Make this | Need code? | Jump to |
|---|---|---|---|
| Change the colours & overall look | Theme | No | Themes → |
| Add a living, moving backdrop | Animated background | No (templates) / optional | Backgrounds → |
| Arrange which widgets go where | Page layout | No | Pages → |
| Build a Stream-Deck-style key grid | Deck profile | No | Deck → |
| Add a clock / monitor / countdown tile | Widget (from a template) | No | Widgets → |
| Build a completely custom widget | Widget (SDK) | Yes | SDK → |
| Design a fullscreen screensaver | Ambient scene | Some | Ambient → |
| Ship a set of key icons for the Deck | Icon pack (SVG/PNG files) | No | Settings → Widgets & sharing → Create icon pack |
| Ship a soundboard (clips for Deck keys) | Sound pack (MP3/OGG/WAV) | No | Settings → Widgets & sharing → Create sound pack |
| Share my whole setup at once | Full package | No | Packages → |
STEP 0Set up & find the two buttons that matter
You only need Xenon installed and two places in the app. Learn these once and everything else clicks into place.
- Install Xenon if you haven't. Download the setup, double-click, done. Open the dashboard (it lives at http://127.0.0.1:3030 in any browser, and full-screen on a Xeneon Edge).
- Find "Import". Open Settings (the gear), choose the Widgets & sharing tab, and look for the Share & Import card. The Import (link or file)… button is how you install anything — a code you copied, a link, or a .json file.
- Find "Layout". Back on the dashboard, the Layout button in the top bar turns on edit mode — drag tiles around, resize them, add new ones from the + palette, add pages. This is where pages and widgets come to life.
Themes — your colours & look
A theme is the whole visual identity of your dashboard: light or dark, canvas, panels, controls, text, borders, state colours, glass, corner roundness and even a custom font. Xenon applies those semantic roles everywhere, so Settings, menus, Deck tools and every built-in widget stay in the same visual language. Change any of it, then save and share it as one code.
Make one without code
- Open Settings → Appearance.
- Pick a mode (Light / Dark / Auto), then set the canvas, panel, secondary surface, controls, text, borders and state colours you care about. Leave a role on Auto and Xenon derives it from the surface.
- Keep Contrast protection on unless you deliberately need exact low-contrast art direction. It repairs unreadable text and state combinations while preserving colours that already pass.
- Adjust panel opacity, blur and roundness until the material feels right. Everything updates live across widgets, Settings, menus and Deck tools.
- Tap Save current theme to keep it as a card you can switch back to anytime.
- To share it: Settings → Widgets & sharing → Share & Import → Export theme. You get a link or a .json — send it to anyone.
One palette, every surface
Theme files use role names instead of component-specific colours. This is the complete authoring contract; old three-colour themes with only accent, background and text still import, and Xenon derives the missing roles safely.
| Theme role | What it controls |
|---|---|
background | App canvas behind all panels |
surface / surfaceAlt | Panels, cards, rows and raised surfaces |
controlColor | Inputs, selects, buttons and control wells |
text / mutedText | Primary copy, labels and secondary details |
lineColor | Borders, dividers and outlines |
accent / accentText | Actions and the readable foreground placed on the accent |
successColor, warningColor, dangerColor, infoColor | Shared status language across every widget and integration |
contrastGuard | Automatic readability repair; defaults to true |
{
"appearance": "light",
"background": "#f1ead5",
"surface": "#fff8df",
"surfaceAlt": "#e4d8bd",
"controlColor": "#d8c9a9",
"text": "#241f1b",
"mutedText": "#61564d",
"lineColor": "#403833",
"accent": "#e53e54",
"accentText": "#ffffff",
"contrastGuard": true
}
One theme, light and dark
A theme's colours are normally fixed: a cream theme stays cream even for someone running Windows in dark mode. Add paletteVariants and you ship both halves in one theme — Xenon paints the one that matches the mode the user picked, and follows Windows live when they're on Auto. Set "appearance": "auto" and the two halves become the palette.
{
"appearance": "auto",
"styleMode": "comic",
"accent": "#e63d4e",
"background": "#efe6cf",
"text": "#1b140d",
"paletteVariants": {
"light": { "accent": "#e63d4e", "background": "#efe6cf", "surface": "#efe6cf", "text": "#1b140d" },
"dark": { "accent": "#ff5566", "background": "#12101d", "surface": "#191527", "text": "#fbf3e0" }
}
}
Each half takes any role from the table above. Write each one as a complete palette: a role you leave out of a half is derived for that tone rather than borrowed from the other half, so a cream surface can never leak into the dark side. The top-level colours stay required — they describe your primary tone and are what older Xenon versions import.
mutedText and status colours. If the user later edits a colour by hand, Xenon keeps what's on screen and drops the pair, so their choice sticks.Override just one widget
- Turn on Layout and tap the paint control on the widget.
- Switch from Follow theme to Custom, then change only the roles that tile needs. Unset roles continue to inherit the global theme.
- The local palette is contrast-checked, saved with the layout, included in backups and page presets, and passed into sandboxed SDK widgets.
- Choose Follow theme again to remove the override cleanly.
Or start from a real one
Here's the same Nebula theme from the top of the page. Copy it, import it (Share & Import → Import), then open Appearance and tweak it into your own. Learning by remixing is the fastest way in.
eyJ4ZW5vblByZXNldCI6MSwiZXhwb3J0ZWRBdCI6IiIsImFwcFZlcnNpb24iOiI0LjUuMSIsImdyaWRDb2xzIjoyNCwia2luZCI6InRoZW1lIiwibmFtZSI6Ik5lYnVsYSIsImRhdGEiOnsiYXBwZWFyYW5jZSI6ImRhcmsiLCJzdHlsZU1vZGUiOiJnbGFzcyIsImFjY2VudCI6IiNiNTdiZmYiLCJiYWNrZ3JvdW5kIjoiIzBhMDcxMyIsInRleHQiOiIjZjFlY2ZhIiwibXV0ZWRUZXh0IjoiIzlhOGZiYiIsImR5bmFtaWNBbGJ1bVRoZW1lIjpmYWxzZSwicGFuZWxBbHBoYSI6MC45LCJ1aVJvdW5kbmVzcyI6MS4zLCJnbGFzc0JsdXIiOjI2LCJnbGFzc1NhdHVyYXRlIjoxNzUsImJnRGltIjowLjUsImJnQXVyb3JhIjp7ImVuYWJsZWQiOnRydWUsImludGVuc2l0eSI6NjIsInNwZWVkIjo0NX0sImJnR3JpZCI6eyJlbmFibGVkIjpmYWxzZX0sImJnU3RhdGljIjp7InN0eWxlIjoibm9uZSIsImludGVuc2l0eSI6NzB9fX0
Animated backgrounds — a living backdrop
Behind your tiles you can run a gentle animation: stars, waves, aurora, a neon grid. Xenon ships ready-made ones you enable with a tap, and if you're curious, the animation is just a tiny piece of JavaScript you can write yourself.
The one-tap way
- Open Settings → Appearance → Animated background.
- Tap a template — Stars, Waves, Neon dots, Nebula, Aurora, Matrix, Confetti, Orbit or Plasma. It turns on instantly.
- Share it with Export background, or install someone else's with Import background.
A real one to import
Starfield Drift — a calm parallax starfield with a soft violet nebula glow. Copy, then Share & Import → Import.
eyJ4ZW5vblByZXNldCI6MSwiZXhwb3J0ZWRBdCI6IiIsImFwcFZlcnNpb24iOiI0LjUuMSIsImdyaWRDb2xzIjoyNCwia2luZCI6ImJnIiwibmFtZSI6IlN0YXJmaWVsZCBEcmlmdCIsImRhdGEiOnsibmFtZSI6IlN0YXJmaWVsZCBEcmlmdCIsImNvZGUiOiIvLyBTdGFyZmllbGQgRHJpZnQg4oCUIGEgY2FsbSwgYm91bmRlZCBwYXJhbGxheCBzdGFyZmllbGQuXG4vLyBDb250cmFjdDogZGVmaW5lIGRyYXcoY3R4LCB0LCB3LCBoKS4gdCA9IHNlY29uZHMgc2luY2Ugc3RhcnQuXG4vLyBGaXhlZCBzdGFyIGNvdW50LCBPKE4pIHBlciBmcmFtZSwgbm8gZ3Jvd2luZyBzdGF0ZSDigJQgc2FmZSB0byBydW4gZm9yZXZlci5cbmNvbnN0IE4gPSAxNTA7XG5sZXQgc3RhcnMgPSBudWxsO1xuXG5mdW5jdGlvbiBzZWVkKCkge1xuICBzdGFycyA9IFtdO1xuICBmb3IgKGxldCBpID0gMDsgaSA8IE47IGkrKykge1xuICAgIC8vIHogPSBkZXB0aCAoMC4yIG5lYXIgLi4gMSBmYXItaXNoKTsgciA9IGEgc3RhYmxlIHBlci1zdGFyIHJhbmRvbSBmb3IgY29sb3VyL3R3aW5rbGUuXG4gICAgc3RhcnMucHVzaCh7IHg6IE1hdGgucmFuZG9tKCksIHk6IE1hdGgucmFuZG9tKCksIHo6IDAuMjUgKyBNYXRoLnJhbmRvbSgpICogMC43NSwgcjogTWF0aC5yYW5kb20oKSB9KTtcbiAgfVxufVxuXG5mdW5jdGlvbiBkcmF3KGN0eCwgdCwgdywgaCkge1xuICBpZiAoIXN0YXJzKSBzZWVkKCk7XG5cbiAgLy8gRGVlcCBzcGFjZSB3YXNoIHdpdGggYSBmYWludCB2aW9sZXQgbmVidWxhIGdsb3cgYXQgdGhlIHRvcC5cbiAgY3R4LmZpbGxTdHlsZSA9ICcjMDYwNDBjJztcbiAgY3R4LmZpbGxSZWN0KDAsIDAsIHcsIGgpO1xuICBjb25zdCBnbG93ID0gY3R4LmNyZWF0ZVJhZGlhbEdyYWRpZW50KHcgKiAwLjUsIC1oICogMC4yLCAwLCB3ICogMC41LCAtaCAqIDAuMiwgaCAqIDEuMSk7XG4gIGdsb3cuYWRkQ29sb3JTdG9wKDAsICdyZ2JhKDE0MCwgOTAsIDIyMCwgMC4yOCknKTtcbiAgZ2xvdy5hZGRDb2xvclN0b3AoMSwgJ3JnYmEoNiwgNCwgMTIsIDApJyk7XG4gIGN0eC5maWxsU3R5bGUgPSBnbG93O1xuICBjdHguZmlsbFJlY3QoMCwgMCwgdywgaCk7XG5cbiAgZm9yIChsZXQgaSA9IDA7IGkgPCBzdGFycy5sZW5ndGg7IGkrKykge1xuICAgIGNvbnN0IHMgPSBzdGFyc1tpXTtcbiAgICBjb25zdCB4ID0gcy54ICogdztcbiAgICAvLyBOZWFyZXIgc3RhcnMgZHJpZnQgZmFzdGVyIChwYXJhbGxheCk7IHdyYXAgc21vb3RobHkgd2l0aCBtb2R1bG8uXG4gICAgY29uc3QgeSA9ICgoKHMueSArIHQgKiAwLjAxMiAqIHMueikgJSAxKSArIDEpICUgMSAqIGg7XG4gICAgY29uc3QgdHdpbmtsZSA9IDAuNTUgKyAwLjQ1ICogTWF0aC5zaW4odCAqIDEuOCArIHMuciAqIDYuMjgzKTtcbiAgICBjdHguZ2xvYmFsQWxwaGEgPSAoMC4yNSArIDAuNzUgKiBzLnopICogdHdpbmtsZTtcbiAgICBjdHguZmlsbFN0eWxlID0gcy5yID4gMC43MiA_ICcjYzdhM2ZmJyA6ICcjZGJlOGZmJztcbiAgICBjdHguYmVnaW5QYXRoKCk7XG4gICAgY3R4LmFyYyh4LCB5LCBzLnogKiAxLjUsIDAsIDYuMjgzKTtcbiAgICBjdHguZmlsbCgpO1xuICB9XG4gIGN0eC5nbG9iYWxBbHBoYSA9IDE7XG59XG4iLCJpbXBvcnRlZCI6dHJ1ZX19
Write your own (optional)
Under the templates there's a code box. Anything you type there runs in a locked sandbox — no internet, no access to your files. You define one function, draw, and Xenon calls it at the background's frame rate — 30 frames per second by default, adjustable from 10 to 60 right next to the editor. Here's the whole idea:
// t = seconds since start · w,h = size in pixels · ctx = a 2D canvas
function draw(ctx, t, w, h) {
ctx.fillStyle = '#06040c'; // paint the backdrop
ctx.fillRect(0, 0, w, h);
// a dot that circles the middle, once every ~6 seconds
const x = w/2 + Math.cos(t) * 120;
const y = h/2 + Math.sin(t) * 120;
ctx.fillStyle = '#b57bff';
ctx.beginPath();
ctx.arc(x, y, 6, 0, Math.PI * 2);
ctx.fill();
}
That's a complete, working background. Keep the work per frame small and steady and it runs forever without slowing anything down. It lives in the same locked sandbox as a widget (no network, no access to your files), so it's always safe to try one.
Frames per second — the performance dial
Every animated background carries its own frame-rate cap: how many times per second the scene is repainted. 30 is the default and the sweet spot for almost any scene — smooth to the eye, light on the machine. Near-static looks (a breathing gradient, a slow starfield) are happy at 10–20; reserve 60 for genuinely fast motion. Animation speed never changes — a lower cap only paints less often.
Theme authors set it when packaging (bgCustom.fps in a theme, or "backgroundFps" in pack.json). And it's always the user's machine, so the dial stays in their hands too: Settings → Animated background → Frames per second works even on imported backgrounds whose code is locked.
Dashboard pages — arrange your widgets
A page is a saved arrangement: which tiles are on screen and where they sit on the grid. You can have several pages and swipe between them — one for gaming, one for work, one for streaming.
- On the dashboard, tap Layout in the top bar to enter edit mode.
- Drag tiles to move them, drag a corner to resize. Tap + to add a widget from the palette, or Add page for a fresh one (up to 8).
- Give each tile a personal touch with per-tile Decor — a background picture, an ornamental frame, an overlay.
- Share it: Share & Import → Export page picks which page to hand out. Import re-checks every widget id, so a shared page can never sneak something in.
Deck profiles — your own control grid
The Deck widget is a Stream-Deck-style grid of keys. Each key can launch an app or website, control your media, run OBS or Discord, fire a hotkey, and more — and you style every key exactly how you like. Each profile also owns its device appearance: cap material, shape, faceplate, key-well artwork and music-strip styling. An imported profile changes only itself; profiles already on the Deck keep their original look.
- Add the Deck widget from the + palette in Layout mode.
- Tap a key to open Edit key: choose an icon or a picture face, a label, colours or a gradient, and the action it runs on tap / double-tap / hold.
- Open Personalizza to choose the profile's device theme. Fumetto makes the chassis, keys, icons and captions look hand-inked on paper; the live preview shows its full background before you apply it.
- Organise keys into folders and pages; group actions into a single key for multi-step macros.
- Share a profile with Export Deck profile. For safety, actions are rebuilt from scratch on export and import, and the import screen shows exactly which action types a profile contains before you add it.
Widgets — the easy way (no code)
A widget is a tile that does its own thing. The quickest way to make one is the built-in Create widget wizard — pick a template, tweak a few options with a live preview, and save. No files, no code.
- Go to Settings → Widgets & sharing → Create → Create widget.
- Pick a template: clock, system monitor, now-playing, label or countdown.
- Adjust the options (colours, size, what it shows) and watch the preview update. Press Save & install — it appears in your + palette.
- Share it with Export widget. Anyone who imports it approves its permissions once, then it's theirs.
WIDGET SDKBuild a widget, from scratch
Templates cover the common tiles. When you want something no template can do — your own design, your own data, your own logic — you build a widget. This is the complete reference: everything is on this page, nothing to look up elsewhere. It starts from "I've never written a widget" and goes all the way to the full protocol, so read as far as you need and stop.
What a widget actually is: a folder with an HTML file. Xenon runs it inside a locked sandbox — no internet, no access to your files or the dashboard — and it talks to Xenon through one small message channel. You ask for the data and controls you need, the user approves them once, and your widget drives the real thing. Nothing leaves the PC.
A folder
A manifest.json (its name and what it needs) and an index.html (what it shows). Optional CSS/JS, images and fonts alongside. That's it.
A sandbox
No network, no cookies, no reach into your files or the dashboard. It only ever receives the streams and actions you declared and the user granted.
One bridge
Your widget and Xenon exchange small messages. You say "hello", Xenon sends live data and the theme, you send actions back. That's the whole API.
1 · Your first widget: two files
A widget is at least two files in a folder: a manifest.json (its name and what it needs) and an index.html (what it shows). This one needs no data and no permissions. It shows the time and follows your dashboard's accent colour.
{
"api": 1,
"id": "neon-clock",
"name": "Neon Clock",
"entry": "index.html",
"streams": [], // no data needed
"actions": [] // no controls needed → the permission dialog says "nothing"
}
<!doctype html>
<meta charset="utf-8">
<style>
:root { --accent:#b57bff; --text:#f1ecfa; }
body { margin:0; display:grid; place-items:center; height:100vh;
background:transparent; color:var(--text); font-family:system-ui; }
#t { font-size:16vw; font-weight:700; color:var(--accent);
text-shadow:0 0 22px color-mix(in srgb,var(--accent),transparent 50%); }
</style>
<div id="t">--:--</div>
<script>
// use this tile's complete, contrast-checked palette
function applyTheme(theme) {
const p = theme && (theme.palette || theme); // fallback for older Xenon
if (!p) return;
if (p.accent) document.documentElement.style.setProperty('--accent', p.accent);
if (p.text) document.documentElement.style.setProperty('--text', p.text);
}
addEventListener('message', e => {
const m = e.data;
if (m && m.xenonSdk === 1 &&
(m.type === 'init' || m.type === 'theme')) applyTheme(m.theme);
});
// tick the clock every second
const pad = n => String(n).padStart(2,'0');
setInterval(() => {
const d = new Date();
document.getElementById('t').textContent = pad(d.getHours())+':'+pad(d.getMinutes());
}, 1000);
// say hello so Xenon starts talking to us
parent.postMessage({ xenonSdk:1, type:'hello' }, '*');
</script>
Two notes for beginners: the folder name must match the manifest id (neon-clock), and inline <script> only works inside your own widget file — in bigger widgets put your JS in a widget.js file and load it with <script src="widget.js"></script>.
Put it on your dashboard
- In Xenon, open Settings → Widgets & sharing and turn on third-party widgets.
- Put your neon-clock folder inside server/data/widgets/ — the folder Xenon watches for widgets.
- Add the Custom widget tile from the + palette and tap Rescan. Your widget appears in the picker. Approve its permissions (here, none) and it renders.
3 · The bridge: how your widget talks to Xenon
Your widget can't reach the network or the dashboard directly. Everything travels as messages. Each message is an object with xenonSdk: 1 and a type. You send messages to window.parent (target origin '*'); Xenon sends messages back to your window. Here is the entire conversation, start to finish:
- You say hello.
parent.postMessage({ xenonSdk:1, type:'hello' }, '*')kicks everything off. - Xenon sends
init. The current theme, the language, and exactly which streams and actions the user granted (which may be fewer than you asked for — always read this, don't assume). - Xenon replays your data. Right after
init, it sends the latest value of every granted stream, so you can paint immediately without waiting for the next update. - Xenon keeps you current. A
datamessage every time a stream changes, athememessage whenever the look changes, and asizemessage with your tile's pixel box + DPR (on mount and on every resize). - You act. Send an
actionto control something; Xenon replies with anaction_result(ok:true, or an error code).
The messages, exactly
// ── widget → Xenon (you send these to window.parent) ──
{ xenonSdk:1, type:'hello' }
{ xenonSdk:1, type:'action', id:42, action:{ type:'media', cmd:'playpause' } }
// ── Xenon → widget (you receive these in a 'message' listener) ──
{ xenonSdk:1, type:'init',
theme:{ appearance:'dark', overrides:['accent'],
palette:{
background:'#070808', surface:'#111314', surfaceAlt:'#16191a', control:'#1c2021',
text:'#f0f3f1', muted:'#a6b1ad', dim:'#7f8a86', line:'#46504c',
accent:'#1ed760', onAccent:'#111111',
success:'#45d483', warning:'#f0b84f', danger:'#ff6268', info:'#62cbea',
surfaceSoft:'rgba(22,25,26,0.92)', panelAlpha:0.92 // card surface at the user's panel opacity (null → use surfaceAlt)
} },
lang:'en', // en · it · ko · ja · zh
streams:['system'], // what the user actually granted
actions:['media'] }
{ xenonSdk:1, type:'data', stream:'system', data:{ /* the live payload, see §5 */ } }
{ xenonSdk:1, type:'theme', theme:{ /* same shape as init.theme */ } }
{ xenonSdk:1, type:'action_result', id:42, ok:true }
{ xenonSdk:1, type:'action_result', id:42, ok:false, error:'not_allowed' }
el.textContent = value, never el.innerHTML. The whole dashboard follows this rule and so should your widget.A real example: a live CPU meter
Same two files, but now the widget asks for the system stream and shows CPU load. Notice the flow: say hello, read init to see what was granted, then handle each data message.
{
"api": 1,
"id": "cpu-meter",
"name": "CPU Meter",
"entry": "index.html",
"streams": ["system"] // → the user is asked to grant "system"
}
<!doctype html>
<meta charset="utf-8">
<style>
:root { --accent:#1ed760; --text:#e9f2ee; }
body { margin:0; display:grid; place-items:center; height:100vh;
font-family:system-ui; color:var(--text); background:transparent; }
#v { font-size:14vw; font-weight:700; color:var(--accent); }
</style>
<div id="v">--%</div>
<script>
addEventListener('message', e => {
const m = e.data;
if (!m || m.xenonSdk !== 1) return;
// palette comes on init and whenever this tile's theme changes
if ((m.type === 'init' || m.type === 'theme') && m.theme) {
const p = m.theme.palette || m.theme;
if (p.accent) document.documentElement.style.setProperty('--accent', p.accent);
if (p.text) document.documentElement.style.setProperty('--text', p.text);
}
// live data for a stream we were granted
if (m.type === 'data' && m.stream === 'system') {
const cpu = Number(m.data && m.data.cpu);
document.getElementById('v').textContent =
Number.isFinite(cpu) ? Math.round(cpu) + '%' : '--%';
}
});
parent.postMessage({ xenonSdk:1, type:'hello' }, '*');
</script>
4 · manifest.json — every field
The manifest declares who your widget is and what it needs. Here's one that uses everything, followed by what each field means.
{
"api": 1,
"id": "my-widget",
"name": "My Widget",
"version": "1.0.0",
"author": "You",
"description": "One or two sentences shown in the picker and the permission dialog.",
"entry": "index.html",
"streams": ["system", "media"],
"actions": ["media", "volume"],
"hosts": ["api.example.com"],
"userHosts": [{ "id": "nas", "label": "NAS address", "scope": "private" }],
"hooks": ["my-event"],
"storage": true,
"storageGroup": "my-widget-set",
"secrets": true
}
| Field | Required | What it does |
|---|---|---|
api | Yes | Must be 1. The protocol is versioned so future Xenon versions stay compatible. |
id | No | If present, must equal the folder name. Rules: lowercase letters, digits and dashes, 2–41 chars. |
name | Yes | Shown in the picker. Up to 60 characters. |
version, author, description | No | Shown to the user (description up to 200 characters). |
entry | No | The HTML file to load. Defaults to index.html. Must sit in the package root. |
streams | No | The live data you want to read. Full list in §5. |
actions | No | The categories of things you want to control. Full list in §6. |
hosts | No | Up to 8 hostnames your widget may reach through Xenon's fetch proxy (§7). A LAN address is fine; loopback and link-local names are rejected. |
userHosts | No | Up to 4 addresses the user types in, for servers you can't know in advance — a NAS, Docker, a printer (§7). |
hooks | No | Up to 8 hook ids a local app may push events to (§7). |
surface | No | "tile" (default) or "ambient" for a fullscreen scene (§10). |
deck | No | Deck contributions: macros, published states and code-run handlers (§9). |
background | No | true lets Deck handlers, persistent badges or advanced Dynamic Island activities run headless, with no tile on screen. |
storage | No | true gives your widget a small key/value store that survives updates (§8). |
storageGroup | No | A shared-store id so a set of sibling widgets share one store. Implies storage (§8). |
secrets | No | true gives you a write-only vault for API keys, used via {{secret:NAME}} in requests (§8). |
island | No | true keeps the short plain-text line API. { "dynamic": true } requests a separate advanced permission for Xenon-rendered Live Activities: text, icons, progress, playback bars, selected built-in readouts and up to two action buttons; timed takeover cards animate in/out and restore the previous content. Works in Full and Minimal. See the SDK guide's Dynamic Island chapter. |
badge | No | true adds a small persistent host-rendered chip next to the clock. The user can disable each installed package from Settings → Dynamic Island. |
clipboard | No | true lets your widget ask to copy text to the clipboard. It can never copy silently and can never read the clipboard: each copy raises a Xenon confirmation the user taps. Right for a password or a one-time code. See the SDK guide's Clipboard chapter. |
accent | No | true lets your widget tint the dashboard's accent colour while it runs — the same channel the album-art theme uses, for players Windows never reports (Plex, a console, a game overlay). The accent only, never saved, and handed back automatically when your widget is removed or paused. See the SDK guide's Dashboard accent chapter. |
streams/actions widget shows a "nothing" permission summary, which is exactly what users trust most.5 · Data streams — everything you can read
List any of these in streams. Live streams arrive as unchanged data messages and replay their latest cached value after init. The richer Discord snapshots are loaded on demand while their UI is visible. Every string in a payload is display text: render it with textContent.
| Stream | What it carries |
|---|---|
system | CPU %, GPU % (or null), memory, temperatures, uptime. |
status | Mic mute state, game mode / activity, foreground app. |
media | Now playing: title, artist, album, playback state, source. |
audio | Master volume, mute, output device, plus speakerApps[] — the per-app mixer, with each session's process name, volume, mute and icon. Polled, so about every 8 seconds. |
audioLevels | How loud each app actually is, right now: { "discord": 0.42 }, peak per process in 0..1, ~12×/s. Needs Xenon Helper, so design for it being absent. There is no extra switch: the user granting this stream is what starts the measurement. Silent apps are omitted rather than sent as 0. |
weather | The user's weather (current conditions and forecast). |
stocks | The quotes and indices the user follows. |
football | Followed teams: fixtures, live scores, results. |
news | Merged headlines from the user's news sources. |
claude | Local Claude Code usage aggregate (the "Xenon Pulse" data). |
obs | OBS state: current scene, recording / streaming flags, audio sources. |
discord | Discord voice: connected, mute / deafen, channel, who's speaking. |
discordChannels | Discord servers, voice channels and their live member roster. Lazy: request a refresh when the Channels view opens. |
discordSoundboard | The connected account's Soundboard catalog. Lazy and cached for 60 seconds. |
discordNotifications | Private DM / mention notifications, respecting the user's privacy setting. Privacy: request this separate grant only when notification content is essential. |
streamerbot | Streamer.bot connection, globals and activity events. |
homeassistant | Home Assistant device / entity states. Privacy: this is your smart-home state, grant it deliberately. |
wavelink | Elgato Wave Link mixer state: inputs, faders, mute flags, monitor mix. |
tasks | { tasks: [...] } — the to-do list, pushed on every change (each task has an id). |
agenda | { events: [...] } — calendar events, pushed on every change. |
notes | { notes: [...] } — the user's notes. Privacy: private scratchpad text, grant it deliberately. |
battery | { devices: [...], sources: {...} } — wireless peripheral battery levels (Corsair via iCUE + Bluetooth), on a ~90s tick. |
system payload as fans, power and sensorAccess. Three things to get right: a fan carries rpm or pct (never both), the GPU fan is the one with kind: 'gpu' (never match on name), and every watt reading is nullable — since Number(null) is 0, guard with v != null first or you'll render a confident "0 W" that isn't real. Full payloads and the sensorAccess states are in the SDK reference.{ xenonSdk:1, type:'refresh', id:7, stream:'discordChannels' }. Xenon sends the normal data payload plus refresh_result. The stream name selects one fixed local source — packages cannot provide a URL — and hidden or ungranted widgets are rejected.6 · Actions — everything you can control
List a category in actions and it unlocks that category's action types. Send { xenonSdk:1, type:'action', id:<yours>, action:{ type, …params } } and you get an action_result back. Everything is re-checked on Xenon's side (the same gate Deck keys pass), and actions are limited to about one every 250 ms per widget.
| Category | Action types & params |
|---|---|
media | { type:'media', cmd:'playpause'|'next'|'previous' } |
volume | { type:'volume', mode:'mute'|'up'|'down'|'set', value }, plus per-app appVolume / appMute (app = the proc from the audio stream). appVolume with set does not unmute — send appMute as well. |
audioDevice | { type:'audioDevice', device } — move the sound to another output device. device is the id of an entry in the audio stream's speakers[]. Separate grant from volume: "change the volume" is not "choose my speakers". The server checks the id against the live output list, so a microphone id is refused; there is no action for input devices. |
mic | { type:'micMute', mode:'toggle'|'mute'|'unmute' } |
lighting | The whole RGB rig (iCUE + WLED / Hue / Nanoleaf / OpenRGB / HA lights): lightPower, lightColor, lightAuto, lightEffect, lightDevice. Needs lighting configured in Settings. |
chroma | Razer Chroma per device: chromaColor, chromaOff (device: all/keyboard/mouse/…). Needs Chroma enabled. |
wavelink | Elgato Wave Link faders / mutes / monitoring (targets come from the wavelink stream). Needs Wave Link enabled. |
spotify | spotifyPlay, spotifyNext, spotifyPrev, spotifyLike, spotifyShuffle, spotifyVolume, spotifySeek, spotifyPlaylist, spotifyDevice… (playback control needs Premium). Needs Spotify connected. |
obs | obsScene, obsSceneNext, obsRecord, obsStream, obsMute, obsInputVolume. Needs OBS connected. |
discord | discordMute, discordDeafen, discordPtt, discordJoin, discordLeave, volume, soundboard. Needs Discord running. |
homeassistant | haToggle, haLight, haMedia, haCover, haClimate, haScene, haScript, haButton… Needs HA configured. |
twitch | twitchClip, twitchMarker, twitchAd, twitchTitle, twitchGame, twitchChat, twitchShoutout. Needs Twitch connected. |
youtube | ytBroadcast — start / stop your broadcast. Needs YouTube connected. |
streamerbot | sbDoAction, sbSendMessage, sbCodeTrigger. Needs Streamer.bot connected. |
url | { type:'openUrl', url:'https://…' } (http / https only) — opens in the default browser, on whichever monitor Windows chooses. |
browser | { type:'browserOpen', url:'https://…', expand:true } — show the page in the Browser tile on the dashboard, so it stays on the Xeneon Edge with touch working. Needs a Browser tile visible. |
tasks | { type:'taskAdd', text }, taskToggle, taskDelete — edit the same to-do list the Tasks tile shows (pair with the tasks stream to read ids). |
A real example: a mute button
A one-key widget that toggles your microphone. It asks for the mic action category, and sends a micMute action on tap.
<!doctype html>
<meta charset="utf-8">
<style>
body { margin:0; height:100vh; display:grid; place-items:center; background:transparent; }
button { font:600 5vw system-ui; padding:.5em 1em; border-radius:16px; cursor:pointer;
border:1px solid #2a3a34; color:#e9f2ee; background:#0e1512; }
</style>
<button id="b">🎙 Mute</button>
<script>
let n = 0;
document.getElementById('b').addEventListener('click', () => {
parent.postMessage({ xenonSdk:1, type:'action', id:++n,
action:{ type:'micMute', mode:'toggle' } }, '*');
});
addEventListener('message', e => {
const m = e.data;
if (m && m.xenonSdk === 1 && m.type === 'action_result' && !m.ok)
console.log('action failed:', m.error); // e.g. 'not_allowed' if the user declined mic
});
parent.postMessage({ xenonSdk:1, type:'hello' }, '*');
</script>
7 · Reaching the internet, tiles & webhooks
Your widget has no direct network, ever — the sandbox blocks fetch, XHR and WebSockets. For the two cases where you need the outside world, Xenon gives you safe, mediated channels.
Calling an API (the fetch proxy)
Name the hostnames you need in the manifest's hosts, then ask Xenon to make the request for you and hand back the result.
// manifest: "hosts": ["api.example.com"]
parent.postMessage({
xenonSdk:1, type:'fetch',
id:7, // your correlation id
url:'https://api.example.com/v1/data',
method:'GET',
headers:{ 'Accept':'application/json' }
}, '*');
addEventListener('message', e => {
const m = e.data;
if (m && m.xenonSdk === 1 && m.type === 'fetch_result' && m.id === 7) {
if (m.ok) { const json = JSON.parse(m.body); /* … */ }
else console.log('fetch error:', m.error); // host_not_allowed · rate_limited · timeout …
}
});
https://to any declared host; plainhttp://only to LAN gear (private IPs,*.local, single-word names).- Loopback and link-local are unreachable, even if a hostname resolves to
127.0.0.1— blocked at connect time. - Redirects are not followed (you get the status and location and decide). Headers are limited to
accept,accept-language,content-type,authorizationandx-*. - Responses cap at 1 MB; text arrives as UTF-8, binary as base64. Rate limit: about one request per second.
127.0.0.1 endpoint, which the sandbox blocks on purpose. Don't try to reach them directly — use the matching chroma / wavelink action category instead and let Xenon do the local talking.Addresses only the user knows (userHosts)
hosts works when you know the address as you build the widget. It doesn't when the server lives on the user's network — a NAS, a Docker host, a printer, a self-hosted Plex. You can't know that address, and hard-coding your own would make the widget useless to everyone else.
So declare a named blank instead. The user fills it in when they approve your widget, and you read it back from init.
// manifest:
// "userHosts": [{ "id": "nas", "label": "NAS address", "scope": "private" }]
// init hands you what they typed, with a ready-made base:
// userHosts: { nas: { host:'192.168.1.50', port:32400, scheme:'http',
// base:'http://192.168.1.50:32400' } }
const nas = init.userHosts.nas;
if (nas) {
parent.postMessage({ xenonSdk:1, type:'fetch', id:8,
url: nas.base + '/library/sections' }, '*');
}
labelis the text above the field — say plainly what you want ("Plex server address").scopeis"private"(default, the user's own network only) or"any"(also a public domain, for a self-hosted service).- The user can type
192.168.1.50:32400,nas.localorhttps://plex.example.com— all fine. Usebaserather than rebuilding the URL yourself. Ports aren't part of the allowlist, so a slot covers any port on that host. - A slot they left empty is simply absent from
init.userHosts— but in practice Xenon won't mount you until every declared slot has an address, and asks them for it instead. - The address is stored with the user's grant, never in your package: it doesn't travel when the widget is shared, and it survives updates. Users change it later from the Store's Installed list → Address.
- Up to 4 slots. Ask for as few as you can — each one is a field a person has to understand and type.
"private" really does pin it to the LAN.Local webhooks (a local app pushes to you)
Declare hook ids in hooks, and any local process (Streamer.bot, AutoHotkey, a script) can push an event straight into your widget.
POST http://127.0.0.1:3030/sdk/hook/<your-package-id>/<hook-id>
Content-Type: application/json
{ "anything": "up to 64 KB" }
addEventListener('message', e => {
const m = e.data;
if (m && m.xenonSdk === 1 && m.type === 'hook') {
console.log('hook', m.hook, m.data); // JSON arrives parsed
}
});
Delivery is live-only: if no dashboard is open the event is dropped and the sender is told so. Hooks reach your widget even on a page that isn't currently on screen, so you can turn them into Deck states (next).
Map & radar tiles
A slippy map (a weather radar, a location picker) needs many small image tiles, which are too slow to pull one-by-one through the fetch proxy. Point your map library's tile layer straight at the same-origin tile route instead — it's allowed by the widget sandbox with no relaxation, and goes through the exact same host allowlist and safety checks as the fetch proxy, with a built-in cache.
// init tells you your package id: { type:'init', pkgId:'weather-radar', … }
// manifest: "hosts": ["tile.example.com"]
const tile = (u) => `/sdk/tile/${pkgId}?u=${encodeURIComponent(u)}`;
L.tileLayer(tile('https://tile.example.com/{z}/{x}/{y}.png'), { /* … */ });
The tile host must be in your hosts. Images only, cached briefly so panning back doesn't re-hit the server, and rate-limited per widget. Bundle the map library (Leaflet's JS/CSS/marker images) inside your package as usual.
8 · Saving settings & secrets
The sandbox gives your widget no cookies and no localStorage, so Xenon hands you two host-mediated stores instead — both opt-in from the manifest, both shown in the permission dialog, and both living outside your package folder so an update that refreshes your widget never wipes the user's data.
Persistent storage — a widget's own settings
Add "storage": true and you get a small key/value store for the things your widget should remember: the teams a user follows, the sources they picked, a map's last centre and zoom.
parent.postMessage({ xenonSdk:1, type:'store', id:1,
op:{ op:'set', key:'teams', value:[64, 65] } }, '*');
parent.postMessage({ xenonSdk:1, type:'store', id:2,
op:{ op:'get', key:'teams' } }, '*');
addEventListener('message', e => {
const m = e.data;
if (m && m.xenonSdk === 1 && m.type === 'store_result' && m.id === 2)
console.log('teams:', m.value); // [64, 65] (null if never set)
});
- Ops:
set(key, value),get,delete,keys,clear. Values are any JSON value. - Caps: up to 16 KB per value, 128 keys, 256 KB per store — over that you get
value_too_large/too_many_keys/store_full. - Share across widgets: give several packages the same
"storageGroup": "my-set"and they read and write one shared store — perfect for a suite of sibling widgets that keep a common list.
Secrets & API keys — never shipped, never seen
A widget you publish must contain no API keys, and the sandboxed page should never hold one where an update could log it. Add "secrets": true and you get a write-only vault: you can store a key and use it, but you can never read it back.
// Save it once — e.g. from a settings field the user fills in:
parent.postMessage({ xenonSdk:1, type:'secret', id:1,
op:{ op:'set', name:'apiKey', value:userInput } }, '*');
// Use it: {{secret:NAME}} is filled in by Xenon just before the request
// leaves — the key never travels through your widget.
parent.postMessage({ xenonSdk:1, type:'fetch', id:7,
url:'https://api.football-data.org/v4/matches',
headers:{ 'X-Auth-Token': '{{secret:apiKey}}' } }, '*');
- Ops:
set(name, value),delete,names,has. There is deliberately noget— a read never returns a value. - Reference a secret with
{{secret:NAME}}anywhere in afetch's url, headers or body (a key-in-URL API like TheSportsDB works too). Substitution can never move the request to a different host. - Up to 16 secrets, 4 KB each. A placeholder for a secret you haven't stored fails the request rather than sending the literal token.
9 · Deck integration
A widget can add keys to the Deck (Xenon's Stream-Deck-style grid) three ways. All of them are declared under a deck object in the manifest.
Macros — bundle built-in actions into one key
A macro is a named, multi-step sequence of the same actions you can send over the bridge. It appears in the Deck editor under "Widgets" as Your widget › Macro name.
"deck": {
"actions": [
{ "id": "quiet", "name": "Quiet mode",
"steps": [
{ "action": { "type": "volume", "mode": "mute" } },
{ "action": { "type": "micMute", "mode": "mute" }, "delayMs": 200 }
] }
]
}
Every category a macro touches must also be in your top-level actions (so the user is asked to grant it). Up to 8 macros, 10 steps each; per-step delay up to 5 s.
Published states — light a key from your widget
Declare state ids, then publish values whenever they change. In the Deck editor, "Reflect a widget state" binds any key to your state: the key stays lit while the value is truthy. You can even carry a live label, icon and colour for the key face.
// manifest: "deck": { "states": [{ "id": "viewers", "name": "Viewers" }] }
parent.postMessage({ xenonSdk:1, type:'state',
id:'viewers', value:1234,
label:'LIVE 1.2k', color:'#ff3355' }, '*'); // label ≤24, color #hex
Handler actions — your code answers a key
Where a macro composes built-in actions, a handler is a Deck key answered by your own code, with a real config form the user fills in. Declare up to 8, each with up to 4 params (text / select / number).
"deck": {
"handlers": [
{ "id": "post", "name": "Post a message", "params": [
{ "name": "text", "label": "Message", "kind": "text" }
] }
]
}
addEventListener('message', e => {
const m = e.data;
if (m && m.xenonSdk === 1 && m.type === 'handler' && m.handler === 'post') {
doYourThing(m.args.text); // use granted actions / fetch / state as usual
// ack so the key can report success (first ack wins; no ack in ~3s → key flashes red)
parent.postMessage({ xenonSdk:1, type:'handler_ack', callId:m.callId, ok:true }, '*');
}
});
Add "background": true (top level) and your handlers can run headless — Xenon mounts a hidden frame so your Deck keys answer even with no tile on screen.
10 · Ambient scenes (fullscreen widgets)
Add "surface": "ambient" to your manifest and your package becomes an Ambient scene — a fullscreen screensaver the user selects in Settings → Ambient instead of placing as a tile. Everything else is identical: same folder, same sandbox, same bridge, same permission dialog, same Export / Import.
- You render the whole screen. It's landscape and watched from a distance, so design big, calm and dim — near-black backgrounds are kind to an always-on display. No scrolling (
overflow: hidden). - Keep the top-right corner clear — Xenon draws an exit ✕ there (Escape also closes). Everywhere else, taps reach your page.
- The clock is yours (use
Datein-frame). Live data still arrives over your granted streams, exactly like a tile. - Pause when hidden: gate your
requestAnimationFrameloop ondocument.hidden— scenes open after long idle and pause during games.
11 · Versioning & the security model
Versioning. api: 1 is the contract on this page. Breaking changes would ship as api: 2 with a migration window; Xenon rejects a manifest whose api it doesn't support, so your widget never half-works.
Why this is safe (worth knowing, whichever side you're on):
- The iframe is sandboxed without
allow-same-origin: the document has an opaque origin and can't touch the dashboard's origin or storage. - Every asset is served with
connect-src 'none'; sandbox allow-scripts, so even opening the widget URL directly stays offline and sandboxed. - The manifest is re-validated on Xenon's side; unknown streams / actions are dropped; asset paths are allowlisted by segment and extension.
- Xenon forwards only the streams the user granted and dispatches only granted actions; every action is re-checked in the server's action registry.
- All network goes through the host proxy: the host allowlist is the authority, loopback is unreachable even via DNS tricks, redirects aren't followed, sizes are bounded. Your widget never holds a network primitive.
- Storage and secrets live on the server, outside your package folder. Secrets are write-only — the frame can never read one back — and are filled into a request only for the hosts you already declared, server-side.
Ambient scenes — a fullscreen screensaver
When your dashboard sits idle it can drift into an Ambient scene: a fullscreen canvas with a huge clock, the weather, what's playing, your agenda, live stats, text and shapes — placed anywhere, in any colour. It's the showpiece look.
- Install one from a share code or the catalog via Share & Import → Import. It lands under Settings → Ambient → My scenes.
- Set it as your screensaver there, and it appears whenever the dashboard is idle.
Full packages — share everything at once
Made a whole look — a theme, a couple of pages, a background, your favourite widgets — and want to hand someone the entire thing? Bundle it into one package code.
- Go to Share & Import → Export package.
- Tick what to include: theme (with its font), pages, Deck profiles, background, your own widgets and Ambient scenes.
- You get a single code. When someone imports it, each part is re-checked on the way in, and any widgets ask for their permissions — exactly like installing them one by one, just in one step.
- To uninstall it later, open Share & Import → Installed content. Xenon shows every resource that download added and Remove all deletes them together without touching the user's own creations.
Publish to the community catalog
Proud of something? Put it in the community catalog so anyone can find and install it. It's free, moderated by hand, and keeps your name on your work.
- Export your creation (theme, page, widget, package…).
- In the share dialog, hit ✨ Publish to the catalog. It copies your code and opens a pre-filled submission form on GitHub.
- Add a title, a short description and a screenshot, then submit. A maintainer reviews it and, once merged, it shows up in the catalog for everyone.
There's no hidden backend and nothing auto-publishes — you're always in control of what goes out and when.