XENON Create
Home / Create
The maker's guide

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.

Nebula — a theme codereal · ready to import
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

Easiest

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.

Fun

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.

Powerful

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.

🎨 Theme✨ Animated background🧩 Page layout🎛️ Deck profile📦 Widget🌙 Ambient scene🖼️ Icon pack🔊 Sound pack🎁 Full package
I want to…Make thisNeed code?Jump to
Change the colours & overall lookThemeNoThemes →
Add a living, moving backdropAnimated backgroundNo (templates) / optionalBackgrounds →
Arrange which widgets go wherePage layoutNoPages →
Build a Stream-Deck-style key gridDeck profileNoDeck →
Add a clock / monitor / countdown tileWidget (from a template)NoWidgets →
Build a completely custom widgetWidget (SDK)YesSDK →
Design a fullscreen screensaverAmbient sceneSomeAmbient →
Ship a set of key icons for the DeckIcon pack (SVG/PNG files)NoSettings → Widgets & sharing → Create icon pack
Ship a soundboard (clips for Deck keys)Sound pack (MP3/OGG/WAV)NoSettings → Widgets & sharing → Create sound pack
Share my whole setup at onceFull packageNoPackages →

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.

  1. 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).
  2. 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.
  3. 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.
Good to know: importing is always safe. Themes and pages carry only values (colours, positions) that Xenon re-checks on the way in. Widgets and scenes ask for permission before they can do anything, and a shared preset can never run code on your machine.

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

  1. Open Settings → Appearance.
  2. 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.
  3. 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.
  4. Adjust panel opacity, blur and roundness until the material feels right. Everything updates live across widgets, Settings, menus and Deck tools.
  5. Tap Save current theme to keep it as a card you can switch back to anytime.
  6. 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 roleWhat it controls
