# Bolt Apps: agent guide

Give this page to your coding agent. It writes a Bolt App (one YAML file, plus a script if needed) that Bolt loads without errors. Everything here matches Bolt 0.1.176. Items marked Rich widgets only work with the switch on: Settings > Labs > Experimental > Rich widgets for launcher apps (Labs is hidden until you tap the version number 7 times in Settings > About Bolt).

Machine-readable schema for the YAML: https://sparcle.app/bolt-app.schema.json

The JSON Schema describes the shape of a file. It is not what enforces the rules: Bolt is. Bolt checks the URL and the path rules itself when the file loads, and refuses a file that breaks them.

## 1. What you produce

- One file named `<id>.yaml`. `id` is lowercase letters, digits and `-`, up to 64 characters, starting with a letter or digit.
- Optionally one script, saved in a folder named `scripts` inside the User Apps folder (the folder "Open folder" shows, so the YAML sits at `<User Apps folder>/<id>.yaml` and the script at `<User Apps folder>/scripts/<name>`). The YAML calls it as `{{scripts_dir}}/<name>`. Name the script after the app, because two apps with the same script name would overwrite each other.
- One YAML file per app. If the person asks for two apps, write two files.
- Nothing else. Do not invent top-level fields: any unknown field makes the file fail to load.

The person installs it in the Bolt desktop app: Settings > Desktop App > User Apps > Open folder, save the file there, click Reload. A file with a mistake is listed with the reason. Windows, macOS and Linux all use the same YAML.

Sharing and safe install are coming. Until then, hand over the YAML file and the script as plain files; do not try to build a `.boltpack` by hand, because a pack must be signed.

## 2. Pick a runtime

| runtime | Use it when | Needs |
|---|---|---|
| `transform` | Reshape text the person pastes or types. No command, no network. | `transform.pipe` |
| `bridge` | Run one program (a CLI or your script) and show its output as rows. | `bridge` block. A script if the logic is more than one command. |
| `http_local` | Read from a service on this computer (for example Ollama on port 11434). | `http` block |

Pick `bridge` for anything that touches the network, a file, or an API: write a script that prints JSON rows. `http_local` can only reach `127.0.0.1`, `::1` or `localhost`.

## 3. Top-level fields (all runtimes)

| Field | Required | Rule |
|---|---|---|
| `schema_version` | yes | Always `1`. |
| `runtime` | yes | `transform`, `bridge` or `http_local`. |
| `id` | yes | `^[a-z0-9][a-z0-9-]{0,63}$`. |
| `chip` | yes | The key typed after `=`. Same pattern as `id`. |
| `title` | yes | Non-empty. Shown in the launcher. |
| `emits` | yes | The kind of thing the rows are: `Person`, `Issue`, `Document`, `Event`, `File`, `Incident`, `Account`, `Place` or `Generic`. It is a label and adds no required row fields. `Generic` is always safe; `File` also makes a row with an absolute `path` act as a file. |
| `presentation` | yes | See section 7. |
| `icon`, `description`, `placeholder_examples` | no | Display only. |
| `args` | no | `[{ name, from: "query.word(N)", default }]`. Words typed after the key, counting from 0. Only `query.word(N)` exists. |
| `inputs`, `submit` | no | A form instead of free text. Section 8. |
| `actions` | no | Buttons on a row. Section 8. |
| `dispatch` | no | Text of the "press Enter" row. Section 8. |
| `detect` | no | Show the key only when a program is installed. Section 8. |
| `recognize` | no | Claim pasted text without the `=` key. Section 8. |
| `transform` / `bridge` / `http` | one of them | The block for the chosen runtime, and only that one. |

Never use these admin fields: `auth_profile_ref`, `requires`, `scopes`, `filters`, `cache_ttl_seconds`, `provisioning_mode`, `departments`, `roles`, `groups`.

Quote every YAML string that contains `{{`, `:` or `#`. An unquoted `{{...}}` at the start of a value is a YAML error.

## 4. Grammar: runtime transform

```
transform:
  pipe: [ step, step, ... ]        # 1 to 32 steps
```

