---
name: nabotv-module
description: Build a NaboTV module — a self-hosted nabo.json manifest plus a web page that NaboTV renders in the living room alongside its built-in features, with the signed-in session, per-profile preferences, shared location, storage, and a capability-gated native bridge. Use this when asked to create, extend, or debug a NaboTV module (an Application, Game, Screensaver, or Ambient surface).
---

# Building a NaboTV module

A **module is a URL you host** — nothing to install, no app to rebuild. It is two files:

- `<url>/nabo.json` — the manifest: what the module is, where it appears, what it may touch.
- `<url>/` (i.e. `index.html`) — your UI, rendered in an in-app WebView. Override with `ui.url`.

A user adds the URL in **Settings → Modules → Add a module by URL**; NaboTV fetches the manifest and renders your page. The same page runs in the app and in a plain browser, so build and test with normal web tools. First-party modules (Weather) use this exact contract.

The canonical human docs: https://developer.nabo.tv . This file is the condensed, copy-pasteable version for coding agents. When something here is ambiguous, prefer the shipped first-party manifest at https://app.nabo.tv/modules/weather/nabo.json as ground truth.

## 1. Minimal working module

`nabo.json`:

```json
{
  "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"]
}
```

`index.html`:

```html
<!doctype html><meta charset="utf-8">
<body style="font-family:sans-serif;background:#101b2e;color:#EAF1FA">
<h1 id="hi">Hello</h1>
<script>
  const h = new URLSearchParams(location.hash.slice(1));
  const token = h.get('t'), profile = h.get('p'); // session (see §5)
  document.getElementById('hi').textContent = profile ? 'Hello from your module!' : 'Hello, guest!';
</script>
```

## 2. Manifest reference (`nabo.json`)

| 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>` built-in icon, e.g. `icon:cloud`. |
| `category` | enum | `Application` · `Game` · `Screensaver` · `Ambient` (§3). |
| `version` | int | Your module's version. |
| `minClientVersion` | int | Hide on NaboTV clients older than this. |
| `ui` | object | `{ "kind": "WebView", "url": "/" }` — page to load, relative to your module URL. |
| `contributions` | array | Extra placements (§4). |
| `host` | object | Shell behavior while on screen (§6). |
| `preferences` | array | Per-profile settings NaboTV renders + persists for you (§7). |
| `scopes` | array | Capabilities you request (§9). |

## 3. Categories (primary home)

| 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. |

## 4. Contributions (appear in several places)

Beyond its category a module contributes surfaces to named extension points. A weather app is an `Application` that also contributes a `Screensaver` panel.

```json
"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; `weight` biases how often it is picked, `minSeconds` is its minimum on-screen time.

## 5. Session & context

For a signed-in profile, NaboTV appends the session to your URL as a **fragment**: `#t=<token>&p=<profileId>`. The fragment is never sent to any server (only your JS reads it), so it is a safe hand-off. Call NaboTV endpoints with it:

```js
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();
}
```

Signed out, no fragment is added — run an anonymous/guest flow. A richer frozen context is being added at load: `window.NaboModule = { profile:{id,name,portraitUrl}, prefs:{...}, location:{lat,lon,place} }`.

## 6. Host policy (`host`)

| 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). |

## 7. Preferences (`preferences`)

Declare per-profile settings; NaboTV renders them in Preferences and persists them per profile — no settings UI to build. Values arrive via the context (§5).

```json
"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`.

## 8. Native bridge

Trigger native actions through a capability-gated bridge (launch a streaming app, start playback, cast, navigate):

```js
// Structured (preferred): validated against your granted scopes, then executed.
NativeBridge.postMessage(JSON.stringify({ action: 'launchProvider', payload: { id: 'netflix' } }));

// Deep link (fire-and-forget): the app intercepts nabotv: URLs.
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, no matter what it declares.

## 9. Permissions (`scopes`)

Sensitive scopes surface to the user as runtime permissions ("Weather is requesting your location"); the user grants and can revoke them. **Design for graceful denial** (e.g. prompt "set a location" instead of erroring).

| Scope | Grants |
|---|---|
| `ReadProfileBasics` | 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. |

## 10. Storage & identity

Persistence without hosting a database, via three managed primitives (all namespaced to your module, 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 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.

## 11. Relaxed screensaver: custom video source (not a module)

The **Relaxed** screensaver can point at your own manifest of ambient clips — **Settings → Screensaver → Relaxed: video source**. JSON, either a top-level array or `{ "aerials": [ … ] }`:

```json
[
  { "id": "coast-01",
    "url1080": "https://example.com/clips/coast-1080.mp4",
    "url720":  "https://example.com/clips/coast-720.mp4",
    "url4k":   "https://example.com/clips/coast-4k.mp4",
    "region":  "Pacific Coast",
    "categories": ["ocean", "sunset"],
    "timeOfDay": "evening",
    "durationSeconds": 120,
    "poi": [ { "t": 8, "text": "Big Sur, California" } ] }
]
```

Only `url1080` is required; `url720`/`url4k` are picked by device; `poi` are captions shown at time `t` (seconds).

## 12. Checklist for a working module

1. Serve `nabo.json` + `index.html` over **https**.
2. `id` is a stable unique slug; bump `version` when you change the manifest.
3. Read the session fragment (§5); handle the signed-out/guest case.
4. Request the **minimum** scopes; degrade gracefully when denied.
5. Test in a plain browser first (append `#t=&p=` manually to exercise the guest path), then add the URL in the app.
6. Publishing = hosting: updating your files updates the module. The URL is the source of truth.

