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.idis lowercase letters, digits and-, up to 64 characters, starting with a letter or digit. - Optionally one script, saved in a folder named
scriptsinside the User Apps folder (the folder “Open folder” shows, so the YAML sits at<User Apps folder>/<id>.yamland 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}}(usepythonon Windows). - Variables:
{{query}}(everything typed after the key), eachargsname, eachinputsname,{{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 “argumentnamerequired”.- 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.envvalues must be exactly${env:NAME}andenv_passthroughmay 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, withline(the text) andidx(0-based). Usetitle_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
kindisurl,composerorutility.flowandconfirmare refused. At most one action may setprimary: true(it takes Enter; the rest go in a menu).- A
utilityaction only pre-fills another User App; the person presses Enter there. It cannot target=ask, built-ins or connectors. url_templateandinsertare not allowed on autilityaction, andtarget_chipandquery_templateare 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.
- Top level has exactly the fields in section 3,
schema_version: 1, and the block that matchesruntime(and no other). idandchipare lowercase kebab, and the file is named<id>.yaml.emitsis one of the nine kinds (useGeneric).- Every command is one program call: no
;,&&,||,|, backtick,$(, no~. - Every
{{variable}}in a command is bare (no quotes around it) and isquery,home,scripts_dir, or declared inargsorinputs. In action templates andbind, row fields are{{row.field}}(or bare{{field}}in an action). - Every string containing
{{,:or#is quoted. - A script prints only JSON (array of objects) to stdout;
parse: jsonmatches. Run it once by hand and check the output parses. title_fieldnames a field that every row has, or each row hastitle.- Every
bindkey exists for the chosenkind(section 7).cardswithlayout: tileshasvalue. - Every action id used in
bind.actionsis defined inactions. Eachurlaction hasurl_template, eachcomposerhasinsert, eachutilityhastarget_chip. - No secret, token, key or personal path is written in the file or the script.
- 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