A step is a bare name or a one-key map. The input is the text typed after the key, or the text the person pasted when `recognize` matches. The app shows one row: the first line of the output as the title, and Enter copies the whole output. `presentation` is required but its fields are not used; write `widget: list`.

| Bare names (no value) | `lines`, `sort`, `dedupe`, `reverse`, `trim`, `upper`, `lower`, `title`, `table`, `markdown`, `csv`, `aligned`, `sql`, `json`, `transpose`, `sanitize` |
|---|---|
| With a value | `split: ","`, `join: ", "`, `prefix: "x"`, `suffix: "x"`, `template: "[{value}]"`, `head: 5`, `tail: 5`, `slice: { from: 0, to: 3 }`, `replace: { from: "regex", to: "text", flags: "g" }`, `filter: { contains: "x" }` (or `not_contains`, `matches`, `not_matches`: exactly one), `sortcol: "name"` or an index, `dedupecol: "name"`, `pick: [a, b]`, `omit: [a]` |

Do not use `camel`, `snake`, `kebab`, `slug`, `yaml`, `toml`, `env`, `properties`, `flatten`, `redact_secrets` or `rename_cols`. They pass the loader but fail when run.

Regular expressions: at most 512 characters, no nested repeats such as `(a+)+`. In YAML double quotes write a backslash twice (`"\\s*,\\s*"`), or use single quotes.

`recognize` (optional) lets pasted text start the app without typing the key:

```
recognize:
  patterns:                        # 1 to 16
    - { regex: 'name\s*,\s*amount', flags: i }   # flags: any of i m s u; regex up to 512 chars
  confidence: ambient              # ambient (default) or high
  reps:                            # optional copy formats, up to 12: { label, template }
    - { label: Markdown, template: "`{value}`" }
```

## 5. Grammar: runtime bridge

```
bridge:
  command:                         # at least one of the three
    macos: "program arg {{query}}"
    linux: "program arg {{query}}"
    windows: "program arg {{query}}"
  parse: json                      # json, lines or raw
  timeout_seconds: 10              # 1 to 60, default 10
  env_passthrough: [AWS_PROFILE]   # optional, see below
  cwd: home                        # optional; absent means a fresh private folder per run
```

Command rules, all checked when the file loads:

- Bolt splits the string into arguments and runs the program directly. There is no shell.
- Forbidden outside a `{{variable}}`: `;`, `&&`, `||`, `|`, backtick and `$(`. `~` is not expanded: use `{{home}}`. Redirects and globs do nothing.
- For more than one step, write a script and call it: `python3 {{scripts_dir}}/my-script.py {{query}}` (use `python` on Windows).
- Variables: `{{query}}` (everything typed after the key), each `args` name, each `inputs` name, `{{home}}`, `{{scripts_dir}}`. Write them bare, never inside quotes: Bolt quotes each value itself. `{{query}}` containing spaces arrives as one argument.
- `{{name | default: value}}` supplies a fallback. A variable that is not defined and has no default fails the run with "argument `name` required".
- Treat `{{query}}` as untrusted text inside your script. Bolt passes it as one argument and never to a shell, but a program may read it as a path or an option. Check it, and treat a missing or empty argument as "no input".
- Every run waits for the person to press Enter. The program runs as the person, with their rights. It starts with a small environment (PATH, HOME, locale) and not much else.
- Do not put secrets in the file. `bridge.env` values must be exactly `${env:NAME}` and `env_passthrough` may name only hosts, regions, profiles and public identifiers: `GH_HOST`, `GH_REPO`, `GITLAB_HOST`, `DOCKER_CONTEXT`, `DOCKER_TLS_VERIFY`, `AWS_PROFILE`, `AWS_REGION`, `AWS_DEFAULT_REGION`, `AWS_CA_BUNDLE`, `AWS_ENDPOINT_URL`, `CLOUDSDK_CORE_PROJECT`, `OKTA_CLIENT_ORGURL`, `SF_TARGET_ORG`, `SF_ORG_INSTANCE_URL`, `TWILIO_ACCOUNT_SID`, `TWILIO_REGION`, `TWILIO_EDGE`, `VERCEL_ORG_ID`, `VERCEL_PROJECT_ID`, `PGHOST`, `PGPORT`, `PGUSER`, `PGDATABASE`, `PGSERVICE`, `PGSSLMODE` (at most 16). Tokens, keys and proxy settings are refused. A script that needs a credential should use the CLI's own saved sign-in.
- A script may use the network. Write only JSON to standard output; send logs to standard error.
- If the person has not named a data source, ask which one. Do not invent an endpoint. If the source needs a key, do not write the key anywhere; use a source with no key, or the CLI's own saved sign-in. Sample data is acceptable if you say so in the app's `description`.

