The hub has a LAN HTTPS surface for paired displays and data producers, and a localhost HTTP admin surface for MCP, the CLI, and local tools. The default ports are 8443 and 8444, respectively.
Widget JavaScript cannot call these endpoints directly. It uses the
window.PREEN SDK, and the native app makes authenticated
requests on its behalf. The SDK has no arbitrary URL proxy.
LAN surface
Authentication varies by operation. Session means a paired device’s bearer token; write means the hub’s producer/admin token. Pairing and WebSocket control use the handshakes described below.
| Path | Method | Auth | Purpose |
|---|---|---|---|
/widget/:id | GET | Session | Widget HTML |
/widget/:id/icon | GET | Session | PNG icon bytes; 404 for a non-PNG icon |
/api/data/:id | GET | Session | Latest JSON feed envelope |
/api/data/:id | POST | Write | Replace the JSON feed |
/api/data/:id/stream | GET | Session | SSE feed stream |
/api/store/:id/:collection | GET / POST / DELETE | Session or write | List / append / clear datastore records |
/api/store/:id/:collection/:recordId | PUT / DELETE | Session or write | Replace / delete one record |
/active | GET | Session | This device’s active widget and version |
/widgets | GET | Session | Widget list and this device’s active id |
/control | WebSocket | Challenge-response | Control messages, including switches and reloads |
/pair | POST | Pairing token in body | Exchange a one-time token for a session |
/api/action/:widgetId/:actionId | POST | Session + consent | Invoke a registered action |
/api/pulse | POST | Session | Animate the sending device’s bird |
/api/active/:id | POST | Session | Select a widget for this device |
/api/foreground | POST | Session | Report the displayed widget, or an empty object for none |
/api/widget/:id/versions | GET | Session | Version history |
/api/widget/:id/revert/:version | POST | Session | Select an earlier version |
/api/widget/:id/delete | POST | Session | Delete a widget |
/api/widgets/order | POST | Session | Set the shared widget order |
/api/unpair | POST | Session | Revoke this device’s pairing |
/api/rename | POST | Session | Rename this device |
/api/bird-color | POST | Session | Set this device’s bird color |
/api/relay | GET | Session | Read relay configuration and address; not an uplink health check |
/api/relay/pass | POST | Session | Deliver a Preen Plus relay pass |
The native SDK supplies the current widget id for db and trigger calls;
widget code cannot select another id through those methods. Tokens themselves
are not scoped to a single widget. A paired native client can access the hub’s
widget library and datastores, while the write token can feed any widget and
access any datastore. Keep both tokens out of widget HTML.
Streaming and WebSocket control
GET /api/data/:id/stream returns text/event-stream. Each data: event contains
an envelope; the native app delivers only its data field to onData:
data: {"widgetId":"wgt_abc123","data":{"tempC":21.4},"ts":1788640000}
The hub sends the current snapshot if one exists, streams subsequent updates, and sends a comment heartbeat about every 15 seconds. There is no event-id replay history. The phone reconnects interrupted streams and also runs a roughly 10-second backup GET poll, so duplicate snapshot delivery is possible.
/control is a separate WebSocket. The hub sends a challenge nonce; the client
responds with its device id and an HMAC made using the session token. After
acceptance, the hub sends control messages such as active-widget selection,
HTML reloads, and appearance changes. The raw token is not a WebSocket query
parameter or authentication frame.
With Preen Plus, the native app carries requests, streamed data, and control messages through an end-to-end encrypted relay. The relay path uses sealed requests and a sealed control WebSocket, rather than exposing the LAN routes as ordinary public endpoints. The widget SDK behaves the same on both paths.
Admin surface (127.0.0.1, write token)
Every route below requires the write token. The listener is bound to loopback; MCP and the CLI are clients of this surface, and other local tools may use it.
| Path | Method | Purpose |
|---|---|---|
/admin/publish | POST | Publish a widget; inline actions remain pending |
/admin/update | POST | Update widget HTML and optional metadata |
/admin/set-active | POST | Select the active widget |
/admin/list | GET | List widgets |
/admin/delete | POST | Delete a widget |
/admin/data/:id | GET / POST | Read / replace the JSON feed |
/admin/html/:id | GET | Read widget HTML |
/admin/data-endpoint/:id | GET | Get the LAN producer URL and write token |
/admin/store/:id/:collection | GET / POST / DELETE | List / append / clear datastore records |
/admin/store/:id/:collection/:recordId | PUT / DELETE | Replace / delete one record |
/admin/pair-status | GET | Pairing and connection status |
/admin/set-icon | POST | Set or replace an icon |
/admin/versions/:id | GET | Version history |
/admin/revert | POST | Select an earlier version |
/admin/register-action | POST | Register an action, pending approval |
/admin/registry-link | POST | Record the registry entry a widget publishes to |
There is no admin endpoint to approve consent. Registering capabilities does not arm them. Approval happens in the Mac app, including through its optional Auto-consent controls setting. See Interactive actions.
Mac Notch pairing, settings, alerts, and rules use /admin/surfaces and
/admin/surfaces/:id, under the same write authentication. Each Notch is bound
to one display; see the Mac Notch endpoint reference.
Discovery and TLS
service type: _preen._tcp
domain: local.
The LAN listener uses a self-signed HTTPS certificate. The native app pins its fingerprint when pairing. External producers need to trust the certificate; see TLS setup.
The iOS client chooses the route. The pairing QR supplies a local host and port, a certificate fingerprint, a one-time pairing token, and an optional relay base URL. An empty host supports relay-only pairing. Bonjour rediscovery uses the resolved address and advertised port; a candidate must authenticate with the saved certificate pin and session token before it replaces the saved endpoint.
With Local Network enabled, reconnect normally tries the saved address, an optional Wake-on-LAN retry, and Bonjour before relay. A recently working relay route or a pairing without a saved local address leads with relay. During a relay connection, the client probes for a verified local route before switching back. Disabled routes are skipped. See the connection guide for user controls.
PairResponse.relayBase supplies the relay address to save; when absent, the
client retains the QR’s address for compatibility. Successful LAN connections
refresh this through GET /api/relay. Its enabled and baseURL fields describe
the Mac’s configuration, independently of uplink liveness. enabled: false
clears the saved relay address. An advertised address alone does not establish
Plus entitlement or relay admission.
POST /pair returns HTTP 401 with {"reason":"expired"} or
{"reason":"invalid"} when the token is refused. The shared client preserves
these reasons on LAN and relay. A relay handshake refusal may have no pairing
reason body; clients must not assume it means the code expired. A pairing
refusal ends the attempt and is separate from an established-session rejection.
Headers
Authorization: Bearer <token> # authenticated HTTP routes
X-Preen-Schema: <version> # wire schema metadata
Pairing supplies its one-time token in the request body. WebSocket control uses the challenge-response exchange above. Neither puts a token in the URL.
Timings
| Behavior | Current value |
|---|---|
| Live data delivery | SSE as updates arrive |
| Native backup data poll | Immediately, then roughly every 10 s |
Stored refreshMs default | 1500 ms; not used to schedule the current iOS feed loop |
| SSE heartbeat | 15 s |
| Control heartbeat | 20 s |
| Pairing token TTL | 120 s |
| Minimum action invocation spacing | 0.5 s per widget |