Getting started

Overview

How the Mac hub, iPhone app, and widget SDK work together.

Preen Display turns an iPhone on a stand into a programmable, glanceable display for your Mac. You author a widget once — a single self-contained HTML document — and your Mac feeds it live data. The phone renders it fullscreen and updates as live data arrives.

The pieces

  • The Mac hub (the Preen app) runs a small local HTTPS/WebSocket server on your network. It stores your widgets, serves their HTML, and pushes their data.
  • The iPhone display (the Preen iOS app) pairs with the hub over your local network or, with Preen Plus, through an end-to-end encrypted cloud relay. It shows the active widget fullscreen and handles connection changes automatically. See Connecting your devices. Preen Plus includes five Mac hubs and five remote displays; see Preen Plus.
  • Saved widgets store private template backups in your account through explicit saves. Plus enables new uploads; restoring creates a new local copy. Live feeds, databases and local actions are not backed up.
  • preen-mcp is an MCP server bundled with the Mac app. It lets Claude Code (or any MCP client) publish widgets and push data in plain language.

The boundary: widget code uses the SDK

Your widget’s JavaScript runs on the phone. It renders data, handles taps, animates, and can compute local UI state. External integrations and credentials stay outside that page. The widget reaches Preen through the window.PREEN SDK.

The native iPhone app holds the authenticated session and handles networking with the hub. It fetches the HTML, receives a live data stream, and delivers JSON to the running page as a structured JavaScript argument. A strict Content-Security-Policy (connect-src 'none') is installed before widget code, blocking direct fetch, XMLHttpRequest, EventSource, and WebSocket connections inside the widget page.

Preen supports streaming and WebSockets. The native app uses Server-Sent Events (SSE) for widget data and a WebSocket for control messages such as widget switches and reloads. These connections belong to the native app; your widget receives data through onData.

<!-- Subscribe to your widget's live JSON feed: -->
<script>
  window.PREEN.onData((d) => render(d)); // d is your JSON, redelivered on every update
  window.PREEN.ready();                  // optional: signal "UI is hydrated"
</script>

Why it matters:

  • The session token never enters agent-authored widget JS — it stays in native code.
  • The SDK scopes datastore requests and action invocations to the current widget.
  • Polling/streaming is handled once by the native app; widgets never think about it.

How data flows

  1. You (or Claude Code) publish a widget’s HTML to the hub.
  2. The hub serves that HTML at GET /widget/:id and holds a JSON feed for it.
  3. The phone fetches the HTML and opens GET /api/data/:id/stream. The hub sends the current feed, if one exists, and streams subsequent updates over SSE.
  4. The native app passes each payload to your onData callback without reloading the page. It also fetches GET /api/data/:id immediately and then roughly every 10 seconds as an independent freshness backup. Interrupted streams reconnect automatically.

The current iOS app does not use the manifest’s refreshMs field to schedule this loop. A healthy stream delivers changes without waiting for a poll. This feed contains complete JSON snapshots; it is not a durable event log.

You write step 1 and the onData renderer. Preen Display does the rest.

Each widget has its own backend

A widget’s HTML is only the front end. On the Mac, every widget also owns an backend context — three facilities addressed by that widget’s id:

  • A data feed — the live JSON you push in, from the MCP, the CLI, or any script. Last value wins.
  • A datastore — its own SQLite database for state the widget keeps and accumulates across reloads (scores, logs, history).
  • Interactive actions — bounded commands it can trigger on the Mac when tapped, once you’ve approved them (ships in release; nothing fires until you consent).

These are facilities in the shared Mac hub, not a separate server process or Mac sandbox for each widget. The SDK supplies the current widget id and never exposes session or write tokens to widget code. Native clients and external producers have broader access under their respective tokens; see Endpoints & protocol.

The reusable code is a template; its installed copy has its own feed, datastore and local permissions. Saved widgets back up the template, not that live backend context. The experimental Mac Desktop counter can read native content from the same widget feed that supplies the HTML display.

Calling the Mac from your front end

The SDK also carries requests from the widget to the Mac:

// Request/response: read and write this widget's durable state.
await PREEN.db.put("settings", { theme: "warm" });
const rows = await PREEN.db.get("settings", { limit: 1 });

// Invoke a capability already registered and approved on the Mac.
PREEN.trigger("volset", { vol: 40 });

The native app turns those calls into authenticated HTTP requests to the hub. Datastore calls return Promises with results. trigger is fire-and-forget: command output and HTTP response bodies are not returned to the widget. A producer can push the resulting state back through the data feed.

There is no general-purpose PREEN.fetch or arbitrary endpoint proxy. To use an external API, run a producer on the Mac or another host and push its JSON. To let a widget cause a Mac-side effect, declare an interactive action. Those actions require consent; datastore access and the bird pulse do not. The actions guide explains the auto-consent option and the limits of the Mac execution boundary.