backgroundApp canvas behind all panels
surface / surfaceAltPanels, cards, rows and raised surfaces
controlColorInputs, selects, buttons and control wells
text / mutedTextPrimary copy, labels and secondary details
lineColorBorders, dividers and outlines
accent / accentTextActions and the readable foreground placed on the accent
successColor, warningColor, dangerColor, infoColorShared status language across every widget and integration
contrastGuardAutomatic readability repair; defaults to true
theme.json — semantic palettecomplete contract
{
  "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.

theme.json — one card, two tonesv4.6.0+
{
  "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.

Check both halves. The contrast guard runs per tone, and a status colour tuned for light paper is usually unreadable on a dark board. Give each half its own 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

  1. Turn on Layout and tap the paint control on the widget.
  2. Switch from Follow theme to Custom, then change only the roles that tile needs. Unset roles continue to inherit the global theme.
  3. The local palette is contrast-checked, saved with the layout, included in backups and page presets, and passed into sandboxed SDK widgets.
  4. 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.

Nebula — themereal · ready to import
eyJ4ZW5vblByZXNldCI6MSwiZXhwb3J0ZWRBdCI6IiIsImFwcFZlcnNpb24iOiI0LjUuMSIsImdyaWRDb2xzIjoyNCwia2luZCI6InRoZW1lIiwibmFtZSI6Ik5lYnVsYSIsImRhdGEiOnsiYXBwZWFyYW5jZSI6ImRhcmsiLCJzdHlsZU1vZGUiOiJnbGFzcyIsImFjY2VudCI6IiNiNTdiZmYiLCJiYWNrZ3JvdW5kIjoiIzBhMDcxMyIsInRleHQiOiIjZjFlY2ZhIiwibXV0ZWRUZXh0IjoiIzlhOGZiYiIsImR5bmFtaWNBbGJ1bVRoZW1lIjpmYWxzZSwicGFuZWxBbHBoYSI6MC45LCJ1aVJvdW5kbmVzcyI6MS4zLCJnbGFzc0JsdXIiOjI2LCJnbGFzc1NhdHVyYXRlIjoxNzUsImJnRGltIjowLjUsImJnQXVyb3JhIjp7ImVuYWJsZWQiOnRydWUsImludGVuc2l0eSI6NjIsInNwZWVkIjo0NX0sImJnR3JpZCI6eyJlbmFibGVkIjpmYWxzZX0sImJnU3RhdGljIjp7InN0eWxlIjoibm9uZSIsImludGVuc2l0eSI6NzB9fX0
Tip: want the assistant to invent one? Turn on Xenon AI in Settings and say "make me a warm sunset theme." It builds it instantly — then you can save and export it just like any other.

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

  1. Open Settings → Appearance → Animated background.
  2. Tap a template — Stars, Waves, Neon dots, Nebula, Aurora, Matrix, Confetti, Orbit or Plasma. It turns on instantly.
  3. 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.

Starfield Drift — backgroundreal · ready to 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:

the draw() contract
// 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.

  1. On the dashboard, tap Layout in the top bar to enter edit mode.
  2. 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).
  3. Give each tile a personal touch with per-tile Decor — a background picture, an ornamental frame, an overlay.
  4. 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.
Prefer to describe it? With Xenon AI's Genesis turned on, "build me a streaming page with now-playing, chat and a big clock" composes the whole layout for you.

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.

  1. Add the Deck widget from the + palette in Layout mode.
  2. 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.
  3. 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.
  4. Organise keys into folders and pages; group actions into a single key for multi-step macros.
  5. 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.

  1. Go to Settings → Widgets & sharing → Create → Create widget.
  2. Pick a template: clock, system monitor, now-playing, label or countdown.
  3. Adjust the options (colours, size, what it shows) and watch the preview update. Press Save & install — it appears in your + palette.
  4. Share it with Export widget. Anyone who imports it approves its permissions once, then it's theirs.
Widgets you make with the wizard stay editable — reopen them anytime to change your mind. When a template can't do what you're picturing, that's your cue for the SDK, next.

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.

You don't need to code to have a widget. The no-code Create widget wizard builds clocks, monitors, countdowns and more with zero code. The SDK is for when you want to go past the templates.

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.

neon-clock/manifest.json
{
  "api": 1,
  "id": "neon-clock",
  "name": "Neon Clock",
  "entry": "index.html",
  "streams": [],   // no data needed
  "actions": []    // no controls needed → the permission dialog says "nothing"
}
neon-clock/index.html
<!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

  1. In Xenon, open Settings → Widgets & sharing and turn on third-party widgets.
  2. Put your neon-clock folder inside server/data/widgets/ — the folder Xenon watches for widgets.
  3. 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.
While you build: change a file, tap Rescan, see it update. Because it's sandboxed, a bug in your widget can never touch the dashboard or your PC — the worst case is your own tile misbehaving.

2 · From your folder to a shareable code

Your folder lives on your PC. To give your widget to someone else, Xenon packs the whole folder — every file — into one self-contained code (a copyable string, or a .json). That's why the code looks so long: it carries all your files inside it, encoded, so whoever imports it gets your exact widget with nothing to download and no folder to assemble.

  1. Install your widget first (the three steps above).
  2. Go to Settings → Widgets & sharing → Share & Import → Export widget and pick it.
  3. Xenon hands you the code (or a .json file). Send it to anyone. They paste it into Import, see exactly what it needs, approve, and it's theirs.

Here is the Neon Clock from step 1, packed into its code. This single string is those two files. Copy it, open Share & Import → Import in Xenon, and you'll get the exact widget:

Neon Clock — packed widget codereal · ready to import
eyJ4ZW5vblByZXNldCI6MSwiZXhwb3J0ZWRBdCI6IiIsImFwcFZlcnNpb24iOiI0LjUuMSIsImdyaWRDb2xzIjoyNCwia2luZCI6IndpZGdldCIsIm5hbWUiOiJOZW9uIENsb2NrIiwiZGF0YSI6eyJpZCI6Im5lb24tY2xvY2siLCJuYW1lIjoiTmVvbiBDbG9jayIsInN1cmZhY2UiOiJ0aWxlIiwic3RyZWFtcyI6W10sImFjdGlvbnMiOltdLCJob3N0cyI6W10sImhvb2tzIjpbXSwicGF5bG9hZCI6eyJpZCI6Im5lb24tY2xvY2siLCJmaWxlcyI6W3sicGF0aCI6ImluZGV4Lmh0bWwiLCJkYXRhIjoiUENGa2IyTjBlWEJsSUdoMGJXdytDanhvZEcxc0lHeGhibWM5SW1WdUlqNEtQR2hsWVdRK0NpQWdQRzFsZEdFZ1kyaGhjbk5sZEQwaWRYUm1MVGdpUGdvZ0lEeHRaWFJoSUc1aGJXVTlJblpwWlhkd2IzSjBJaUJqYjI1MFpXNTBQU0ozYVdSMGFEMWtaWFpwWTJVdGQybGtkR2dzSUdsdWFYUnBZV3d0YzJOaGJHVTlNU0krQ2lBZ1BHeHBibXNnY21Wc1BTSnpkSGxzWlhOb1pXVjBJaUJvY21WbVBTSjNhV1JuWlhRdVkzTnpJajRLSUNBOGRHbDBiR1UrVG1WdmJpQkRiRzlqYXp3dmRHbDBiR1UrQ2p3dmFHVmhaRDRLUEdKdlpIaytDaUFnUEdScGRpQmpiR0Z6Y3owaVkyeHZZMnNpUGdvZ0lDQWdQR1JwZGlCamJHRnpjejBpZEdsdFpTSWdhV1E5SW5ScGJXVWlQaTB0T2kwdFBDOWthWFkrQ2lBZ0lDQThaR2wySUdOc1lYTnpQU0prWVhSbElpQnBaRDBpWkdGMFpTSStKbTVpYzNBN1BDOWthWFkrQ2lBZ1BDOWthWFkrQ2lBZ1BDRXRMU0JPYnlCcGJteHBibVVnUEhOamNtbHdkRDQ2SUhSb1pTQnpZVzVrWW05NElFTlRVQ0JwY3lCelkzSnBjSFF0YzNKaklDZHpaV3htSnk0Z1RHOW5hV01nYkdsMlpYTWdhVzRnZDJsa1oyVjBMbXB6TGlBdExUNEtJQ0E4YzJOeWFYQjBJSE55WXowaWQybGtaMlYwTG1weklqNDhMM05qY21sd2RENEtQQzlpYjJSNVBnbzhMMmgwYld3K0NnPT0ifSx7InBhdGgiOiJtYW5pZmVzdC5qc29uIiwiZGF0YSI6ImV3b2dJQ0poY0draU9pQXhMQW9nSUNKcFpDSTZJQ0p1Wlc5dUxXTnNiMk5ySWl3S0lDQWlibUZ0WlNJNklDSk9aVzl1SUVOc2IyTnJJaXdLSUNBaWRtVnljMmx2YmlJNklDSXhMakF1TUNJc0NpQWdJbUYxZEdodmNpSTZJQ0paYjNVaUxBb2dJQ0prWlhOamNtbHdkR2x2YmlJNklDSkJJR0pwWnl3Z1oyeHZkMmx1WnlCamJHOWpheTRnVG1WbFpITWdibThnWkdGMFlTQmhibVFnYm04Z2NHVnliV2x6YzJsdmJuTWc0b0NVSUdsMElHcDFjM1FnYzJodmQzTWdkR2hsSUhScGJXVWdhVzRnZVc5MWNpQmtZWE5vWW05aGNtUW5jeUJoWTJObGJuUWdZMjlzYjNWeUxpSXNDaUFnSW1WdWRISjVJam9nSW1sdVpHVjRMbWgwYld3aUxBb2dJQ0p6ZEhKbFlXMXpJam9nVzEwc0NpQWdJbUZqZEdsdmJuTWlPaUJiWFN3S0lDQWlhRzl6ZEhNaU9pQmJYU3dLSUNBaWFHOXZhM01pT2lCYlhRcDlDZz09In0seyJwYXRoIjoid2lkZ2V0LmNzcyIsImRhdGEiOiJPbkp2YjNRZ2V5QXRMV0ZqWTJWdWREb2dJMkkxTjJKbVpqc2dMUzEwWlhoME9pQWpaakZsWTJaaE95QjlDaW9nZXlCaWIzZ3RjMmw2YVc1bk9pQmliM0prWlhJdFltOTRPeUJ0WVhKbmFXNDZJREE3SUgwS2FIUnRiQ3dnWW05a2VTQjdJR2hsYVdkb2REb2dNVEF3SlRzZ2ZRcGliMlI1SUhzS0lDQmthWE53YkdGNU9pQm5jbWxrT3lCd2JHRmpaUzFwZEdWdGN6b2dZMlZ1ZEdWeU93b2dJR1p2Ym5RdFptRnRhV3g1T2lCemVYTjBaVzB0ZFdrc0lDMWhjSEJzWlMxemVYTjBaVzBzSUNKVFpXZHZaU0JWU1NJc0lITmhibk10YzJWeWFXWTdDaUFnWTI5c2IzSTZJSFpoY2lndExYUmxlSFFwT3dvZ0lHSmhZMnRuY205MWJtUTZJSFJ5WVc1emNHRnlaVzUwT3dvZ0lDMTNaV0pyYVhRdFptOXVkQzF6Ylc5dmRHaHBibWM2SUdGdWRHbGhiR2xoYzJWa093cDlDaTVqYkc5amF5QjdJSFJsZUhRdFlXeHBaMjQ2SUdObGJuUmxjanNnZFhObGNpMXpaV3hsWTNRNklHNXZibVU3SUgwS0xuUnBiV1VnZXdvZ0lHWnZiblF0YzJsNlpUb2dZMnhoYlhBb016UndlQ3dnTWpKMmR5d2dNVEl3Y0hncE93b2dJR1p2Ym5RdGQyVnBaMmgwT2lBM01EQTdDaUFnYkdsdVpTMW9aV2xuYUhRNklERTdDaUFnYkdWMGRHVnlMWE53WVdOcGJtYzZJREF1TURGbGJUc0tJQ0JqYjJ4dmNqb2dkbUZ5S0MwdFlXTmpaVzUwS1RzS0lDQjBaWGgwTFhOb1lXUnZkem9nTUNBd0lESXljSGdnWTI5c2IzSXRiV2w0S0dsdUlITnlaMklzSUhaaGNpZ3RMV0ZqWTJWdWRDa2dOVFVsTENCMGNtRnVjM0JoY21WdWRDazdDaUFnWm05dWRDMTJZWEpwWVc1MExXNTFiV1Z5YVdNNklIUmhZblZzWVhJdGJuVnRjenNLZlFvdVpHRjBaU0I3Q2lBZ2JXRnlaMmx1TFhSdmNEb2dNQzQwWlcwN0NpQWdabTl1ZEMxemFYcGxPaUJqYkdGdGNDZ3hNWEI0TENBMExqVjJkeXdnTWpCd2VDazdDaUFnWm05dWRDMTNaV2xuYUhRNklEVXdNRHNLSUNCc1pYUjBaWEl0YzNCaFkybHVaem9nTUM0eE5HVnRPd29nSUhSbGVIUXRkSEpoYm5ObWIzSnRPaUIxY0hCbGNtTmhjMlU3Q2lBZ2IzQmhZMmwwZVRvZ01DNDJNanNLZlFvPSJ9LHsicGF0aCI6IndpZGdldC5qcyIsImRhdGEiOiJKM1Z6WlNCemRISnBZM1FuT3dvdkx5Qk9aVzl1SUVOc2IyTnJJT0tBbENCWVpXNXZiaUJUUkVzZ1FWQkpJSFl4TGlCT1pXVmtjeUJ1YnlCemRISmxZVzF6SUdGdVpDQnVieUJ3WlhKdGFYTnphVzl1Y3pvS0x5OGdhWFFnYjI1c2VTQnlaV0ZrY3lCMGFHVWdiRzlqWVd3Z1kyeHZZMnNnWVc1a0lHWnZiR3h2ZDNNZ2RHaGxJR1JoYzJoaWIyRnlaQ2R6SUdGalkyVnVkQ0JqYjJ4dmRYSXVDaTh2Q2k4dklFSnlhV1JuWlNBb1lXeHNJRzFsYzNOaFoyVnpJSHNnZUdWdWIyNVRaR3M2TVN3Z2RIbHdaU3dnNG9DbUlIMHNJSEJ2YzNSbFpDQjBieUIwYUdVZ2NHRnlaVzUwS1RvS0x5OGdJQ0IzYVdSblpYUWc0b2FTSUdodmMzUTZJQ2RvWld4c2J5Y2dJQ2h2Ym1ObElISmxZV1I1S1Fvdkx5QWdJR2h2YzNRZzRvYVNJSGRwWkdkbGREb2dKMmx1YVhRbklDQWc0b2FTSUhzZ2RHaGxiV1VzSU9LQXBpQjlJQ0FnWVc1a0lDQW5kR2hsYldVbklPS0draUI3SUhSb1pXMWxJSDBnSUc5dUlHTm9ZVzVuWlFvb1puVnVZM1JwYjI0Z0tDa2dld29nSUdOdmJuTjBJSFJwYldWRmJDQTlJR1J2WTNWdFpXNTBMbWRsZEVWc1pXMWxiblJDZVVsa0tDZDBhVzFsSnlrN0NpQWdZMjl1YzNRZ1pHRjBaVVZzSUQwZ1pHOWpkVzFsYm5RdVoyVjBSV3hsYldWdWRFSjVTV1FvSjJSaGRHVW5LVHNLQ2lBZ1puVnVZM1JwYjI0Z2MyVnVaQ2h0YzJjcElIc2dkMmx1Wkc5M0xuQmhjbVZ1ZEM1d2IzTjBUV1Z6YzJGblpTaDdJSGhsYm05dVUyUnJPaUF4TENBdUxpNXRjMmNnZlN3Z0p5b25LVHNnZlFvS0lDQXZMeUJHYjJ4c2IzY2dkR2hsSUdSaGMyaGliMkZ5WkNCMGFHVnRaU0J6YnlCMGFHVWdZMnh2WTJzZ1lXeDNZWGx6SUcxaGRHTm9aWE1nZEdobElHTjFjbkpsYm5RZ2JHOXZheTRLSUNCbWRXNWpkR2x2YmlCaGNIQnNlVlJvWlcxbEtIUm9aVzFsS1NCN0NpQWdJQ0JwWmlBb0lYUm9aVzFsSUh4OElIUjVjR1Z2WmlCMGFHVnRaU0FoUFQwZ0oyOWlhbVZqZENjcElISmxkSFZ5YmpzS0lDQWdJR052Ym5OMElISnZiM1FnUFNCa2IyTjFiV1Z1ZEM1a2IyTjFiV1Z1ZEVWc1pXMWxiblF1YzNSNWJHVTdDaUFnSUNCcFppQW9kR2hsYldVdVlXTmpaVzUwS1NCeWIyOTBMbk5sZEZCeWIzQmxjblI1S0NjdExXRmpZMlZ1ZENjc0lIUm9aVzFsTG1GalkyVnVkQ2s3Q2lBZ0lDQnBaaUFvZEdobGJXVXVkR1Y0ZENrZ2NtOXZkQzV6WlhSUWNtOXdaWEowZVNnbkxTMTBaWGgwSnl3Z2RHaGxiV1V1ZEdWNGRDazdDaUFnZlFvS0lDQmpiMjV6ZENCd1lXUWdQU0FvYmlrZ1BUNGdVM1J5YVc1bktHNHBMbkJoWkZOMFlYSjBLRElzSUNjd0p5azdDaUFnWm5WdVkzUnBiMjRnZEdsamF5Z3BJSHNLSUNBZ0lHTnZibk4wSUc1dmR5QTlJRzVsZHlCRVlYUmxLQ2s3Q2lBZ0lDQjBhVzFsUld3dWRHVjRkRU52Ym5SbGJuUWdQU0J3WVdRb2JtOTNMbWRsZEVodmRYSnpLQ2twSUNzZ0p6b25JQ3NnY0dGa0tHNXZkeTVuWlhSTmFXNTFkR1Z6S0NrcE93b2dJQ0FnTHk4Z2RHVjRkRU52Ym5SbGJuUWdiMjVzZVNEaWdKUWdibVYyWlhJZ1luVnBiR1FnU0ZSTlRDQm1jbTl0SUhaaGJIVmxjeTRLSUNBZ0lHUmhkR1ZGYkM1MFpYaDBRMjl1ZEdWdWRDQTlJRzV2ZHk1MGIweHZZMkZzWlVSaGRHVlRkSEpwYm1jb2RXNWtaV1pwYm1Wa0xDQjdJSGRsWld0a1lYazZJQ2R6YUc5eWRDY3NJR1JoZVRvZ0oyNTFiV1Z5YVdNbkxDQnRiMjUwYURvZ0ozTm9iM0owSnlCOUtUc0tJQ0I5Q2dvZ0lIZHBibVJ2ZHk1aFpHUkZkbVZ1ZEV4cGMzUmxibVZ5S0NkdFpYTnpZV2RsSnl3Z0tHVXBJRDArSUhzS0lDQWdJR052Ym5OMElHMGdQU0JsTG1SaGRHRTdDaUFnSUNCcFppQW9JVzBnZkh3Z2JTNTRaVzV2YmxOa2F5QWhQVDBnTVNCOGZDQjBlWEJsYjJZZ2JTNTBlWEJsSUNFOVBTQW5jM1J5YVc1bkp5a2djbVYwZFhKdU93b2dJQ0FnYVdZZ0tHMHVkSGx3WlNBOVBUMGdKMmx1YVhRbklIeDhJRzB1ZEhsd1pTQTlQVDBnSjNSb1pXMWxKeWtnWVhCd2JIbFVhR1Z0WlNodExuUm9aVzFsS1RzS0lDQjlLVHNLQ2lBZ2RHbGpheWdwT3dvZ0lITmxkRWx1ZEdWeWRtRnNLSFJwWTJzc0lERXdNREFwT3dvZ0lITmxibVFvZXlCMGVYQmxPaUFuYUdWc2JHOG5JSDBwT3lBZ0lDOHZJSE4wWVhKMElIUm9aU0JvWVc1a2MyaGhhMlVLZlNrb0tUc0sifV19fX0
Nothing runs on import. A shared widget stays inert until the person turns on third-party widgets and approves its permissions, exactly like a manual install. It can never grant itself anything, and you can even lock a code behind an access code so only people you choose can open it.

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:

  1. You say hello. parent.postMessage({ xenonSdk:1, type:'hello' }, '*') kicks everything off.
  2. 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).
  3. 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.
  4. Xenon keeps you current. A data message every time a stream changes, a theme message whenever the look changes, and a size message with your tile's pixel box + DPR (on mount and on every resize).
  5. You act. Send an action to control something; Xenon replies with an action_result (ok:true, or an error code).

