Perch
Building modsThe package format

The package format

Every mod.json field, the entry contract, palettes, and how the loader validates a package.

4 min readUpdated Jul 21, 2026

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.

    FieldRequiredMeaning
    idmust equal the package folder name
    titledisplay name (settings, Workshop); defaults to id
    versionyour versioning; shown to users and stamped into Workshop tags
    apiAPI generation — currently 1; a newer value is rejected with “needs a newer perch”
    scopesthe grant list (reference); shown verbatim on the Workshop page
    entrythe Lua entry file, default "main.lua"
    hintone-line hint shown under the mod card
    authorcredited on the settings card
    descriptionone-liner for settings/Workshop
    settingsthe mod’s settings-rail page (below)
    widgetswidget declarations (contexts)
    themestheme packages (themes)
    paletteup 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:

    1. Manifest gates. Valid JSON object; api known; id matches the folder; no code-shaped keys; builtin-only flags (bypass_suppression, exclusive) rejected from disk mods; palette ≤4 valid colors.

    2. Entry gates. The entry runs inside the sandbox with the full instruction/memory budget armed; it must return the package shape.

    3. 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').

    4. Registration. Descriptors build, trees ship to the view once, and on_start fires. From here your package is indistinguishable from a builtin.

    esc
    Type to search
    navigate open