## 6. Grammar: runtime http_local

```
http:
  port: 11434                      # 1 to 65535
  host: localhost                  # optional: 127.0.0.1 (default), ::1 or localhost
  request: { method: GET, path: "/api/tags" }   # GET or POST; path starts with / and not with //; no backslash, no control character; {{var}} is percent-encoded
  parse: json                      # json, lines or raw
  rows_path: models                # optional dotted path to the array inside a JSON reply
  timeout_seconds: 5               # 1 to 60
  headers: { X-Mode: "fast" }      # optional; a value is a literal or exactly one ${env:NAME} from the list above
```

With `parse: json` and no `rows_path`, the reply must be an array, or an object with a `rows` array.

The path is a path, not an address: it starts with `/`, it never starts with `//` (that would be a protocol-relative address to another host), and it holds no backslash and no control character. Bolt then builds the address from the loopback host and port you configured, parses the final URL, and requires the hostname and the port to be exactly those. Every redirect the service answers with is checked against the same loopback origin, so a service cannot bounce the request off the machine.

## 7. Rows and panes

### Row contract (bridge and http_local output)

- `parse: json`: stdout is a JSON array of objects, or `{ "rows": [ ... ] }`. A non-object element becomes `{ "value": x }`. Invalid JSON, or text around the JSON, fails the run.
- `parse: lines`: one row per non-empty line, with `line` (the text) and `idx` (0-based). Use `title_field: line`.
- `parse: raw`: one row `{ "raw": "<all output>" }`.
- At most 2000 rows and 4 MiB of output are kept; the rest is dropped.

Every field of a row can be used in templates as `{{row.field}}`. These fields have a meaning of their own, all optional:

| Row field | Meaning |
|---|---|
| `title`, `subtitle` | Shown in the list. If `title` is missing, `presentation.title_field` is read. |
| `description` | Extra line of text. |
| `copy_value` | What Enter copies. |
| `icon` | An icon name. |
| `url` | Link opened by Enter. Only `http://` or `https://`, up to 2048 characters, never a local or private address. |
| `image_url` | An inline `data:image/...` image only. Web addresses are not loaded. |
| `path` | With `emits: File`, an absolute path (or `~/...`) makes the row act as a file: Enter opens it. |
| `badges` | Up to 4 `{ "label": "...", "tone": "..." }`. Shown in brand blue and grey. |
| `category` | Group name. |
| `actions` | Extra buttons for this row: `[{ "id": "open", "kind": "url", "label": "Open", "url_template": "https://example.com/{{row.slug}}" }]`. Kinds `url`, `composer`, `utility`. `id` matches `[A-Za-z0-9_-]{1,64}`. A `url_template` is either a literal `http://` or `https://` address, or a single `{{row.field}}` placeholder. Only those two schemes can be written: `javascript:`, `data:`, `file:` and `vbscript:` are refused, and a control character is refused. Bolt parses the rendered URL and refuses any scheme other than `http` or `https`. |

The footer shows at most 6 buttons and cuts each label at 14 characters (the full label, up to 40, is the tooltip). Labels such as Allow, Delete, Confirm or OK are shown as "App: Delete". Row actions are added after the actions in the YAML and cannot replace them.

### presentation

