preen is a human-facing command-line tool for authoring and publishing widgets
from your terminal. It talks to the same localhost admin surface the MCP uses
(with a write token). It covers widget publishing, feeds, datastore operations,
versioning, and registry sharing; the MCP also exposes the
authoring guide and action registration. new and watch are CLI authoring
helpers. Its headline feature is watch: save an .html file
and the phone reloads live.
Run either the Preen Mac app or the standalone headless hub. Both serve the admin API; the daemon also supports terminal pairing, device and consent management, account sign-in, and a macOS user service.
Install
The preen binary ships inside the Mac app. To put it on your $PATH, open the
Preen menu and turn on the CLI toggle (it’s also offered during first-run
setup). The app installs preen for you and tells you if the install directory
isn’t already on your $PATH. Confirm with preen list.
Auth & environment
The CLI resolves its target hub and token from the environment, with per-command overrides:
| Variable | Default | Meaning |
|---|---|---|
PREEN_URL | http://127.0.0.1:8444 | Admin base URL (localhost only) |
PREEN_TOKEN | read from disk | Write token |
PREEN_REGISTRY_TOKEN | — | Publisher token, for preen registry publish only |
PREEN_REGISTRY_URL | https://platform.preendisplay.com | Registry base (local dev) |
When PREEN_TOKEN is unset, the CLI discovers a running daemon through its
private endpoint/token files, or falls back to the Mac app’s
~/Library/Application Support/Preen/write-token. Set PREEN_STATE_DIR or pass
--state-dir PATH for a custom daemon. Explicit URLs for another endpoint do
not automatically receive the local token.
Commands
preen new <name> [--out FILE.html] [--force] scaffold a starter widget
preen list list widgets (* = active)
preen status is a phone paired + connected?
preen publish <file.html> [--name N] [--icon SPEC] [--refresh MS]
preen update <id|name> <file.html> [--icon SPEC] [--refresh MS] [--note TEXT]
preen icon <id|name> <SPEC> set/replace icon (no HTML resend)
preen versions <id|name> list code history (* = current)
preen set-version <id|name> <version> set which version is live
preen watch <file.html> [--name N] [--icon SPEC] [--refresh MS]
preen data <id|name> <json | @file.json | -> push live data
preen active <id|name> make a widget the active one
preen delete <id|name>
preen endpoint <id|name> print external push URL + token
preen db put <id|name> <collection> <json | @file | -> append a datastore record
preen db get <id|name> <collection> [--limit N] [--since TS] [--order asc|desc]
preen db delete <id|name> <collection> [--record ID] delete one record / clear
preen registry export <id|name> --contract TEXT [--description TEXT]
[--sample <json|@file|->] [--out FILE]
preen registry publish <id|name> --contract TEXT [--description TEXT]
[--sample <json|@file|->] [--changelog TEXT]
[--reapply] [--registry-id UUID]
preen db reaches a widget’s datastore — durable, per-widget
SQLite, unlike preen data (a single last-value feed). db put appends a record
(add --record ID to replace one); db get prints matching records as JSON;
db delete removes one record, or the whole collection when --record is omitted.
Most commands take an id or a (unique, case-insensitive) name — so preen active Temperature works as well as preen active wgt_abc123. An ambiguous name
is rejected; pass the id.
preen status is the read-only health check: it reports whether an iPhone is
paired and currently connected — the CLI counterpart of the MCP’s pair_status.
preen status # → "Paired: Desk (iPhone, connected)" or "No device paired."
Mac Notches
preen surface status lists connected displays and their paired Notches. Pair
with preen surface pair DISPLAY_ID, then target its returned ID explicitly:
preen surface show SURFACE_ID "Battery"
preen surface event SURFACE_ID '{"title":"Build finished"}'
preen surface configure SURFACE_ID '{"hoverEnabled":true}'
preen surface rules SURFACE_ID @rules.json
preen surface hide SURFACE_ID
preen surface remove SURFACE_ID
One Notch belongs to one display. Hiding keeps its pairing; removing frees the screen. See Mac Notches for layouts, hooks, and settings.
The dev loop: watch
watch is the reason to reach for the CLI over the MCP. It publishes the file
once (updating an existing widget of the same name, or publishing a new one),
then re-publishes on every save:
preen new battery --out battery.html # scaffold a starter
preen watch battery.html --name "Battery" # edit the file → phone reloads live
Save the file and watch updates the hub. The hub sends a WebSocket reload
message so the connected phone refetches the HTML automatically. Data-only
updates use the separate live feed and do not reload the page. Ctrl-C to stop.
--refresh stores manifest metadata; it does not control the current iOS
streaming or backup polling interval. See Pushing data.
Widgets you publish with the CLI appear in the Mac app’s gallery under Browse widgets ▸ Custom, labeled as CLI-published — the same space MCP-published widgets land in. The Built-in tab is reserved for the trusted templates that ship with the app itself.
Pushing data
preen data accepts JSON three ways — inline, from a file, or from stdin:
preen data Battery '{"pct": 82, "available": true}' # inline
preen data Battery @reading.json # from a file
some-sensor | preen data Battery - # from stdin
For producers that aren’t the CLI (cron, sensors, another machine), preen endpoint <id|name> prints the standalone push URL and token — the same pair
get_data_endpoint returns. See Pushing data.
Versioning
Every publish, update, and watch save appends to a bounded code history;
preen update --note "added sparkline" annotates the new cut. preen versions
lists the history newest-first (* marks the version currently live, and cuts
that are pristine copies of a shipped built-in template carry a
[built-in vN] marker); preen set-version selects an earlier snapshot as
live — nothing is appended and history stays intact, so you can flip back and
forth freely. A snapshot is the whole widget cut (HTML, icon, actions), so
reverting restores all of it. These mirror the MCP’s list_versions and
set_widget_version.
preen versions Battery # 4* · 3 · 2 · 1 (number, timestamp, note)
preen set-version Battery 2 # v2 is now live; `versions` shows 4 · 3 · 2* · 1
If that widget is the active one, the phone reloads it.
Sharing a widget publicly
preen registry submits a widget to the public registry, so anyone can install
it from a link. Submissions are reviewed before they go public — publish
reports the share link and “pending review”, never “live”.
Mint a publisher token at
platform.preendisplay.com/account/tokens
(it is shown once — only its hash is stored) and set it as
PREEN_REGISTRY_TOKEN. It is separate from PREEN_TOKEN: that one reaches your
own Mac, this one reaches the internet. The CLI can read PREEN_TOKEN from the
hub’s local token file; the publisher token must be supplied separately.
If the widget you’re publishing was itself installed from the registry, publish
credits the widget it came from automatically — both share pages then show the
lineage. --derived-from UUID,UUID adds parents for anything you borrowed
without installing it. It’s attribution, not a dependency: nothing is fetched,
and a parent going away doesn’t affect your widget.
export PREEN_REGISTRY_TOKEN=preen_pub_…
# Look at what would be submitted, without submitting it.
preen registry export "Bus times" --contract '{ "minutes": number, "route": string }' \
--description "Next departures for the 38." --out bus-envelope.json
# Submit it. stdout is the share link, so it pipes.
preen registry publish "Bus times" --contract '{ "minutes": number, "route": string }' \
--description "Next departures for the 38."
--contract is required: it is the plain-English shape of the JSON your widget
renders, and without it an installer has your widget and no idea what to feed it.
Everything else about the widget — name, HTML, icon, refresh interval — comes off
the hub, so it describes what is actually running.
sampleData defaults to the widget’s last-pushed feed, which is usually
right: a running widget already has real data of the right shape, and that is
what the reviewer and the share page’s preview render. Override it with
--sample (a JSON object, a @file, or - for stdin).
Publishing the same widget again submits a new version of the same registry
entry — add --changelog "what changed", which shows on the share page. After a
rejection, fix it and add --reapply: that keeps the version number and counts
as another attempt.
Two limits worth knowing: registry installs receive no Mac actions (the
envelope has no actions key, so interactive actions do not
travel; local UI and datastore access still work), and
only SF Symbol icons do — an emoji or PNG icon is left out, and the CLI says
so when it happens.
Icons
--icon takes a kind:value spec. preen icon <id|name> <SPEC> sets or replaces
a widget’s icon using the same grammar without re-sending its HTML (the CLI
counterpart of the MCP’s set_widget_icon); the --icon flag on publish /
update / watch carries it inline.
| Spec | Icon |
|---|---|
emoji:📊 | An emoji glyph |
sf:chart.bar.fill | An SF Symbol |
png:/path/to.png | A custom PNG (base64-encoded for you) |
preen icon Battery sf:battery.100 # swap the glyph, leave the HTML alone