Build for the living room

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.

Preview. The platform is in active development. Sections marked Preview describe the intended contract and may change; sections marked Live work today. First-party modules (like Weather) use the exact same contract you do.
Building with an AI coding agent? Download 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 with ui.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

FieldTypeMeaning
idstringStable slug, unique to your module.
titlestringDisplay name.
blurbstringOne-line description shown in Settings.
iconstringicon:<name> (a built-in icon) e.g. icon:cloud.
categoryenumApplication · Game · Screensaver · Ambient. See Categories.
versionintYour module's version.
minClientVersionintHide on NaboTV clients older than this.
uiobject{ "kind": "WebView", "url": "/" }, the page to load, relative to your module URL.
contributionsarrayExtra placements. See Contributions.
hostobjectHow the app shell behaves while your module is on screen. See Host policy.
preferencesarrayPer-profile settings shown in Preferences. See Preferences.
scopesarrayCapabilities you request. See Permissions.

Categories & surfaces Live

A module's category decides its primary home in the app:

CategoryWhere it lives
ApplicationA sidebar item with its own full screen (Weather is one). Renders inside the shell, sidebar intact.
GameAn entry in the Games hub.
ScreensaverAn option in the idle screensaver rotation.
AmbientAn 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:

FieldDefaultEffect
showsClocktrueKeep the NaboClock overlay visible.
blocksScreensaverfalseSuppress the idle screensaver (interactive modules).
keepAwakefalseHold a wakelock (long passive displays).
fullscreenfalsefalse 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).

ScopeGrants
ReadProfileBasicsThe active profile's name + portrait.
ReadLocationThe profile's shared location (lat/lon, place).
ReadModuleState / WriteModuleStateYour own key/value storage.
ReadStats / WriteStats / ReadLeaderboardYour 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.

SurfaceFileRuns onRole
rulesrules.jshost (TV), sandboxed JSThe authority, all game logic + state.
boardboard.jshost (TV)Renders the shared screen as a node-tree.
controllercontroller.htmleach player's phonePer-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"]
ScopeGates
WriteModuleStateSave game state (the TV “Save checkpoint”).
ReadModuleStateResume a save + list this game's saves.
WriteStatsRecord a completed run (cross-session history / challenge leaderboard).
ReadStats / ReadLeaderboardRead your own stats / rankings.
ReadProfileBasicsPlayer names + avatars beyond the current room.
NativeLaunchProvider / NativePlayTitle / NativeCastNative actions.
ReadLocationApproximate 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 memberWhat
ctx.hostYour secret host-side state (solutions, decks). Never sent to phones.
ctx.stateThe public shared state (board + all controllers).
ctx.phase · ctx.seats · ctx.namesPhase 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.setScoreScoring → scoreboard + leaderboard.
ctx.finish(payload)End the game (finalizes stats, always call it).
ctx.rng() · ctx.shuffle(arr)Seeded RNG + shuffle.
ctx.actionAtMsServer-stamped time of the current action (fair timing).
ctx.host.contentThe parsed response of your game.content endpoint.
The TV must never be required to advance, arm a __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:

GroupNode types
Layoutstack, column/row, wrap, grid/cell, spacer, panel, backdrop, felt
Texttext (style/size/weight/color/maxLines/h), badge, icon
Inputbutton (label/enabled/autofocus/repeatKeys/onSelect), menuRow, choiceRow, voteChip
Playersseat, avatar, cardHand, qr
Statusprogress, scoreboard, leaderboard, timer, lobby
Mediaimage (url/w/h/fit/radius/blurSigma), audioClip, equalizer, videoEmbed
EffectsfadeOut (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}
Reference modules. The shipped games are the ground truth, trivia is the most complete (phase machine, timed reveal, media, music); anagrams is a compact host-authority example with a content endpoint and a d-pad tile picker.

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)
  }
]
FieldRequiredMeaning
url1080yes1080p clip URL (served over https).
url720 / url4knoAlternate resolutions; the client picks by device.
idnoStable id (defaults to a hash of url1080); keeps the rotation cursor aligned.
region / categories / timeOfDaynoMetadata for filtering/labeling.
durationSecondsnoClip length.
poinoCaptions: { "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.