Your logic is a table of on_* handlers returned from the entry. Every
handler receives host first — the same table as the global perch, two
spellings of one API. The complete function reference lives in the
Lua API section; this page is the mental model.
The lifecycle
function M.on_start(host) end -- declare presence, arm timers
function M.on_stop(host) end -- final push_data is safe here
function M.on_timer(host, name) end -- your named timers
function M.on_command(host, widget, action) end -- buttons, actions, seeks
function M.on_widget_state(host, widget, tbl) end -- field edits returning
function M.on_page_visibility(host, widget, visible) end -- poll throttling
function M.on_configure(host, settings) end -- settings changed, live
Plus provider events — every scope you hold delivers its stream as
on_<event>(host, payload): on_media, on_call, on_weather,
on_volume, on_note… Each provider’s events are documented on
its API page.
Handlers are optional — declare what you use. Nothing is polled on your behalf; between events and timers, your mod is not running.
The data loop
function M.on_start(host)
host.set_presence("mywidget", true, initial()) -- I exist, here's data
host.timer("poll", 60 * 1000) -- wake me every minute
end
function M.on_timer(host, name)
host.push_data("mywidget", { temp = "21°" }) -- partial pushes merge
end
set_presence(widget, present, data)— presence and payload in one call. Fortransientcontexts, presence auto-retracts after the declaredtimeout_ms.push_data(widget, data)— the steady-state channel. Pushes are melted at ~5/s sustained per mod (burst 20); over-cap pushes park and merge per key, newest wins — the final state always reaches the view, just possibly coalesced. Design for partial payloads and you’ll never notice the melt.
Timers
host.timer("tick", 30 * 1000) -- repeating, floored at 1000 ms
host.timer("settle", 250, true) -- one-shot, floored at 100 ms
host.cancel_timer("tick")
Re-arming a name replaces it. Timers fire on_timer(host, name) on the
main thread. The floors are the idle budget: if you think you need a 100 ms
repeating timer, you’re computing something the view should be doing —
check ticker and
rotate_bind first.
Commands round-trip
-- displays.lua
{ type = "button", action = "next" }
{ type = "bar", seek = true, action = "seek:{frac}" }
{ type = "list", bind = "apps", item = { ..., action = "open:{i}" } }
-- logic.lua
function M.on_command(host, widget, action)
if action == "next" then perch.media.control("next")
elseif action:sub(1, 5) == "seek:" then
perch.media.control("seek:" .. math.floor(tonumber(action:sub(6)) * dur))
elseif action:sub(1, 5) == "open:" then
perch.apps.launch("recent", tonumber(action:sub(6)))
end
end
The command string is the entire vocabulary between view and logic — ≤64
chars, with {i} and {frac} filled by the view. Keep them verbs.
The sandbox
Your lua_State is yours alone — one per mod, so a crash disables only
you.
| Available | Removed |
|---|---|
base (minus the loaders), string, table, math, utf8 | io, os, debug, coroutine, dofile, loadfile, load |
require reads only your package folder. Everything platform-shaped comes
through granted perch.* capabilities instead — that’s what makes the
Workshop scope page honest.
Budgets
| Budget | Limit | On breach |
|---|---|---|
| instructions per callback | ~2M ops | callback aborted |
| wall clock per callback | 50 ms | callback aborted |
| memory per mod | 16 MB | allocation fails |
| strikes | 3 consecutive | mod disabled, settings notice |
Any clean callback resets the strike counter. Blocking work never happens in Lua — HTTP and file APIs are async: you request, a C++ worker performs, your callback lands back on the main thread later.
Threading model
All Lua runs on the main thread. You will never see a concurrent callback, never need a lock, and never block the loop — the runtime guarantees all three by construction.