Scripts and data files
The zero-code tier. A palette can be a data file (json, jsonl or toml) or a
shell script that prints JSON lines, described by a small TOML table. No
TypeScript. The scripts extension reads those tables from a config file in the
previous pal's format and turns each into a palette named after its table, with
the config id scripts-<name>. The same extension also runs script
commands: single executable files with a # @pal.title header, one row each,
in one Script Commands palette ("Script commands", at the end).
Where the tables live
The extension reads the file named by its config setting, by default
~/.config/pal/config.toml, and takes every [palette.<name>] table in it.
That default is pal's own config file, and pal's parser does not know a
palette table: it keeps it and reports one "unknown key" warning in the
Settings window (the editor's schema flags it too). The palettes still
work. To keep the warning away, put the tables in their own file and point
the extension at it:
[extensions.scripts]
config = "~/.config/pal/scripts.toml"
Relative paths inside that file (data, base, general.env_file) are
resolved against the file's directory. ~ is expanded.
Coming from the previous pal, the first launch does this for you: the old
config is kept
as config.v1.toml next to pal's, and config is set to it (the
migration is described in Config).
A data-file palette
[palette.links]
auto_list = true
data = "links.json" # relative to the config file's directory
icon_utf = "🔗"
actions = [
{ id = "open", title = "Open", action = "open", key = "url", primary = true },
{ id = "copy", title = "Copy link", action = "copy", key = "url", shortcut = "cmd+c" },
]
links.json, a JSON array (or one JSON object per line, or a TOML file
whose first top-level array is the rows):
[
{ "name": "Home Assistant", "url": "http://ha.lan", "keywords": ["ha", "home"] },
{ "name": "Grafana", "url": "http://grafana.lan", "subtitle": "dashboards" },
{ "name": "GitHub", "url": "https://github.com", "icon_utf": "🐙" }
]
Every row is an item: name is the title, id defaults to name,
subtitle and keywords are what they say. The palette's actions apply
to every row: Enter opens url (the primary one goes first), ⌘C
copies it, and both show in the action panel.
Without actions, the shorter form is one built-in action for every row:
[palette.links]
auto_list = true
data = "links.json"
auto_pick = true
default_action = "open" # copy | open | cmd | type
action_key = "url" # which row field the action takes
A script palette
A directory with a run.sh (or any command) that answers list and
pick:
[palette.hosts]
base = "plugins/hosts" # the directory, relative to the config file
requires = ["awk"] # gated on the binary being on PATH
ttl = 3600 # reuse a listing for an hour
plugins/hosts/run.sh, listing the hosts in ~/.ssh/config and copying an
ssh command line on pick:
#!/usr/bin/env bash
case "$1" in
list)
awk '/^Host / && $2 !~ /\*/ {
printf "{\"id\":\"%s\",\"name\":\"%s\",\"icon_utf\":\"🖥\"}\n", $2, $2
}' ~/.ssh/config
;;
pick)
printf '{"clipboard":"ssh %s"}\n' "$PAL_ID"
;;
esac
chmod +x it. list prints one JSON object per line; pick gets the
picked row as JSON on stdin and every field as an environment variable
(PAL_ID, PAL_NAME, ...), and prints a result envelope. Here the
envelope copies the text, and the panel hides.
The palette table
Keys of [palette.<name>]. A plugin.toml in the base directory can
carry the same keys; the table's values win where set.
| key | what |
|---|---|
base |
The plugin directory. ~/..., absolute, relative to the config file, or github:<user>/<repo>/<path>[@ref] (looked up in the previous pal's plugin cache under ~/.local/share/pal/plugins/github.com/...; for zcag/pal the v1_repo checkout). builtin/... bases from the previous pal have no equivalent and show one inert row. |
command |
The script, a string or an argv list, resolved in base. Default: run.sh in base. |
data |
The data file, used when auto_list is true. |
auto_list |
Read data instead of running command list. |
auto_pick |
With no actions: run default_action on the row's action_key field instead of command pick. |
default_action |
copy, open, cmd or type (default cmd). |
action_key |
The row field an action takes as its value when the action has no key or value of its own (default name). |
actions |
A list of actions for every row (below). |
icon_utf, icon, icon_xdg |
The palette's icon: a glyph, an emoji or a hex colour; icon_xdg is an XDG icon name. |
input |
The palette lists on every keystroke inside it, with the query. Its rows are not at the root. |
input_prompt |
The search box placeholder inside the palette. |
live |
Listed again every time the panel shows, arrival order kept. |
ttl |
Seconds a listing stays good for (below). |
tier |
The palette's tier at the root: "primary", "normal" or "catalog" (Extensions). Unset, a data-file palette of 100 rows or more is a catalog (the nerd icons, the kde icons, chars), anything else normal. |
view |
"grid" for tiles; display = { columns = 8 } sets the width. |
display |
{ detail = true } opens the palette with the detail pane showing; { columns = N } for a grid. |
filter |
A list of { id, name }: a scope dropdown, Tab cycles it, the chosen id reaches the script as PAL_FILTER. The first is the default. |
requires |
Binaries that must be on PATH, a vertical bar between alternatives (["jq", "gh|glab"]). The palette is skipped when one is missing. |
os |
"macos" or "linux": skipped elsewhere. |
The scripts file's [general] env_file = "..." names a .env-style file whose
variables every script gets.
The script protocol
command list runs for a listing. For an input palette the query is on
stdin and in PAL_QUERY, on every keystroke; otherwise stdin is empty
(the null device) and there is no query. The chosen filter is PAL_FILTER.
Stdout is JSON lines: one object per row, blank lines skipped, lines that
are not a JSON object dropped. Stderr goes to pal's log.
Row fields:
| field | what |
|---|---|
id |
Defaults to name. What pick gets back. |
name |
The title. |
subtitle |
Under the title. |
keywords |
Extra words the search matches. |
section |
Consecutive rows with the same section get a header. |
icon_utf, icon, icon_xdg |
The row's icon, same forms as the palette's; the palette's when absent. Raycast icon names (icon_rc) are dropped. |
url |
A row with a url and no icon (its own or the palette's) gets the site's favicon. |
accessories |
Right-aligned on the row: [{ "text": "..." }, { "tag": "...", "color": "amber" }, { "date": 1700000000000 }]. The Raycast forms { "text": { "value", "color" } } and { "tag": { "value", "color" } } work too. |
detail |
{ "markdown": "...", "metadata": [{ "label", "text" }, { "label", "text", "link" }, { "label", "tags": ["a", "b"] }] }, the detail pane's content. |
actions |
This row's own actions, replacing the palette's. |
preview |
A shell command whose stdout is the detail pane's markdown, run lazily (below). |
Anything else rides along and comes back to pick as PAL_<KEY>.
command pick runs when a row is picked and the action is a pick one
(the default when the row and the palette declare no actions, or an action
without action). The row's JSON is on stdin, and every field is in the
environment as PAL_<KEY> in upper case: strings as they are, anything else
as JSON. PAL_ACTION is the action's id when it has one. Stdout is read
for a result envelope: the whole output when it is one JSON object, else
the last line. A line that is not JSON is ignored.
Every script also gets _PAL_CONFIG (the config file), _PAL_CONFIG_DIR,
_PAL_PALETTE (the table's name) and _PAL_PLUGIN_CONFIG (the merged table
as JSON), the variables from env_file, and the arguments of a drill-in
(below). PATH is the app's plus ~/.local/bin, ~/.cargo/bin,
/opt/homebrew/bin and /usr/local/bin, since the app under launchd has
none of them.
A list or pick still running after timeout seconds (default 30) is
sent SIGTERM with its whole process group, then SIGKILL two seconds later,
and counts as failed.
Actions
An entry of the palette's actions or a row's:
| field | what |
|---|---|
id |
Name the script sees as PAL_ACTION. title when absent. |
title |
What the action panel shows. |
action |
pick (run command pick), copy, open, cmd (run the value with bash -c, the row's variables set), type (paste the value into the app in front; Accessibility on macOS, see Palettes), or the name of an action plugin under plugins/actions/<name> next to the config or in v1_repo. Default pick. |
value |
The literal value for copy, open, cmd, type. |
key |
Take the value from this row field instead; falls back to the palette's action_key. |
primary |
This one goes first, so Enter runs it. Otherwise the first listed is primary and the second is ⌘Enter. |
shortcut |
"cmd+shift+c" style, lower case, + joined. |
style |
"destructive" colours it as such. |
confirm |
A question to ask first; the title is the go-ahead. |
reload |
Stay open and list again after the action. |
A cmd action is waited on for up to 3 seconds so that a reload sees
its work and an envelope it prints counts; a slower one runs on alone.
The result envelope
The JSON object a pick (or a cmd action, or an action plugin) prints.
Any of these keys makes it an envelope:
| key | effect |
|---|---|
clipboard |
Copy the string. |
open |
Open the url or path. |
toast |
{ "title", "message", "style" } (success or failure); the panel stays open to show it. |
hud |
A passive message: shown as a toast only when the envelope has no toast, clipboard, open or close, since those hide the panel. |
show |
{ "title", "markdown", "metadata" }: pushes a read-only level with that detail full width; Enter or Esc goes back. |
palette |
Drill into another script palette: { "palette": "otp-codes", "env": { "ACCOUNT": "..." } } pushes a level scoped to that palette; its list and pick run with env exported, on every keystroke, and its picks are not remembered in the search history. |
reload |
Stay open and list again. |
close |
Recognised as an envelope key; the panel hides anyway after a pick without toast or reload. |
No envelope: the panel hides.
ttl
Two things, one key. In the extension, a listing is cached for ttl
seconds per query, filter and drill-in arguments, so a show or a drill-in
inside that window does not run the script again. In the core, every
listing is written to disk and restored at the next start; with a ttl
the palette is listed again only when the restored listing is older than
that (in a low-priority pass shortly after startup), without one on every
start. A live palette relists on every show; with a ttl only when its
last listing is older than that. ⌘R (Refresh) runs the script regardless.
A table without ttl takes the extension's ttl setting (default an
hour) when that is above 0; a live table without one is exempt and keeps
relisting on every show, since that is what live asks for.
preview: lazy detail
A row of a script palette can carry preview, a shell command. It runs
only when the detail pane is open and the cursor has rested on the row;
its stdout becomes the pane's markdown, and the row's variables are set as
for pick. At most preview_max (default 4) run at once, each with a 10
second limit; the answer is cached per row until the palette lists again.
preview_max = 0 turns previews off. Data-file rows never run previews.
Script commands
The cheapest palette of all: a folder of executable files, each one a row
of the Script Commands palette (scripts-commands), described by a
comment block at the top of the file. Nothing else to declare. The folder
is the commands setting, ~/.config/pal/commands by default, and it is
watched: a file saved there is read again on the next listing (the
palette is live, so the next time the panel shows).
#!/usr/bin/env bash
# @pal.title Deploy site
# @pal.description Push the site to an environment
# @pal.icon 🚀
# @pal.mode hud
# @pal.confirm true
# @pal.keyword deploy ship
# @pal.args target Environment: staging or prod
# @pal.args note Release note (optional)
echo "Deployed to $1${2:+ ($2)}"
chmod +x it. With the cursor on the row the search bar shows a field
for target and one for note after the query (Tab into them); Enter
runs the file with them as $1 and $2, the panel hides, and the HUD
shows the first line it printed.
A pick that arrives without the fields (an item hotkey, a bare pal run,
a script) gets the same two as a form. The two files under
examples/commands/ in the repo are this one and a list-mode one.
Any language: the file runs as it is, so the shebang decides
(#!/usr/bin/env python3), and the tags are read from #, //, --,
; or * comments in the first 60 lines. A file that is not executable,
has no title, is a dotfile or has .template. in its name is not a
command.
The tags
| tag | what |
|---|---|
@pal.title |
The row's name. Required. |
@pal.description |
The row's subtitle (an inline command's output replaces it). |
@pal.icon |
An emoji or a Nerd Font glyph, a hex colour, one of the twelve brand colours (amber: a tile in that colour with the script mark), a PNG, JPEG or SVG next to the script (relative to it, inlined up to 64 KB), or an https url. Without one the row wears the script mark in the extension's colour. |
@pal.mode |
hud (default): the panel hides and the HUD shows the first output line, "Done" with none. silent: nothing shown unless the run failed. show: the whole output comes back as a level, in the detail pane, with a Copy action. list: the output is rows, below. inline: the first output line is the row's subtitle, refreshed on every show once refresh has passed. |
@pal.args |
<name> <placeholder…>, one per line, in the order the script gets them as $1, $2, ... They are the row's typed arguments, one field each in the search bar while the cursor is on the row (Tab into them); Enter runs; a placeholder ending in (optional) makes the field optional, the rest are required. A pick without the values (a hotkey, pal run) gets them as a form. |
@pal.confirm |
true: ask before running (the title as the go-ahead). A command with args asks through its fields instead. |
@pal.keyword |
Extra words the search matches, space or comma separated; repeatable. |
@pal.section |
A section header the rows with the same one share. |
@pal.cwd |
The working directory (~ expanded, relative to the script's folder); the script's folder by default. |
@pal.refresh |
How long an inline command's line is kept before it runs again: 10s, 2m, 1h, a bare number of seconds. A minute by default. |
Raycast's headers work as they are. A script command written for
Raycast drops into the folder unchanged: @raycast.title, @raycast.mode
(compact is hud, fullOutput is show, silent and inline are
themselves), @raycast.icon, @raycast.description,
@raycast.packageName (the section), @raycast.argument1..3 (the JSON
form, placeholder and optional read), @raycast.needsConfirmation,
@raycast.currentDirectoryPath, @raycast.refreshTime.
@raycast.schemaVersion, author, authorURL and iconDark are
ignored.
The palette
| keys | action |
|---|---|
enter |
Run (as the mode says); Open, for a list command; with args, the arguments in the bar first ($1, $2, ... in header order) |
cmd+o |
Open the script file in its editor |
cmd+c |
Copy output: runs the command (up to 8 s) and copies what it printed; with args, the arguments in the bar first |
cmd+shift+c |
Copy the file's path |
cmd+i |
The detail pane: file, mode, arguments, confirm, working directory |
cmd+r |
List again now |
The row's id is the file name, so a global hotkey for one command is
[palettes.scripts-commands.item_hotkeys] with "deploy-site.sh" = "ctrl+alt+d" (Config). The mode sits on the right of the
row unless it is hud. A run that exceeds the extension's timeout
(30 s) is killed with its process group; inline lines and Copy output
get 8 s at most. Scripts get the same PATH as the table palettes (the app's
plus ~/.local/bin, ~/.cargo/bin, /opt/homebrew/bin,
/usr/local/bin).
list mode
Enter pushes a level whose rows are what the script printed: JSON lines (the row
fields of "The script protocol" above: name, id, subtitle, keywords,
section, url, icon_utf, accessories, detail, actions), a JSON array
of them, or, when no line is JSON, one row per plain line. A row with url
opens it on Enter, one with copy copies that string; any other row runs the
script again with PAL_PICK set to the row's id (and the same arguments), and
the HUD shows the first line it prints. A row's own actions (the table shape
above, copy and open with key or value) are honoured.
#!/usr/bin/env bash
# @pal.title Listening ports
# @pal.mode list
# @pal.icon cyan
[ -n "$PAL_PICK" ] && { echo "Port $PAL_PICK"; exit 0; }
lsof -nP -iTCP -sTCP:LISTEN | awk 'NR > 1 { split($9, a, ":"); p = a[length(a)]
printf "{\"id\":\"%s\",\"name\":\":%s\",\"subtitle\":\"%s\",\"copy\":\"%s\"}\n", p, p, $1, p }'
inline mode
#!/usr/bin/env bash
# @pal.title Battery
# @pal.mode inline
# @pal.refresh 30s
pmset -g batt | awk -F'\t' 'NR == 2 { print $2 }'
The first line the script prints is the row's subtitle. The palette is
live, so every show of the panel lists it again; the line is kept for
refresh and the script runs again only past that. Enter runs it once
more, in hud mode.
Settings
[extensions.scripts]:
| key | type | default | what |
|---|---|---|---|
config |
path | ~/.config/pal/config.toml |
The file whose [palette.<name>] tables become palettes. |
skip |
list | ["combine", "pals", "apps", "bookmarks", "calc", "emoji", "clipboard"] |
Table names not to load, because a bundled extension covers them. |
v1_repo |
path | ~/proj/pal-v1 |
A checkout of the previous pal: where github:zcag/pal/... bases resolve when its plugin cache has no copy. |
commands |
path | ~/.config/pal/commands |
The folder of single-file script commands ("Script commands", above). Read on every listing, and watched. |
timeout |
seconds, 1 to 300 | 30 |
A list or pick still running after this is killed. |
preview_max |
0 to 32 | 4 |
How many preview commands run at the same time. 0 turns previews off. |
ttl |
seconds, 0 to 604800 | 3600 |
Listing lifetime for non-live tables that declare no ttl; a table's own ttl wins. 0 runs every script on every start. |
config, skip, v1_repo and ttl are read when the extension loads;
after changing them, Settings > Restart extension host. timeout,
preview_max and commands apply to the next run or listing.
Rendered from docs/scripts.md in the pal repo, 2026-11-02.