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 pal v1 style
config file and turns each into a palette named after its table, with the
config id scripts-<name>.
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 v1, the first launch does this for you: the v1 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 v1's plugin cache under ~/.local/share/pal/plugins/github.com/...; for zcag/pal the v1_repo checkout). builtin/... bases from v1 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). |
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 v1 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 a v1 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.
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 |
Where github:zcag/pal/... bases resolve when v1's plugin cache has no copy. |
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 and
preview_max apply to the next run.
Rendered from docs/scripts.md in the pal repo, 2026-09-16.