Most widgets are read-only. Interactive widgets (decision D12) can also fire a named, pre-registered command on the Mac when the user taps — e.g. a soundbar widget that adjusts volume, or media controls.
The two halves
1. Register the action (backend). register_action binds an actionId to a
shell command scoped to one widget:
// register_action
{
"widgetId": "wgt_abc123",
"actionId": "volset",
"command": "yamaha-ctl volset",
"description": "Set soundbar volume",
"argKeys": ["vol"]
}
2. Fire it (frontend). In the widget, call window.PERCH.trigger on tap:
button.addEventListener("click", () => window.PERCH.trigger("volset", { vol: 40 }));
window.PERCH.trigger("mutetoggle"); // no args
A widget can only fire actions registered against its own id.
How arguments are passed (safely)
The invocation args are handed to the command as a JSON string in the
$PERCH_ARGS environment variable — never interpolated into the command line.
So there’s no shell-injection surface: the command is fixed at registration; only
the JSON payload varies.
# In your command, parse $PERCH_ARGS — e.g. with jq:
VOL=$(echo "$PERCH_ARGS" | jq -r '.vol')
argKeys documents/validates which keys the action expects (["vol"] above).
trigger vs pulse
| Call | Use | Works in release? |
|---|---|---|
trigger(actionId, args?) | Run a registered command on the Mac | No — action runner is debug-only |
pulse(detail?) | Side-effect-free echo (bird beat + optional notification) | Yes |
Debug-only by design
The action runner and register_action are compiled out of release builds.
Perch v1 ships read-only: a downloaded, notarized Perch app will not execute
widget-triggered commands. trigger simply does nothing there. Use actions when
running a debug build locally; use pulse for feedback that
must work everywhere.
Neither call is a network request — both post over a native message-handler
bridge, so the widget’s connect-src 'none' CSP is never relaxed.