A package is a folder. The folder name is the mod id, and two files are contractual:
- <id>/
- mod.jsonthe manifest — pure data, validated hard
- main.luathe entry (rename via "entry") — returns displays + handlers
- displays.luaconvention, loaded via require
- logic.luaconvention
- assets/images only, served file:// by the host
mod.json
The manifest is pure data: identity, scopes, widgets, settings. Code
lives in the entry — the loader takes displays and every on_* handler
from the entry’s return value (anything code-shaped you put in mod.json is
simply overwritten). The one data-shaped exception: each widget may carry a
preview object — the sample payload editors and pickers render with.
| Field | Required | Meaning |
|---|---|---|
id | ✔ | must equal the package folder name |
title | display name (settings, Workshop); defaults to id | |
version | your versioning; shown to users and stamped into Workshop tags | |
api | ✔ | API generation — currently 1; a newer value is rejected with “needs a newer perch” |
scopes | the grant list (reference); shown verbatim on the Workshop page | |
entry | the Lua entry file, default "main.lua" | |
hint | one-line hint shown under the mod card | |
author | credited on the settings card | |
description | one-liner for settings/Workshop | |
settings | the mod’s settings-rail page (below) | |
widgets | widget declarations (contexts) | |
themes | theme packages (themes) | |
palette | up to 4 named identity colors (below) |
The entry contract
The loader runs one Lua file and expects one shape back:
return {
displays = { <name> = <tree>, ... }, -- required, a table of display trees
on_start = fn, on_timer = fn, ... -- any on_* handler, wired by name
}
How you organize code behind that is yours. The convention — main.lua
merging require("displays") and require("logic") — keeps visuals and
behavior reviewable in isolation, but the loader doesn’t care.
require inside the sandbox reads only your package folder: module
names are [A-Za-z0-9_], resolved as <name>.lua, cached like real
require. There is no path escape.
Palette
"palette": { "brand": "#88c0d0", "hot": "#e5484d" }
Up to 4 named colors, validated at load — parseable hex, luminance-
bounded against the black island so nothing unreadable ships. Trees
reference them as color = "@brand"; they resolve to CSS custom properties
the active theme may remap wholesale. Prefer palette colors over raw hex
anywhere the value is your identity rather than incidental.
Settings
"settings": [
{ "key": "unit", "label": "Temperature unit", "kind": "choice",
"options": ["celsius", "fahrenheit"], "default": "celsius" },
{ "key": "compact", "label": "Compact pill", "kind": "toggle", "default": false },
{ "key": "city", "label": "City", "kind": "text", "default": "Tokyo" },
{ "key": "work", "label": "Work minutes", "kind": "slider", "min": 15, "max": 90, "default": 25 }
]
Six kinds for packages: toggle, slider (min/max/step/default),
choice (options), text, status (read-only line your logic updates),
and button (fires on_command). A seventh, tiles, is builtin-only. A setting may carry inline_with_previous: true to
join the previous setting’s row — compound rows like a text field with its
choice chips sharing one bar.
The declared page renders in the settings rail exactly like a builtin’s,
stores under the mod’s own config, and live-applies: every change calls
on_configure(host, settings) with the full current table. No restart
button exists anywhere in Perch — don’t design for one.
Assets
assets/ holds images only, served to your trees over file:// by the
host. Reference them with paths relative to the package in image nodes’
value. Vector art doesn’t live here — it ships as SVG compiled into
shape path sets at load, where only
<path> elements and the viewBox survive.
Validation — what the loader gates
Loading is where everything is checked, so nothing can fail later at render:
-
Manifest gates. Valid JSON object;
apiknown;idmatches the folder; no code-shaped keys; builtin-only flags (bypass_suppression,exclusive) rejected from disk mods; palette ≤4 valid colors. -
Entry gates. The entry runs inside the sandbox with the full instruction/memory budget armed; it must return the package shape.
-
Tree validation. Every display tree is walked against the component and prop whitelists — unknown component, unknown prop, out-of-range value, over-limit tree: each failure names the exact path (
display.children[1].children[2]: component 'text' has no prop 'colour'). -
Registration. Descriptors build, trees ship to the view once, and
on_startfires. From here your package is indistinguishable from a builtin.