NaboTV module platform
NaboTV is an expanding platform, not a fixed app. A module is a URL you host: a
nabo.json manifest and a web page. Add it by URL and it appears on the TV alongside the
built-in features, with the same session, preferences, and location.
SKILL.md, a single condensed, copy-pasteable reference
(manifest schema, session hand-off, contributions, scopes, storage, examples) that you can hand to
Claude Code or any assistant so it can scaffold a working module in one shot.Overview Live
Every module is a location on the web that hosts two things:
<url>/nabo.json, the manifest: what the module is, where it appears, and what it may touch.<url>/(i.e.index.html), your UI, rendered in an in-app WebView. Override withui.url.
There is no SDK to install and no app to rebuild. You host the files anywhere (your server, GitHub Pages, S3), a user adds the URL, and NaboTV fetches your manifest and renders your page. The same page runs in the app and in a plain browser, so you can build and test with the tools you already use.
Quick start Live
1. Host nabo.json and index.html at a URL. A minimal manifest:
{
"id": "hello",
"title": "Hello",
"blurb": "My first NaboTV module.",
"icon": "icon:extension",
"category": "Application",
"version": 1,
"minClientVersion": 705,
"ui": { "kind": "WebView", "url": "/" },
"contributions": [],
"host": { "showsClock": true },
"preferences": [],
"scopes": ["ReadProfileBasics"]
}
2. A minimal index.html that reads the signed-in session (see Session & context):
<!doctype html><meta charset="utf-8">
<body style="font-family:sans-serif;background:#101b2e;color:#EAF1FA">
<h1 id="hi">Hello</h1>
<script>
const p = new URLSearchParams(location.hash.slice(1));
// p.get('t') = session token, p.get('p') = profile id (see Session & context)
document.getElementById('hi').textContent = 'Hello from your module!';
</script>
3. In NaboTV: Settings → Modules → Add a module by URL, paste your URL. NaboTV reads your manifest and your module appears in its category.
Host it straight from a GitHub repo
You do not need a web host. Commit nabo.json to a public repo and add the module by its
owner/repo shorthand, which NaboTV expands to that repo's raw file URLs:
darkfrog26/myproject # main branch, repo root
darkfrog26/myproject@dev # a specific branch
darkfrog26/myproject/modules/clock # a subdirectory
darkfrog26/myproject/modules/clock@dev
With no branch named, main is tried first and then master, so any repo works
without you thinking about it. Anything containing :// (or a dotted first segment, like
example.com/mod) is treated as a normal URL and used as-is.
Cache your files on the TV with files
List your module's own web files in nabo.json and NaboTV downloads exactly those to the
device, then renders the module from local storage:
"files": ["index.html", "app.js", "style.css"]
Only the listed paths are fetched — nothing is crawled — so a repo with a hundred files costs the
handful you name. Paths are relative to your module base; absolute paths and .. are
rejected. Include your entry point (the file ui.url resolves to, or index.html).
This is what makes a repo-hosted WebView module work at all: a local file gets its type from
its extension, so the raw host's text/plain stops mattering. It is also faster for every
module — after the first open there is no network on the path — and it keeps working offline. Without
files, the module is simply loaded from its URL as before.
One caveat if you skip files. GitHub's raw file host serves
every file as text/plain — so a WebView module loaded straight from it would display
its HTML as source rather than rendering it. Either list your files (above) or serve a
repo-hosted module as a
a script contribution instead ("script": "panel.js"):
NaboTV fetches and runs that JavaScript itself, so the content type never matters, and you get native
TV rendering for free. If you specifically want a WebView module, host it somewhere that
serves real text/html (GitHub Pages, Netlify, Cloudflare Pages, your own server) and add
it by its full URL.
nabo.json reference Live
| Field | Type | Meaning |
|---|---|---|
id | string | Stable slug, unique to your module. |
title | string | Display name. |
blurb | string | One-line description shown in Settings. |
icon | string | icon:<name> (a built-in icon) e.g. icon:cloud. |
category | enum | Application · Game · Screensaver · Ambient. See Categories. |
version | int | Your module's version. |
minClientVersion | int | Hide on NaboTV clients older than this. |
ui | object | { "kind": "WebView", "url": "/" }, the page to load, relative to your module URL. |
contributions | array | Extra placements. See Contributions. |
host | object | How the app shell behaves while your module is on screen. See Host policy. |
preferences | array | Per-profile settings shown in Preferences. See Preferences. |
scopes | array | Capabilities you request. See Permissions. |
Categories & surfaces Live
A module's category decides its primary home in the app:
| Category | Where it lives |
|---|---|
Application | A sidebar item with its own full screen (Weather is one). Renders inside the shell, sidebar intact. |
Game | An entry in the Games hub. |
Screensaver | An option in the idle screensaver rotation. |
Ambient | An overlay eligible to draw on other surfaces. |
Contributions Preview
Beyond its category, a module can contribute surfaces to named extension points, so one module can
appear in several places. A weather app, for instance, is an Application that also contributes
a Screensaver panel.
"contributions": [
{ "kind": "NavItem", "route": "/weather", "order": 30 },
{ "kind": "Screensaver",
"panel": { "title": "Weather", "surface": { "url": "/screensaver" },
"weight": 2, "minSeconds": 20, "usesLocation": true } }
]
Kinds: HubTile, NavItem, SettingsPanel, AmbientOverlay,
Screensaver. The screensaver host composes every enabled module's panel into the idle rotation.
Host policy Live
Declare how the app shell should present your module while it is on screen:
| Field | Default | Effect |
|---|---|---|
showsClock | true | Keep the NaboClock overlay visible. |
blocksScreensaver | false | Suppress the idle screensaver (interactive modules). |
keepAwake | false | Hold a wakelock (long passive displays). |
fullscreen | false | false renders inside the shell with the sidebar; true is immersive (hidden sidebar). |
Preferences Preview
Declare per-profile settings in the manifest and NaboTV renders them in the Preferences screen and persists them per profile, no settings UI to build. Values are delivered to your page (see Session).
"preferences": [
{ "key": "units", "label": "Units", "help": "Imperial or metric",
"prefType": "Choice", "default": "imperial",
"choices": [ { "value": "imperial", "label": "Imperial" }, { "value": "metric", "label": "Metric" } ] }
]
Types: Toggle, Choice, IntRange, Text.
Session & context Live
When NaboTV loads your page for a signed-in profile, it appends the session to your URL as a
fragment, #t=<token>&p=<profileId>. The fragment is never sent to any
server (only your JavaScript can read it), so it is a safe way to hand you the session.
const h = new URLSearchParams(location.hash.slice(1));
const token = h.get('t'), profile = h.get('p');
async function api(path, body = {}) {
const headers = { 'Content-Type': 'application/json' };
if (token) { headers['Authorization'] = 'Bearer ' + token; if (profile) headers['X-Nabo-Profile'] = profile; }
const r = await fetch(path, { method: 'POST', headers, body: JSON.stringify(body) });
return r.json();
}
Preview A richer, frozen context is also being added at load:
window.NaboModule = { profile: { id, name, portraitUrl }, prefs: { ... }, location: { lat, lon, place } };
Signed out, no fragment is added and your page should run an anonymous / guest flow.
Native bridge Preview
A module can trigger native actions through a capability-gated bridge, launching a streaming app, starting playback, casting, navigating. Two mechanisms:
// Structured (preferred): the app validates the action against your granted scopes, then acts.
NativeBridge.postMessage(JSON.stringify({ action: 'launchProvider', payload: { id: 'netflix' } }));
// Deep link (fire-and-forget): navigate to a nabotv: URL; the app intercepts it.
location.href = 'nabotv://launch/netflix';
Enforcement is native-side against the scopes the user granted, a page cannot invoke an action it was not granted, regardless of what it declares.
Permissions Preview
Scopes that touch sensitive data or native actions are not silently granted by the manifest, they surface to the user as permissions, like a phone's runtime permissions (“Weather is requesting your location”). Declare what you need; the user grants it and can revoke it. Design for graceful denial (e.g. show a “set a location” prompt rather than an error).
| Scope | Grants |
|---|---|
ReadProfileBasics | The active profile's name + portrait. |
ReadLocation | The profile's shared location (lat/lon, place). |
ReadModuleState / WriteModuleState | Your own key/value storage. |
ReadStats / WriteStats / ReadLeaderboard | Your own counters + rankings. |
NativeLaunchProvider, NativePlayTitle, NativeCast, … | Bridge actions. |
Storage & identity Preview
Modules get persistence without hosting a database, through three managed primitives (all namespaced to your module, all reached with your scoped session):
State
A JSON document per profile / account / module. A save, a checkpoint, settings beyond declared prefs.
Counters
Atomic named counters per profile (wins, plays, points).
Leaderboards
Ranked counters, scoped to friends / account / global.
Identity is minimal: you only ever receive { id, name, portraitUrl } for the active
profile and for participants already in your module's context (a game's seats, a leaderboard's rows) ,
never a directory of the household.
Building a game Preview
A Game is a module with a game block. It runs across three surfaces, all hosted at
your module URL, and there is no HTML on the TV: the shared screen is drawn from a node-tree.
| Surface | File | Runs on | Role |
|---|---|---|---|
rules | rules.js | host (TV), sandboxed JS | The authority, all game logic + state. |
board | board.js | host (TV) | Renders the shared screen as a node-tree. |
controller | controller.html | each player's phone | Per-player input (a plain web page). |
Solo is multiplayer: one player on the TV is the host seat running the same
rules.js; board.js takes the remote's d-pad directly.
The game block
"game": {
"authority": "host",
"surfaces": { "rules": "/rules.js", "board": "/board.js", "controller": "/controller.html" },
"seats": { "min": 1, "max": 8, "solo": true, "bots": true },
"host": { "keepAwake": true, "blocksScreensaver": true, "showsClock": false },
"content": "/api/your/content",
"music": ["lobby-1.mp3", "lobby-2.mp3"]
}
content (optional) is a server URL NaboTV fetches into ctx.host.content at
start (QuickJS can't do HTTP), keep decks, pools, and answers server-side, off the phones.
seats.bots (default true) says whether AI players may fill empty seats; set it
false when your rules can't drive a bot and the lobby hides the fill and Add/Remove AI
controls for your game. seats.max <= 0 means unlimited. music (optional) lists
asset filenames played as your game's lobby bed while its card is selected; the engine picks
randomly and avoids repeating the last track, and with no declaration the shared bed plays.
Two top-level manifest fields also matter here. background (beside id/version)
is your module's own lobby-carousel art, a module-relative filename resolved against your assets dir or an
absolute URL; undeclared, the shared room background is used. And assets/manifest.json is the
exact download list: the client fetches only the files it names, so an asset dropped into
assets/ without a manifest entry is silently never fetched, no error, just a 404 from
g.assetUrl(...) on device. Add the manifest line in the same change as the file.
Capabilities & scopes Enforced
A game runs in a sandbox, no HTTP, filesystem, database, or native access, so it can only use
the host ctx/g APIs. Most of what a game does is core and needs no
scope: seat names/avatars (its own players), the in-session scoreboard, ctx.finish, and its
own content service.
To use a shared, cross-session capability, declare it in the top-level nabo.json
scopes array. The server refuses the operation otherwise, and the scope is disclosed to the
user on the enable screen:
"scopes": ["ReadModuleState", "WriteModuleState", "WriteStats", "ReadProfileBasics"]
| Scope | Gates |
|---|---|
WriteModuleState | Save game state (the TV “Save checkpoint”). |
ReadModuleState | Resume a save + list this game's saves. |
WriteStats | Record a completed run (cross-session history / challenge leaderboard). |
ReadStats / ReadLeaderboard | Read your own stats / rankings. |
ReadProfileBasics | Player names + avatars beyond the current room. |
NativeLaunchProvider / NativePlayTitle / NativeCast | Native actions. |
ReadLocation | Approximate location. |
A module can never reach a capability it didn't declare, and can't touch native actions,
location, other profiles, or arbitrary endpoints at all. Declare the minimum; a module's own
content service is not a scope.
rules.js, the authority
Export globals: onStart(ctx), onAction(ctx, seatId, kind, payload),
onLeave(ctx, seatId), botMove(seatId, view, phase), and
onSystem(ctx, kind, payload) (__tick carries payload.nowMs ~1/sec;
__lifecycle is paused/resumed).
| ctx member | What |
|---|---|
ctx.host | Your secret host-side state (solutions, decks). Never sent to phones. |
ctx.state | The public shared state (board + all controllers). |
ctx.phase · ctx.seats · ctx.names | Phase string; [{seatId,name,avatar,host}]; seatId→name. |
ctx.setState(patch, {phase}) | Merge public state; optionally switch phase. |
ctx.setPrivate(seatId, patch) | Per-seat private state (a hidden role), only that seat sees it. |
ctx.addScore · ctx.setScore | Scoring → scoreboard + leaderboard. |
ctx.finish(payload) | End the game (finalizes stats, always call it). |
ctx.rng() · ctx.shuffle(arr) | Seeded RNG + shuffle. |
ctx.actionAtMs | Server-stamped time of the current action (fair timing). |
ctx.host.content | The parsed response of your game.content endpoint. |
__tick countdown that auto-advances, with buttons only as accelerators.board.js, the TV screen (node-trees, never HTML)
window.NaboGame (g): read g.state, g.phase,
g.seats, g.scoreboard, g.code, g.joinUrl; render with
g.ui.render(nodeTree); act with g.start(),
g.sendAction(kind, payload), g.hostAction(kind); subscribe
g.onState/onSeats/onScoreboard/onLifecycle(cb); media g.music(),
g.sfx(), g.assetUrl(path).
The board returns a tree of node objects, never markup:
| Group | Node types |
|---|---|
| Layout | stack, column/row, wrap, grid/cell, spacer, panel, backdrop, felt |
| Text | text (style/size/weight/color/maxLines/h), badge, icon |
| Input | button (label/enabled/autofocus/repeatKeys/onSelect), menuRow, choiceRow, voteChip |
| Players | seat, avatar, cardHand, qr |
| Status | progress, scoreboard, leaderboard, timer, lobby |
| Media | image (url/w/h/fit/radius/blurSigma), audioClip, equalizer, videoEmbed |
| Effects | fadeOut (afterMs/ms/children) — show children, then fade them out; the screensaver-overlay pattern |
autofocus:true on a button re-anchors the d-pad focus on each
render, keep focus inside a tile grid and move it to Submit only when the grid empties. Key repeat on a
held d-pad button is opt-in via repeatKeys:true; leave it off unless holding should
scroll. text.h reserves a fixed block height so growing text (a countdown, a score) can't
shove its siblings between renders.
Layout trap, every module author hits it once: a column/row
inside a stack shrink-wraps to its content unless a child expands or the flex is
marked fill, so justify/align silently do nothing until then. To pin
something to a corner or edge, give the flex an expanding spacer on each axis to push against.
controller.html, the phone
A plain web page (HTML is fine here). The room shell seats the player and passes
?c=<code>&sid=<seatId>&stk=<seatToken>. Join, poll, and act:
POST /api/game/room/join {code, name, avatar} -> {ok, seatId, seatToken}
POST /api/game/room/snapshot {code, seatId, seatToken} -> {ok, phase, publicState, finished}
POST /api/game/room/action {code, seatId, seatToken, kind, payload}
Example: the Weather module Live
Weather is a first-party module built on this exact contract. Its manifest lives at
app.nabo.tv/modules/weather/nabo.json; it is an
Application that also contributes a Screensaver panel, reads the shared
ReadLocation, and calls NaboTV's weather endpoints with the session from the fragment ,
current conditions, hourly, daily, alerts, and an animated radar map. No external services, no keys.
Relaxed screensaver: custom video source Live
Not a module, but the same “bring your own URL” idea: the Relaxed screensaver can point
at your own manifest of ambient/aerial clips. Set it in Settings → Screensaver → Relaxed: video
source. The manifest is JSON, either a top-level array, or { "aerials": [ … ] }:
[
{
"id": "coast-01",
"url1080": "https://example.com/clips/coast-1080.mp4",
"url720": "https://example.com/clips/coast-720.mp4", // optional
"url4k": "https://example.com/clips/coast-4k.mp4", // optional
"region": "Pacific Coast", // optional
"categories": ["ocean", "sunset"], // optional
"timeOfDay": "evening", // optional
"durationSeconds": 120, // optional
"poi": [ { "t": 8, "text": "Big Sur, California" } ] // optional captions at time t (seconds)
}
]
| Field | Required | Meaning |
|---|---|---|
url1080 | yes | 1080p clip URL (served over https). |
url720 / url4k | no | Alternate resolutions; the client picks by device. |
id | no | Stable id (defaults to a hash of url1080); keeps the rotation cursor aligned. |
region / categories / timeOfDay | no | Metadata for filtering/labeling. |
durationSeconds | no | Clip length. |
poi | no | Captions: { "t": <seconds>, "text": "…" } shown at time t. |
Publishing Live
Host your nabo.json + page anywhere reachable over https. Share the URL and anyone adds it
with Settings → Modules → Add a module by URL. A curated directory for discovery is on the roadmap;
the URL is always the source of truth, so updating your hosted files updates the module.
A public GitHub repo needs no hosting at all: share it as owner/repo and NaboTV resolves it
to the repo's raw files (see Quick start). Pushing to the branch publishes the
update, since the raw URL always serves the branch head.