# Button YAML Format This document is the format reference for the button definitions stored at `~/.buttonize/buttons///button.yml` on the host (v3 bundle layout — resources live in the same directory). The desktop app owns this file and is the source of truth; a paired phone reads and edits buttons over the WebRTC data-channel RPC (`list_buttons` / `button_edit_full`). > Audience: this file is written for an AI agent that authors buttons — it is the > exact field reference an agent needs (humans can read it too). It is served as > plain Markdown so an agent can fetch it directly. Companion document: > https://buttonizer.app/guide ## Basic structure ```yaml id: deploy title: Deploy description: "Nightly deploy to staging (runs ./deploy.sh)" command: "./deploy.sh --env {{env}}" params: - name: env type: select description: "Target environment (short name only)" default: staging options: [staging, prod] color: "#3B82F6" color_tl: "#3B82F6" color_tr: "#8B5CF6" color_br: "#EC4899" color_bl: "#22C55E" size: 1.0 shape: rounded icon: lucide:rocket timeout: 300 show_log: true run_mode: short created_at: "2026-08-17T00:00:00Z" updated_at: "2026-08-17T00:00:00Z" ``` ## Top-level fields (ButtonDef) | Field | Type | Required | Description | | --------------------- | ------ | --- | ----------------------------------------------------------------------------------------------------------------------- | | `id` | string | Y | The button's unique id. Keeping it consistent with the bundle directory name (`//button.yml`) is safest — the directory name wins if they disagree. | | `title` | string | Y | The name shown on the button | | `description` | string | N | One-line summary. When empty, the run command is shown instead. **It can also be edited and saved in the mobile full editor** | | `command` | string | Y | Shell command to run on the host. May contain `{{param}}` placeholders. It always runs with the button's own directory (the folder containing `button.yml`) as its working directory — there is no `workdir`/`cwd` key, so use absolute paths or `cd` inside the command; `$BTZ_RES` is that same directory | | `params` | array | N | List of parameter definitions | | `param_groups` | array | N | Presentation only: an ordered list of group objects `{ title, collapsed }` (camelCase `paramGroups` is also accepted on read). Params with no `group` render first with no header; declared groups follow in declared order (honouring `collapsed`); a `group` name used by a param but not declared appears after them, in first-appearance order. Never changes the rendered command; clients that do not know the key ignore it | | `color` | string | N | Center color. The background color when solid, the center color when a gradient. Default `#3B82F6` | | `color_tl` | string | N | Top-left corner gradient color | | `color_tr` | string | N | Top-right corner gradient color | | `color_br` | string | N | Bottom-right corner gradient color | | `color_bl` | string | N | Bottom-left corner gradient color | | `size` | number | N | 0.5 ~ 1.0, default 1.0 | | `shape` | string | N | `rounded` \| `circle` \| `square`, default `rounded` | | `icon` | string | N | `lucide:` (e.g. `lucide:zap`, default `lucide:circle`) — renders identically on every platform via the bundled Lucide font. Old SF Symbol values (such as `bolt.fill`) are mapped to Lucide automatically on read and normalized to `lucide:` on save. `res:` (bundled image) and `https://…` (remote) are also supported | | `run_mode` | enum | N | `short` \| `long` \| `service` \| `scheduled` \| `detach` (default `short`; the app UI shows these as **Immediate · Long Running · Service · Scheduled · Detach**). short: return the result immediately (default timeout 5s) · long: return to the screen immediately and notify on completion (default timeout 600s) · service: keep running until stopped (unlimited when timeout is empty) · scheduled: run automatically at the times in `schedule` (see "Scheduled runs" below) · **detach**: fire and forget — start it, stop tracking (see "Fire and forget" below) | | `schedule_enabled` | bool | N | Schedule **active/inactive**. Omitted means `true` (active) — a button is scheduled as soon as it is created. `false` makes the host leave the schedule in place and **skip it silently** (paused). Toggled by the button menu's "Pause schedule / Resume schedule"; the Schedules view shows it as "paused" | | `schedule` | string | N | **Only for `run_mode: scheduled`** (ignored in other modes; validator WARN). A **cron string** `"minute hour day-of-month month day-of-week"` — e.g. `"0 9 * * 1"` (every Monday at 09:00). See "Scheduled runs" below | | `notify_on_done` | bool | N | (legacy) true is interpreted as `long`. Ignored when an explicit `run_mode` is present — a long-only concept | | `restart_on_exit` | bool | N | Long-running/**service** mode only (meaningless for the other modes). When the service process exits on its own, the desktop restarts it if `true`; `false` = notify only. Restarts are supervised with backoff **1s → 2s → 4s → 8s → 16s → 30s (cap)**; after **5 restarts inside 10 minutes** the supervisor gives up (rapid-failure loop protection) and reports the service as stopped with that reason. A user-initiated stop (UI or `POST /api/service/stop`) cancels supervision, and a disabled button is never restarted. Restart state is visible via `GET /api/services` (`restart_count`, plus `restart_stop_reason` after a give-up) | | `timeout` | number | N | Run timeout in seconds. **Empty (absent) means the mode default** — short 5 / long 600 / service unlimited. When set explicitly it is clamped to the 1–86400 range | | `history_limit` | integer | N | Per-button **finished-run history retention**: keep at most this many history records for this button (newest kept, oldest dropped). Absent = **100**; `0` keeps no history records for this button — the run result is still returned to the caller normally. Applies to `history/.jsonl` on the desktop and to the local run-history cache on iOS/Android. Must be an integer `>= 0`: a negative value, a non-integer (`1.5`) or a non-numeric type (`abc`, `true`) is a validation **ERROR** (`--validate`, exit 1). There is no upper bound — a very large value is allowed. See "Run history retention" below | | `service_log_seconds` | integer | N | **Startup-log delay for `run_mode: service` (seconds)**: when a service is started, the app waits this long, collects the output produced in that window (the host keeps the newest 100 KB) and shows it — the "did it come up?" check. `0` = no automatic log sheet (open the log view manually; it always shows the recent 100 KB). Absent = **5**. Must be an integer `>= 0`: a negative value, a non-integer (`1.5`) or a non-numeric type (`abc`, `true`) is a validation **ERROR** (`--validate`, exit 1). There is no upper bound. See "Service startup log" below | | `output_path` | string | N | **Run artifact path** (optional). Supports `{{param}}` substitution. A relative path is resolved against the button's bundle folder; `~/…` and absolute paths are allowed **only under the host's `$HOME`** (anything else is a validation **ERROR**). After a run finishes the file is **moved** (not copied) into the button's `history/` folder and recorded on that history line as `artifact: {orig, file, size, mtime}`. Only a fixed allowlist of extensions is collected — a last extension outside it is a validation **WARN** and the file is left where it is. See "Output artifacts" below | | `output_auto_open` | bool | N | Open the collected artifact automatically when the run finishes. Default `false`. Applies **only when `run_mode: short` (Immediate) AND a present desktop client triggered the run** — `long`/`service`/`scheduled` never auto-open, nor do scheduled, service/restart, chain or other unattended runs. Must be a real boolean: a quoted `"true"` or any non-boolean is a validation **ERROR**; `true` with a non-`short` effective mode is a validation **WARN** (accepted but no effect). See "Output artifacts" below | | `span_cols` | integer | N | How many grid **columns** this button fills — `1` or `2`, default `1`. `2` = a wide (two-column) button. Values are clamped to `1..2` when drawn; anything outside that range is a validation **WARN**. See "Grid span and image background" below | | `span_rows` | integer | N | How many grid **rows** this button fills — `1` or `2`, default `1`. `2` = a tall (two-row) button; with `span_cols: 2` as well it is a **2×2 (four-cell)** button. Same clamping/WARN rule as `span_cols`. See "Grid span and image background" below | | `icon_background` | bool | N | Use the **image** icon (`res:` or `https://…`) as the **full-tile background** instead of a small glyph in the icon slot — default `false`. The image is drawn behind the whole tile (cropped to fill), a dark scrim is laid over it and the text turns white. Ignored for glyph icons (`lucide:…`). See "Grid span and image background" below | | `show_log` | bool | N | Whether to show the output on screen after the run | | `enabled` | bool | N | On/off switch for the button. Omitted means `true` (enabled) — old files and old clients stay valid. `false` draws the button **dimmed** on every client and it does not run at all: no manual run, no channel request, no scheduled run, no service start (`POST /api/service/start`) or supervised restart, no CLI `--run`, no editor entry from the card (property edits inside the editor still work, so it can always be re-enabled). The HTTP run/start paths refuse it with `409 {"error":"button is disabled","detail":"button \"\" is disabled (enabled: false)"}`. The apps write the key **only when it is `false`** — choosing "Enabled" removes it again. Use `schedule_enabled` only to pause the schedule while keeping the button manually runnable | | `created_at` | string | N | ISO8601 creation time — **recorded automatically by the app on save** | | `updated_at` | string | N | ISO8601 modification time — **recorded automatically by the app on save** (used to decide sync drift) | ### Disabled buttons (`enabled: false`) ```yaml enabled: false # omit = true (enabled) ``` A disabled button is drawn **dimmed** on every client and its tile does nothing when tapped — no run, no channel request, no editor entry from the card. The button menu's **Edit** still opens the editor, and property changes saved there (including switching the toggle back to Enabled) apply normally. The key is written **only when it is `false`**; choosing "Enabled" removes it again, so a button that was never disabled keeps a pristine `button.yml`. A missing key, `enabled: true`, and any payload that omits the field are all treated as enabled. Hand-editing the file is enough on the desktop (it watches `buttons/` — no restart, no reload); the phone sees it on its next list refresh (~10 s while the app is open). `enabled: false` stops **every** execution path: manual taps, channel requests, `run_mode: scheduled` runs (host log: `skipped (disabled)`), service start (`POST /api/service/start`) and the CLI `--run` (refused, exit 1). The HTTP run/start paths answer `409 {"error":"button is disabled","detail":"button \"\" is disabled (enabled: false)"}` and no restart is attempted, so run, phone-requested run and service start behave the same. Use `schedule_enabled: false` instead when you only want to pause the automatic runs and keep the button manually runnable. ### Run history retention (`history_limit`) Every finished run is appended to that button's history on the host (`buttons///history/.jsonl`, one JSON line per run). `history_limit` caps how many of those records are kept **per button**: ```yaml history_limit: 100 # omit = 100; 0 = keep no history records for this button ``` - **Newest kept, oldest dropped.** The desktop enforces the limit right after each run is recorded, and a one-time startup sweep applies it to history that already exists — so buttons that predate this key end up limited to the most recent 100 too. Monthly files that fall wholly outside the limit are deleted; a file that straddles the boundary is truncated to its newest surviving lines. - `0` is valid and keeps **no** history records for that button. Returning the run result to the caller (and showing it in the run panel) is unaffected. - The limit applies to the phone's local history cache as well (iOS/Android): each client keeps at most `history_limit` records per button, default 100. - An invalid value — negative, non-integer, or a non-numeric type — is a validation **ERROR** (`--validate` exits 1). The raw value is preserved in the file (like an unknown key) rather than deleted, and the runtime falls back to the default 100 until it is fixed. There is no artificial upper bound. ### Fire and forget (`run_mode: detach`) `detach` starts the command and **stops tracking it**. There is no running badge, no timeout kill, no completion notification, no restart supervision and no run history entry — the tile never says "Running" for it: ```yaml run_mode: detach # fire and forget — start it and let it go ``` - The process is started in its own process group, with stdout/stderr written straight to the button bundle's `tail.log` (truncated at start). It keeps running even after the desktop app quits. - **The log stays viewable**: the tile menu → Run Log / History shows the tail (the desktop's `/api/status` returns it even when nothing is running, and the phones' log view reads the same `tail.log`). - Nothing is recorded in history, so "did it run?" is answered by the log, not by a run record. Use `long` when you want a completion notification, `service` when you want start/stop/restart supervision. - `timeout` is ignored (like `service`: no limit) — pass one only if you explicitly want a bounded run, which `detach` never enforces. - Validation: unknown values still fall back to `short`; the documented aliases `fire_and_forget` / `fire-and-forget` parse as `detach`. ### How fast does a change show up? (measured 2026-09-30) The desktop watches `~/.buttonize/buttons` with the OS file watcher (FSEvents / inotify, **not** polling) and pushes the change to every client that is connected — the app window over `WS /api/events`, paired phones over the channel data channel (`{"t":"ev", scope, path, rev}`). | What changed | Detected on the host | Desktop app applies | Phone applies | |---|---|---|---| | `button.yml` / `folder.yml` | **13 ms** (measured 12–14 ms) | next event tick (grid reload, ~0.2 s) | on the next push — the phone refetches `list_buttons` (RTT + decode; LAN tens of ms, relay ~0.1–0.3 s) | | a bundle resource (`res:` image, SVG) | **12 ms** for a single write; **~166 ms** while the same file is rewritten every second (a 150 ms debounce collapses the burst) | the tile refetches **only that file** and swaps it after it decodes (~0.2 s; no reload, no flicker) | **only that file** is refetched (`scope: "res"`, `path` included) — no `list_buttons` reload | | `status.json` / `tail.log` | same watcher (`services` scope) | running badge / tail refresh | same (push) | | `access.json` (phone access control) | 1 s (mtime poll — a human-rate setting) | — | push `access` → phone reloads its list | - Nothing is *periodically polled* for these: a file that never changes produces no traffic. The 150 ms debounce is the only deliberate delay. - The phones' icon cache is invalidated by the `res` event, so a live background (a per-second SVG graph) keeps updating on the phone too — it used to stay at the first frame because only the button list was refreshed. - A file that is rewritten every second (a monitor graph) therefore updates the desktop and the phones about once per second, at the moment the writer saves. Everything a run produces lands on the **host** first; the phones show a subset. "View log / Run Log" on a button now shows both sides: | What | Where it lives | Visible from the phone? | |---|---|---| | The **live output** of the run (newest 100 KB) | host: the button bundle's `tail.log` | **Yes** — View log fetches the host tail (desktop `/api/status` returns it even when nothing is running, e.g. `detach` runs) | | Runs the **host** started — `scheduled`, desktop/web, tray, chain, service restarts | host: `buttons///history/YYYY-MM.jsonl` (newest first, capped by `history_limit`, default 100) | **Yes** — the button's log sheet lists them **in one time-ordered list** — each row carries a source badge (`scheduled`, `desktop`, `phone`, `service`, `chain`) so host runs and phone runs are told apart without separate sections (`history` RPC; needs a desktop new enough to answer it) | | Runs **this phone** started | phone-local store (`store.logs` / `RunHistoryStore`) | Yes — the same list, badges as `this phone`, with the full output view | | Runs started by **another phone** | host history (the host is the source of truth) | **Yes** — they appear in **Desktop runs** with the `phone` label | | A run's **full output** beyond the inline cap (large logs) | host: the run-output spool | Yes for runs this phone triggered (`run_output` paging); for host-side runs the history entry carries the capped tail | | `detach` runs | host: only `tail.log` (no status, no history entry) | Yes — the tail (View log); there is no history row by design | | Service deaths and restart give-ups | host: notification list (+ OS banner) and, for the phone that started it, a push | Yes — notification inbox | | Desktop-only notifications/emails/banners | host notification store | Yes — the inbox (`/api/notifications` is mirrored into the app) | - The **limit** is `history_limit` (default 100 per button, per desktop). Older entries are trimmed on the host, so a long-running scheduled button shows its 100 most recent runs everywhere. - A button that never ran (invalid cron, missing parameter defaults, a schedule that has not come due yet) has an empty history — the reason shows up in `--validate` and, for unattended failures, as a notification. ### Per-mode monitoring rules (who watches the process) | `run_mode` | wait for exit | default timeout | state written | "Running" badge | notification | restart | |---|---|---|---|---|---|---| | `short` | yes (result returned) | 5 s | `running` → `done` | while it runs | no | no | | `long` | no (returns immediately) | 600 s | `running` → `done` | while it runs | `notify_on_done` | no | | `service` | no | none (until stopped) | `running` until stopped | yes, until stopped | **on unexpected exit** (not restarted / gave up / restart failed) | `restart_on_exit` (backoff, gives up after 5 tries / 10 min) | | `scheduled` | no | 600 s | `running` → `done` | while it runs | `notify_on_done` | no | | `detach` | no | none (ignored) | **none** | **never** | no | no | - A `service` that exits **on its own** is reported: the desktop records a notification (and shows the OS banner), and pushes it to the phone that started the service, when it is *no longer supervised* — `restart_on_exit: false` ("notify only"), a disabled/removed button, a restart that failed to spawn, or the supervisor giving up after 5 restarts in 10 minutes. A death that is about to be restarted is not announced (otherwise a flapping service would spam); `restart_count` shows that instead. A user-initiated stop never notifies. - A phone only believes a button is running when the **host** says so (`service_states.active`), plus runs it started itself while they are still in flight. A local run that the host no longer reports (and that is older than the 30 s grace) is dropped, so a lost result can never leave a tile stuck on "Running" for days (2026-09-29 fix). ### Service startup log (`service_log_seconds`) A `run_mode: service` button never finishes, so there is no run result to read. When the service is **started**, the app waits `service_log_seconds` and then shows the output produced in that window — the "did it come up?" check: ```yaml run_mode: service service_log_seconds: 5 # seconds; omit = 5; 0 = no automatic log sheet ``` | value | meaning | |---|---| | omitted | **5 seconds** | | `0` | no automatic log sheet — the log is still collected, open it from the menu | | `N > 0` | wait N seconds after starting, then show the output collected in that window | - The host keeps the newest 100 KB of the running process in the button's `tail.log`. Both this startup sheet and the manual log view (button menu → Run Log / History) show that window, running or finished. - An invalid value (negative, `1.5`, `abc`, `true`, a list) is a validation **ERROR** (`--validate` exits 1). The raw value is preserved in the file and the runtime falls back to 5 seconds. There is no upper bound. - The key is omitted from `button.yml` while it is absent, so buttons that never declared it keep their file unchanged. - Phones (iOS/Android) read the same key and behave the same way. ### Service start values A service starts with the values from the run sheet (the same sheet the tile menu's `Parameters & preview…` opens): defaults are prefilled and what the user changes is what the process receives. - A `{{param}}` with **no value and no default** blocks the start — the apps open the sheet, and `POST /api/service/start` returns `400 {"error":"missing params","params":[…]}`. - When every value is known (defaults), tapping the tile starts the service directly; use `Parameters & preview…` to change a value first. `POST /api/service/start {"id":…,"params":{…}}` is the same path from scripts. - `Run` on the service sheet **starts** the service (it never runs it once and waits — services are supervised, `restart_on_exit` applies as usual). ### Output artifacts (`output_path` / `output_auto_open`) A button can declare the file it produces, so the host keeps it together with the run record: ```yaml output_path: ~/out/{{name}}.png # omit = no artifact; {{param}} is substituted output_auto_open: true # default false — short (Immediate) + desktop-triggered runs only ``` - **Resolution.** `{{param}}` tokens are replaced with the values used for that run (a supplied value first, then the declared default; an unset token renders empty). A relative path is relative to the button's bundle folder (`buttons///`); `~/…` and absolute paths are allowed **only inside the host's `$HOME`** — an absolute path that escapes `$HOME` is a validation **ERROR**. A `{{param}}` that names no declared parameter is also an **ERROR**. - **Collection.** When the run finishes (success **or** failure) the host validates the file — it must exist, be a regular file (not a symlink), have a last extension in the allowlist, and canonicalize to a path under `$HOME` — and then **moves** it to `history/--` (basenames are truncated to 80 characters, keeping the extension; a collision adds `-2`, `-3`, … before the extension). The original path no longer exists after a successful collection. - **History record.** The move is recorded on that run's history line as an optional object: `"artifact": {"orig": "", "file": "", "size": , "mtime": ""}`. When `history_limit` trims a record, the artifact file that record references is deleted too — only a plain name inside that button's `history/` is ever removed. - **Allowlist** (case-insensitive; the **last** extension decides): `png jpg jpeg gif webp heic pdf txt md log csv json yaml yml xml mp4 mov mp3 m4a`. A last extension outside this list is a validation **WARN** and the file is left where it is (never moved, never opened). A file with **no** extension is moved into history but cannot be opened. Scripts, executables and installers (`sh bash zsh command app scpt workflow dmg pkg jar py exe bat ps1 lnk`) are never collected. - **Auto-open** (`output_auto_open: true`) opens the collected file with the host's default application. It applies **only** when **both** hold: the effective `run_mode` is `short` (the UI's **Immediate**) **and** a present desktop client triggered the run — so `long`, `service` and `scheduled` never open anything, and neither do chain/CLI/other unattended runs. `true` with a non-`short` effective mode is a validation **WARN** (including the legacy inference `run_mode` absent + `notify_on_done: true`, which is `long`); the key is accepted but has no effect. A non-boolean value (including a quoted `"true"`) is a validation **ERROR**. - Both keys are optional; absent means "no artifact / do not auto-open", so buttons written before this feature keep working unchanged. When an `output_path` is declared the button is **moved into `history/`** rather than left in place. ### Grid span and image background (`span_cols` / `span_rows` / `icon_background`) A button can take up more than one cell of the library grid, and an image icon can be stretched across the whole tile: ```yaml span_cols: 2 # 1..2 — grid columns this button fills (omit = 1) span_rows: 2 # 1..2 — grid rows this button fills (omit = 1) icon_background: true # image icon as the tile's full background (default false) ``` - **Cells.** `span_cols: 2` = a wide (two-column) button, `span_rows: 2` = a tall (two-row) button, and both together = a **2×2 (four-cell)** button. Each value is clamped to `1..2` when drawn, and a value outside that range is a validation **WARN** (`--validate`, exit 0) — a bad number never breaks the layout. Like `timeout`, the keys accept a number or quoted digits; a value that is not a number at all makes the bundle fail to load (`yaml_parse`), exactly like the other typed keys. - **The rest of the grid flows around it.** A multi-cell tile is placed at the first free cell (left to right, top to bottom); the remaining buttons fill the cells around it, so a wide or tall button can leave the cells it skipped empty — the same result as a CSS grid without `dense` packing. - **Every client agrees.** The desktop library is a CSS grid; iOS and Android compute the placement with the same greedy rule and draw the tile at `span_cols × span_rows` cells. A tall or wide tile is sized to the cells it occupies (`rows`/`cols` × the 16:9 cell plus the gaps). - **Defaults stay out of the file.** Absent means `1`, and the apps write the key only when it is `2` — a button that does not use spans keeps its `button.yml` byte-for-byte identical. - **`icon_background`** applies only when `icon` is an image (`res:` or `https://…`): the image is drawn **behind** the whole tile (cropped to fill — `object-fit: cover` / `ContentScale.Crop` / `scaledToFill`), a dark scrim is laid over it and the title/status text is forced to white so it stays readable. A glyph icon (`lucide:…`) ignores the key and keeps the small icon slot. If the image fails to load, the tile falls back to its normal colour/gradient background (never a giant placeholder glyph). - **SVG works in both slots** (`res:logo.svg`, `https://…/logo.svg`), and an SVG that declares its own colours keeps them (that is the fix for "the SVG background does nothing": a colour logo used to be drawn as a white silhouette). Each platform draws it its own way: - **Desktop (web)**: a plain ``, so the whole SVG language works (shapes, gradients, CSS, ``, filters). - **iOS**: `UIImage` cannot decode SVG (measured `nil` on iOS 26), so the app rasterizes it **once** with WebKit (PDF → bitmap, longest side ≤ 1024 px, cached per SVG + size) and then treats it like any raster: `fit` in the icon slot, `cover` for `icon_background`. Fidelity matches a browser. - **Android**: rendered by **AndroidSVG** (`com.caverock:androidsvg`, the library Coil/Sketch use for SVG too) into a bitmap once per icon, then drawn like any raster. It covers the static SVG 1.1/1.2 Tiny elements — linear gradients (**including `gradientTransform`** and `xlink:href` inheritance), radial gradients, ``, ``, ``, ``/``/ ``, basic ``, simplified CSS. Limits: `` is not rendered, radial gradients ignore the `fx`/`fy` focal point, patterned strokes and exotic text attributes are not supported. An SVG with **no colours of its own** (`currentColor`/undeclared) is *not* rasterized — the built-in vector parser draws it and the app tint (the tile's text colour) is applied, so monochrome icon files keep working on dark tiles. If AndroidSVG cannot parse the file the built-in parser is used as a fallback (it approximates gradients by their first stop, reads `style="fill: …"`, `rgb()`/`rgba()`, `fill-opacity`, follows `xlink:href`, and fills shapes with the SVG default black rather than leaving the tile colour visible). - All three keys are presentation-only: they never affect the command, parameters, execution, history or scheduling, and clients that do not know them keep working unchanged. ### Image fitting — exact aspect-ratio rules An image icon is drawn in exactly one of two places, and the two places use **different fitting rules**. The rules are the same on every platform; only the box they are applied to differs: | Drawn as | Box (aspect) | Rule | When the image's ratio differs | |---|---|---|---| | **Icon slot** (default) | a **1:1 square** — desktop `30×30 px`, iOS `16×16 pt` (in a 22 pt slot), Android `20×20 dp` (in a 22 dp slot) | **contain**: `object-fit: contain` / `scaledToFit()` / `ContentScale.Fit` | the **whole** image is visible, ratio preserved: the shorter side leaves empty (transparent) bars — a 16:9 image is letterboxed top/bottom (each bar ≈ 21.9% of the slot), a portrait one is pillarboxed left/right. Never cropped, never stretched; the tile's colour/gradient shows through the bars. | | **Tile background** (`icon_background: true`) | the **whole tile** (ratio below) | **cover**: `object-fit: cover` / `scaledToFill()` / `ContentScale.Crop` | ratio preserved, both sides filled, image centred, overflow **clipped** by the tile's rounded corners. No bars, no stretching. The visible share is `smaller ratio / larger ratio`: a **1:1** image in a 16:9 phone tile keeps only its middle **56%** of height; a **1.6:1** image in a 1.44:1 desktop tile keeps **90%** of width. | The **box ratio** the background is cropped to: - **iOS / Android**: one cell is exactly **16:9** (`aspectRatio(16/9)`); a spanning tile is `cols × cellW + gap × (cols−1)` wide and `rows × cellW × 9/16 + gap × (rows−1)` tall (gap = 12 pt/dp). A 1×1 tile is therefore exactly 16:9, while multi-cell tiles are a little wider than an exact multiple (measured on Android: 2×2 = 1016×583 ≈ 1.74:1, 1×2 = 495×583 ≈ 0.85:1, 2×1 = 1016×278 ≈ 3.66:1). - **Desktop**: the library grid uses the same **16:9 per cell** as the phones (`repeat(auto-fill, minmax(150px, 1fr))`, and the row height is set from the measured column width × 9/16, so a `span_cols: 2` / `span_rows: 2` tile is `2×cell + gap` — the phones' formula). All three platforms therefore crop a background image identically. (Until 2026-09-30 the desktop used `grid-auto-rows: minmax(110px, auto)`, i.e. a content-driven height of 1.42–1.85:1 depending on the window, which cropped backgrounds differently from the phones.) Worked examples for a `1600×1000` (1.6:1) logo, reading the table as "contain = the whole picture, cover = the whole box": - **Icon slot** — desktop: a 30 px box letterboxes it to `30×18.75`; iOS: `16×10` inside the 16 pt slot; Android: `20×12.5` inside the 20 dp slot. Bars above and below on all three; the picture is never cut. - **`icon_background` on a 1×1 tile** — iOS/Android tile is 16:9 → the image is scaled to the tile height and **90% of its width** is visible (5% cut off on each side, centred). Desktop tile at 1280 px is 1.44:1 → **90% of width** visible; at 900 px it is 1.85:1 → **86%** visible. - **`icon_background` on a 1×2 (tall) tile** — the box is taller than it is wide, so the same 1.6:1 logo is scaled to the tile width and most of its **height** is cropped: only ≈ 0.85/1.6 ≈ 53% (Android) or ≈ 0.69/1.6 ≈ 43% (desktop at a 1280 px window) of the height is visible. - **SVG**: the ratio and the drawing scale come from `viewBox`, or — when there is none — from the root **`width`/`height`**, which all three platforms now treat the same way as a ``'s intrinsic size (a viewBox-less Inkscape file used to be drawn at 1:1 and end up as a small patch in a corner, or clipped to its top-left corner on Android). Still, **give every SVG a `viewBox`**: a file with neither a viewBox nor numeric `width`/`height` falls back to the platform default — Android **24×24 (square)**, iOS **1:1**, a browser **300×150**. iOS (WebKit) and Android (AndroidSVG) each rasterize the SVG **once** (longest side ≤ 1024 px, upright) and then apply the same contain/cover rules to that bitmap — a colourful file therefore looks the same on the phones and in the desktop web UI, except for SVG features neither phone renderer implements (filters on both; radial `fx`/`fy` on Android). - **Animated GIF**: the desktop webview plays the animation; the phones decode a still bitmap, so only the **first frame** is shown. - **A failed fetch or an undecodable file** never stretches and never leaves a giant placeholder: the icon slot falls back to a `circle` glyph (iOS/Android) or `🔘`/`🖼️` emoji (desktop), and `icon_background` falls back to **nothing** on iOS (tile gradient + scrim), a small 32 dp `circle` on Android, or the small emoji above the title on the desktop. ### Scheduled runs (`run_mode: scheduled`) With `run_mode: scheduled`, the button runs **automatically** when the host's **local time** matches `schedule`. Everything else behaves like `long` (background, default timeout 600s, a desktop notification on failure or timeout). Manual runs keep working as before. `schedule` is a **cron string** (5 fields) — `minute hour day-of-month month day-of-week`. ```yaml run_mode: scheduled schedule: "0 9 * * 1" # every Monday at 09:00 schedule_enabled: true # omit = true (active). false = paused ``` | Field | Range | Notes | |---|---|---| | minute | 0-59 | required | | hour | 0-23 | | | day-of-month | 1-31 | for date-based runs such as the 1st or the 15th | | month | 1-12 | names `jan`…`dec` accepted | | day-of-week | 0-7 | **0 and 7 are both Sunday**; names `sun`…`sat` accepted | Value syntax: | Syntax | Meaning | |---|---| | `*` | every value (`?` in cron means the same) | | `5` | that value only | | `1-5` | range | | `1,3,5` | list | | `*/15` | every 15 | | `1-10/2` | 1…10 step 2 | | `5/10` | from 5 to that field's maximum, step 10 | Macros work too: `@yearly` (`0 0 1 1 *`), `@monthly` (`0 0 1 * *`), `@weekly` (`0 0 * * 0`), `@daily`/`@midnight` (`0 0 * * *`), `@hourly` (`0 * * * *`). `@reboot` is not supported (ERROR). Common expressions: | cron | Meaning | |---|---| | `"0 9 * * *"` | daily at 09:00 | | `"0 9 * * 1-5"` | weekdays at 09:00 | | `"*/30 * * * *"` | every 30 minutes | | `"15 * * * *"` | hourly at :15 | | `"0 0 1 * *"` | the 1st of every month at 00:00 | | `"0 8 1 * 1"` | the 1st of the month **or** any Monday at 08:00 | | `"@daily"` | every day at 00:00 | **Active/inactive**: a scheduled button is **active as soon as it is created** (there is no separate arming step). Saving `schedule_enabled: false`, or using **button menu → "Pause schedule"** in any app, makes the host skip that button silently. The schedule value stays as it is, and "Resume schedule" starts it again from the next matching time. In the Schedules view a paused button is shown with a "paused" badge and no next run time. Rules to know: - **When both day-of-month and day-of-week are set**, cron convention means it runs **when either one matches** (`"0 8 1 * 1"` = on the 1st of the month and also every Monday). - Times are **host local time**; there is no timezone setting. - One run per matching minute. Times missed while the app was closed are not caught up. - If the previous run is still in progress, the new one is skipped (no pile-up). - These are unattended runs, so every `{{param}}` used in `command` must have a `default` (`type: device` is filled automatically; `type: command` is not resolved, so it needs a `default` too). The validator blocks this with an ERROR, and the scheduler re-checks right before running and skips (with a notification and a banner). - If the cron cannot be parsed (e.g. `"61 9 * * *"`, `"0 9 * *"`), the button does not run — it fails safe rather than silently running at some other time. The validator reports an ERROR and the Schedules view shows ⚠. - The legacy four-field block (`schedule: {minute: 0, hour: 9, weekday: mon}`) is still read and converted to cron — saving from the app rewrites it as a cron string such as `schedule: 0 9 * * 1` (the validator reports a WARN). - When `run_mode` is not `scheduled`, `schedule` is ignored (validator WARN). ## Parameter fields (ParamDef) | Field | Type | Required | Description | |---|---|---|---| | `name` | string | Y | Placeholder name. Used as `{{name}}` | | `type` | string | Y | `string` \| `number` \| `boolean` \| `select` \| `file` \| `upload` \| `path` \| `image` \| `command` \| `device` \| `text` | | `description` | string | N | One-line help text — shown in the phone/desktop run sheet | | `default` | string | N | Default value (used when the value is empty) | | `options` | array | N | List of choices for the `select` type | | `multiple` | bool | N | `file`/`upload` (and `image`, which always expands): the picker accepts several files and the value expands to **N single-quoted arguments**, one per newline-separated path. Default `false`; ignored on any other type (validator WARN) | | `min` | number | N | `type: number` only: lower bound. When **both** `min` and `max` are set the desktop UI renders a **slider**, otherwise a number input. Enforced at every entry point (desktop run sheet, REST run, phone channel, CLI `--run`); an out-of-range value is refused with an error naming the param, the value and the allowed range | | `max` | number | N | `type: number` only: upper bound. See `min`. `--validate` errors when `min > max` | | `step` | number | N | `type: number` only: slider granularity, default `1`. **UI only — never enforced**; `--validate` errors on `step <= 0` | | `group` | string | N | Presentation only: the run-sheet group this param belongs to (matched against a button-level `param_groups` title). Never changes the rendered command; clients that do not know it ignore it | | `sourceCommand` | string | N | For the `command` type: the command to run (desktop local execution) | | `outputMode` | string | N | For `command`: `all` (the whole output is the value) \| `pick` (choose one line, default) | | `separator` | string | N | With `pick`, the display/value separator (`displayvalue`) | | `tabSeparated` | bool | N | (legacy) With `pick`, split on tab (or a literal `\\t`) | | `isInternal` | bool | N | When true, not shown in the run sheet (only for values consumed mid-chain) | ### Parameter groups (`param_groups` / `group`) — presentation only The run sheet can group parameters under headers. Put `group: ""` on each param and optionally declare the groups (and whether each starts collapsed) at button level: ```yaml param_groups: - title: Connection collapsed: false - title: Advanced collapsed: true params: - name: host type: string group: Connection - name: flags type: string group: Advanced - name: note type: text # no group → renders first, with no header ``` Order: params **without** a group render first with no header; declared groups follow in declared order (honouring `collapsed`); any group name used by a param but not declared appears after them, in first-appearance order. Groups change the run-sheet layout only — they do **not** change the rendered command — and clients that do not know the keys ignore them. The YAML key is `param_groups` (camelCase `paramGroups` is also accepted on read); a param's `group` is the group's display name string. The desktop editor exposes the same keys: a per-param `group` field and a button-level group list with a title and a collapsed toggle. ### Per-type "value" — what actually goes into a run Four of these types name files, and it matters whose filesystem they refer to. `file` and `path` are **host-side** (the desktop that runs the command): the desktop offers a local picker, and a paired phone browses the **host** filesystem over the channel (`file_list` RPC) — so they are supported from the phone too. `upload` and `image` are **device-side on the phone**: the phone's document picker (`upload`) or photo library (`image`) is the source, the bytes are uploaded to the desktop, and the substituted value is the resulting **host** path. On the desktop both `upload` and `image` are local pickers. Whatever the source, the value is always an absolute **host** path. | type | What you see on phone/desktop | Final value | |---|---|---| | `string` | One line of text | The entered string | | `number` | Numeric input (format-validated) | The numeric string | | `boolean` | Toggle | `true` \| `false` | | `select` | `options` dropdown | The selected option string | | `device` | **No input (automatic)** | The device id of the app performing the run — phone run: the phone uid `du-…`; desktop/HTTP run: that desktop's `dsk-…` (channel.json desk_id) | | `file` | **Host-side.** Desktop: local file picker. Phone: the **host** filesystem browsed over the channel (`file_list` RPC) — supported from the phone, not desktop-run only. Shown as "Host file" on the phone | **Absolute host path** (host file root + the path picked in the browser) | | `path` | **Host-side.** Desktop: local file/folder picker. Phone: the **host** filesystem browsed over the channel (`file_list` RPC) | **Absolute host path** (host file root + the path picked in the browser) | | `upload` | **Device-side on the phone.** Phone: document picker (`UIDocumentPicker` on iOS, SAF on Android) → uploaded to the desktop. Desktop: local file picker. Shown as "Device file" on the phone | The uploaded/selected **host** path (`~/.buttonize/uploads/…` when uploaded from the phone) | | `image` | **Guest/device-side.** Phone: photo library → uploaded to the desktop. Desktop: local image picker | The uploaded/selected **host** path (`~/.buttonize/uploads/…` when uploaded from the phone) | | `command` | Chosen from the result of running the source command | See the rules below | | `text` | Multiline free text (older clients fall back to a single-line input) | The entered text — substituted as exactly **ONE** POSIX single-quoted argument, newlines preserved | `multiple: true` on `file`, `upload` (or `image`) expands the newline-separated paths into **one quoted shell argument each**. ### device type (Device ID) rules 1. The user does not enter a value — the app performing the run fills in **its own device id** automatically. 2. Running from a phone inserts that **phone's uid** (`du-…`, stable device identity). 3. Running from the desktop app (or its REST API) inserts **that desktop's desk id** (`dsk-…`). 4. The run form has no input field (read-only display), and `default`/`options` are not used. 5. In commands it is referenced as `{{name}}` like any other parameter. ```yaml params: - name: device type: device command: "curl -s -d device={{device}} http://…/notify" ``` ### command type (command-chain) rules 1. When the run sheet opens or an earlier parameter changes, **the desktop runs `sourceCommand`** (30s timeout) to build the candidates. 2. `outputMode: pick` (default) → the candidates are the output's **lines**: - With `separator`, split into `display nameactual value` (e.g. `~~~`). - Without `separator`, the whole line is both display name and actual value. - With `tabSeparated: true` (legacy), split on tab (or a literal `\t`). 3. `outputMode: all` → the source command's **entire output is the value** (the user can edit it). 4. **Chaining**: if `sourceCommand` references `{{earlier parameter}}`, the command is executed with that earlier value substituted. When an earlier value changes, the candidates of the later `command` parameters are reloaded. 5. Substituted values are automatically single-quoted (see caution 1 below). 6. `isInternal: true` → not shown in the sheet, but the value is still used for substitution/chaining. Pinning the value with `default` lets the chain proceed without user input. 7. If the value is empty and there is no `default`, the **run is blocked** (the same rule on phone and desktop; the desktop error message is `missing params: …`). > The standard is to write YAML keys in camelCase (`sourceCommand`, > `outputMode`, `isInternal`, `tabSeparated`); the snake_case form from older > saves (`source_command`, etc.) is also accepted on read. ## Examples by type ### 1. Simple string parameter ```yaml id: greet title: Greet command: "echo hello {{name}}" params: - name: name type: string default: world color: "#22C55E" size: 1.0 shape: rounded icon: lucide:hand show_log: false ``` ### 2. select parameter ```yaml params: - name: env type: select default: staging options: - staging - prod ``` ### 3. command output parameter (pick + separator) ```yaml params: - name: session type: command sourceCommand: "tmux list-sessions -F '#{session_name}'" outputMode: pick - name: limit type: number default: "100" ``` In `pick` mode, specify `separator` to use `displayvalue`. ```yaml params: - name: session type: command sourceCommand: "python3 ~/.buttonize/bin/xxx_sessions.py" outputMode: pick separator: "~~~" ``` - Before `~~~`: the name shown on screen - After `~~~`: the value actually substituted into the command ### 4. Parameter chain ```yaml params: - name: session type: command sourceCommand: "tmux list-sessions -F '#{session_name}'" outputMode: pick isInternal: true - name: window type: command sourceCommand: "tmux list-windows -t {{session}} -F '#{session_name}:#{window_index}:#{window_name}\\t#{session_name}:#{window_index}'" outputMode: pick separator: "|" ``` ### 5. Multiline text parameter ```yaml params: - name: body type: text description: "Commit message body (multiline)" command: "git commit -m {{body}}" ``` `text` is substituted as exactly **one** single-quoted argument; embedded newlines are preserved. Older clients that do not know the type fall back to a single-line input. ### 6. Multiple files ```yaml params: - name: inputs type: file multiple: true command: "ffmpeg -i {{inputs}} out.mkv" ``` The picker accepts several files and the value becomes newline-separated paths, each substituted as its own single-quoted argument (the same rule as `image`). ### 7. Number bounds (slider) ```yaml params: - name: retries type: number min: 0 max: 10 step: 1 default: "3" ``` When **both** `min` and `max` are set the desktop run sheet renders a slider; `step` (default `1`) is its granularity only. Values outside `min`…`max` are refused at every entry point, not only at the slider. ### 8. Grouped parameters ```yaml param_groups: - title: Connection collapsed: false - title: Advanced collapsed: true params: - name: host type: string group: Connection - name: flags type: string group: Advanced - name: note type: text ``` Groups are presentation only: the rendered command is unchanged. ## Unknown keys (unrecognized top-level keys) Top-level keys Buttonizer does not recognize are **preserved with their values intact**, not deleted. So when a key that was added later is edited with an older app, its value does not disappear. Preserved keys get a comment on save marking that nothing reads them. ```yaml title: Deploy command: ./deploy.sh my_future_key: 42 # unknown key — Buttonizer does not interpret it and preserves it as-is ``` - **Only top-level keys** are preserved. Unknown keys inside `params:` entries or (in the legacy block form) inside `schedule:` are still dropped — `--validate` reports both those and unknown top-level keys. - The marker comment is regenerated on save. Because YAML does not preserve comments when a file is read, a **stale marker** left on a key that has since been adopted into the schema is **removed on the next save**, and markers never pile up as duplicates. (Hand-written plain comments disappearing on save is the same behavior as before.) - `--validate` still reports an unknown top-level key as an ERROR. The value is kept, but nothing acts on it, so a typo such as `runmod:` needs to be visible. ## Cautions 1. **Placeholder quoting** - When the app substitutes `{{param}}`, it automatically single-quotes the value. - If you add quotes yourself, e.g. `session_id='{{session}}'` in SQL, it becomes `''value''` and can error. - Prefer writing the form `session_id={{session}}`. 2. **Bundle directory and id** - The bundle directory name should match the button's `id` (`//button.yml`). - If the internal `id:` disagrees with the directory name, the **directory name wins** — the desktop overrides the stale value on load. 3. **Optional fields** - Missing fields are handled with the app defaults. 4. **Do not wrap numeric fields in quotes** - Writing a number as a string, e.g. `timeout: '900'`, makes the app's YAML decode (desktop `serde_yaml`) fail. - A bundle whose `button.yml` fails to decode is **not runnable**, but it is **no longer hidden**: the desktop library shows it as a **broken entry** with the file path and the decoder's reason, and paired phones show the same entry in a "Broken" section, so a button that does not appear is visibly explained instead of silently missing. - Numeric fields such as `timeout` and `size` must be written in the form `timeout: 300`. 5. **Duplicate ids are an ERROR** - Two bundles under `~/.buttonize/buttons/` must not share the same directory name. Only the first one found is loaded (root-level bundles before subfolders, sibling folders in name order); the other copies are **duplicates** — never create `//`. - `--validate ` fails with `duplicate id "": N bundles share this directory name: , ` (every candidate path is listed), and `--run ` refuses to execute an ambiguous id. - The shadowed copies also appear as broken entries (kind `duplicate_id`) in the desktop library and on the phone. ## Gradients (optional) Five colors in total can be specified. - `color` — center color - `color_tl` — top-left - `color_tr` — top-right - `color_br` — bottom-right - `color_bl` — bottom-left **Rules** - Any **one or more** of the five may be specified. - Specifying **two or more** renders a gradient that smoothly connects those colors. - Corners that are not specified are filled automatically with a blend of the specified colors. - Specifying only one renders a solid color. ```yaml color: "#FFFFFF" # center color_tl: "#3B82F6" # top-left color_tr: "#8B5CF6" # top-right color_br: "#EC4899" # bottom-right color_bl: "#22C55E" # bottom-left ``` ```yaml # Example with only two corners specified (the rest are blended) color: "#3B82F6" color_tl: "#3B82F6" color_br: "#EC4899" ``` ## See also The authoring guide is at https://buttonizer.app/guide and this document is at https://buttonizer.app/yaml .