## 13. Building a game module

A **Game** is a module with a `game` block. It runs across THREE surfaces, all hosted at your module URL:

| Surface | File | Runs on | Role |
|---|---|---|---|
| `rules` | `rules.js` | the host (TV), sandboxed JS | **the authority** — all game logic + state |
| `board` | `board.js` | the host (TV) | presentation: renders the shared screen as a node-tree |
| `controller` | `controller.html` | each player's phone (web) | per-player input |

**Solo is multiplayer**: one player on the TV is just the host seat, running the same `rules.js`. On the TV, `board.js` takes d-pad input directly (there is NO HTML on the TV); phones use `controller.html`.

### `nabo.json` game block

```json
"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/answers server-side, never on the phones.

`seats.bots` (default **true**) says whether AI players may fill empty seats. A game whose rules can't drive a bot (per-player physical stations, external hardware) sets `false`, and the lobby hides both "Fill with AI players" and the Add/Remove AI controls for it. `seats.max <= 0` means unlimited.

`music` (optional) is a list of asset FILENAMES (from your `assets/`) played as this game's lobby bed while its card is selected in the carousel. The engine picks randomly and avoids repeating the last track. Declare nothing and the shared room bed plays.

Two more top-level manifest fields matter to a game module:

- `background` (top-level in `nabo.json`, beside `id`/`version`): 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. This is how a third-party module controls its own look without touching lobby internals.
- `assets/manifest.json`: the client downloads EXACTLY the files this lists, nothing else. A file dropped into `assets/` without an entry here is silently never fetched — no error, your `g.assetUrl(...)` just 404s on device. When you add an asset, add the manifest line in the same change (first-party modules regenerate it with `./tool-refresh-module-assets.sh`).

### Capabilities & scopes (enforced)

A game runs in a **sandbox**: no HTTP, filesystem, database, or native access — 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:

```json
"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; the enable screen shows exactly what you asked for. A module's own `content` service is NOT a scope.

### `rules.js` — the authority (sandboxed JS on the host)

Export these globals (no module system):

- `onStart(ctx)` — the host started the game.
- `onAction(ctx, seatId, kind, payload)` — a seat acted (`seatId === '__system'` for engine events).
- `onLeave(ctx, seatId)` — a seat left.
- `botMove(seatId, view, phase)` — return `{action, payload, repeat?}` for an AI bot, or `null`.
- `onSystem(ctx, kind, payload)` — `kind` is `__tick` (`payload.nowMs`, ~1/sec) or `__lifecycle` (`payload.event` = `paused`/`resumed`, e.g. all phones away).

The `ctx`:

| Member | What |
|---|---|
| `ctx.host` | your SECRET host-side state (solutions, decks, timers). Never sent to phones. |
| `ctx.state` | the PUBLIC shared state, sent to the board + every controller. |
| `ctx.phase` | current phase string. |
| `ctx.seats` | `[{ seatId, name, avatar, host }]`; `ctx.names` maps seatId → name. |
| `ctx.setState(patch, { phase })` | merge into public state; optionally switch phase. |
| `ctx.setPrivate(seatId, patch)` | per-seat private state (e.g. a hidden role) — only that seat sees it. |
| `ctx.addScore(seatId, n)` / `ctx.setScore(seatId, n)` | scoring → scoreboard + leaderboard. |
| `ctx.finish(payload)` | end the game (finalizes stats — you MUST call it so stats close). |
| `ctx.rng()` / `ctx.shuffle(arr)` | seeded RNG + shuffle. |
| `ctx.setScreensaverHold(bool)` / `ctx.setImmersive(bool)` | hold the idle screensaver / go immersive. |
| `ctx.launch(...)` | launch a provider/title (scoped, like the native bridge). |
| `ctx.actionAtMs` | server-stamped time of the current action (for fair timing). |
| `ctx.host.content` | the parsed response of your `game.content` endpoint. |