```
presentation:
  widget: list            # list, table, cards or detail: the left list. Use list unless you have a reason.
  title_field: title      # row field for the title
  subtitle_field: subtitle
  searchable: true        # optional: lets the person filter rows
  right_widget:           # optional pane on the right
    kind: entity_preview
    mode: selected_row    # selected_row (highlighted row, default) or list_data (all rows)
    bind: { ... }         # keys depend on kind
```

Templates in `bind` are `{{row.field}}` (dotted paths work: `{{row.owner.name}}`) with optional filters: `| upper`, `| lower`, `| capitalize`, `| default:'none'`, `| truncate:40`, `| bytes`, `| date:'rel'` (also `iso`, `long`, `short`). A missing field gives empty text. Panes draw at most 500 rows.

`right_widget.kind` and its `bind` keys. An unknown key, wrong type or too-long value is refused when the file loads, with the key named.

| kind | Shows | bind keys |
|---|---|---|
| `card` | one card | `title`, `subtitle`, `actions` |
| `markdown` | a Markdown body | `title`, `subtitle`, `body` (up to 16384 chars), `actions` |
| `entity_preview` | media, title, label/value facts, optional Markdown body, buttons | `title`, `subtitle`, `image_url`, `fields` (list of `{ label, value, kind, tone }`, up to 40; `kind`: text, badge, chips, branch, diff; `tone`: merged, open, closed, draft, success, warn, danger, neutral), `body`, `actions` |
| `detail` | label/value pairs | `title`, `subtitle`, `fields` (list of `{ label, value, copy }`, up to 40) |
| `table` | all rows as a table | `columns` (list of `{ field, label, align }`, align: left, right, center), and Rich widgets: `sort: { field, dir }` (dir asc or desc), `totals_label`, per column `total` (sum, avg, min, max, count, none), `format` (text, number, percent, currency, date), `unit` |
| `cards` | all rows as a grid of cards | `title`, `subtitle`, `badge`, `image`, `footer`, and Rich widgets: `layout: tiles` with `value` (required for tiles), `unit`, `delta`, `delta_label`, `max` and `min` (a number or template; `max` draws a meter, the gauge), `trend` (a list of numbers in a row field) |
| `chart` | line, bar or sparkline | `type` (line, bar, sparkline), `x`, `y` (one point per row), or `values` (a number list in one row), `label`, `color` (accent or muted), `unit` |
| `json_tree` | JSON as a tree | `source` |
| `diff` | two texts side by side | `left`, `right`, `leftLabel`, `rightLabel` |
| `image_grid` | pictures | `src` (inline data image), `caption`, `alt`, `columns` (1 to 8) |

With Rich widgets off, `layout: tiles` draws the normal grid and the table keys marked Rich widgets have no effect. Never rely on them for a basic result. Do not use the kinds `map`, `image`, `calc`, `color`, `weather` or `date`: they load but show a plain "cannot show app data" message. `entity` is refused.

Use a colour-free design: Bolt draws brand blue and grey. Do not put colour names or CSS in any field.

## 8. Actions, dispatch, args, inputs, detect

Actions (the buttons; an entry in `bind.actions` names them by `id`). In `insert`, `url_template` and `query_template` a row field is written `{{row.field}}` or bare `{{field}}` (both work), and `{{query}}` is what the person typed. This differs from a command, where only the variables in section 5 exist:

```
actions:
  - { id: copy, kind: composer, target: row, label: Copy, insert: "{{title}}" }       # insert required
  - { id: open, kind: url, target: row, label: Open, url_template: "https://example.com/{{row.slug}}" }   # url_template required
  - { id: next, kind: utility, label: Next, target_chip: other-app, query_template: "{{row.id}}" }   # target_chip required
```

- `kind` is `url`, `composer` or `utility`. `flow` and `confirm` are refused. At most one action may set `primary: true` (it takes Enter; the rest go in a menu).
- A `utility` action only pre-fills another User App; the person presses Enter there. It cannot target `=ask`, built-ins or connectors.
- `url_template` and `insert` are not allowed on a `utility` action, and `target_chip` and `query_template` are only allowed on one.

Dispatch (text of the Enter row; bridge always asks first):