The messages, exactly

the whole protocol
// ── 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' }
Golden safety rule: treat every string that arrives in a stream as untrusted. Show it with 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.

cpu-meter/manifest.json
{
  "api": 1,
  "id": "cpu-meter",
  "name": "CPU Meter",
  "entry": "index.html",
  "streams": ["system"]   // → the user is asked to grant "system"
}
cpu-meter/index.html
<!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.

manifest.json (all fields)
{
  "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
}
FieldRequiredWhat it does
apiYesMust be 1. The protocol is versioned so future Xenon versions stay compatible.
idNoIf present, must equal the folder name. Rules: lowercase letters, digits and dashes, 2–41 chars.
nameYesShown in the picker. Up to 60 characters.
version, author, descriptionNoShown to the user (description up to 200 characters).
entryNoThe HTML file to load. Defaults to index.html. Must sit in the package root.
streamsNoThe live data you want to read. Full list in §5.
actionsNoThe categories of things you want to control. Full list in §6.
hostsNoUp 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.
userHostsNoUp to 4 addresses the user types in, for servers you can't know in advance — a NAS, Docker, a printer (§7).
hooksNoUp to 8 hook ids a local app may push events to (§7).
surfaceNo"tile" (default) or "ambient" for a fullscreen scene (§10).
deckNoDeck contributions: macros, published states and code-run handlers (§9).
backgroundNotrue lets Deck handlers, persistent badges or advanced Dynamic Island activities run headless, with no tile on screen.
storageNotrue gives your widget a small key/value store that survives updates (§8).
storageGroupNoA shared-store id so a set of sibling widgets share one store. Implies storage (§8).
secretsNotrue gives you a write-only vault for API keys, used via {{secret:NAME}} in requests (§8).
islandNotrue 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.
badgeNotrue adds a small persistent host-rendered chip next to the clock. The user can disable each installed package from Settings → Dynamic Island.
clipboardNotrue 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.
accentNotrue 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.
Dynamic Island is user-owned. Settings lists only installed packages that contribute an activity or badge. A user may disable your package or all timed takeovers without uninstalling it, so keep essential information and controls in the tile too. The complete message schema and music/goal examples live in WIDGET_SDK.md.
Two rules that save you time: one invalid entry (a bad id, a host you're not allowed) rejects the whole manifest with a clear reason, so you never half-work by accident. And request only what you need — an empty 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.

StreamWhat it carries
systemCPU %, GPU % (or null), memory, temperatures, uptime.
statusMic mute state, game mode / activity, foreground app.
mediaNow playing: title, artist, album, playback state, source.
audioMaster 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.
audioLevelsHow 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.
weatherThe user's weather (current conditions and forecast).
stocksThe quotes and indices the user follows.
footballFollowed teams: fixtures, live scores, results.
newsMerged headlines from the user's news sources.
claudeLocal Claude Code usage aggregate (the "Xenon Pulse" data).
obsOBS state: current scene, recording / streaming flags, audio sources.
discordDiscord voice: connected, mute / deafen, channel, who's speaking.
discordChannelsDiscord servers, voice channels and their live member roster. Lazy: request a refresh when the Channels view opens.
discordSoundboardThe connected account's Soundboard catalog. Lazy and cached for 60 seconds.
discordNotificationsPrivate DM / mention notifications, respecting the user's privacy setting. Privacy: request this separate grant only when notification content is essential.
streamerbotStreamer.bot connection, globals and activity events.
homeassistantHome Assistant device / entity states. Privacy: this is your smart-home state, grant it deliberately.
wavelinkElgato 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.
Fans and power draw need no stream of their own — they ride the 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.
Refreshing rich Discord data: send { 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.

CategoryAction 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' }
lightingThe whole RGB rig (iCUE + WLED / Hue / Nanoleaf / OpenRGB / HA lights): lightPower, lightColor, lightAuto, lightEffect, lightDevice. Needs lighting configured in Settings.
chromaRazer Chroma per device: chromaColor, chromaOff (device: all/keyboard/mouse/…). Needs Chroma enabled.
wavelinkElgato Wave Link faders / mutes / monitoring (targets come from the wavelink stream). Needs Wave Link enabled.
spotifyspotifyPlay, spotifyNext, spotifyPrev, spotifyLike, spotifyShuffle, spotifyVolume, spotifySeek, spotifyPlaylist, spotifyDevice… (playback control needs Premium). Needs Spotify connected.
obsobsScene, obsSceneNext, obsRecord, obsStream, obsMute, obsInputVolume. Needs OBS connected.
discorddiscordMute, discordDeafen, discordPtt, discordJoin, discordLeave, volume, soundboard. Needs Discord running.
homeassistanthaToggle, haLight, haMedia, haCover, haClimate, haScene, haScript, haButton… Needs HA configured.
twitchtwitchClip, twitchMarker, twitchAd, twitchTitle, twitchGame, twitchChat, twitchShoutout. Needs Twitch connected.
youtubeytBroadcast — start / stop your broadcast. Needs YouTube connected.
streamerbotsbDoAction, 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.

mute-key/index.html (manifest: "actions": ["mic"])
<!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.

fetch through the host
// 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; plain http:// 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, authorization and x-*.
  • Responses cap at 1 MB; text arrives as UTF-8, binary as base64. Rate limit: about one request per second.
Apps like Razer Synapse or Wave Link expose a 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.

an address the user provides
// 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' }, '*');
}
  • label is the text above the field — say plainly what you want ("Plex server address"). scope is "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.local or https://plex.example.com — all fine. Use base rather 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.
This is the one place a widget has a say in where it can reach — so, to be exact: it still can't choose. You declare that you need an address and how wide it may reach; the value only ever comes from the person at the keyboard, and Xenon re-checks it on every request. Loopback and link-local stay unreachable through a slot, and "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.

the sender (any local app)
POST http://127.0.0.1:3030/sdk/hook/<your-package-id>/<hook-id>
Content-Type: application/json

{ "anything": "up to 64 KB" }
your widget receives it
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.

tiles as a normal <img> / Leaflet layer
// 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.

store.set / store.get over the bridge
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.

store a key, then use it in a request
// 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 no get — a read never returns a value.
  • Reference a secret with {{secret:NAME}} anywhere in a fetch'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.
This is how you ship a widget that uses your own provider. Build with the SDK, keep your keys in the vault, publish the package with no keys inside — and it keeps working across every Xenon update, because none of it touches core files.

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.

manifest.json → deck.actions
"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.

publishing a state
// 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).

manifest.json → deck.handlers
"deck": {
  "handlers": [
    { "id": "post", "name": "Post a message", "params": [
      { "name": "text", "label": "Message", "kind": "text" }
    ] }
  ]
}
handling the key press
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 Date in-frame). Live data still arrives over your granted streams, exactly like a tile.
  • Pause when hidden: gate your requestAnimationFrame loop on document.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.
