Extend Bolt

Agent guide.

Give this page to your coding agent. It writes a Bolt App that loads the first time.

How to use it.

The brief.

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

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

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:

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:

#!/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 ;, &&, `
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):

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
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
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}}"
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)" }
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
schema_version: 1
runtime: transform
id: bad-field
chip: bad-field
title: Bad field
emits: Generic
permissions: [network]
transform:
  pipe: [trim]
presentation:
  widget: list
schema_version: 1
runtime: transform
id: BadId
chip: bad-id
title: Bad id
emits: Generic
transform:
  pipe: [trim]
presentation:
  widget: list