```
dispatch:
  confirm_label: 'Search for "{{query}}"'
  confirm_hint: "Press Enter to run"
```

`dispatch` is optional; without it Bolt shows a default "press Enter" row. `mode` can only be `confirm`. `choices` (a list of `{ id, label, hint, args }`, where each key in `args` names an entry of the top-level `args`) cannot be combined with `confirm_label` or `confirm_hint`.

Inputs (a form; each value becomes `{{name}}`):

```
inputs:
  - { name: city, type: text, label: City, required: true, max_length: 60, pattern: '^[A-Za-z .-]+$' }
submit: { label: Search }
```

Field `type`: `text`, `number` (`min`, `max`, `step`), `date` (YYYY-MM-DD), `select` (needs non-empty `options`), `toggle` (true/false), `secret`. `name` matches `^[a-z][a-z0-9_]*$` and is unique.

Detect (hides the key when the program is missing):

```
detect: { binary: git, version_min: "2.30.0", refresh: on_focus }
```

`binary` is a bare name, never a path. `refresh` is `on_boot`, `on_focus` or `manual`.

## 9. Limits at a glance

| Thing | Limit |
|---|---|
| `id`, `chip` | 64 characters, `^[a-z0-9][a-z0-9-]{0,63}$` |
| Transform steps | 1 to 32 |
| Regular expression | 512 characters |
| `recognize.patterns` | 1 to 16; `reps` up to 12 |
| `bridge.timeout_seconds`, `http.timeout_seconds` | 1 to 60 (bridge default 10) |
| `env_passthrough` | 16 names, from the list in section 5 |
| Output kept | 4 MiB, 2000 rows |
| Rows drawn in a pane | 500 (24 for tiles) |
| `bind` text | 4096 characters; `body` 16384; names 200 |
| `fields`, `columns` | 40 each; `bind.actions` 16 ids |
| `image_grid.columns` | 1 to 8 |
| Row `badges` | 4 |
| Footer buttons | 6, label cut at 14 characters |
| Link | 2048 characters, http or https, public address |

## 10. Three complete examples

All three load as written. Replace the ids and text for your app.

### A. Transform: tidy a comma list

```yaml example=transform
schema_version: 1
runtime: transform
id: tidy-list
chip: tidy-list
title: Tidy a comma list
description: Trims, removes duplicates and sorts a comma separated list, one item per line.
placeholder_examples: ["pear, apple,pear , fig"]
emits: Generic
transform:
  pipe:
    - trim
    - replace: { from: "\\s*,\\s*", to: "," }
    - split: ","
    - dedupe
    - sort
presentation:
  widget: list
  title_field: line
```

Type `=tidy-list pear, apple,pear , fig`. Enter copies `apple`, `fig`, `pear`, one per line.

### B. Bridge: wrap a command (`ls`), rows from lines

```yaml example=bridge-ls
schema_version: 1
runtime: bridge
id: lsdir
chip: lsdir
title: List a folder
description: Lists the names in a folder under your home folder with ls.
placeholder_examples: ["Documents", "Downloads"]
emits: Generic
bridge:
  command:
    macos: "ls -1 {{home}}/{{query}}"
    linux: "ls -1 {{home}}/{{query}}"
  parse: lines
  timeout_seconds: 5
dispatch:
  confirm_label: 'List "{{query}}"'
  confirm_hint: "Press Enter to list the folder"
presentation:
  widget: list
  title_field: line
  searchable: true
actions:
  - { id: copy, kind: composer, target: row, label: Copy name, insert: "{{line}}" }
```

Type `=lsdir Documents`, press Enter. Each line of `ls` is a row. There is no `windows` line, so the key does nothing on Windows; add one only with a command that exists there.

### C. Bridge with a script: rich rows and a detail pane

The YAML:

