# 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`) — the raw YAML is never synced over SSH/SFTP. > 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` (default `short`; the app UI shows these as **Immediate · Long Running · Service · Scheduled**). 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) | | `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 | | `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. ### 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 **skipped at load** — it does not appear in the desktop library or on the phone. - Numeric fields such as `timeout` and `size` must be written in the form `timeout: 300`. ## 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 .