That's the whole SDK. You've got the folder, the bridge, every stream and action, the network and webhook channels, Deck keys and ambient scenes. When you've built something, publish it to the catalog so others can enjoy it.

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.

  1. Install one from a share code or the catalog via Share & Import → Import. It lands under Settings → Ambient → My scenes.
  2. Set it as your screensaver there, and it appears whenever the dashboard is idle.
Authoring a scene is a code-first job today (there's no in-app scene editor yet). The friendliest route: ask Claude with the Xenon creator tooling to build one from your description, or start from a scene in the catalog and import it. If you want to build one yourself, it's a fullscreen widget — see Ambient scenes in the SDK below.

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.

  1. Go to Share & Import → Export package.
  2. Tick what to include: theme (with its font), pages, Deck profiles, background, your own widgets and Ambient scenes.
  3. 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.
  4. 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.
Your work stays yours. You can only export creations you made. Anything you imported from someone else is marked as theirs and can't be re-shared — so credit never gets lost.
Imports from older Xenon versions: previous releases did not save package ownership. They appear in one labelled legacy group and can be cleaned up together; every new import is tracked separately and survives restart, backup export and restore.

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.

  1. Export your creation (theme, page, widget, package…).
  2. In the share dialog, hit ✨ Publish to the catalog. It copies your code and opens a pre-filled submission form on GitHub.
  3. 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.

Get help

🗨️ Questions, or want feedback on your creation? Join the Xenon Discord.
📘 Ready for the deep end? The complete Widget SDK reference is right here on this page.
🖼️ Looking for inspiration? Browse the community catalog.
↑ Back to top