Everything you see in Perch — the media card, the Discord facepile, the volume slider, the clock — is a mod. The built-ins ship inside the app, but they load through the identical path your package will, written in the identical vocabulary. That is the guarantee the API is sufficient: if Perch’s own widgets can be built with it, so can yours.
The three layers
- Core is the product’s physics: the arbitration engine, the motion runtime, config, windows and tray, the Steam ownership gate, the mic-live privacy indicator. Never exposed, never replaceable — no mod can suppress or impersonate a trust surface.
- Providers are where all platform logic lives: D-Bus, media sessions,
Discord RPC, sensors, HTTP. A provider has no presentation; it surfaces
as a scope-gated
perch.*Lua API and a stream of events. - Mods are content packages: widgets (display trees + Lua glue) and themes (validated color/shadow token sets). Your code computes facts; the runtime owns every pixel of how they look and move.
What a widget is
A widget is three things:
- A declaration in
mod.json— its id, label, urgency, and which contexts it can render (the expanded panel, a notch pill, a transient HUD…). - Display trees — pure-data Lua tables describing structure: rows, icons, text, bars. No callbacks, no DOM, no loops.
- Handlers — plain Lua functions (
on_start,on_timer, buttons’on_command…) that compute view-ready values and push them withhost.push_data. Nodes bound to a field repaint when it changes, and only then.
Where mods load from
There is deliberately no mods folder. Packages load from exactly three places:
| Source | Who it’s for |
|---|---|
mods/builtin/ inside the app’s assets | everyone — the built-in set |
| Steam Workshop subscriptions | the only third-party channel, with a consent page listing the mod’s scopes |
PERCH_MOD_DEV=<dir> | you, while developing |
Five minutes to a running package
-
Make a folder. The folder name is your mod id.
- ~/perch-dev/
- hello/
- mod.json
- main.lua
- hello/
- ~/perch-dev/
-
Declare it in
mod.json— pure data, no code:{ "id": "hello", "title": "Hello", "version": "1.0", "api": 1, "scopes": [], "widgets": [ { "id": "hello", "label": "Hello", "urgency": "ambient", "contexts": { "expanded": { "display": "card" } } } ] } -
Return the package from
main.lua— displays plus handlers:return { displays = { card = { type = "row", align = "center", pad = "m", children = { { type = "icon", value = "hand-waving", set = "duotone", color = "accent" }, { type = "text", bind = "msg", size = 15, weight = 700 }, } }, }, on_start = function(host) host.set_presence("hello", true, { msg = "Hello, notch" }) end, } -
Run Perch through the dev tap:
PERCH_MOD_DEV=~/perch-dev perchYour widget appears in
Settings → Pages, placeable like any built-in. A broken package disables itself with a path-precise error in Settings — it never touches the overlay or its neighbours.
Where to go next
- Your first real widget — the full walk-through with live data and a button.
- The package format — every
mod.jsonfield. - Components — the complete display vocabulary.
- The Lua API — every function your logic can call.