```yaml example=bridge-folders
schema_version: 1
runtime: bridge
id: folders
chip: folders
title: Folders
description: Subfolders of a folder, with item counts and dates.
placeholder_examples: ["", "Documents"]
emits: Generic
bridge:
  command:
    macos: "python3 {{scripts_dir}}/bolt-folders.py {{query}}"
    linux: "python3 {{scripts_dir}}/bolt-folders.py {{query}}"
    windows: "python {{scripts_dir}}/bolt-folders.py {{query}}"
  parse: json
  timeout_seconds: 10
dispatch:
  confirm_label: 'Show folders in "{{query | default: home}}"'
  confirm_hint: "Press Enter to scan"
presentation:
  widget: list
  title_field: title
  subtitle_field: subtitle
  searchable: true
  right_widget:
    kind: entity_preview
    mode: selected_row
    bind:
      title: "{{row.title}}"
      subtitle: "{{row.subtitle}}"
      fields:
        - { label: Items, value: "{{row.items}}" }
        - { label: Modified, value: "{{row.modified}}" }
        - { label: Path, value: "{{row.path}}" }
      actions: [copy-path]
actions:
  - { id: copy-path, kind: composer, target: row, label: Copy path, insert: "{{path}}" }
```

The script, saved as `bolt-folders.py` in the `scripts` folder. It prints one JSON array and nothing else:

```python example-script=bolt-folders.py
#!/usr/bin/env python3
import json, os, sys, time

home = os.path.expanduser("~")
arg = sys.argv[1].strip() if len(sys.argv) > 1 else ""
base = os.path.join(home, arg) if arg else home
rows = []
try:
    names = sorted(os.listdir(base))[:300]
except OSError as e:
    print(f"cannot read {base}: {e}", file=sys.stderr)
    names = []
for name in names:
    path = os.path.join(base, name)
    if name.startswith(".") or not os.path.isdir(path):
        continue
    try:
        items = len(os.listdir(path))
        modified = time.strftime("%Y-%m-%d", time.localtime(os.path.getmtime(path)))
    except OSError:
        continue
    rows.append({
        "title": name,
        "subtitle": f"{items} items",
        "items": items,
        "modified": modified,
        "path": path,
    })
print(json.dumps(rows))
```

## 11. Pre-flight checklist

Run through this before you hand the file over.

1. Top level has exactly the fields in section 3, `schema_version: 1`, and the block that matches `runtime` (and no other).
2. `id` and `chip` are lowercase kebab, and the file is named `<id>.yaml`.
3. `emits` is one of the nine kinds (use `Generic`).
4. Every command is one program call: no `;`, `&&`, `||`, `|`, backtick, `$(`, no `~`.
5. Every `{{variable}}` in a command is bare (no quotes around it) and is `query`, `home`, `scripts_dir`, or declared in `args` or `inputs`. In action templates and `bind`, row fields are `{{row.field}}` (or bare `{{field}}` in an action).
6. Every string containing `{{`, `:` or `#` is quoted.
7. A script prints only JSON (array of objects) to stdout; `parse: json` matches. Run it once by hand and check the output parses.
8. `title_field` names a field that every row has, or each row has `title`.
9. Every `bind` key exists for the chosen `kind` (section 7). `cards` with `layout: tiles` has `value`.
10. Every action id used in `bind.actions` is defined in `actions`. Each `url` action has `url_template`, each `composer` has `insert`, each `utility` has `target_chip`.
11. No secret, token, key or personal path is written in the file or the script.
12. If the schema is available, validate against https://sparcle.app/bolt-app.schema.json.

## 12. Common errors and fixes

The text in the first column is what Bolt (or the run) reports.

