Perch
Building modsHow modding works

How modding works

The layer model, what a mod can and cannot do, and the five-minute path to a running package.

3 min readUpdated Jul 21, 2026

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:

  1. A declaration in mod.json — its id, label, urgency, and which contexts it can render (the expanded panel, a notch pill, a transient HUD…).
  2. Display trees — pure-data Lua tables describing structure: rows, icons, text, bars. No callbacks, no DOM, no loops.
  3. Handlers — plain Lua functions (on_start, on_timer, buttons’ on_command…) that compute view-ready values and push them with host.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:

SourceWho it’s for
mods/builtin/ inside the app’s assetseveryone — the built-in set
Steam Workshop subscriptionsthe 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

  1. Make a folder. The folder name is your mod id.

    • ~/perch-dev/
      • hello/
        • mod.json
        • main.lua
  2. 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" } } }
      ]
    }
  3. 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,
    }
  4. Run Perch through the dev tap:

    PERCH_MOD_DEV=~/perch-dev perch

    Your 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

esc
Type to search
navigate open