API

Pushing data

How live JSON reaches a widget — via the MCP, or from any script.

A widget renders whatever JSON is currently set as its feed. Update the feed and the hub streams it to the native phone app, which invokes the widget’s onData — the page never reloads.

From the MCP

push_data sets the feed to an arbitrary JSON object the widget understands:

// push_data
{ "id": "wgt_abc123", "data": { "tempC": 21.4, "available": true } }

The data is opaque to the hub — it’s the contract between your widget’s onData renderer and whatever is producing values. Push again to update.

From a script (no MCP) — feeding the widget

A Preen widget never calls out; something has to push values in. That producer is the feed side of the widget’s backend, and it can be anything that speaks HTTP — a cron job, a sensor daemon, a CI step, a Raspberry Pi. There’s no Preen SDK to install: you POST JSON to the widget’s feed. Your producer runs wherever you install it, including on the Mac if it needs local data. Preen does not schedule or sandbox that producer; its setup and permissions are separate from widget action consent.

Get the feed’s URL and token once with get_data_endpoint:

// get_data_endpoint
{ "id": "wgt_abc123" }
// → { "url": "https://<mac-lan-ip>:<port>/api/data/wgt_abc123", "token": "…" }

Use the returned url verbatim — it’s the widget’s LAN data path (/api/data/:id), reachable from any host on your network. Don’t hand-build an /admin/… URL: the admin surface is localhost-only and isn’t the producer write path.

Then POST JSON with the bearer token:

curl -X POST "$URL" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"tempC": 22.0, "available": true}'

The body becomes the feed wholesale — the same object your onData renderer receives. A 200 means the hub accepted the feed update; connected stream subscribers receive it without waiting for a poll. It does not acknowledge that a phone has rendered it.

Treat the token like a secret. It’s your hub’s write token, and it can push data to any widget’s feed — not just this one — so don’t hand it out per widget. Keep it in an env var or your secrets store, never in the widget HTML.

TLS

The hub serves HTTPS with a self-signed certificate (the paired phone pins it). A strict client will reject that cert unless you trust the Mac’s certificate explicitly; for a quick local producer, curl -k skips verification.

Keep it fed (cron / launchd)

A backend is usually just this POST on a timer — read once, push once, and let your scheduler handle the loop:

#!/usr/bin/env bash
# preen-feed.sh — run from cron/launchd. PREEN_URL + PREEN_TOKEN come from
# get_data_endpoint; export them in the job's environment.
set -euo pipefail
cpu=$(ps -A -o %cpu | awk '{ s += $1 } END { print int(s) }')
curl -fsS -X POST "$PREEN_URL" \
  -H "Authorization: Bearer $PREEN_TOKEN" \
  -H "Content-Type: application/json" \
  -d "{\"cpu\": $cpu, \"available\": true}" || true

Feeding several widgets is just several POSTs — one get_data_endpoint (URL + token) per widget id. If a push fails, the widget keeps showing its last value; push {"available": false} when you’d rather the renderer drop to an explicit “no data” state.

How fast it updates

The native app subscribes to GET /api/data/:id/stream, an SSE stream that sends the current feed and subsequent updates. It reconnects interrupted streams and also fetches GET /api/data/:id immediately, then roughly every 10 seconds, as a freshness backup. Both the direct LAN connection and the encrypted relay support streamed data.

The manifest still stores refreshMs (default 1500 ms), but the current iOS app does not use it to schedule data delivery. Lowering it will not make the stream faster or change the backup polling interval. Control update volume in your producer instead.

onData can receive the same snapshot more than once, including from the backup poll or after reconnecting. Render idempotently. The feed keeps the latest value and does not replay every update missed while disconnected; use the datastore for history you need to retain.

Shape your payload for the widget

Because data arrives as a whole object each time, design a flat, self-describing shape and have the renderer tolerate missing keys:

{ "cpu": 34, "gpu": 12, "mem": 61, "available": true }
window.PREEN.onData((d) => {
  d = d || {};
  if (d.available === false) return;
  set("cpu", d.cpu); set("gpu", d.gpu); set("mem", d.mem);
});