| Reported | Fix |
|---|---|
| `unknown field` | Delete the field, or move it to the block it belongs to. Fields of other runtimes and admin fields are refused. |
| `contains forbidden shell metachar` | The command has `;`, `&&`, `||`, `|`, backtick or `$(`. Use one program call, or move the steps into a script. |
| `references ... inside double quotes` (or single) | Remove the quotes around the `{{variable}}`. Bolt quotes it. |
| `unterminated {{` or `malformed variable placeholder` | Close with `}}`. Names match `[A-Za-z_][A-Za-z0-9_]*`. |
| `argument ... required (no default)` | Declare it in `args` or `inputs`, or write `{{name \| default: x}}`. |
| `id must be lowercase-kebab` | Use `a-z`, `0-9` and `-`, 1 to 64 characters. |
| `emits ... not in` | Use one of the nine kinds. |
| `runtime: bridge requires a bridge block` (or transform, http) | Add the matching block; a transform must not have `bridge` or `http`. |
| `unknown key for a cards pane (known: ...)` | Use only the bind keys in the section 7 table for that kind. |
| `bind.value is required when layout is tiles` | Add `value: "{{row.some_number}}"`. |
| `right_widget.kind ... not in` | Use a kind from the table. `entity` is not allowed. |
| `dispatch.mode must be "confirm"` | Remove `mode`, or set `confirm`. |
| `choices and confirm_label ... mutually exclusive` | Keep one of them. |
| `bridge.env value must be exactly one "${env:NAME}"` | Use `${env:NAME}` only, with NAME from the section 5 list. |
| `is secret-bearing and is forwarded only to apps shipped inside Bolt` | Remove the token variable. Let the CLI use its own saved sign-in. |
| `http.host ... is not a loopback address` | Only `127.0.0.1`, `::1` or `localhost`. For a remote service use a bridge script. |
| `parse:json - body is not valid JSON` | The script printed text besides JSON. Print only the JSON; send logs to stderr. |
| `top-level object must have a rows: [...] field` | Print an array, `{ "rows": [...] }`, or set `http.rows_path`. |
| `primitive ... recognized but not yet supported` | Use a step from section 4 that is not on the do-not-use list. |
| Rows show "result 1", "result 2" | Set `title` on each row or a correct `presentation.title_field`. |
| Tiles or table totals look plain | The Rich widgets switch is off. They are optional; the basic result still works. |
| The file is not listed | Extension must be `.yaml` or `.yml`, in the User Apps folder; "Allow User Apps on this device" is on; click Reload. |
| YAML parse error near `{{` | Quote the whole value. |

Bad forms, so you recognise them (each fails to load):

```yaml bad=forbidden-pipe
schema_version: 1
runtime: bridge
id: bad-pipe
chip: bad-pipe
title: Bad pipe
emits: Generic
bridge:
  command:
    linux: "ls -1 | sort"
  parse: lines
presentation:
  widget: list
  title_field: line
```

```yaml bad=variable-in-quotes
schema_version: 1
runtime: bridge
id: bad-quotes
chip: bad-quotes
title: Bad quotes
emits: Generic
bridge:
  command:
    linux: 'echo "{{query}}"'
  parse: lines
presentation:
  widget: list
  title_field: line
```

```yaml bad=tiles-without-value
schema_version: 1
runtime: bridge
id: bad-tiles
chip: bad-tiles
title: Bad tiles
emits: Generic
bridge:
  command:
    linux: "echo [{}]"
  parse: json
presentation:
  widget: list
  title_field: title
  right_widget:
    kind: cards
    bind:
      layout: tiles
      title: "{{row.name}}"
```

```yaml bad=javascript-url-template
schema_version: 1
runtime: bridge
id: bad-url
chip: bad-url
title: Bad url scheme
emits: Generic
bridge:
  command:
    linux: "echo [{}]"
  parse: json
presentation:
  widget: list
  title_field: title
actions:
  - { id: open, kind: url, target: row, label: Open, url_template: "javascript:alert(1)" }
```

```yaml bad=protocol-relative-path
schema_version: 1
runtime: http_local
id: bad-path
chip: bad-path
title: Bad request path
emits: Generic
http:
  port: 11434
  request: { method: GET, path: "//evil.example/steal" }
  parse: json
presentation:
  widget: list
  title_field: title
```

```yaml bad=unknown-field
schema_version: 1
runtime: transform
id: bad-field
chip: bad-field
title: Bad field
emits: Generic
permissions: [network]
transform:
  pipe: [trim]
presentation:
  widget: list
```

```yaml bad=uppercase-id
schema_version: 1
runtime: transform
id: BadId
chip: bad-id
title: Bad id
emits: Generic
transform:
  pipe: [trim]
presentation:
  widget: list
```
