Perch
Building modsLogic & lifecycle

Logic & lifecycle

The handler set, the data-push rules, timers, and the sandbox your Lua runs in.

3 min readUpdated Jul 21, 2026

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. For transient contexts, presence auto-retracts after the declared timeout_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.

AvailableRemoved
base (minus the loaders), string, table, math, utf8io, 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

BudgetLimitOn breach
instructions per callback~2M opscallback aborted
wall clock per callback50 mscallback aborted
memory per mod16 MBallocation fails
strikes3 consecutivemod 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.

esc
Type to search
navigate open