Standing rule: 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` (call it `g`): read state (`g.state`, `g.phase`, `g.seats`, `g.scoreboard`, `g.code`, `g.joinUrl`, `g.platform`, `g.solo`, `g.title`, `g.content`, `g.bots`); render `g.ui.render(nodeTree)`; act `g.start()`, `g.sendAction(kind, payload)` (as the host seat), `g.hostAction(kind)` (host control); subscribe `g.onState(cb)`, `g.onSeats(cb)`, `g.onScoreboard(cb)`, `g.onLifecycle(cb)`, `g.onBaseBack(cb)`; screens `g.pushScreen(...)`/`g.popScreen()`; media `g.music(...)`, `g.sfx(...)`, `g.assetUrl(path)`. Wire it with `g.onState(render); g.onSeats(render); g.onScoreboard(render); render();`.

### The node-tree UI kit (what `g.ui.render` accepts)

Return a tree of node objects (`{ type, ...props, children }`) — the renderer draws them natively. Available types:

- Layout: `stack`, `column`/`row` (`gap`, `align`, `expand`, `w`, `padH`), `wrap` (`gap`), `grid`/`cell`, `spacer` (`h`), `panel` (`color`, `borderColor`, `radius`, `padding`), `backdrop`, `felt`.
- Text: `text` (`value`, `style`: `title`/`subtitle`/`label`, `size`, `weight`, `color`, `maxLines`, `h`), `badge`, `icon`.
- Input: `button` (`label`, `enabled`, `autofocus`, `repeatKeys`, `onSelect`), `menuRow`, `choiceRow`, `voteChip`.
- Players: `seat`, `avatar`, `cardHand`, `qr`.
- Status: `progress` (`label`, `value` 0..1, `highlight`), `scoreboard` (`standings`), `leaderboard`, `timer`, `lobby` (`title`, `art`, `code`, `joinUrl`, `startLabel`, `startEnabled`, `seats`, `onSelect`).
- Media: `image` (`url`, `w`, `h`, `fit`, `radius`, `blurSigma`), `audioClip`, `equalizer`, `videoEmbed`.
- Effects: `fadeOut` (`afterMs`, `ms`, `children`) — shows its children, then fades them out after `afterMs` over `ms`. The screensaver-overlay pattern: paint an info panel, let the scene reclaim the screen.

`autofocus: true` on a `button` re-anchors the d-pad focus there on each render — use it to keep focus inside a tile grid and move to Submit only when the grid empties (see anagrams `board.js`). Key REPEAT on a held d-pad button is opt-in via `repeatKeys: true` — leave it off unless holding should scroll, or a held key races your own render loop. `text.h` RESERVES a fixed pixel height for the block, so text that grows (a countdown, a score) cannot shove its siblings around between renders. Image `w`/`h` are pixels, clamped to the screen; size clue art to leave room for the rest of the screen.

**Layout trap** (every module author hits this): a `column`/`row` inside a `stack` shrink-wraps to its content unless a child `expand`s or the flex is marked `fill` — until then `justify`/`align` silently do nothing, because the flex is only as big as its children. To pin something to a corner or edge, give the flex an expanding `spacer` on each axis you want to push against.

### `controller.html` — the phone (HTML is fine here)

A plain web page. The room shell seats the player and passes `?c=<code>&sid=<seatId>&stk=<seatToken>`. Join/poll/act via the room API:

- `POST /api/game/room/join` `{code, name, avatar}` → `{ok, seatId, seatToken, message}`.
- `POST /api/game/room/snapshot` `{code, seatId, seatToken}` → `{ok, phase, publicState, finished, message}`.
- `POST /api/game/room/action` `{code, seatId, seatToken, kind, payload}`.

### Reference implementations (ground truth)

The shipped modules are authoritative: **trivia** (`marketing/games/trivia/`) is the most complete (phase machine, timed reveal, media, music); **anagrams** (`marketing/games/anagrams/`) is a compact host-authority example with a content endpoint (`/api/games/anagrams/puzzles`) and a d-pad tile picker.

### Game checklist

1. `nabo.json` has a `game` block (authority `host`, three surfaces, `seats`).
2. `rules.js` owns ALL state — secrets in `ctx.host`, shared in `ctx.state`; always call `ctx.finish(...)`.
3. `board.js` only renders node-trees + relays input; never emit HTML on the TV.
4. Keep d-pad focus sane with `autofocus`; never REQUIRE the TV to advance (arm a `__tick` countdown).
5. Put decks/pools behind `game.content`; keep answers host-side.
6. Solo works for free — it's the host seat on the same code path.

Questions: **support@nabo.tv**.
