Building extensions
The Deck itself is coming soon — it isn’t part of the Gatecaster beta, which ships the touch driver. The manifest format below is stable and you can author against it today; the host that renders your tiles arrives in a later release.
Extensions add tiles to the Deck. An extension is a folder containing a JSON manifest (plus any optional assets it references), installed at:
~/Library/Application Support/Gatecaster/Extensions/<id>/manifest.json<id> is a reverse-DNS identifier — e.g. com.gatecaster.nowplaying. The folder
name should match the manifest’s id. Extensions run only inside the Deck,
but they are free to author and test locally: drop a folder into the directory
above and reload.
What ships today vs. what’s on the roadmap. The declarative layer is
available now: the manifest (schema v1 and v2), fields, buttons, named
actions, and refresh polling. The push provider runtime, WebView
presentation, OAuth / secrets injection, the capability enforcement
model, and the registry / distribution flow are designed but not yet
implemented — they are specified in the app repo’s
docs/Plugins/PLATFORM_SPEC.md and are called out inline below. A manifest may
declare the roadmap fields (the loader decodes them tolerantly), but the host
does not yet act on them. Do not ship a pack that depends on them.
The manifest
The current schema is v2, a superset of v1. The manifest’s v field
records the version: absent or 1 means v1, 2 means v2. Every v2 field is
additive — a v1 manifest decodes with all of them absent and behaves exactly as
before. On load the host runs a normalized() migration step that promotes a v1
manifest to the v2 shape (wraps it as a declarative view, treats a refresh
with no parse as JSON), so v1 manifests keep working with no author action.
Core fields
| Field | Type | Notes |
|---|---|---|
id | string | Required. Reverse-DNS, unique. |
name | string | Required. Tile header title. |
symbol | string | SF Symbol name for the header icon. |
colorHex | string | Header icon tint, e.g. "#32D74B". |
minW / minH | int | Smallest span (in grid cells) the tile renders well at. |
defaultW / defaultH | int | Span when first dropped (defaults to min). |
A complete declarative tile (this is the bundled Apple Music example, a v1 manifest that auto-migrates):
{
"id": "com.gatecaster.nowplaying", // required, reverse-DNS
"name": "Apple Music", // required, header title
"symbol": "music.note", // SF Symbol
"colorHex": "#32D74B",
"minW": 2, "minH": 1, // smallest sensible span
"defaultW": 3, "defaultH": 2, // span on first drop
"fields": [
{ "label": "Track", "refreshKey": "title" },
{ "label": "Artist", "refreshKey": "artist" }
],
"buttons": [
{ "symbol": "backward.fill", "action": { "kind": "media", "value": "previous" } },
{ "symbol": "playpause.fill", "action": { "kind": "media", "value": "playpause" } },
{ "symbol": "forward.fill", "action": { "kind": "media", "value": "next" } }
],
// Poll a shell command every 5s; its JSON stdout feeds the refreshKeys above.
"refresh": {
"command": "osascript -e 'tell application \"Music\" to set t to name of current track & \"|\" & artist of current track' 2>/dev/null | awk -F'|' '{printf \"{\\\"title\\\":\\\"%s\\\",\\\"artist\\\":\\\"%s\\\"}\", $1, $2}'",
"everySeconds": 5
}
}Fields
A fields array renders labelled values down the tile. Each entry:
| Key | Notes |
|---|---|
label | The row label. |
refreshKey | Key into the refresh JSON (or provider state). The live value renders. |
value | Static text shown when there is no refreshKey. |
type | "text" (default) · "image" · "range" · "slider" · "dial". Unknown → text. |
size | "small" · "regular" · "large". |
min / max | Range for range / slider / dial (default 0–100). |
orientation | "vertical" (default) · "horizontal" — slider only; dial ignores it. |
action / run | Fired when the control is set (see below). |
text shows the value as a string. image renders the value as a tile image
(data URI, file path, or a provider-pushed PNG — the last is roadmap). range
draws a read-only bar from 0 to max.
slider and dial are interactive: the user drags to pick a value in
[min, max], and the dragged integer is substituted as the $value token into
the field’s inline action or named run (with run taking precedence). The
set is fired throttled. A slider/dial with no action simply displays.
{
"label": "Brightness", "type": "slider", "size": "large",
"min": 0, "max": 100, "orientation": "horizontal",
"refreshKey": "bri", // current value to display
"action": { "kind": "shell", "value": "set-brightness $value" }
}Buttons
A buttons array renders a grid of action chips. Each entry:
| Key | Notes |
|---|---|
label | Caption under the icon. |
symbol | SF Symbol. |
states[] | Multi-state cycle — each tap advances and fires the next state’s action. |
toggle | Deprecated on/off form; superseded by states[]. |
action / run | Inline action, or a named action by id. run wins when both are present. |
Actions
There are two ways to attach behavior: an inline action object, or a
named action declared in the manifest’s actions map (v2) and referenced by
run. An action’s kind is one of:
kind | value semantics |
|---|---|
app | App name ("Spotify") or full path — opens it. |
url | A URL, opened in the default browser. |
keystroke | A shortcut, e.g. "cmd+shift+m", "fn+f8". |
shortcut | The name of an Apple Shortcut to run. |
shell | A zsh command. |
volume | Output volume as a percent, "0"–"100". |
media | "playpause" · "next" · "previous". |
page | Switch the Deck to a page — "2" or a page name. |
activate | Bring an app to front / launch it (app name). |
provider | Forward the action to the tile’s provider. Roadmap (needs the provider runtime). |
interpreter | Run a multi-line script. See v2 extras below. |
v2 action extras (on entries in the actions map):
params[]— parameter names, resolved from$config.<key>or the caller.then—"refresh"re-polls the tile after the action fires;"none"(default) does not.interpreter("osascript"|"zsh") +script— a multi-line script as an alternative tokind+value.
"actions": {
"ping": { "kind": "provider", "value": "ping", "then": "refresh" },
"mute": {
"interpreter": "osascript",
"script": "set volume with output muted",
"then": "refresh"
}
}Polling refresh
A refresh object pulls data periodically. Its command’s stdout feeds the
refreshKeys declared on your fields.
| Key | Notes |
|---|---|
command | A zsh command. Its stdout is parsed into a flat key→value dict. |
everySeconds | Poll interval; clamped to a minimum of 2s. |
parse | v2. How to read stdout (see below). Default JSON. |
transform | v2. Per-key value remap (see below). |
parse is { "kind": "json" } by default — stdout must be a flat JSON object.
Alternatively { "kind": "delimiter", "delimiter": "|", "fields": [...], "trim": true }
splits the first non-empty line into the named keys positionally. ("parse": "json"
as a bare string is also accepted.)
transform remaps a key’s raw value: either a $value template string
("Volume: $value%") or an exact-match lookup map
({ "true": "Muted", "false": "" }).
Wiring a refreshKey to refresh output:
{
"fields": [
{ "label": "Volume", "refreshKey": "vol" },
{ "label": "Status", "refreshKey": "muted" }
],
"refresh": {
"command": "osascript -e 'output volume of (get volume settings)' && ...",
"everySeconds": 3,
"parse": { "kind": "delimiter", "delimiter": "|", "fields": ["vol", "muted"], "trim": true },
"transform": { "muted": { "true": "Muted", "false": "On" } }
}
}A tile is either poll (refresh) or push (provider), never both. The
poll command runs with your user’s privileges — the only code an extension
executes is the actions and refresh command you installed.
Config (per-instance settings)
A configSchema array renders a settings form for each placed tile. The values
the user enters surface as $config.<key> tokens, substituted into commands and
action params. Each row:
type | Renders |
|---|---|
text | A text field. |
toggle | A switch. |
slider | A slider (min / max). |
select | A picker from static options[]. |
device-picker | A picker. With source: "provider:<key>", options are pushed live by a provider — roadmap (needs the provider runtime). |
connect-button | Fires a named action (often pairing / OAuth) and stores the result in a secret. Roadmap (needs provider + OAuth). |
secret | A keychain-backed token field. Roadmap (needs the secrets store). |
Common keys: key (required; the $config.<key> name), label, default, and
the type-specific fields above.
Example packs
The gatecaster-examples
repo ships runnable manifests under
extensions/ —
including:
com.gatecaster.nowplaying/— a v1 declarative tile with a polling refresh (auto-migrated to v2 on load).com.gatecaster.audio/,com.gatecaster.spotify/,com.gatecaster.zoom/,com.figma.shortcuts/— v2 declarative tiles (sliders, transport buttons, keystroke pads).com.gatecaster.heartbeat/— a v2 manifest paired with aprovider— roadmap; it parses and loads today, but the provider does not yet run.
To try one, copy its folder into the Extensions directory and reload the Deck:
cp -R extensions/com.gatecaster.nowplaying \
~/Library/Application\ Support/Gatecaster/Extensions/See Examples for the full list and the Touch API clients.
Roadmap
Everything in this section is designed but not yet implemented. It is
specified in the app repo’s docs/Plugins/PLATFORM_SPEC.md. Manifests may
declare these fields (the loader is tolerant), but the host does not act on them
yet. Do not present a pack as working if it depends on them.
Push provider runtime
A provider declares a long-lived process ({ command, args, caps }) the host
spawns on demand and speaks to over stdio NDJSON. Direction and message
types:
- host → provider:
{ "action", "params" }and{ "action": "refresh" }. - provider → host:
hello(announcescaps),patch(shallow-merges into the tile’s state dict),image(a base64 PNG for animagefield),options(feeds adevice-picker), anderror.
The host manages the process lifecycle: lease-by-close, spawn-on-demand, reap-on-idle, and crash-restart — mirroring the Touch API’s self-healing-lease discipline.
WebView presentation
A view of { "kind": "webview", "entry": "<html>" } loads HTML from the pack
and exposes a gatecaster.* JavaScript bridge for richer tile UI.
Secrets and OAuth
Keychain-backed secrets injected into the provider/command environment as
GATECASTER_SECRET_<KEY>, plus an OAuth redirect catcher (a loopback HTTP
listener or a registered x-gatecaster:// URL scheme) that captures a token and
stores it.
Capability model
Each pack declares capabilities[] — a subset of
shell · network · process · secrets · native-binary — enforced at
runtime as the pack’s ceiling.
touch and suppress are deliberately not grantable to extensions. That
isolation is intentional — the Deck and the input-event surface are kept
separate. If you need raw contacts, gestures, or input suppression, use the
Touch API instead, which is out-of-process and ships free in
the beta.
Distribution
.gatecaster pack files (a zip of the manifest + assets), x-gatecaster://install?src=…
deep links, an install disclosure sheet that surfaces every declared
capability before the user agrees, Ed25519-signed verified packs (CryptoKit),
and a curated registry.
Versioning
Every message and manifest carries a v. It bumps only on breaking changes —
additive fields don’t bump it. Always ignore unknown fields and unknown kind
values so a forward manifest still loads and renders something.