# Building a Peek feature A **feature** is a directory under `apps/desktop/features/` holding a `manifest.json` and the HTML/JS/CSS it declares. The manifest names one or more **tiles**; each tile is a page Peek loads in its own sandboxed `BrowserWindow`, with `apps/desktop/main/tile-preload.cts` as its preload. That preload is the only way in: it exposes `window.app`, and what `window.app` will actually do for a given tile is decided by the capabilities that tile's manifest declared. There are about three dozen features in the tree. Every UI surface in Peek is a tile — the command panel, settings, browser pages — so building a feature is the same job as building anything else here. Related reading: `docs/architecture.md` for the whole system, `docs/tile-api.md` for the full `window.app` reference, `docs/tiles-single-file.md` for the tile model. --- ## Start by copying `hello-world` `apps/desktop/features/hello-world/` is the smallest self-contained feature: three files, no imports outside its own directory, enabled by default. Copy the directory, rename it, and change the `id`. ``` apps/desktop/features/hello-world/ manifest.json 35 lines — what the feature is and what it may do home.html 261 lines — the tile page: markup plus one module script home.css 209 lines — local styling, on the theme variables ``` **`manifest.json`** — the whole file: ```json { "manifestVersion": 3, "id": "hello-world", "shortname": "hello-world", "name": "Hello World", "description": "Tutorial and diagnostic tile for tile authors", "version": "1.0.0", "builtin": true, "tiles": [ { "id": "home", "url": "home.html", "width": 700, "height": 900, "title": "Hello World" } ], "capabilities": { "pubsub": { "scopes": ["global", "system"], "topics": ["hello:ping", "item:created", "item:deleted"] }, "datastore": { "tables": ["items"] }, "commands": true }, "commands": [ { "name": "hello world", "description": "Open Hello World tile (tutorial + debug)", "action": { "type": "execute" } } ] } ``` One tile, `home`, served at `peek://hello-world/home.html`. It may publish and subscribe on three topics, read the `items` table, and register commands. It declares one command, so Peek arms the tile lazily and boots it the first time someone runs `hello world` from the command panel. **`home.html`** carries a CSP in its `` and one ` ``` `background.js` exports `{ id, init, uninit, … }` and `init()` subscribes one handler per declared command: ```js api.pubsub.subscribe('cmd:execute:todo', async (msg) => { const result = await handleTodo(msg.search?.trim() || msg.data?.text || ''); if (msg.expectResult && msg.resultTopic) { api.pubsub.publish(msg.resultTopic, result || { success: true }); } }); ``` Two things to copy from that: a command declared in the manifest arrives as a publish on `cmd:execute:`, and you answer by publishing to `msg.resultTopic` when the caller asked for a result. `todo` registers nothing at runtime — its manifest declares all four commands — and it keeps its decisions in a separate module with no `window.app` in it, which is what makes them unit-testable. --- ## The manifest The schema lives in `packages/capabilities/index.js`, which `apps/desktop/main/tile-manifest.ts` re-exports wholesale. Three functions matter: - `detectManifestVersion(json)` — returns `'tile'` or `'legacy'`. - `validateTileManifest(json)` — returns a list of `{ path, message }`; empty means valid. - `parseManifestFile(path)` in `tile-manifest.ts` — the only filesystem entry point. A missing file returns null silently; a JSON syntax error or a validation failure is logged and returns null. **Schema v3 is `"manifestVersion": 3`.** `detectManifestVersion` decides in three steps: `manifestVersion: 3` is a tile manifest outright, `manifestVersion: 2` is legacy outright, and anything else — the field absent, or any other value — falls through to a sniff that calls it a tile manifest only if `tiles` is an array and `capabilities` is a non-null object. A legacy manifest is refused by every loader: `tile-loader.ts` reports "has a legacy manifest (manifestVersion < 3)", `feature-installer.ts` rejects local installs, `manifest-cache.ts` refuses to cache it. Set the field to `3` and move on. Two keys are rejected outright as removed in v3: `tiles[].type` (use `resident: true`) and `tiles[].windowHints` (put the window fields directly on the tile entry). ### Top-level fields Validated — a failure here means the feature does not load: | Field | Required | Shape | Notes | |---|---|---|---| | `id` | yes | non-empty string | The URL host and the feature's identity. `manifest-cache.ts` requires it to equal the directory-derived feature id. | | `name` | yes | non-empty string | Display name; the window-title fallback. | | `tiles` | yes | array, at most 10 | See below. | | `capabilities` | yes | object | May be empty, but the key must be present. | | `commands` | no | array | Each entry needs `name` and an `action` object. | | `shortcuts` | no | array | Each needs `keys` plus exactly one of `command` or `wakeTopic`. | | `rules` | no | array | `{ topic, scriptId }` plus optional `match`, `config`, `display`, `enabled`, `ordering`; reconciled into the `rules` table by `rules.ts syncManifestRules`, which `feature-startup.ts` calls at startup. | Read by the code but not validated: `manifestVersion`, `version` (defaults to `0.0.0`), `description`, `shortname`, `builtin`, `settingsSchema`, `defaults`, `nouns`, `wakeShortcutsFromSettings`. `"builtin": true` is load-bearing, not decoration: `resolveCapabilities` in `packages/capabilities/index.js` denies `filesystem` and `sync` to anything that is not builtin, and strips the `system` pubsub scope from it. There is no top-level `pubsub` key (it is `capabilities.pubsub`), no `entries` key (tile entries live in `tiles[]`), and no `enabled`/`disabled` key — see "Enabling and disabling" below. ### `tiles[]` Validated: `id` (required, unique within the manifest) and `url` (required), plus `lazyEvents` if present (array of non-empty strings). The rest is typed in `packages/capabilities/index.d.ts` as `TileEntry`. The window-shaping keys are read by `apps/desktop/main/tile-launcher.ts createTileBrowserWindow`; the activation keys are read by the arming and lazy-wake code instead, as noted per row: | Key | Effect | |---|---| | `resident: true` | Launch this tile eagerly at startup. Read in `tile-arm.ts`, not the launcher. | | `lazy: true` | Marks which entry a wake should launch — `tile-lazy.ts` takes `tiles.find(t => t.lazy === true)` and falls back to `tiles[0]`. Does **not** by itself make the feature lazy. | | `lazyEvents: [...]` | Topics that wake this tile. Read in `tile-arm.ts`, `tile-lazy.ts` and `manifest-cache.ts`. | | `keepLive: true` | Hide rather than destroy the window when it is dismissed. The launcher hands it to the window machine, which owns the hide-versus-close decision (`window-kernel.ts`, `windows.ts closeOrHideWindow`). | | `width`, `height` | Defaults 800 × 600. | | `minWidth`, `minHeight` | Minimum size. | | `frame: true` | Opt into OS chrome; tiles are frameless by default. | | `transparent`, `alwaysOnTop` | `alwaysOnTop` is ignored in headless runs. | | `focusable`, `resizable` | Default true; set `false` to opt out. | | `title` | Falls back to `name`. | | `role` | Defaults to `'workspace'`; `'overlay'` for full-screen overlays. | | `key` | Carried into the window hints, but nothing downstream reads it. Collapsing repeat opens onto one window is driven by the `key` passed to `app.window.open` (`ipc.ts`), not by this field. | ### `capabilities` Anything a tile can do beyond rendering itself is gated. `resolveCapabilities(tileId, requested, isBuiltin)` turns the declared block into a grant, and each namespace is enforced in main by its own module. Unknown keys are ignored rather than rejected, so a typo fails silently — check against this list: | Key | Declared as | Enforced in | |---|---|---| | `pubsub` | `{ scopes: ('self'\|'global'\|'system')[], topics?: string[] }` | `tile-ipc.ts`, `tile:pubsub:publish` | | `datastore` | `{ tables: string[], createTables?: boolean }` (max 20 tables) | `tile-datastore-scope.ts` | | `network` | `{ domains: string[] }` (max 50) | `tile-network-enforcement.ts` | | `window` | `true` or `{ create?, query?, manage?, urls? }` | `tile-window-enforcement.ts` | | `commands` | `true` | `tile-ipc.ts` | | `shortcuts` | `true` or `{ keys?: string[] }` | `tile-shortcuts-enforcement.ts` | | `settings` | `true` or `{ readForeign?: string[] }` | `tile-ipc.ts`, `tile-settings-foreign-enforcement.ts` | | `settingsForeign` | `string[]` — the feature ids whose settings this tile may read. A top-level fallback for `settings.readForeign`, which wins when both are present. | `tile-settings-foreign-enforcement.ts` | | `context` | `true` or `{ read?, write?, modes?, queryWindows? }` | `tile-context-enforcement.ts` | | `dialogs` | `true` or `{ types?: ('save'\|'open')[] }` | `tile-dialogs-enforcement.ts` | | `features` | `true` or `{ read?, install?, manage?, update?, dev?, publish?, devtools?, browse?, sources? }` | `tile-features-enforcement.ts` | | `oauth` | `true` or `{ providers?: string[] }` | `tile-oauth-enforcement.ts` | | `session` | `true` | `tile-session-enforcement.ts` | | `izui` | `true` | `tile-izui-enforcement.ts` | | `theme` | `true` | `tile-ipc.ts` | | `rules` | `true` | `tile-ipc-gate.ts` | | `automation` | `{ origins: string[], downloads?: boolean }` | `page-automation.ts checkAutomationAllowed()` | | `secrets` | `true` | `feature-secrets.ts SecretStore` | | `filesystem` | `{ read?, write? }` — builtin only | `tile-ipc.ts` | | `sync` | `true` — builtin only | `tile-sync-enforcement.ts` | `api.escape` is deliberately ungated. ### Personal-automation features An ordinary (non-builtin) feature can drive pages and keep files without any feature-specific main-process code. A quarterly VAT bundle builder (bank CSV and statement PDFs plus matched invoices from mail, zipped on the machine, no cloud model) was built this way, and the capabilities below are what it needed. All are manifest-gated and general. - **Page automation** (`automation`): `page-automation.ts` drives a page by window id (`url`, `navigate`, `waitFor`, `eval`, `click`, `printToPDF`). Every operation except `url` requires the page's current origin to be in `automation.origins`. `automation.downloads` lets the tile capture a download as bytes (`download-capture.ts DownloadCapture.claim()`) instead of showing a save dialog. - **Secrets** (`secrets`): `feature-secrets.ts SecretStore` keeps a feature's own tokens and passwords, encrypted by the platform. - **Binary network responses**: `tile:network:fetch` with `responseType: 'bytes'`. - **Install from a folder**: `feature-installer.ts previewLocalDirectory()` / `registerLocalDirectory()` install a feature from a local folder with approval. The flag for builtin comes from the registry (`feature-trust.ts isBuiltinFeature()`), and approval is evaluated at enable and at launch (`feature-approval.ts evaluateFeatureApproval()`). Removing such a feature leaves its folder on disk. - **Hot reload of folder-installed features**: `local-feature-watch.ts` watches the folder and `local-feature-reload.ts reloadLocalFeature()` reloads it, re-showing the windows that were showing without activating the app. A reload ends a run in progress. Approving or enabling a feature clears a stale load error (`tile-ipc.ts clearFeatureLoadError`). - **Saving a file from a tile**: a window showing its own page is not closed when it starts a download (`izui-roles.ts downloadCloseTarget()`). Gaps that remain for such features: - No click-by-visible-label operation; the feature tags an element through `eval` and clicks the selector. A `clickText` operation is worth adding when a second feature needs it. - A non-builtin tile cannot load the `` components (`tile-csp.ts`); its UI is plain HTML and CSS. - `tile-api.d.ts` types `window.open` as returning `windowId` while callers read `.id`, and the payload a pubsub subscriber receives is undocumented. - A page window cannot be opened hidden: `show: false` opens visible but not activated. - Restoring a window of a feature that is not loaded (refused for re-approval) requests its page from the app's own folder and shows file-not-found. - `printToPDF` has not been exercised against a real page. - No zip-building API; a feature carries its own writer. - Each app run leaves an empty `peek-download-capture-` folder in the OS temp dir. Site-specific lessons from that build: a bank dashboard signed out within about 1 h 40 min idle, so a periodic page reload kept the session alive, and a reload after sign-out meets a bot check that needs the user; after a security check a page can look logged-in before a sign-in redirect, so wait for two consecutive logged-in polls. Mail providers answer from regional hosts, so the `network` domains need a wildcard. Rule-based matching of invoices to payments needed no local model. ### `capabilities.pubsub.topics` **List every topic the tile subscribes to, not only the ones it publishes.** A consumer that forgets is the classic silent failure here, and it is caught at test time rather than at runtime: `apps/desktop/main/manifest-subscribe-drift.test.ts` walks every `.js`, `.cjs` and `.mjs` file under `apps/desktop/features/`, extracts literal `subscribe('…')` topics and `liveQuery({ topics: […] })` arrays, and fails if one is not covered by that feature's `capabilities.pubsub.topics`. It runs in the main-process unit runner, so an undeclared subscribe breaks the gate. Nothing stops it at runtime — the `tile:pubsub:subscribe` handler does no allowlist check and the broadcaster delivers to every tile window — so this test is the whole enforcement. Note it reads JavaScript files only: a topic subscribed from an inline ` ``` Then import the components you use: ```html ``` `peek://app/components/index.js` pulls in all of them at once. `apps/desktop/features/windows/` is the reference baseline — a real on-system surface built from ``, `` and ``. `apps/desktop/renderer/settings/` is **not**: it is bespoke CSS that only looks close. --- ## Features worth reading | Read | For | |---|---| | `apps/desktop/features/hello-world/home.html` | The minimum viable tile, plus a live probe of the tile API. | | `apps/desktop/features/todo/` | The ordinary shape: lazy background tile, manifest-declared commands, `resultTopic` replies, pure logic split out and unit-tested. | | `apps/desktop/features/websearch/home.js` | The lazy-wake init ordering done right, with an `initReady` guard. | | `apps/desktop/features/windows/windows.html` | The `` baseline and the importmap, verbatim. | | `apps/desktop/features/slides/background.js` | Settings: load, hot-reload on `slides:settings-changed`, and guarding a wake handler on `settingsLoaded`. Schema in `settings-schema.json`. | | `apps/desktop/features/tag-actions/background.js` | A 73-line lazy tile woken by a rules-engine topic. The smallest complete handler in the tree. | | `apps/desktop/features/dropzone/manifest.json` | A declarative `action: { type: "window" }` command with `tiles: []` — a feature with no tile code at all. | | `apps/desktop/features/theme-basic/` | A theme: `type: "theme"`, base16 `variables`, light and dark `colorSchemes`. See `docs/themes.md`. | | `apps/desktop/features/example/background.js` | An older `export default` shape with runtime `api.commands.register` and its own datastore table. Useful contrast; not the current idiom. | | `apps/desktop/features/pubsub-repro/` | Producer and consumer of the same topic pair across two tiles. It is a test repro, so read it for the topic wiring only. | --- ## Gotchas - **`tile-preload.cts` must stay `.cts`.** The backend `tsconfig.json` emits ESM for `.ts`, and Electron's sandboxed preload silently rejects ESM: `window.app` comes up empty and the entire tile API vanishes with no error. Never rename or convert it. - **An undeclared subscribe topic breaks the gate, not the app.** Nothing filters subscribes at runtime — `manifest-subscribe-drift.test.ts` is what fails. An undeclared *publish* is the one silently dropped at runtime. Declare every topic in `capabilities.pubsub.topics` either way. - **A missing importmap leaves `` elements inert**, with nothing thrown. - **Register wake handlers before your awaits**, or the tile misses the publish that woke it. - **A typo in a capability key is ignored, not reported.** Check it against the table above. - **Prefer pubsub** over directed `webContents.send`; reach for the latter only when you see a broadcast-then-filter smell. - `apps/desktop/CLAUDE.md` holds the operational rules for this app — testing, profiles, and the lazy-activation history in more detail. --- ## History What this document described before it was rewritten no longer exists. In the older model a handful of built-in features shared one host window as iframes while the rest got windows of their own, feature directories sat in a top-level `features` directory, and pages were addressed under an `ext` URL host. Every surface is now a tile in its own window under `apps/desktop/features/`, addressed at `peek:///`, with capabilities granted per manifest; there is no shared host window and no consolidation list. Two remnants are still visible and are not patterns to copy: `apps/desktop/main/protocol.ts` maps the legacy `ext` URL host so saved session URLs keep resolving, and several older features declare `ext:`-prefixed lifecycle topics (`ext:ready`, `ext::shutdown`) in their manifests.